diff --git a/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html b/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html
index cc30447..3e2abc9 100644
--- a/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html
+++ b/plugins/ms-ai-architect/playground/ms-ai-architect-playground.html
@@ -502,6 +502,10 @@
const loaded = await persistence.load();
// Sørg for at schemaVersion finnes (cold start kan returnere uten).
if (!loaded.schemaVersion) loaded.schemaVersion = SCHEMA_VERSION;
+ // Data-version migrasjon (v1.9.0 -> v1.10.0): utled verdict/keyStats
+ // for eksisterende parser-output. Idempotent via state.dataVersion-guard.
+ try { migrateDataVersion(loaded, defaultArchetypeFor); }
+ catch (e) { console.warn('[playground v3] migrateDataVersion failed:', e); }
store = createStore(loaded, sharedBus);
scheduleWrite = makeThrottledWriter(function () {
return persistence.save(store.raw);
@@ -625,12 +629,17 @@
// Migrer envelope opp til gjeldende SCHEMA_VERSION før vi commit-er.
const migrated = migrateState({
schemaVersion: envelope.schemaVersion,
+ dataVersion: envelope.dataVersion,
shared: envelope.shared || INITIAL_STATE.shared,
projects: envelope.projects || [],
activeProjectId: envelope.activeProjectId || null,
activeSurface: envelope.activeSurface || 'home',
preferences: envelope.preferences || INITIAL_STATE.preferences
});
+ // Data-version migrasjon (additive — verdict/keyStats utledes for
+ // pre-v1.10.0 envelope-er). Idempotent via dataVersion=2 guard.
+ try { migrateDataVersion(migrated, defaultArchetypeFor); }
+ catch (e) { console.warn('[playground v3] migrateDataVersion (import) failed:', e); }
// Skriv direkte til persistens for å unngå at debounce-vinduet
// svelger import-en ved en samtidig page-unload.
if (persistence) {
@@ -640,6 +649,7 @@
// sin referanse fordi Proxy-en er bundet til den; muter feltvis.
const target = store.raw;
target.schemaVersion = migrated.schemaVersion;
+ if (migrated.dataVersion != null) target.dataVersion = migrated.dataVersion;
target.shared = migrated.shared;
target.projects = migrated.projects;
target.activeProjectId = migrated.activeProjectId;
@@ -3237,6 +3247,7 @@
slot.innerHTML = '
' + summaryHtml + headerHtml + rowsHtml + '
';
}
+ // === V2_FOUNDATION_BEGIN ===
// ============================================================
// FOUNDATION HELPERS (v1.10.0 Sesjon 1)
// ============================================================
@@ -3511,6 +3522,50 @@
);
}
+ // ============================================================
+ // DATA-VERSION MIGRATION (v1->v2)
+ // ============================================================
+ //
+ // State.dataVersion sporer parser-output-format separat fra
+ // state.schemaVersion (som sporer state-shape). v1.9.0 produserte
+ // parser-output uten verdict/keyStats; v1.10.0 utvider med felles
+ // grunnskjelett-data. Migrasjonen er additive — eksisterende felter
+ // forblir uendret.
+ //
+ // v1_to_v2-handler: itererer projects[].reports[cmdId].parsed; hvis
+ // verdict eller keyStats mangler, utled fra eksisterende felter via
+ // inferVerdict + inferKeyStats. Setter state.dataVersion = 2 så
+ // migrasjonen er idempotent (re-kjøring er no-op).
+
+ function migrateDataVersion(state, archetypeFor) {
+ if (!state) return state;
+ if (state.dataVersion === 2) return state;
+ const projects = state.projects || [];
+ for (let i = 0; i < projects.length; i++) {
+ const reports = (projects[i] && projects[i].reports) || {};
+ const ids = Object.keys(reports);
+ for (let j = 0; j < ids.length; j++) {
+ const cmdId = ids[j];
+ const r = reports[cmdId];
+ if (!r || !r.parsed) continue;
+ const arche = typeof archetypeFor === 'function' ? archetypeFor(cmdId) : null;
+ if (!arche) continue;
+ if (r.parsed.verdict == null) r.parsed.verdict = inferVerdict(r.parsed, arche);
+ if (!Array.isArray(r.parsed.keyStats)) r.parsed.keyStats = inferKeyStats(r.parsed, arche);
+ }
+ }
+ state.dataVersion = 2;
+ return state;
+ }
+
+ function defaultArchetypeFor(commandId) {
+ const cmds = (CATALOG && CATALOG.commands) || [];
+ for (let i = 0; i < cmds.length; i++) {
+ if (cmds[i].id === commandId) return cmds[i].report_archetype || null;
+ }
+ return null;
+ }
+
// Eksponer for tester og fremtidig renderer-iterasjon (Sesjon 3-5)
window.__renderPageShell = renderPageShell;
window.__renderVerdictPill = renderVerdictPill;
@@ -3518,6 +3573,9 @@
window.__inferVerdict = inferVerdict;
window.__inferKeyStats = inferKeyStats;
window.__KEY_STATS_CONFIG = KEY_STATS_CONFIG;
+ window.__migrateDataVersion = migrateDataVersion;
+ window.__defaultArchetypeFor = defaultArchetypeFor;
+ // === V2_FOUNDATION_END ===
// ---- RENDERERS routing-objekt (17 commands) ----
diff --git a/plugins/ms-ai-architect/playground/test-fixtures/state-v1-snapshot.json b/plugins/ms-ai-architect/playground/test-fixtures/state-v1-snapshot.json
new file mode 100644
index 0000000..60c6987
--- /dev/null
+++ b/plugins/ms-ai-architect/playground/test-fixtures/state-v1-snapshot.json
@@ -0,0 +1,111 @@
+{
+ "schemaVersion": 1,
+ "shared": {
+ "organization": {
+ "name": "Acme AS",
+ "sector": "Statlig",
+ "regulatory_requirements": ["Personopplysningsloven/GDPR"]
+ },
+ "technology": {
+ "cloud_platform": ["Azure"],
+ "license_type": "E5",
+ "ai_services_in_use": ["Azure OpenAI"]
+ },
+ "security": {
+ "data_classification": ["Intern"],
+ "dpia_practice": "Systematisk"
+ },
+ "architecture": {
+ "annual_ai_budget": "500k-2M"
+ },
+ "business": {}
+ },
+ "projects": [
+ {
+ "id": "p-snapshot-classify",
+ "name": "Demosystem A — klassifisering",
+ "description": "Fiktiv test-prosjekt for v1->v2 migrasjons-test.",
+ "scenarios": [],
+ "createdAt": "2026-04-15T10:00:00.000Z",
+ "reports": {
+ "classify": {
+ "input": { "system_name": "Demosystem A" },
+ "raw_markdown": "# AI Act-klassifisering\n\nRisikonivå: Høy\nRolle: deployer",
+ "parsed": {
+ "risk_level": "Høy",
+ "role": "deployer",
+ "reasoning": "Beslutningsstøtte i forvaltningsbehandling.",
+ "obligations": ["Art. 13 — transparens", "Art. 14 — menneskelig tilsyn", "Art. 27 — FRIA"]
+ }
+ },
+ "ros": {
+ "input": { "system_name": "Demosystem A" },
+ "raw_markdown": "# ROS-analyse\n\nNS 5814 / ISO 31000.",
+ "parsed": {
+ "matrix_cells": [
+ { "row": 4, "col": 4, "count": 2 },
+ { "row": 3, "col": 5, "count": 1 }
+ ],
+ "threats": [
+ { "id": "T1", "description": "Modell-bias mot minoriteter", "severity": "Høy", "mitigation": "Bias-audit + kalibrering" },
+ { "id": "T2", "description": "Privacy leak via prompts", "severity": "Kritisk", "mitigation": "DLP + redaction" },
+ { "id": "T3", "description": "Hallusinerte fakta", "severity": "Medium", "mitigation": "Citation-grounding + reviewer" }
+ ],
+ "radar_axes": [
+ { "name": "Tilgjengelighet", "score": 3 },
+ { "name": "Konfidensialitet", "score": 4 },
+ { "name": "Integritet", "score": 4 },
+ { "name": "Robusthet", "score": 3 },
+ { "name": "Sporbarhet", "score": 2 },
+ { "name": "Fairness", "score": 2 },
+ { "name": "Transparens", "score": 3 }
+ ]
+ }
+ }
+ }
+ },
+ {
+ "id": "p-snapshot-cost",
+ "name": "Demosystem B — kostnadsestimat",
+ "description": "Fiktiv kostnadsestimat-rapport for migrasjons-test.",
+ "scenarios": [],
+ "createdAt": "2026-04-20T09:30:00.000Z",
+ "reports": {
+ "cost": {
+ "input": { "system_name": "Demosystem B" },
+ "raw_markdown": "# Kostnadsestimat\n\nP10/P50/P90 i NOK/mnd.",
+ "parsed": {
+ "p10": 45000,
+ "p50": 82000,
+ "p90": 165000,
+ "monthly_breakdown": [
+ { "component": "Azure OpenAI gpt-4o", "cost": 48000 },
+ { "component": "Azure AI Search", "cost": 12000 },
+ { "component": "Storage + log", "cost": 8000 }
+ ],
+ "tco_table": [],
+ "tco_headers": []
+ }
+ },
+ "summary": {
+ "input": {},
+ "raw_markdown": "# Sammendrag\n\nBetinget anbefaling.",
+ "parsed": {
+ "verdict": "go-with-conditions",
+ "sub": "Med betingelser",
+ "rationale": "Kostnaden er innenfor rammen, men avhengig av governance-modning.",
+ "key_metrics": [
+ { "label": "P50/mnd", "value": "82 000 NOK" },
+ { "label": "Risikonivå", "value": "Høy" }
+ ],
+ "metrics_headers": [],
+ "next_steps": ["Etabler DPIA", "Avklar dataleverandør-kontrakt"]
+ }
+ }
+ }
+ }
+ ],
+ "activeProjectId": "p-snapshot-classify",
+ "activeSurface": "project",
+ "preferences": { "theme": "dark" }
+}
diff --git a/plugins/ms-ai-architect/tests/test-playground-migrations.sh b/plugins/ms-ai-architect/tests/test-playground-migrations.sh
new file mode 100755
index 0000000..2493a22
--- /dev/null
+++ b/plugins/ms-ai-architect/tests/test-playground-migrations.sh
@@ -0,0 +1,185 @@
+#!/bin/bash
+# test-playground-migrations.sh — Playground v3 dataVersion v1->v2 idempotency
+#
+# Verifiserer:
+# 1. fixture (playground/test-fixtures/state-v1-snapshot.json) eksisterer + er v1
+# 2. V2_FOUNDATION-blokken kan ekstraheres fra HTML-fila
+# 3. migrateDataVersion(fixture) gir resultat A
+# 4. migrateDataVersion(A) gir resultat B
+# 5. JSON.stringify(A) === JSON.stringify(B) -> idempotency
+# 6. A.dataVersion === 2 -> migrasjonen ble utført
+#
+# Bash 3.2-kompatibel. Bruker node til JS-eval; ingen npm-deps.
+# Designet for å integreres i tests/run-e2e.sh --playground.
+
+set -euo pipefail
+
+PLUGIN_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+HTML_FILE="$PLUGIN_ROOT/playground/ms-ai-architect-playground.html"
+FIXTURE_FILE="$PLUGIN_ROOT/playground/test-fixtures/state-v1-snapshot.json"
+
+# shellcheck disable=SC1091
+source "$PLUGIN_ROOT/tests/lib/e2e-helpers.sh"
+
+init_suite "Playground v3 — dataVersion v1->v2 migration idempotency"
+
+# ---- 1. Filer eksisterer ----
+if [ ! -f "$HTML_FILE" ]; then
+ fail "HTML-fila finnes ikke: $HTML_FILE"
+ print_summary; exit 1
+fi
+pass "HTML-fil finnes: $(basename "$HTML_FILE")"
+
+if [ ! -f "$FIXTURE_FILE" ]; then
+ fail "Fixture mangler: $FIXTURE_FILE"
+ print_summary; exit 1
+fi
+pass "Fixture finnes: $(basename "$FIXTURE_FILE")"
+
+# ---- 2. Fixture er v1 (mangler dataVersion eller dataVersion < 2) ----
+if node -e "
+const f = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'));
+if (f.dataVersion === 2) { console.error('fixture er allerede v2'); process.exit(1); }
+if (typeof f.schemaVersion !== 'number') { console.error('fixture mangler schemaVersion'); process.exit(1); }
+if (!Array.isArray(f.projects) || f.projects.length === 0) { console.error('fixture har ingen projects'); process.exit(1); }
+" "$FIXTURE_FILE" 2>/dev/null; then
+ pass "Fixture er v1-state (dataVersion ikke satt)"
+else
+ fail "Fixture er ikke gyldig v1-state"
+ print_summary; exit 1
+fi
+
+# ---- 3. V2_FOUNDATION-markører eksisterer i HTML ----
+if grep -q "V2_FOUNDATION_BEGIN" "$HTML_FILE" && grep -q "V2_FOUNDATION_END" "$HTML_FILE"; then
+ pass "V2_FOUNDATION_BEGIN/END markører finnes"
+else
+ fail "Mangler V2_FOUNDATION-markører i HTML"
+ print_summary; exit 1
+fi
+
+# ---- 4. migrateDataVersion-funksjonen er definert ----
+if grep -q "function migrateDataVersion" "$HTML_FILE"; then
+ pass "migrateDataVersion-funksjonen finnes"
+else
+ fail "migrateDataVersion-funksjonen mangler"
+ print_summary; exit 1
+fi
+
+# ---- 5. Eval idempotency-test i node ----
+# Strategi: ekstraher V2_FOUNDATION-blokken via sed, wrap med stubs (window,
+# CATALOG-mock som returnerer archetype basert på fixture command-IDs), eval,
+# kjør migrasjon to ganger på fixture-deep-copy, sammenlign JSON.stringify.
+
+IDEMPOTENCY_RESULT=$(node -e '
+const fs = require("fs");
+const path = require("path");
+const htmlPath = process.argv[1];
+const fixturePath = process.argv[2];
+
+const html = fs.readFileSync(htmlPath, "utf8");
+const beginMarker = "// === V2_FOUNDATION_BEGIN ===";
+const endMarker = "// === V2_FOUNDATION_END ===";
+const beginIdx = html.indexOf(beginMarker);
+const endIdx = html.indexOf(endMarker);
+if (beginIdx < 0 || endIdx < 0) {
+ console.error("MARKER_MISSING");
+ process.exit(2);
+}
+const block = html.substring(beginIdx, endIdx + endMarker.length);
+
+// Stub-ene som blokken trenger
+const stubs = `
+ const window = {};
+ function escapeHtml(s) { return String(s == null ? "" : s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); }
+ function escapeAttr(s) { return escapeHtml(s); }
+ // Mock CATALOG: kun de command-id-ene fixture-en bruker.
+ const CATALOG = { commands: [
+ { id: "classify", report_archetype: "aiact" },
+ { id: "ros", report_archetype: "matrix-risk" },
+ { id: "cost", report_archetype: "cost-distribution" },
+ { id: "summary", report_archetype: "verdict" }
+ ]};
+`;
+
+const wrapped = stubs + block + "\nreturn { migrateDataVersion, defaultArchetypeFor };";
+let api;
+try {
+ api = (new Function(wrapped))();
+} catch (e) {
+ console.error("EVAL_FAILED:", e.message);
+ process.exit(3);
+}
+
+const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8"));
+
+// Deep clones så vi sammenligner uavhengige objekter
+const stateA = JSON.parse(JSON.stringify(fixture));
+api.migrateDataVersion(stateA, api.defaultArchetypeFor);
+
+const stateB = JSON.parse(JSON.stringify(stateA));
+api.migrateDataVersion(stateB, api.defaultArchetypeFor);
+
+const a = JSON.stringify(stateA);
+const b = JSON.stringify(stateB);
+
+if (stateA.dataVersion !== 2) {
+ console.error("DATA_VERSION_NOT_BUMPED");
+ process.exit(4);
+}
+
+// Sjekk at minst én rapport fikk verdict + keyStats utledet
+let verdictsAdded = 0, statsAdded = 0;
+for (const p of (stateA.projects || [])) {
+ for (const id of Object.keys(p.reports || {})) {
+ const r = p.reports[id];
+ if (r && r.parsed) {
+ if (r.parsed.verdict != null) verdictsAdded++;
+ if (Array.isArray(r.parsed.keyStats)) statsAdded++;
+ }
+ }
+}
+if (verdictsAdded === 0) { console.error("NO_VERDICTS_ADDED"); process.exit(5); }
+if (statsAdded === 0) { console.error("NO_KEYSTATS_ADDED"); process.exit(6); }
+
+if (a === b) {
+ console.log("IDEMPOTENT verdicts=" + verdictsAdded + " stats=" + statsAdded);
+} else {
+ console.error("NOT_IDEMPOTENT");
+ process.exit(7);
+}
+' "$HTML_FILE" "$FIXTURE_FILE" 2>&1) || RC=$?
+
+if [ "${RC:-0}" -eq 0 ] && echo "$IDEMPOTENCY_RESULT" | grep -q "^IDEMPOTENT"; then
+ pass "migrateDataVersion er idempotent ($IDEMPOTENCY_RESULT)"
+else
+ fail "Idempotency-test feilet: $IDEMPOTENCY_RESULT"
+fi
+
+# ---- 6. dataVersion bumpes til 2 ved første kjøring ----
+DV_RESULT=$(node -e '
+const fs = require("fs");
+const html = fs.readFileSync(process.argv[1], "utf8");
+const begin = html.indexOf("// === V2_FOUNDATION_BEGIN ===");
+const end = html.indexOf("// === V2_FOUNDATION_END ===");
+const block = html.substring(begin, end + 32);
+const stubs = `
+ const window = {};
+ function escapeHtml(s) { return String(s == null ? "" : s); }
+ function escapeAttr(s) { return escapeHtml(s); }
+ const CATALOG = { commands: [
+ { id: "classify", report_archetype: "aiact" }
+ ]};
+`;
+const api = (new Function(stubs + block + "\nreturn { migrateDataVersion, defaultArchetypeFor };"))();
+const state = { schemaVersion: 1, projects: [] };
+api.migrateDataVersion(state, api.defaultArchetypeFor);
+console.log(state.dataVersion);
+' "$HTML_FILE" 2>&1) || true
+
+if [ "$DV_RESULT" = "2" ]; then
+ pass "dataVersion bumpes til 2"
+else
+ fail "dataVersion ble ikke bumpet til 2 (got '$DV_RESULT')"
+fi
+
+print_summary