From ace68b09146519a0369fd85cdb488727905bc748 Mon Sep 17 00:00:00 2001 From: Daniel Oon Date: Sun, 6 Sep 2026 15:37:27 +0800 Subject: [PATCH 01/15] feat: GUI domain selector with batched release evidence Track B parity: the CLI has supported --domain since the inventory domain landed, but the GUI had no way to select it. Added a Domain selector (default refund), validated on the server like every other run option, and pushed through to the orchestrator. Also records the batched release evidence for the review-workspace, replay, integrator-example, settled-review, history/comparison, inventory, and extension-guide merges, including the pending run-lifecycle and replay-stdin entries, and distinguishes browser tests from component and orchestrator unit tests. --- README.md | 3 ++- bin/aas-gui.mjs | 10 +++++--- docs/release-readiness.md | 22 ++++++++++++++++++ test/browser/workbench.spec.mjs | 12 +++++++++- test/gui.test.mjs | 41 ++++++++++++++++++++++++++++++++- 5 files changed, 82 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 64c9cad..b185c7a 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ node ./bin/aas.mjs demo --domain inventory --fault duplicate --prove rail Both domains keep their own policy fixture and their own remedy scope field (`max_amount_minor` for refunds, `max_quantity` for allocations), so neither -is disguised as the other. Everything remains synthetic: no warehouse, +is disguised as the other. The GUI exposes the same choice with a Domain +selector. Everything remains synthetic: no warehouse, merchant, payment, or external provider integration is involved, and a recorded review proves the handoff rather than any real-world reversibility. diff --git a/bin/aas-gui.mjs b/bin/aas-gui.mjs index c90f6e0..98e2289 100644 --- a/bin/aas-gui.mjs +++ b/bin/aas-gui.mjs @@ -255,6 +255,7 @@ export function renderPage() {

Agent Action Stack

Run the local decide, act, and prove flow using the reviewed component lock.

+
@@ -307,7 +308,7 @@ runButton.addEventListener('click',async()=>{ bindings.innerHTML=''; clearImported(); output.textContent='Running...'; - const query=new URLSearchParams({response:document.getElementById('response').value,fault:document.getElementById('fault').value,prove:document.getElementById('prove').value}); + const query=new URLSearchParams({response:document.getElementById('response').value,fault:document.getElementById('fault').value,prove:document.getElementById('prove').value,domain:document.getElementById('domain').value}); if(document.getElementById('dispute').checked) query.set('dispute','1'); let runBody; try { @@ -460,7 +461,7 @@ export function createGuiServer({ return; } if (request.method === "POST" && url.pathname === "/api/run") { - const allowedKeys = new Set(["response", "fault", "dispute", "prove"]); + const allowedKeys = new Set(["response", "fault", "dispute", "prove", "domain"]); if ([...url.searchParams.keys()].some((key) => !allowedKeys.has(key))) { sendJson(response, 400, { error: "Invalid options" }); return; @@ -468,15 +469,18 @@ export function createGuiServer({ const selectedResponse = url.searchParams.get("response") ?? "pass"; const selectedFault = url.searchParams.get("fault") ?? "none"; const selectedProve = url.searchParams.get("prove") ?? "simulate"; + const selectedDomain = url.searchParams.get("domain") ?? "refund"; if (!new Set(["pass", "fail"]).has(selectedResponse) || !new Set(["none", "duplicate"]).has(selectedFault) || !new Set([null, "1"]).has(url.searchParams.get("dispute")) - || !new Set(["simulate", "rail"]).has(selectedProve)) { + || !new Set(["simulate", "rail"]).has(selectedProve) + || !new Set(["refund", "inventory"]).has(selectedDomain)) { sendJson(response, 400, { error: "Invalid options" }); return; } const args = ["--response", selectedResponse, "--fault", selectedFault, "--json"]; if (url.searchParams.get("dispute") === "1") args.push("--dispute"); + if (selectedDomain !== "refund") args.push("--domain", selectedDomain); if (selectedProve !== "simulate") args.push("--prove", selectedProve); try { assertFullStackNodeVersion( diff --git a/docs/release-readiness.md b/docs/release-readiness.md index bff00b8..9966f42 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -197,6 +197,28 @@ path were exercised live against real child processes. No browser harness exists in this repository; DOM event dispatch is covered through a stub-DOM test of the real page script. +## Integration evidence, review-workspace and domain batch + +Component pins: testbench `16b2faa7` and mandatebound `e526c4c` unchanged; +Consequence Rail at `6c61e9f` (inventory-allocation domain, PR #22). This +batch covers the GUI review workspace (PR #26), portable replay (PR #27), +the integrator example (PR #28), settled-review integration coverage +(PR #30), case history and comparison (PR #35), the inventory domain in the +stack (PR #36), the integrator extension guide and connector conformance +example (PR #37), and the pending run-lifecycle and replay-stdin entries +from PRs #32 and #33. + +Unit-test coverage: stack suite 107/107; consequence-rail 162/162; +mandatebound 228/228; testbench 120/120. Full-stack coverage: `npm run +integration` (pass, refusal, dispute, settled-review, rail-review, and +inventory paths plus export/replay and history/compare), the integrator +example and connector-conformance example, and 10 real browser workflow +tests, on Ubuntu and Windows with Node.js 22.12.0 and 24 and Python 3.13. + +Browser tests are a distinct category from component and orchestrator unit +tests: they drive real clicks, file selection, and asynchronous responses +against the pinned components, and are the only evidence for UI behaviour. + ## Publication boundary This document is a readiness checklist, not a publication approval. A separate diff --git a/test/browser/workbench.spec.mjs b/test/browser/workbench.spec.mjs index dff2723..7fd11c6 100644 --- a/test/browser/workbench.spec.mjs +++ b/test/browser/workbench.spec.mjs @@ -16,10 +16,11 @@ function caseFile(name, contents) { return path; } -async function runStack(page, { response = "pass", fault = "none", prove = "simulate", dispute = false } = {}) { +async function runStack(page, { response = "pass", fault = "none", prove = "simulate", domain = "refund", dispute = false } = {}) { await page.selectOption("#response", response); await page.selectOption("#fault", fault); await page.selectOption("#prove", prove); + await page.selectOption("#domain", domain); if (dispute) await page.check("#dispute"); else await page.uncheck("#dispute"); await page.click("#run"); @@ -135,6 +136,15 @@ test("case history loads and two cases can be compared through the UI", async ({ await expect(page.locator("#compare-result")).toContainText("matching metadata does not prove matching evidence"); }); +test("the inventory domain runs through the UI with its own policy", async ({ page }) => { + await page.goto("/"); + await runStack(page, { domain: "inventory", fault: "duplicate", prove: "rail" }); + await expect(page.locator("#summary")).toContainText("decide: passed"); + await expect(page.locator("#summary")).toContainText("policy aas-inventory-gate-v1"); + await expect(page.locator("#summary")).toContainText("mode rail-review"); + await expect(page.locator("#bindings")).toContainText("recomputed match"); +}); + test("comparison reports an explicit error when a selection is missing", async ({ page }) => { await page.goto("/"); await page.click("#compare"); diff --git a/test/gui.test.mjs b/test/gui.test.mjs index 7454e2a..287efee 100644 --- a/test/gui.test.mjs +++ b/test/gui.test.mjs @@ -293,7 +293,7 @@ function pageScript() { function stubDocument() { const elements = {}; - for (const id of ["response", "fault", "dispute", "prove", "run", "download", "output", "summary", "bindings", "case-file", "replay", "import-status", "import-result", "load-history", "left-case", "right-case", "compare", "compare-status", "compare-result", "history-list"]) { + for (const id of ["response", "fault", "dispute", "prove", "run", "download", "output", "summary", "bindings", "case-file", "replay", "import-status", "import-result", "load-history", "left-case", "right-case", "compare", "compare-status", "compare-result", "history-list", "domain"]) { elements[id] = { value: "pass", checked: false, disabled: false, textContent: "", innerHTML: "", href: null, style: {}, listeners: {}, addEventListener(name, fn) { this.listeners[name] = fn; }, removeAttribute(name) { delete this[name]; } }; @@ -618,3 +618,42 @@ test("GUI history and compare endpoints serve summaries and classifications", as await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } }); + +test("GUI exposes a domain selector defaulting to refund", () => { + const page = renderPage(); + assert.match(page, / + +

Load recent cases to search. The bounded history may omit older or unreadable cases.

@@ -307,6 +316,14 @@ let latestToken=0; let importToken=0; let compareToken=0; let historyToken=0; +let loadedCases=[]; +function drawHistory(){ + const cases=filterHistory(loadedCases,document.getElementById("history-search").value,document.getElementById("history-outcome").value); + historyList.innerHTML=historyModel(cases); + document.getElementById("history-count").textContent=cases.length+" of "+loadedCases.length+" loaded cases shown. Search filters the list; comparison selectors retain all loaded cases."; +} +document.getElementById("history-search").addEventListener("input",drawHistory); +document.getElementById("history-outcome").addEventListener("change",drawHistory); function clearComparison(){ compareToken++; compareResult.innerHTML=""; compareStatus.textContent=""; compareButton.disabled=false; } leftCase.addEventListener("change",clearComparison); rightCase.addEventListener("change",clearComparison); @@ -376,7 +393,7 @@ async function refreshHistory(token){ catch(error){ if(token!==historyToken) return; historyList.textContent='History unavailable: '+error.message; return; } if(token!==historyToken) return; const cases=(body&&Array.isArray(body.cases))?body.cases:[]; - historyList.innerHTML=historyModel(cases); + loadedCases=cases; drawHistory(); const options=renderCaseOptions(cases); const leftValue=leftCase.value; const rightValue=rightCase.value; diff --git a/bin/aas.mjs b/bin/aas.mjs index 2f1e2e1..86aa7e5 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -1177,6 +1177,7 @@ export function summarizeRun(runId, { outputRoot = DEFAULT_PATHS.outputRoot } = run_id: runId, created_at: typeof manifest.created_at === "string" ? manifest.created_at : null, schema_version: manifest.schema_version, + domain: typeof report.domain === "string" ? report.domain : null, exit_code: manifest.exit_code ?? null, flow: typeof report.flow === "string" ? report.flow : null, stages, diff --git a/test/gui.test.mjs b/test/gui.test.mjs index c31c294..5bb575a 100644 --- a/test/gui.test.mjs +++ b/test/gui.test.mjs @@ -5,7 +5,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { bindingsModel, compareModel, createGuiServer, historyModel, renderPage, replayHttpStatus, replayResultModel, summaryModel } from "../bin/aas-gui.mjs"; +import { filterHistory, bindingsModel, compareModel, createGuiServer, historyModel, renderPage, replayHttpStatus, replayResultModel, summaryModel } from "../bin/aas-gui.mjs"; import { exportRunBundle, runDemo, selectPython } from "../bin/aas.mjs"; const provenance = [ @@ -293,7 +293,7 @@ function pageScript() { function stubDocument() { const elements = {}; - for (const id of ["response", "fault", "dispute", "prove", "run", "download", "output", "summary", "bindings", "case-file", "replay", "import-status", "import-result", "load-history", "left-case", "right-case", "compare", "compare-status", "compare-result", "history-list", "domain"]) { + for (const id of ["response", "fault", "dispute", "prove", "run", "download", "output", "summary", "bindings", "case-file", "replay", "import-status", "import-result", "load-history", "left-case", "right-case", "compare", "compare-status", "compare-result", "history-list", "domain", "history-search", "history-outcome", "history-count"]) { elements[id] = { value: "pass", checked: false, disabled: false, textContent: "", innerHTML: "", href: null, style: {}, listeners: {}, addEventListener(name, fn) { this.listeners[name] = fn; }, removeAttribute(name) { delete this[name]; } }; @@ -739,3 +739,11 @@ test("history refresh does not strand comparisons and selection changes invalida document.elements['left-case'].listeners.change(); assert.equal(document.elements['compare-result'].innerHTML, ''); }); + +test("history search matches case metadata without broadening outcome filters", () => { + const cases = [{run_id: 'A', policy_id: 'Inventory-Gate', domain: 'inventory', outcome: 'settled'}, {run_id: 'B', review_verdict: 'recorded', outcome: 'compensated'}]; + assert.deepEqual(filterHistory(cases, ' INVENTORY ', 'settled'), [cases[0]]); + assert.deepEqual(filterHistory(cases, 'inventory', 'compensated'), []); + assert.deepEqual(filterHistory(cases, 'recorded'), [cases[1]]); + assert.deepEqual(filterHistory(cases), cases); +}); From 1cd6056ec552b8584d9b57ea3e3306878714cc9a Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:48:41 +0800 Subject: [PATCH 09/15] feat: inspect and download saved cases without executing a run --- bin/aas-gui.mjs | 31 +++++++++++++++++++++++++++++++ test/gui.test.mjs | 17 +++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/bin/aas-gui.mjs b/bin/aas-gui.mjs index 68b3db1..335213b 100644 --- a/bin/aas-gui.mjs +++ b/bin/aas-gui.mjs @@ -291,6 +291,9 @@ export function renderPage() { + +Download selected saved case +
@@ -312,6 +315,34 @@ const loadHistoryButton=document.getElementById('load-history'); const compareButton=document.getElementById('compare'); const compareStatus=document.getElementById('compare-status'); const compareResult=document.getElementById('compare-result'); +let savedToken=0; +const inspectButton=document.getElementById('inspect-case'); +const savedDownload=document.getElementById('saved-download'); +const savedStatus=document.getElementById('saved-status'); +function clearSaved(){ savedToken++; inspectButton.disabled=false; savedDownload.style.display='none'; savedDownload.removeAttribute('href'); document.getElementById('saved-summary').innerHTML=''; document.getElementById('saved-bindings').innerHTML=''; savedStatus.textContent=''; } +leftCase.addEventListener('change',clearSaved); +inspectButton.addEventListener('click',async()=>{ + clearSaved(); + const token=savedToken; + const runId=leftCase.value; + if(!runId){ savedStatus.textContent='Select a saved case on the left first.'; return; } + inspectButton.disabled=true; + savedStatus.textContent='Loading saved case '+runId+'...'; + try { + const response=await fetch('/api/bundle/'+encodeURIComponent(runId)); + const bundle=await response.json(); + if(response.ok===false) throw new Error(bundle?.error ?? 'Case unavailable'); + validateRunBundle(bundle,runId); + const html=await bindingsModel(bundle); + if(token!==savedToken) return; + document.getElementById('saved-summary').innerHTML=summaryModel(bundle.report); + document.getElementById('saved-bindings').innerHTML=html; + savedDownload.href='/api/bundle/'+encodeURIComponent(runId); + savedDownload.style.display='inline-block'; + savedStatus.textContent='Saved case '+runId+'. Inspection only; use imported replay for verification.'; + } catch(error){ if(token!==savedToken) return; savedStatus.textContent='Saved case unavailable: '+error.message; } + if(token===savedToken) inspectButton.disabled=false; +}); let latestToken=0; let importToken=0; let compareToken=0; diff --git a/test/gui.test.mjs b/test/gui.test.mjs index 5bb575a..bb6b1c8 100644 --- a/test/gui.test.mjs +++ b/test/gui.test.mjs @@ -293,9 +293,9 @@ function pageScript() { function stubDocument() { const elements = {}; - for (const id of ["response", "fault", "dispute", "prove", "run", "download", "output", "summary", "bindings", "case-file", "replay", "import-status", "import-result", "load-history", "left-case", "right-case", "compare", "compare-status", "compare-result", "history-list", "domain", "history-search", "history-outcome", "history-count"]) { + for (const id of ["response", "fault", "dispute", "prove", "run", "download", "output", "summary", "bindings", "case-file", "replay", "import-status", "import-result", "load-history", "left-case", "right-case", "compare", "compare-status", "compare-result", "history-list", "domain", "history-search", "history-outcome", "history-count", "inspect-case", "saved-download", "saved-status", "saved-summary", "saved-bindings"]) { elements[id] = { value: "pass", checked: false, disabled: false, textContent: "", innerHTML: "", href: null, style: {}, listeners: {}, - addEventListener(name, fn) { this.listeners[name] = fn; }, + addEventListener(name, fn) { const prior = this.listeners[name]; this.listeners[name] = prior ? (...args) => { prior(...args); return fn(...args); } : fn; }, removeAttribute(name) { delete this[name]; } }; } elements.response.value = "pass"; @@ -747,3 +747,16 @@ test("history search matches case metadata without broadening outcome filters", assert.deepEqual(filterHistory(cases, 'recorded'), [cases[1]]); assert.deepEqual(filterHistory(cases), cases); }); + +test("saved case inspection binds its download and keeps live output separate", async () => { + const document = stubDocument(); document.elements['left-case'].value='saved-1'; + document.elements.summary.innerHTML='live result'; + const fetch = async () => ({ json: async () => ({ manifest: {run_id: 'saved-1'}, report: {run_id: 'saved-1', domain: 'inventory', stages: {}}, stages: {} }) }); + new Function('document','fetch','crypto',pageScript())(document,fetch,globalThis.crypto); + await document.elements['inspect-case'].listeners.click(); + assert.equal(document.elements['saved-download'].href, '/api/bundle/saved-1'); + assert.match(document.elements['saved-summary'].innerHTML, /inventory/); + assert.equal(document.elements.summary.innerHTML, 'live result'); + document.elements['left-case'].listeners.change(); + assert.equal(document.elements['saved-download'].href, undefined); +}); From b1bfca60d38064d8323c87628cf5d506adf68b02 Mon Sep 17 00:00:00 2001 From: EauDoon <47585778+EauDoon@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:49:17 +0800 Subject: [PATCH 10/15] feat: guide synthetic settlement refusal and review scenarios --- bin/aas-gui.mjs | 24 ++++++++++++++++++++++-- test/gui.test.mjs | 18 ++++++++++++++++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/bin/aas-gui.mjs b/bin/aas-gui.mjs index 335213b..b6cd96b 100644 --- a/bin/aas-gui.mjs +++ b/bin/aas-gui.mjs @@ -259,15 +259,27 @@ export function validateRunBundle(bundle, runId) { return bundle; } +export function scenarioPreset(name) { + const presets = { + settled: { response: "pass", fault: "none", prove: "simulate", dispute: false, note: "Expected: decide passes, synthetic action settles, prove is skipped." }, + refusal: { response: "fail", fault: "none", prove: "rail", dispute: false, note: "Expected: policy refusal stops before act and prove." }, + compensated: { response: "pass", fault: "duplicate", prove: "rail", dispute: false, note: "Expected: duplicate synthetic action is compensated and same-case review records the handoff." }, + review: { response: "pass", fault: "none", prove: "rail", dispute: true, note: "Expected: settled synthetic action receives a requested same-case review." }, + }; + return Object.hasOwn(presets, name) ? presets[name] : null; +} + export function renderPage() { - const embedded = [filterHistory, validateRunBundle, escapeHtml, stageHeadline, summaryModel, bindingsModel, replayResultModel, compareModel, historyModel, renderCaseOptions, sha256HexText] + const embedded = [scenarioPreset, filterHistory, validateRunBundle, escapeHtml, stageHeadline, summaryModel, bindingsModel, replayResultModel, compareModel, historyModel, renderCaseOptions, sha256HexText] .map((fn) => fn.toString()).join("\n"); return ` Agent Action Stack

Agent Action Stack

Run the local decide, act, and prove flow using the reviewed component lock.

-
+
+

Choose a scenario or configure the options below. Applying a scenario only changes controls.

+ @@ -299,6 +311,14 @@ export function renderPage() {