From c418752854f6a840e7dc31b54ab020d363a3a8da Mon Sep 17 00:00:00 2001 From: Daniel Oon Date: Sun, 6 Sep 2026 15:13:03 +0800 Subject: [PATCH] feat: wire the inventory domain through the stack The second synthetic domain is now reachable end to end, not just in the rail: policy evaluation, execution, same-case review, export, and offline replay. - `aas demo --domain refund|inventory` (default refund) selects the domain fixture for decide and the rail demo for act; unknown domains are rejected before any stage runs. - Inventory fixtures added (policy plus pass/fail responses). The two domains keep separate policy references and separate remedy scope fields, so an allocation never passes as a refund. - stack-lock.json moves consequence-rail to the reviewed inventory-domain merge (6c61e9f); other pins unchanged. - Integration proof covers an inventory run with duplicate allocation and rail review, asserting flow, prove mode, compensation, policy reference, and provenance. --- README.md | 20 ++++++++++ bin/aas.mjs | 33 +++++++++++++--- fixtures/inventory.policy.json | 40 +++++++++++++++++++ fixtures/inventory.response.fail.json | 8 ++++ fixtures/inventory.response.pass.json | 8 ++++ scripts/integration-check.mjs | 23 +++++++++++ stack-lock.json | 2 +- test/stack.test.mjs | 57 ++++++++++++++++++++++++++- 8 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 fixtures/inventory.policy.json create mode 100644 fixtures/inventory.response.fail.json create mode 100644 fixtures/inventory.response.pass.json diff --git a/README.md b/README.md index f6b9036..64c9cad 100644 --- a/README.md +++ b/README.md @@ -207,3 +207,23 @@ Browser artifacts are written to `test-results/` and `playwright-report/` ## License Apache-2.0 + +## Synthetic action domains + +`--domain` selects the synthetic action domain; both use the same rail, +recourse, and review machinery: + +- `refund` (default): the documented refund scenario. +- `inventory`: a bounded synthetic inventory allocation that reserves a + declared quantity of one synthetic SKU for one synthetic order; its + pre-reserved remedy reverses only the allocation bound to the action. + +```bash +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, +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.mjs b/bin/aas.mjs index 5322319..3b5a7f1 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -108,7 +108,8 @@ export const DEFAULT_PATHS = Object.freeze({ const STAGE_NAMES = ["decide", "act", "prove"]; const DEMO_FLAG_OPTIONS = new Set(["--dispute", "--json"]); -const DEMO_VALUE_OPTIONS = new Set(["--response", "--fault", "--prove"]); +const DEMO_VALUE_OPTIONS = new Set(["--response", "--fault", "--prove", "--domain"]); +const DEMO_DOMAINS = new Set(["refund", "inventory"]); const PROVE_MODES = new Set(["simulate", "rail"]); const STDERR_LIMIT = 800; /** Child stdout is capped so a runaway tool cannot inflate the run bundle. */ @@ -237,7 +238,7 @@ export function helpText() { return `Agent Action Stack Usage: - aas demo [--response pass|fail] [--fault none|duplicate] [--dispute] [--prove simulate|rail] [--json] + aas demo [--response pass|fail] [--fault none|duplicate] [--dispute] [--prove simulate|rail] [--domain refund|inventory] [--json] aas export [--out ] aas replay [--json] aas cases [--json] @@ -259,6 +260,7 @@ Options: --dispute Force MandateBound prove after a settled act --prove simulate|rail Prove path: canned operator simulation (default) or review of the same-case rail bundle + --domain refund|inventory Synthetic action domain (default: refund) --json Print the run report as JSON -h, --help Show this help @@ -573,9 +575,13 @@ export function runDecide( fixturesDir = DEFAULT_PATHS.fixtures, runner = runCapture, python = null, + domain = "refund", } = {}, ) { - const policyPath = join(fixturesDir, "policy.json"); + const policyPath = join( + fixturesDir, + domain === "inventory" ? "inventory.policy.json" : "policy.json", + ); const pythonPath = join(depsDir, "constitutional-agent-testbench", "src"); const decideCli = join(pythonPath, "constitutional_agent_testbench", "cli.py"); if (runner === runCapture && !existsSync(decideCli)) { @@ -628,13 +634,18 @@ export function runDecide( */ export function runAct( fault, - { depsDir = DEFAULT_PATHS.deps, runner = runCapture, persistRailBundle = false } = {}, + { + depsDir = DEFAULT_PATHS.deps, + runner = runCapture, + persistRailBundle = false, + domain = "refund", + } = {}, ) { const crctl = join(depsDir, "consequence-rail", "cmd", "crctl.js"); if (runner === runCapture && !existsSync(crctl)) { throw missingChildTool("act CLI (deps/consequence-rail/cmd/crctl.js)"); } - const args = ["demo", "refund", "--json"]; + const args = ["demo", domain, "--json"]; if (fault && fault !== "none") args.push("--fault", fault); const railDir = join(depsDir, "consequence-rail"); let scratch = null; @@ -1456,7 +1467,15 @@ export async function runDemo(args = [], options = {}) { if (!PROVE_MODES.has(proveMode)) { throw new UsageError("--prove must be simulate or rail"); } - const responsePath = join(paths.fixtures, `response.${responseName}.json`); + const domain = option(args, "--domain", "refund"); + if (!DEMO_DOMAINS.has(domain)) { + throw new UsageError("--domain must be refund or inventory"); + } + const responseFile = + domain === "inventory" + ? `inventory.response.${responseName}.json` + : `response.${responseName}.json`; + const responsePath = join(paths.fixtures, responseFile); if (!existsSync(responsePath) && !options.runDecideFn) throw new Error(`Missing fixture: ${responsePath}`); const runId = options.runId ?? createRunId(options.now ? new Date(options.now) : new Date()); const componentProvenance = options.componentResolver @@ -1500,6 +1519,7 @@ export async function runDemo(args = [], options = {}) { depsDir: paths.deps, fixturesDir: paths.fixtures, runner, + domain, ...(options.python ? { python: options.python } : {}), }); const decideStderr = clipChildStderr(decide.stderr); @@ -1542,6 +1562,7 @@ export async function runDemo(args = [], options = {}) { const act = await (options.runActFn ?? runAct)(fault, { depsDir: paths.deps, runner, + domain, persistRailBundle: proveMode === "rail" && options.runActFn === undefined, }); const outcome = act.raw?.outcome ?? null; diff --git a/fixtures/inventory.policy.json b/fixtures/inventory.policy.json new file mode 100644 index 0000000..6cf1d77 --- /dev/null +++ b/fixtures/inventory.policy.json @@ -0,0 +1,40 @@ +{ + "policy_id": "aas-inventory-gate-v1", + "schema_version": "1.0", + "rules": [ + { + "kind": "required_field", + "path": "summary", + "rule_id": "summary-present" + }, + { + "kind": "equals", + "path": "decision", + "rule_id": "decision-accept", + "value": "accept" + }, + { + "kind": "one_of", + "path": "action_type", + "rule_id": "action-is-allocation", + "values": ["allocate_inventory"] + }, + { + "kind": "one_of", + "path": "risk_level", + "rule_id": "risk-allowed", + "values": ["low", "moderate"] + }, + { + "kind": "false", + "path": "blocked", + "rule_id": "blocked-is-false" + }, + { + "kind": "equals", + "path": "recourse_required", + "rule_id": "recourse-required", + "value": true + } + ] +} diff --git a/fixtures/inventory.response.fail.json b/fixtures/inventory.response.fail.json new file mode 100644 index 0000000..ec9a5c4 --- /dev/null +++ b/fixtures/inventory.response.fail.json @@ -0,0 +1,8 @@ +{ + "summary": "Requested allocation exceeds the bound allowed without recourse.", + "decision": "deny", + "action_type": "allocate_inventory", + "risk_level": "high", + "blocked": true, + "recourse_required": false +} diff --git a/fixtures/inventory.response.pass.json b/fixtures/inventory.response.pass.json new file mode 100644 index 0000000..0d76f2f --- /dev/null +++ b/fixtures/inventory.response.pass.json @@ -0,0 +1,8 @@ +{ + "summary": "Authorize a bounded synthetic inventory allocation with recourse reserved before execution.", + "decision": "accept", + "action_type": "allocate_inventory", + "risk_level": "moderate", + "blocked": false, + "recourse_required": true +} diff --git a/scripts/integration-check.mjs b/scripts/integration-check.mjs index 6f9a437..bdecc7b 100644 --- a/scripts/integration-check.mjs +++ b/scripts/integration-check.mjs @@ -269,6 +269,29 @@ async function main() { } } + const inventory = run(process.execPath, ["./bin/aas.mjs", "demo", "--domain", "inventory", "--fault", "duplicate", "--prove", "rail"]); + check(inventory.status === 0, `inventory demo failed: ${inventory.stderr.slice(-400)}`); + { + const { bundleDir, manifest } = latestBundle(); + check(manifest.stages.decide?.status === "passed", "inventory: decide did not pass"); + check(manifest.stages.act?.status === "passed", "inventory: act did not pass"); + check(manifest.stages.prove?.status === "passed", "inventory: prove did not pass"); + const report = readJson(join(bundleDir, "report.json")); + check(report.flow === "decide -> act -> prove", `inventory: unexpected flow ${report.flow}`); + check(report.stages.prove?.mode === "rail-review", "inventory: prove mode not recorded"); + const act = readJson(join(bundleDir, "stages", "act.json")); + check(act.outcome === "compensated", "inventory: act is not compensated"); + check( + report.stages.decide?.policy_id === "aas-inventory-gate-v1", + `inventory: unexpected policy ${report.stages.decide?.policy_id}`, + ); + const provenance = readJson(join(bundleDir, "manifest.json")); + check( + provenance.component_provenance.some((entry) => entry.name === "consequence-rail"), + "inventory: rail provenance missing", + ); + } + const cases = run(process.execPath, ["./bin/aas.mjs", "cases", "--json"]); check(cases.status === 0, `cases failed: ${cases.stderr.slice(-300)}`); if (cases.status === 0) { diff --git a/stack-lock.json b/stack-lock.json index bc4feac..6efa3d8 100644 --- a/stack-lock.json +++ b/stack-lock.json @@ -13,7 +13,7 @@ { "name": "consequence-rail", "repository": "https://github.com/EauDoon/consequence-rail.git", - "commit": "89811e423a1a41bad3ecb77e18ebf557615219f8", + "commit": "6c61e9fdcd1a4701afad1d2371abcb3f13bbab57", "expected_entrypoints": [ "package.json", "cmd/crctl.js" diff --git a/test/stack.test.mjs b/test/stack.test.mjs index fe9e366..c064f37 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -59,7 +59,7 @@ const PROVENANCE = [ { name: "consequence-rail", repository: "https://github.com/EauDoon/consequence-rail.git", - commit: "89811e423a1a41bad3ecb77e18ebf557615219f8", + commit: "6c61e9fdcd1a4701afad1d2371abcb3f13bbab57", origin: "https://github.com/EauDoon/consequence-rail.git", detached: true, clean: true, @@ -1531,3 +1531,58 @@ test("comparison never implies causation or equivalence of evidence", () => { const serialized = JSON.stringify(summary); assert.doesNotMatch(serialized, /rule_results|rail_bundle/); }); + +test("demo accepts a synthetic action domain and threads it through decide and act", async () => { + const outputRoot = tempRoot(); + const calls = []; + const result = await runDemo(["--domain", "inventory"], { + ...stubOptions(outputRoot, { runId: "inventory-run" }), + runDecideFn: async (responsePath, options) => { + calls.push(["decide", responsePath, options.domain]); + return { ok: true, raw: { passed: true, policy_id: "aas-inventory-gate-v1", rule_results: [] }, status: 0 }; + }, + runActFn: async (fault, options) => { + calls.push(["act", options.domain]); + return { ok: true, raw: { outcome: "settled", state: "CLOSED", fault: "none", action_id: "act_inv" }, status: 0 }; + }, + }); + assert.equal(result.exitCode, 0); + assert.equal(calls[0][2], "inventory"); + assert.match(String(calls[0][1]), /inventory\.response\.pass\.json$/); + assert.equal(calls[1][1], "inventory"); +}); + +test("demo rejects an unknown domain before running any stage", async () => { + const outputRoot = tempRoot(); + await assert.rejects( + () => runDemo(["--domain", "payments"], stubOptions(outputRoot)), + (error) => /--domain must be refund or inventory/.test(error.message), + ); + const rejected = await captureMain(["demo", "--domain", "payments"]); + assert.match(rejected.stderr, /--domain must be refund or inventory/); + assert.equal(rejected.exitCode, 2); +}); + +test("runAct targets the requested rail demo domain", () => { + const seen = []; + const runner = (bin, args) => { + seen.push(args); + return { status: 0, stdout: '{"outcome":"settled","state":"CLOSED","fault":"none","action_id":"a"}\n', stderr: "", error: null }; + }; + runAct("none", { depsDir: "deps", runner, domain: "inventory" }); + assert.equal(seen[0][1], "demo"); + assert.equal(seen[0][2], "inventory"); +}); + +test("runDecide uses the domain policy fixture", () => { + const seen = []; + const runner = (bin, args) => { + seen.push(args); + return { status: 0, stdout: '{"passed":true,"policy_id":"p","rule_results":[]}\n', stderr: "", error: null }; + }; + runDecide("unused", { depsDir: "deps", fixturesDir: "fixtures", runner, domain: "inventory" }); + assert.ok( + seen[0].some((arg) => String(arg).includes("inventory.policy.json")), + `policy fixture missing: ${seen[0].join(" ")}`, + ); +});