Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
33 changes: 27 additions & 6 deletions bin/aas.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 <run-id> [--out <path>]
aas replay <bundle-file|-> [--json]
aas cases [--json]
Expand All @@ -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

Expand Down Expand Up @@ -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",
);
Comment on lines +581 to +584

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Unknown domains select refund policy

Direct callers passing a misspelled domain to runDecide silently receive the refund policy. Every value except inventory follows the refund branch, allowing the wrong policy decision.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

const pythonPath = join(depsDir, "constitutional-agent-testbench", "src");
const decideCli = join(pythonPath, "constitutional_agent_testbench", "cli.py");
if (runner === runCapture && !existsSync(decideCli)) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Comment on lines +1470 to +1472

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Inventory unavailable in guided GUI

GUI requests never pass the new domain option to runDemo. The GUI request path neither exposes nor accepts it, so every GUI run remains a refund.

Prompt for agents
Wire the inventory domain through the guided GUI as well as the CLI. Add a refund/inventory selector in bin/aas-gui.mjs, include its value in the browser's /api/run query, allow and validate the domain query parameter in createGuiServer, and append --domain to the runDemo arguments. Add GUI server and page tests covering inventory selection and rejection of unknown values.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
40 changes: 40 additions & 0 deletions fixtures/inventory.policy.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
8 changes: 8 additions & 0 deletions fixtures/inventory.response.fail.json
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions fixtures/inventory.response.pass.json
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 23 additions & 0 deletions scripts/integration-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion stack-lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
57 changes: 56 additions & 1 deletion test/stack.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(" ")}`,
);
});
Loading