diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 7887ea5..bfafa37 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -26,7 +26,9 @@ What happens. Include error text, stack trace, or logs. ## Environment -Repo, commit or tag, OS, runtime version, relevant inputs. +Repo, commit or tag, OS, runtime version, relevant inputs. Include the +output of `node bin/aas.mjs --version`, and for a saved case the +`stack_version` in its `manifest.json`. ## Severity diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 4dbc067..8449011 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,11 +1,3 @@ ---- -name: Pull Request -about: Open a change for review -title: "" -labels: "" -assignees: "" ---- - ## Description What does this PR do and why? @@ -20,9 +12,9 @@ How did you verify this? Commands run, scenarios exercised, evidence captured. ## Checklist -- [ ] Branch name follows the repo convention -- [ ] No edits to main -- [ ] No edits to LICENSE, CODEOWNERS, CHANGELOG, SECURITY, CONTRIBUTING -- [ ] No edits to existing CI workflows -- [ ] Linked issue or rationale included -- [ ] Validation commands and outputs attached or referenced \ No newline at end of file +- [ ] Branch is named `imp/-` (see `CONTRIBUTING.md`) +- [ ] `npm run check` passes (unit suite, syntax gate, version consistency) +- [ ] `npm run integration` passes, if orchestration, bootstrap, or lock files changed +- [ ] `CHANGELOG.md` has a bullet under `## [Unreleased]` in the right category +- [ ] A `stack-lock.json` change cites the sibling PR and merge SHA and updates `docs/release-readiness.md` +- [ ] No private data, real credentials, or production endpoints diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..426fc6e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +# Dependabot only opens pull requests. Each one goes through the full CI +# matrix and is reviewed and merged by hand; nothing here merges or releases. +version: 2 +updates: + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + all: + patterns: ["*"] + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + all: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b40f27..c4bbb07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,13 @@ jobs: runs-on: ${{ matrix.os }} steps: - name: Check out - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Nothing here pushes. Keep the token out of .git/config while + # installed packages and pinned component code run in the workspace. + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node }} cache: npm @@ -34,11 +38,9 @@ jobs: - name: Run tests run: npm test - name: Check syntax - # Every shipped module except the test files, which `npm test` and - # `npm run test:browser` already parse by running them. A parse error - # in the integration proof or an example otherwise surfaces only in the - # integration and browser jobs, and only on one platform. - run: node --check bin/aas.mjs && node --check bin/aas-gui.mjs && node --check bin/aas-gui-worker.mjs && node --check bin/case-review.mjs && node --check scripts/bootstrap.mjs && node --check scripts/integration-check.mjs && node --check examples/connector-conformance.mjs && node --check examples/review-handoff.mjs && node --check playwright.config.mjs + run: npm run check:syntax + - name: Check version consistency + run: npm run check:version - name: Smoke test GUI run: npm run gui:smoke integration: @@ -48,19 +50,30 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] node: ["22.12.0", 24] + python: ["3.13"] + # The documented floor (MIN_PYTHON, README) on one leg. Overriding an + # original matrix value makes this a fifth job, not a change to one. + include: + - os: ubuntu-latest + node: "22.12.0" + python: "3.11" runs-on: ${{ matrix.os }} steps: - name: Check out - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Nothing here pushes. Keep the token out of .git/config while + # installed packages and pinned component code run in the workspace. + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node }} cache: npm - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - python-version: "3.13" + python-version: ${{ matrix.python }} - name: Run full-stack integration proof run: npm run integration --ignore-scripts - name: Run integrator example (pass path) @@ -82,19 +95,23 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Nothing here pushes. Keep the token out of .git/config while + # installed packages and pinned component code run in the workspace. + persist-credentials: false - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22.12.0" cache: npm - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" - name: Install locked package set run: npm ci --ignore-scripts - - name: Install the Chromium browser (bounded, cached) + - name: Install the Chromium browser run: npx playwright install --with-deps chromium - name: Bootstrap pinned components run: npm run bootstrap diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..01a1e8f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Release + +# Runs only when a vX.Y.Z tag is pushed. It re-verifies the tagged commit, +# checks the tag against package.json, package-lock.json, and CHANGELOG.md, +# and publishes that version's CHANGELOG section as a GitHub Release. Nothing +# is published to a package registry. +on: + push: + tags: ["v*.*.*"] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 25 + permissions: + # Only this job can write, and only to create the GitHub Release. + contents: write + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "22.12.0" + # A job with write permission restores no cache. + package-manager-cache: false + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: Install locked package set + run: npm ci --ignore-scripts + - name: Run unit, syntax, and version checks + run: npm run check + - name: Run full-stack integration proof + run: npm run integration --ignore-scripts + - name: Check the tag and extract its release notes + env: + TAG: ${{ github.ref_name }} + run: node scripts/check-version.mjs --tag "$TAG" --notes "$RUNNER_TEMP/release-notes.md" + - name: Publish the GitHub Release + env: + TAG: ${{ github.ref_name }} + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "Release $TAG already exists; leaving it unchanged." + else + gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag \ + --title "Agent Action Stack $TAG" --notes-file "$RUNNER_TEMP/release-notes.md" + fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db3447..b841e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,188 @@ project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.3.0] - 2026-10-09 + +The first tagged release since 0.2.0. `package.json` carried 0.2.1 to 0.2.5 +during this cycle without tags or changelog sections; those changes are +included here. + ### Added +- Since 0.2.0, before this changelog existed: same-case review + (`--prove rail`), which verifies the act stage's rail bundle with the rail's + own verifier and records a MandateBound review of the same bytes (#20); + portable `aas export` and offline `aas replay` (#27); `aas runs` and + `aas prune` (#32); `aas cases` and `aas compare` (#35); `aas inspect`, + `aas verify`, and `aas latest` for saved cases, and `--root` for an + alternate case store (#40, #41); the inventory allocation domain, + `--domain inventory` (#36); and Python 3.11+ interpreter selection with + `AAS_PYTHON` (#16). +- Since 0.2.0, the local GUI grew into a case workbench: same-case review, + imported-case replay, guided scenarios, paged and searchable case history, + comparison, and saved-case inspection, verification, and Markdown review + downloads (#26, #34, #35, #39, #40). The integrator examples + `review-handoff` (#28) and `connector-conformance` (#37) and the Playwright + real-browser tests (#34) arrived in the same period. +- `aas --version` (also `aas version`, with `--json`) prints the orchestrator + version from `package.json`. New runs record it as `stack_version` in + `report.json` and `manifest.json`; `aas cases --json`, `aas inspect`, and + the Markdown case review show it ("Orchestrator version"); and the GUI's + `GET /api/health` returns it as `version`. The field is additive within the + `agent-action-stack.run/v1` schema: cases saved before it was added load + and verify unchanged and report it as unavailable. It is informational and + is not one of the fields `aas compare` compares. +- `.github/workflows/release.yml` publishes a GitHub Release when a `vX.Y.Z` + tag is pushed. It re-runs `npm run check` and the integration proof on the + tagged commit, requires the tag to match `package.json`, the lock, and this + changelog, and uses the version's changelog section as the release notes. + Only that job can write, and only to create the release; `ci.yml` stays + read-only. `package.json` is now `"private": true`: the package works from a + checkout, not as an installed dependency (it clones into `deps/` beside + itself), so `npm publish` now refuses it. `npm link` and the `aas` bin are + unaffected. +- `npm run check:version` (`scripts/check-version.mjs`), part of + `npm run check` and CI's test job. It fails when `package-lock.json`, this + changelog, the `docs/release-readiness.md` pin list, or a `--tag` disagree + with the `package.json` version, and `--notes` extracts one release's + section. It found and this change repairs two drifts: the lock still said + 0.2.0 after five patch bumps, and `[Unreleased]` carried three separate + `### Changed` headings and a `compare/main...HEAD` link that compared + nothing. `CONTRIBUTING.md` now describes the versioning and changelog + rules. - `CONTRIBUTING.md` describing cross-repo coordination and the `stack-lock.json` policy. - `CHANGELOG.md` (this file). - Playwright setup note in the Quick start section of `README.md`. +### Changed +- BREAKING: requires Node.js 22.12.0 or newer (was 20 in 0.2.0); see #18. + Bootstrap, `aas demo`, `aas replay`, `aas verify`, and GUI runs refuse an + older runtime before any side effect. Python stays at 3.11 or newer. +- The three component pins were refreshed to reviewed revisions, with a + pinned legacy replay fixture (`fixtures/legacy-rail-review.json`) and a + component compatibility test in the integration proof (9449bd3, #87). The + current pins are in `stack-lock.json` and `docs/release-readiness.md`. +- The pins then moved to the component releases of 2026-10-09, each at its + release merge commit: Constitutional Agent Testbench 0.6.0 (`ed46f0c`), + Consequence Rail 0.3.0 (`78d6f8a`), and MandateBound 2.0.0 (`32d5256`). No + entrypoint, install, or build hook changed, and the orchestrator needed no + code change: MandateBound 2.0.0's breaking changes are in commands and APIs + the stack does not call, and its `simulate` and `review` behave as before. + Cases saved under the previous pins still replay and verify. The component + compatibility test now also replays + `fixtures/previous-pins-inventory-review.json`, an inventory case saved under + the previous pins, against the new Rail verification checks. +- `npm run check` now runs the unit suite and a syntax gate, + `npm run check:syntax`, which parses every module `git ls-files` reports + with `node --check`. CI's test job runs the same gate in place of a + hand-written list of nine files, which had already drifted once. A new + `.gitattributes` pins LF line endings, so a Windows checkout no longer + turns every tracked file into a CRLF modification. +- CI now tests the documented Python 3.11 floor: the integration matrix adds + a fifth leg on Ubuntu with Node.js 22.12.0 and Python 3.11, next to the four + Python 3.13 legs. The pinned workflow actions move to their current + releases (checkout v7.0.1, setup-node v7.0.0, setup-python v7.0.0), still + pinned by commit SHA, and `.github/dependabot.yml` proposes weekly grouped + updates for GitHub Actions and npm. A test fails if any workflow step uses + an action without a full commit SHA and version comment. The browser job's + install step no longer claims a cache it does not have. +- `aas help` now documents the whole CLI surface. The usage block listed + neither `aas runs` nor `aas prune` although both are dispatched, the command + list omitted `help`, and the option reference omitted nine accepted flags: + `--out`, `--overwrite`, `--before`, `--limit`, `--outcome`, `--search`, + `--keep`, `--dry-run`, and `--markdown`. +- `aas cases --search` no longer accepts a value that begins with `-`. + `aas cases --search --json` used to consume `--json` as the search term and + exit 0 with an unfiltered listing, so a mistyped or reordered flag silently + changed the result set. It now reports a missing value and exits 2, matching + every other value-taking option in the CLI. The `aas cases` usage message + also lists the `--domain`, `--outcome`, and `--search` filters it accepts. +- `loadComponentLock` now rejects an `install` or `build` value other than the + supported tokens (`npm-ci`, `npm-run-build`). Those fields are dispatched by + exact string match, so an unrecognised value was skipped silently and + `npm run bootstrap` still reported success for a component that had never + been installed or built. The shipped `stack-lock.json` is unchanged. +- `npm run gui:smoke` now requests `/api/health` and the workbench page over + loopback HTTP and asserts the response status, the `text/html` content type, + the content security policy header, and the presence of every embedded page + helper. It previously started the server, closed it, and asserted nothing, so + it passed even when the page renderer or a route was broken. The rendered + helper list is now a single `PAGE_HELPERS` constant shared by `renderPage` + and the smoke check. +- The connector conformance example now runs rules 2 to 6 on both synthetic + connectors and measures the effect itself: active refunds bound to the + action, and inventory on hand against the connector's baseline. It used to + touch the refund connector only for capability advertisement and checked + only call counts and equal return values, so it could not see a second + effect or an incomplete reversal. The refund remedy check covers the + duplicate fault (only the duplicate is voided, the primary stays active) + and a clean refund (`no_change`). +- Documentation now matches the code. `aas help` and the export and prune + usage errors list the `--root` option both commands accept, the `--root` + reference names every command that takes it, and the flow line says + `consequence-rail demo ` rather than always `refund`. + `docs/stack-lock.md` now says a lock mismatch stops the run before any + stage and writes no bundle; it claimed the mismatch was recorded in the + manifest. The examples README uses the direct `node` invocation CI uses and + says the conformance example drives rail modules in-process. The pull + request template drops issue-form front matter that rendered as text, and + its checklist now follows `CONTRIBUTING.md` instead of forbidding the + changelog and CI edits this repository requires. + ### Fixed +- `aas`, `aas-gui`, and `bootstrap` now run when launched through a link: an + npm bin symlink, `npm link`, a macOS /tmp checkout, or a Windows junction. + The entrypoint guard compared the realpathed module URL with the unresolved + `process.argv[1]`, so a linked launch exited 0 without doing anything, and + `npm run gui:smoke` through a link reported success without checking. The + six scripts with a `#!/usr/bin/env node` line are now tracked as + executable, so `./bin/aas.mjs` runs on a POSIX checkout. +- `aas demo` validates its options before it checks the runtime. With a broken + `AAS_PYTHON`, a usage error such as `aas demo --bogus` exited 1 with the + interpreter message, after up to seven interpreter probes, instead of the + documented exit 2. `parseDemoOptions` now runs first, and `runDemo` uses + the same parser, so the messages are unchanged. +- `aas export`, `verify`, `inspect`, and `latest` now report a missing saved + case as `Saved case not found: `, and a missing or unreadable file + inside a case by its case-relative name. They printed the raw `ENOENT` + error, including the absolute local path. The error code stays `ENOENT`, + so the GUI's 404 responses are unchanged. +- After an act-stage error, `report.json` now records prove as skipped with + reason `act_error`, matching `manifest.json`. The report kept its initial + `not_reached`, so the GUI summary and the saved-case review disagreed about + the same run. The GUI summary also labels the `act_failed` skip reason + ("act failed") instead of printing the raw token. +- The workbench now reports run progress and failures in a visible status line + under **Run stack** (`#run-status`, announced by screen readers): running, + run not started (including the busy 503 and the `npm run bootstrap` hint + when `deps/` is missing), request failed, finished with its flow, finished + with a stage failure, and finished without a bundle. Those messages used + to go only to the raw report inside a collapsed `
`, so a click on + **Run stack** before bootstrapping appeared to do nothing. The raw report + now holds only the run JSON. +- `npm run gui` now exits 1 with `Cannot listen on 127.0.0.1: + (EADDRINUSE). Set AAS_GUI_PORT to a free loopback port.` when the port is + taken. It crashed with an unhandled `'error'` event and a stack trace. +- `GET /api/history` now answers a refused case store (a `runs` path that is + not a regular directory) with HTTP 422, as `/api/compare` already did, and + a worker failure with HTTP 500 `History could not be loaded.` instead of + the generic `Request failed`. Its busy 503 now sends `Retry-After: 1`, like + the run and replay routes. +- `npm run bootstrap` now clones each missing component into a hidden staging + directory and moves it into `deps/` only after checkout. A failed + or interrupted first fetch used to leave a `.git` with an origin and no + `HEAD` in the final directory, and every later bootstrap refused it with no + way forward except deleting it by hand. A failure now removes the staging + directory and says nothing was left; the refusal for an older + half-initialized checkout names the directory to remove; a failed command + now includes the tool's own error text; and bootstrap errors print + `bootstrap failed: ` instead of a stack trace. +- The review-handoff example now gates refunds on the stack's own policy, + `fixtures/policy.json` (`aas-refund-gate-v1`, 6 rules), with the stack's + response fixtures. It used the testbench checkout's 5-rule example policy, + so step 1 did not demonstrate the gate that `aas demo` in step 6 applies. + The example now fails if step 1 or the orchestrated run reports a policy + other than the domain's gate. - Saved-case review now includes persisted stage stderr. `aas inspect` and the Markdown review omitted the diagnostic that the run report already stored, so a failed act looked like it had no child output. @@ -82,33 +257,51 @@ project adheres to [Semantic Versioning](https://semver.org/). and from `aas runs`/`aas cases`/`aas compare` summaries for any run whose proof failed. Only the raw child payload is stripped now. -### Changed -- `aas help` now documents the whole CLI surface. The usage block listed - neither `aas runs` nor `aas prune` although both are dispatched, the command - list omitted `help`, and the option reference omitted nine accepted flags: - `--out`, `--overwrite`, `--before`, `--limit`, `--outcome`, `--search`, - `--keep`, `--dry-run`, and `--markdown`. -- `aas cases --search` no longer accepts a value that begins with `-`. - `aas cases --search --json` used to consume `--json` as the search term and - exit 0 with an unfiltered listing, so a mistyped or reordered flag silently - changed the result set. It now reports a missing value and exits 2, matching - every other value-taking option in the CLI. The `aas cases` usage message - also lists the `--domain`, `--outcome`, and `--search` filters it accepts. +### Security +- Every CI checkout now sets `persist-credentials: false`. The integration and + browser jobs run `npm ci` and builds inside the pinned components, and the + job token used to stay in `.git/config` while that code ran. +- The workbench content security policy no longer allows `'unsafe-inline'` + script or style. The page's one script and one stylesheet are static per + process, so `script-src` and `style-src` now pin their SHA-256 hashes, and + the policy adds `object-src 'none'`. Imported and saved case data still + reaches the page only through `escapeHtml`; an injected inline script would + now also be refused by the browser. The script is hashed after CRLF is + normalized to LF, so a Windows checkout serves a matching hash, and + `npm run gui:smoke` checks the served policy against the page content. -### Changed -- `loadComponentLock` now rejects an `install` or `build` value other than the - supported tokens (`npm-ci`, `npm-run-build`). Those fields are dispatched by - exact string match, so an unrecognised value was skipped silently and - `npm run bootstrap` still reported success for a component that had never - been installed or built. The shipped `stack-lock.json` is unchanged. +## [0.2.0] - 2026-08-03 -### Changed -- `npm run gui:smoke` now requests `/api/health` and the workbench page over - loopback HTTP and asserts the response status, the `text/html` content type, - the content security policy header, and the presence of every embedded page - helper. It previously started the server, closed it, and asserted nothing, so - it passed even when the page renderer or a route was broken. The rendered - helper list is now a single `PAGE_HELPERS` constant shared by `renderPage` - and the smoke check. +Requires Node.js 20+ and Python 3.11+. + +### Added +- A loopback-only local GUI (`npm run gui`, `npm run gui:smoke`). It binds + to `127.0.0.1`, requires the exact loopback Host and same-origin + boundary, runs the same orchestrator, and downloads the selected run + bundle. +- `stack-lock.json`, which pins the three public components by commit. + `npm run bootstrap` checks out those exact commits and refuses a + pre-existing checkout that is substituted, not detached at the pin, or + dirty. +- Isolated run bundles: each run is written atomically under + `.out/runs/` with its manifest, report, and stage artifacts, and + `.out/latest.json` points at the newest complete bundle. +- GitHub Actions CI, the release readiness checklist + (`docs/release-readiness.md`), unit tests, and `package-lock.json`. + +## [0.1.0] - 2026-07-31 + +Requires Node.js 20+ and Python 3.11+. + +### Added +- The initial `aas demo` orchestrator: decide with Constitutional Agent + Testbench, act with the Consequence Rail synthetic refund demo, and prove + with a MandateBound simulation, with `--response`, `--fault`, + `--dispute`, and `--json`. +- A refund policy fixture with passing and failing responses, and a + bootstrap script that clones the three public components into `deps/`. -[Unreleased]: https://github.com/EauDoon/agent-action-stack/compare/main...HEAD \ No newline at end of file +[Unreleased]: https://github.com/EauDoon/agent-action-stack/compare/v0.3.0...HEAD +[0.3.0]: https://github.com/EauDoon/agent-action-stack/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/EauDoon/agent-action-stack/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/EauDoon/agent-action-stack/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 735a483..902dccf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,22 +30,60 @@ Do not bypass the lockfile. Editing `deps/` directly, swapping a remote URL, or relaxing the dirty-checkout rejection are all out of scope for normal contributions. +## Versioning and changelog + +`package.json` `version` is the single source of truth. `npm run +check:version` (part of `npm run check` and CI's test job) fails when +`package-lock.json`, `CHANGELOG.md`, `docs/release-readiness.md`, or a release +tag disagrees with it. + +- Feature and fix pull requests add one bullet under the matching category + of the existing `## [Unreleased]` section (Added, Changed, Deprecated, + Removed, Fixed, or Security; each at most once per section). They do not + bump the version. +- Prefix a breaking change with `BREAKING:`. The project is pre-1.0, so a + breaking change or a new feature takes a minor bump and a fix a patch. +- A release pull request runs `npm version X.Y.Z --no-git-tag-version + --ignore-scripts`, renames `[Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, opens + a new empty `## [Unreleased]` above it, and updates the link definitions at + the bottom of the file. After it merges, the merge commit is tagged `vX.Y.Z`. + +Cutting a release after the release pull request merges (with a merge +commit, so the reviewed commits stay on `main`): + +1. `git fetch origin && git checkout main && git pull --ff-only`. +2. `node scripts/check-version.mjs --tag vX.Y.Z` must print `consistent`. +3. `git tag -a vX.Y.Z -m "Agent Action Stack X.Y.Z"` and + `git push origin vX.Y.Z`. +4. The tag push runs `.github/workflows/release.yml`, which re-verifies the + tagged commit and publishes the GitHub Release from the CHANGELOG section. + Confirm with `gh run list -w release.yml -L 1` and `gh release view vX.Y.Z`. + +Never move or delete a pushed tag. If a release is wrong, fix it forward in +the next patch version. Nothing is published to npm. + ## Pull requests - One branch, one focused change. - Branch names: `imp/-`. - Ship via a pull request; do not push to `main` directly. +- Work through the checklist in `.github/PULL_REQUEST_TEMPLATE.md`, which + GitHub fills into every new pull request. - Keep the orchestrator thin. If a change adds real behavior, it usually belongs in one of the three sibling repos, not here. ## Local checks ```bash -npm test +npm run check npm run integration npm run example:review-handoff ``` +`npm run check` is the unit suite (`npm test`) followed by the syntax gate +(`npm run check:syntax`), which parses every tracked JavaScript module. +`.gitattributes` keeps every text file LF, including on Windows checkouts. + `npm run test:browser` requires Playwright Chromium: ```bash diff --git a/README.md b/README.md index 55b94b9..929a6cf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Agent Action Stack [![build](https://img.shields.io/github/actions/workflow/status/EauDoon/agent-action-stack/ci.yml?branch=main)](https://github.com/EauDoon/agent-action-stack/actions) +[![release](https://img.shields.io/github/v/release/EauDoon/agent-action-stack)](https://github.com/EauDoon/agent-action-stack/releases) [![license](https://img.shields.io/github/license/EauDoon/agent-action-stack)](https://github.com/EauDoon/agent-action-stack/blob/main/LICENSE) [![last commit](https://img.shields.io/github/last-commit/EauDoon/agent-action-stack)](https://github.com/EauDoon/agent-action-stack) @@ -44,6 +45,10 @@ npm run bootstrap node ./bin/aas.mjs demo --fault duplicate --prove rail ``` +`node ./bin/aas.mjs --version` prints the orchestrator version. Every run +records it as `stack_version` in its report and manifest, and +`GET /api/health` on the local GUI returns it as `version`. + This primary demonstration compensates a duplicate synthetic refund, verifies its rail receipt, and records a MandateBound review of those same bytes. Expect `act_outcome: compensated`, `prove_mode: rail-review`, and @@ -210,7 +215,13 @@ npm test npm run check ``` -`npm test` is the unit suite (orchestrator and GUI models). `npm run +`npm run check` mirrors the CI test job: the unit suite, the syntax gate, and +the version consistency check (`npm run check:version`). + +`npm test` is the unit suite (orchestrator and GUI models). `npm run check` +runs the unit suite and then the syntax gate, which parses every tracked +JavaScript module with `node --check`; the module list comes from +`git ls-files`, so a new script is covered without editing CI. `npm run integration` proves the pinned components from a clean checkout, and `npm run example:review-handoff` runs the integrator example. Installation, bootstrap, tests, and replay can write dependencies, caches, or diff --git a/SECURITY.md b/SECURITY.md index 51fae41..81d7846 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,6 +2,17 @@ This repository is a local reference demo. It clones three public libraries into `deps/` and runs synthetic scenarios only. +## Supported versions + +| Version | Supported | +| --- | --- | +| 0.3.x | Yes: the latest release only | +| Earlier than 0.3.0 | No: these versions receive no fixes | + +`node bin/aas.mjs --version` prints the version of a checkout, and each saved run records it as `stack_version`. + +## Reporting + Report security issues for: - this orchestrator: open a GitHub issue on this repository, or contact the maintainer through the GitHub profile @@ -9,4 +20,6 @@ Report security issues for: - [consequence-rail](https://github.com/EauDoon/consequence-rail/security) - [mandatebound](https://github.com/EauDoon/mandatebound/security) +Issues on this repository are public. Do not include exploit details, proof-of-concept code, or reproduction steps for an unfixed vulnerability in a public issue; open an issue asking for a private contact instead, and share the details once one is arranged. + Do not submit real credentials, personal data, production transaction evidence, or private repository contents in issues or fixtures. diff --git a/bin/aas-gui.mjs b/bin/aas-gui.mjs old mode 100644 new mode 100755 index 228a138..e60b980 --- a/bin/aas-gui.mjs +++ b/bin/aas-gui.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node /** Lightweight local GUI for the Agent Action Stack orchestrator. */ import { inspectCase, renderCaseMarkdown, renderComparisonMarkdown } from "./case-review.mjs"; +import { createHash } from "node:crypto"; import { createServer, request } from "node:http"; import { runGuiTask } from "./aas-gui-worker.mjs"; -import { pathToFileURL } from "node:url"; import { CHILD_JSON_LIMIT, DEFAULT_GUI_PORT, @@ -16,8 +16,9 @@ import { resolveGuiPort, runCapture, selectPython, + stackVersion, } from "./aas.mjs"; -import { assertFullStackNodeVersion } from "../scripts/bootstrap.mjs"; +import { assertFullStackNodeVersion, isEntrypoint } from "../scripts/bootstrap.mjs"; /** Bounded, strict JSON request body. The cap is enforced here regardless of any client-side check. */ async function readJsonRequest(request, { maxBytes = CHILD_JSON_LIMIT } = {}) { @@ -82,6 +83,7 @@ function stageHeadline(name, stage) { no_dispute: "settled and no dispute requested", not_reached: "not reached", act_error: "act errored", + act_failed: "act failed", }; if (!stage || typeof stage !== "object") return `${name}: unknown`; if (stage.status === "skipped") { @@ -312,57 +314,14 @@ export function scenarioPreset(name) { */ const PAGE_HELPERS = [stageDetailsModel, isReviewRunId, validatedRunSettings, scenarioPreset, filterHistory, validateRunBundle, escapeHtml, stageHeadline, summaryModel, bindingsModel, replayResultModel, compareModel, historyModel, renderCaseOptions, sha256HexText]; -export function renderPage() { - const embedded = PAGE_HELPERS.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.

-

Synthetic local demo only. No real account operations. The testbench response check gates the run; it is not a signed authorization over the rail proposal. Rail receipt verification and MandateBound recording remain separate authorities. Source truth is unknown; legal effect is not determined.

-
-

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

- - - - - -
-Download run bundle
-
-
-
Raw current run report
Ready.
-

Replay an imported case

-

Import an exported case to inspect and re-verify it. Verification only: no action runs and no remedy is attempted. The imported case is reported separately from any live run above.

- - -
-
-

Case history and comparison

-

Compare two persisted cases by identity, policy reference, component revisions, outcome, evidence digest, and review result. This view loads summaries only, never raw evidence, and never modifies or deletes a case.

- -

- - -

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

- - - -Download comparison review - - -Bookmark this local case -Download case review - -
-Download selected saved case -
-
-
-
-
`; +`; + +/** + * The page's only script: the embedded helpers, then the controller. Built + * once, with line endings normalized: fn.toString() returns CRLF source on a + * CRLF checkout, while the browser hashes the script after the HTML parser + * has turned CRLF into LF. + */ +export const PAGE_SCRIPT = normalizeNewlines(`\n${PAGE_HELPERS.map((fn) => fn.toString()).join("\n")}\n${PAGE_CONTROLLER}`); + +function cspHash(text) { + return `'sha256-${createHash("sha256").update(text, "utf8").digest("base64")}'`; +} + +/** + * The page content is static per process, so the policy pins the exact + * script and style by hash. Case data reaches the DOM only through + * escapeHtml, and no other inline script or style can run. + */ +export const PAGE_CSP = `default-src 'self'; script-src ${cspHash(PAGE_SCRIPT)}; style-src ${cspHash(PAGE_STYLE)}; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`; + +export function renderPage() { + return ` + +Agent Action Stack + +

Agent Action Stack

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

+

Synthetic local demo only. No real account operations. The testbench response check gates the run; it is not a signed authorization over the rail proposal. Rail receipt verification and MandateBound recording remain separate authorities. Source truth is unknown; legal effect is not determined.

+
+

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

+ + + + + +
+

+Download run bundle
+
+
+
Raw current run report
Ready.
+

Replay an imported case

+

Import an exported case to inspect and re-verify it. Verification only: no action runs and no remedy is attempted. The imported case is reported separately from any live run above.

+ + +
+
+

Case history and comparison

+

Compare two persisted cases by identity, policy reference, component revisions, outcome, evidence digest, and review result. This view loads summaries only, never raw evidence, and never modifies or deletes a case.

+ +

+ + +

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

+ + + +Download comparison review + + +Bookmark this local case +Download case review + +
+Download selected saved case +
+
+
+
+
`; } function savedPathId(pathname, prefix) { @@ -614,22 +650,30 @@ function requestBoundaryFailure(request) { if (hostHeaders.length !== 1 || request.headers.host !== expectedHost) return "host"; const origin = request.headers.origin; // Distinguish a missing-Origin POST (caller forgot to identify itself) from - // a wrong-Origin POST (caller is some other origin). The first is a 400 - // ("you forgot to send Origin"), the second is a 403 ("Origin does not - // match this server"). Both still return 403 today; the missing-Origin - // case is the surprising one for programmatic local clients. + // a wrong-Origin POST (caller is some other origin). The first answers 400 + // ("Origin header required for POST"), the second 403 ("Forbidden"). if (request.method === "POST" && origin === undefined) return "missing-origin"; if (request.method === "POST" && origin !== `http://${expectedHost}`) return "origin"; if (origin !== undefined && origin !== `http://${expectedHost}`) return "origin"; return null; } +const RUNS_DIRECTORY_REFUSAL = "Runs directory must be a regular directory."; + +/** 422 for a case store the orchestrator refuses to read; anything else is a 500. */ +function storeErrorStatus(error) { + return /Runs directory must be a regular directory/.test(error?.message ?? "") ? 422 : 500; +} + export function createGuiServer({ runDemoFn, outputRoot = DEFAULT_PATHS.outputRoot, runOptions = {}, replayRunner, depsDir, + // Injectable so tests can hold a history page open; production pages are + // read in the worker like every other case-store scan. + historyTask = (options) => runGuiTask({ operation: "history", options }), } = {}) { let activeWork = false; let historyInFlight = false; @@ -659,7 +703,7 @@ export function createGuiServer({ if (request.method === "GET" && url.pathname === "/") { response.writeHead(200, { "cache-control": "no-store", - "content-security-policy": "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + "content-security-policy": PAGE_CSP, "content-type": "text/html; charset=utf-8", "x-content-type-options": "nosniff", "x-frame-options": "DENY", @@ -668,7 +712,7 @@ export function createGuiServer({ return; } if (request.method === "GET" && url.pathname === "/api/health") { - sendJson(response, 200, { ok: true, stack: "agent-action-stack" }); + sendJson(response, 200, { ok: true, stack: "agent-action-stack", version: stackVersion() }); return; } if (request.method === "POST" && url.pathname === "/api/run") { @@ -796,10 +840,18 @@ export function createGuiServer({ || (url.searchParams.has("limit") && !/^(?:[1-9]|[1-4][0-9]|50)$/.test(url.searchParams.get("limit")))) { sendJson(response, 400, { error: "Invalid history page options." }); return; } - if (historyInFlight) { sendJson(response, 503, { error: "A history page is already loading." }); return; } + if (historyInFlight) { sendJson(response, 503, { error: "A history page is already loading." }, { "retry-after": "1" }); return; } historyInFlight = true; let page; - try { page = await runGuiTask({ operation: "history", options: { outputRoot, before: url.searchParams.get("before"), limit: Number(url.searchParams.get("limit") ?? 25) } }); } finally { historyInFlight = false; } + try { + page = await historyTask({ outputRoot, before: url.searchParams.get("before"), limit: Number(url.searchParams.get("limit") ?? 25) }); + } catch (error) { + const status = storeErrorStatus(error); + sendJson(response, status, { error: status === 422 ? RUNS_DIRECTORY_REFUSAL : "History could not be loaded." }); + return; + } finally { + historyInFlight = false; + } sendJson(response, 200, { ok: true, ...page }); return; } @@ -815,8 +867,8 @@ export function createGuiServer({ try { comparison = compareRuns(left, right, { outputRoot }); } catch (error) { - if (/regular directory/.test(error?.message ?? "")) { - sendJson(response, 422, { error: "Runs directory must be a regular directory." }); + if (storeErrorStatus(error) === 422) { + sendJson(response, 422, { error: RUNS_DIRECTORY_REFUSAL }); return; } throw error; @@ -860,7 +912,20 @@ export function createGuiServer({ export async function startGui({ port = DEFAULT_GUI_PORT, host = "127.0.0.1", ...options } = {}) { if (host !== "127.0.0.1") throw new TypeError("GUI host must be 127.0.0.1."); const server = createGuiServer(options); - await new Promise((resolve) => server.listen(port, host, resolve)); + await new Promise((resolve, reject) => { + // Without a listener a taken port is an unhandled 'error' event and a + // stack trace. The server never bound, so there is nothing to close. + const onError = (error) => { + reject(error?.code === "EADDRINUSE" || error?.code === "EACCES" + ? new Error(`Cannot listen on ${host}:${port} (${error.code}). Set AAS_GUI_PORT to a free loopback port.`) + : error); + }; + server.once("error", onError); + server.listen(port, host, () => { + server.off("error", onError); + resolve(); + }); + }); const address = server.address(); const actualPort = typeof address === "object" && address ? address.port : port; process.stdout.write(`Agent Action Stack GUI: http://${host}:${actualPort}\n`); @@ -895,12 +960,18 @@ async function main() { if (page.status !== 200 || !/text\/html/.test(page.headers["content-type"] ?? "")) { throw new Error(`GUI page request failed: ${page.status}`); } - if (!page.headers["content-security-policy"]) throw new Error("GUI page is missing its content security policy."); + const policy = page.headers["content-security-policy"]; + if (policy !== PAGE_CSP || policy.includes("unsafe-inline")) { + throw new Error("GUI page does not send the hash-pinned content security policy."); + } if (!page.body.includes("`) || !page.body.includes(``)) { + throw new Error("GUI page script or style differs from the hashed content."); + } for (const fn of PAGE_HELPERS) { - if (!page.body.includes(fn.toString())) throw new Error(`GUI page is missing embedded helper ${fn.name}.`); + if (!page.body.includes(normalizeNewlines(fn.toString()))) throw new Error(`GUI page is missing embedded helper ${fn.name}.`); } } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); @@ -911,7 +982,7 @@ async function main() { await startGui({ port: resolveGuiPort() }); } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +if (isEntrypoint(import.meta.url)) { main().catch((error) => { process.stderr.write(`${JSON.stringify({ error: { message: error.message } })}\n`); process.exitCode = 1; diff --git a/bin/aas.mjs b/bin/aas.mjs old mode 100644 new mode 100755 index cd8d86d..ac87aaa --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -25,12 +25,13 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import { isDeepStrictEqual } from "node:util"; import { assertDependencyDirectory, assertFullStackNodeVersion, inspectDependencyDirectory, + isEntrypoint, loadComponentLock, } from "../scripts/bootstrap.mjs"; @@ -109,6 +110,31 @@ export const DEFAULT_PATHS = Object.freeze({ lock: join(root, "stack-lock.json"), }); +let cachedStackVersion; + +/** + * The orchestrator version, read from this package's package.json, which is + * the single source of truth for it. Read lazily and cached: the integration + * proof's offline replay copies bin/aas.mjs without package.json, so an + * import-time read would break every command there. Returns null when the + * file is missing, unreadable, or not this package's manifest. + * + * @returns {string|null} + */ +export function stackVersion() { + if (cachedStackVersion === undefined) { + try { + const pkg = JSON.parse(readFileSync(join(DEFAULT_PATHS.root, "package.json"), "utf8")); + cachedStackVersion = pkg?.name === "agent-action-stack" && typeof pkg.version === "string" && pkg.version !== "" + ? pkg.version + : null; + } catch { + cachedStackVersion = null; + } + } + return cachedStackVersion; +} + const STAGE_NAMES = ["decide", "act", "prove"]; const DEMO_FLAG_OPTIONS = new Set(["--dispute", "--json"]); const DEMO_VALUE_OPTIONS = new Set(["--response", "--fault", "--prove", "--domain"]); @@ -242,7 +268,7 @@ export function helpText() { Usage: aas demo [--response pass|fail] [--fault none|duplicate] [--dispute] [--prove simulate|rail] [--domain refund|inventory] [--json] - aas export [--out ] [--overwrite] [--json] + aas export [--root output-dir] [--out ] [--overwrite] [--json] aas replay [--json] aas latest [--root output-dir] [--json] aas verify [--root output-dir] [--json] @@ -250,7 +276,8 @@ Usage: aas runs [--root output-dir] [--json] aas cases [--root output-dir] [--before run-id] [--limit 1..50] [--domain refund|inventory|unknown] [--outcome settled|compensated|none] [--search text] [--json] aas compare [--root output-dir] [--json|--markdown] - aas prune --keep [--dry-run] [--json] + aas prune --keep [--root output-dir] [--dry-run] [--json] + aas --version [--json] aas help Commands: @@ -263,6 +290,7 @@ Commands: cases List bounded case summaries (outcome, policy, review, digest) compare Compare two cases and classify identical, different, or not comparable prune Remove oldest runs beyond --keep (latest stays; --dry-run previews) + version Print the orchestrator version (also --version) help Show this help (also -h or --help) Options: @@ -272,7 +300,8 @@ Options: --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) - --root output-dir Select saved-case storage for inspect/runs/cases/compare/export/prune + --root output-dir Saved-case storage for latest, verify, inspect, runs, cases, + compare, export, and prune (default: .out) --out Write an export to this file (refuses to replace; see --overwrite) --overwrite Allow --out to replace an existing file --before run-id Start a cases page older than this run id @@ -284,11 +313,12 @@ Options: --dry-run prune: report what would be removed without deleting --markdown Print the readable Markdown form (compare, inspect) --json Print machine-readable JSON + --version Print the orchestrator version -h, --help Show this help Flow: decide -> constitutional-agent-testbench evaluate - on pass -> consequence-rail demo refund + on pass -> consequence-rail demo on dispute -> mandatebound simulate --scenario operator on dispute --prove rail -> rail bundle verify + mandatebound review @@ -1104,19 +1134,45 @@ export function readRunBundle(outputRoot, runId) { } } +/** + * Replace a filesystem error from a saved-case read with one that names the + * case instead of the absolute local path. The code is kept, so callers that + * map ENOENT to "not found" (the GUI's 404s) behave exactly as before. + */ +function caseReadError(error, runId, file) { + if (!error || typeof error.syscall !== "string") return error; + const message = error.code === "ENOENT" + ? (file === null ? `Saved case not found: ${runId}` : `Saved case ${runId} is missing ${file}.`) + : `Saved case ${runId} could not be read${file === null ? "" : ` (${file})`}: ${error.code ?? "filesystem error"}.`; + return Object.assign(new Error(message), { code: error.code }); +} + function readRunBundleBody(outputRoot, runId) { if (!isValidRunId(runId)) throw new Error("Invalid run id."); assertRunsDirectory(outputRoot); const bundleDir = join(outputRoot, "runs", runId); - const directory = lstatSync(bundleDir); + let directory; + try { + directory = lstatSync(bundleDir); + } catch (error) { + throw caseReadError(error, runId, null); + } if (!directory.isDirectory() || directory.isSymbolicLink()) throw new Error("Case directory must be a regular directory."); - const manifest = readBoundedCaseJson(join(bundleDir, "manifest.json")); + // `file` is the manifest-relative name, already restricted by safeBundleFile. + const readCaseFile = (path, file) => { + try { + return readBoundedCaseJson(path); + } catch (error) { + throw caseReadError(error, runId, file); + } + }; + const manifest = readCaseFile(join(bundleDir, "manifest.json"), "manifest.json"); validateSavedManifest(manifest, bundleDir); - const report = readBoundedCaseJson(safeBundleFile(bundleDir, manifest.report, "report path")); + const report = readCaseFile(safeBundleFile(bundleDir, manifest.report, "report path"), manifest.report); if (manifest.run_id !== runId || report?.run_id !== runId) throw new Error("Case identity does not match its directory."); const stages = {}; for (const [name, stage] of Object.entries(manifest.stages)) { - if (stage.artifact) stages[name] = readBoundedCaseJson(safeBundleFile(bundleDir, stage.artifact, "stage artifact path")); + if (stage.artifact) stages[name] = readCaseFile(safeBundleFile(bundleDir, stage.artifact, "stage artifact path"), stage.artifact); } const bundle = { manifest, report, stages }; if (Buffer.byteLength(JSON.stringify(bundle, null, 2) + "\n") > CHILD_JSON_LIMIT) throw new Error("Exported case exceeds the replay byte limit."); @@ -1287,6 +1343,8 @@ 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, + // Informational only, never compared: a release changes it on every case. + stack_version: typeof manifest.stack_version === "string" ? manifest.stack_version : null, domain: typeof report.domain === "string" ? report.domain : null, exit_code: manifest.exit_code ?? null, flow: typeof report.flow === "string" ? report.flow : null, @@ -1464,6 +1522,7 @@ export function persistRunBundle({ componentProvenance, exitCode, now = new Date().toISOString(), + orchestratorVersion = stackVersion(), }) { // The same rule as readers. The regex alone accepts ".", "..", and "...", // and `RegExp.test` stringifies non-strings, so ".." was a path escape. @@ -1497,6 +1556,8 @@ export function persistRunBundle({ run_id: runId, created_at: now, exit_code: exitCode, + // Additive within run/v1: readers treat a missing field as null. + stack_version: orchestratorVersion, component_provenance: componentProvenance, stages: stageManifest, report: "report.json", @@ -1558,21 +1619,15 @@ export function printHuman(report, bundleDir = null) { } /** - * Run decide → act → prove and persist an isolated bundle. + * Validate demo flags and their values without touching the environment. + * `main` calls this before probing Node.js and Python, so a usage error + * exits 2 even when the runtime is broken, and costs no interpreter spawns. * - * @param {string[]} [args] Demo flags: --response, --fault, --dispute, --json. - * @param {object} [options] - * @returns {Promise} + * @param {string[]} args + * @returns {{responseName: "pass"|"fail", fault: string, forceDispute: boolean, asJson: boolean, proveMode: string, domain: string}} */ -export async function runDemo(args = [], options = {}) { +export function parseDemoOptions(args) { validateDemoArgs(args); - const childTimeoutMs = options.childTimeoutMs ?? resolveChildTimeoutMs(); - const runner = options.runner ?? ((command, args, opts = {}) => - runCapture(command, args, { timeout: childTimeoutMs, ...opts })); - const paths = { - ...DEFAULT_PATHS, - ...(options.paths ?? {}), - }; const responseName = option(args, "--response", "pass"); if (responseName !== "pass" && responseName !== "fail") { throw new UsageError("--response must be pass or fail"); @@ -1591,6 +1646,25 @@ export async function runDemo(args = [], options = {}) { if (!DEMO_DOMAINS.has(domain)) { throw new UsageError("--domain must be refund or inventory"); } + return { responseName, fault, forceDispute, asJson, proveMode, domain }; +} + +/** + * Run decide → act → prove and persist an isolated bundle. + * + * @param {string[]} [args] Demo flags: --response, --fault, --dispute, --json. + * @param {object} [options] + * @returns {Promise} + */ +export async function runDemo(args = [], options = {}) { + const { responseName, fault, forceDispute, asJson, proveMode, domain } = parseDemoOptions(args); + const childTimeoutMs = options.childTimeoutMs ?? resolveChildTimeoutMs(); + const runner = options.runner ?? ((command, args, opts = {}) => + runCapture(command, args, { timeout: childTimeoutMs, ...opts })); + const paths = { + ...DEFAULT_PATHS, + ...(options.paths ?? {}), + }; const responseFile = domain === "inventory" ? `inventory.response.${responseName}.json` @@ -1609,6 +1683,7 @@ export async function runDemo(args = [], options = {}) { let exitCode = 0; const report = { stack: "agent-action-stack", + stack_version: stackVersion(), response: responseName, requested_options: { response: responseName, domain, fault, prove: proveMode, dispute: forceDispute }, domain, @@ -1633,6 +1708,7 @@ export async function runDemo(args = [], options = {}) { stages, componentProvenance, exitCode, + orchestratorVersion: report.stack_version, }); return { report, manifest: persisted.manifest, bundleDir: persisted.bundleDir, exitCode, asJson }; }; @@ -1755,7 +1831,12 @@ export async function runDemo(args = [], options = {}) { stages.prove = stageRecord("error", stageErrorFields(error)); report.stages.prove = stages.prove; } - if (actStarted && !proveStarted) stages.prove = stageRecord("skipped", { reason: "act_error" }); + if (actStarted && !proveStarted) { + // Record the same skip reason the manifest carries; the report used to + // keep its initial not_reached here. + stages.prove = stageRecord("skipped", { reason: "act_error" }); + report.stages.prove = stages.prove; + } if (proveStarted && stages.act.status === "pending") { stages.act = stageRecord("skipped", { reason: "prove_error" }); report.stages.act = stages.act; @@ -1928,10 +2009,10 @@ function runPruneCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot } } else if (token === "--dry-run") { dryRun = true; } else if (token !== "--json") { - throw new UsageError(`Unsupported prune option: ${token} (expected --keep [--dry-run] [--json])`); + throw new UsageError(`Unsupported prune option: ${token} (expected --keep [--root output-dir] [--dry-run] [--json])`); } } - if (keep === null) throw new UsageError("Usage: aas prune --keep [--dry-run] [--json]"); + if (keep === null) throw new UsageError("Usage: aas prune --keep [--root output-dir] [--dry-run] [--json]"); const result = pruneRuns({ keep, dryRun, outputRoot }); if (asJson) { process.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, 2)}\n`); @@ -1992,7 +2073,7 @@ function runExportCommand(args, { asJson, outputRoot = DEFAULT_PATHS.outputRoot runId = token; } } - if (runId === null) throw new UsageError("Usage: aas export [--out ] [--overwrite] [--json]"); + if (runId === null) throw new UsageError("Usage: aas export [--root output-dir] [--out ] [--overwrite] [--json]"); if (overwrite && out === null) throw new UsageError("--overwrite requires --out"); let bundle; try { @@ -2077,6 +2158,23 @@ export async function main(argv = process.argv.slice(2), options = {}) { process.exitCode = 0; return; } + if (command === "--version" || command === "version") { + try { + const rest = argv.slice(1); + if (rest.length > 1 || rest.some((token) => token !== "--json")) throw new UsageError("Usage: aas --version [--json]"); + const version = stackVersion(); + if (version === null) throw new Error("Orchestrator version unavailable: package.json is missing or unreadable."); + process.stdout.write(asJson + ? `${JSON.stringify({ ok: true, name: "agent-action-stack", version })}\n` + : `agent-action-stack ${version}\n`); + process.exitCode = 0; + } catch (error) { + const usage = error instanceof UsageError; + writeCliError(error, { asJson, usage }); + process.exitCode = usage ? 2 : 1; + } + return; + } if (command === "latest") { try { const { args, outputRoot } = parseRootArgs(argv.slice(1)); @@ -2161,6 +2259,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { return; } try { + parseDemoOptions(argv.slice(1)); assertFullStackNodeVersion( options.nodeVersion === undefined ? {} : { version: options.nodeVersion }, ); @@ -2176,7 +2275,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { } } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { +if (isEntrypoint(import.meta.url)) { main().catch((error) => { process.stderr.write(`${JSON.stringify({ error: { message: error.message } })}\n`); process.exitCode = 1; diff --git a/bin/case-review.mjs b/bin/case-review.mjs index 5111827..c36ecdb 100644 --- a/bin/case-review.mjs +++ b/bin/case-review.mjs @@ -63,7 +63,9 @@ export function inspectCase(runId, options = {}) { return { verification_readiness: readiness, schema_version: 'agent-action-stack.case-review/v1', run_id: runId, - created_at: bundle.manifest.created_at ?? null, domain: report.domain ?? null, + created_at: bundle.manifest.created_at ?? null, + stack_version: typeof bundle.manifest.stack_version === 'string' ? bundle.manifest.stack_version : null, + domain: report.domain ?? null, requested_options: report.requested_options ?? null, policy_failures: policyFailures, stages: ['decide','act','prove'].map(name => ({ name, status: bundle.manifest.stages[name]?.status ?? 'unknown', reason: bundle.manifest.stages[name]?.reason ?? null, code: bundle.manifest.stages[name]?.code ?? null, stderr: clipChildStderr(bundle.manifest.stages[name]?.stderr) || null, artifact_available: Object.hasOwn(bundle.stages,name) })), @@ -90,7 +92,8 @@ export function markdownText(value) { export function renderCaseMarkdown(review) { const lines = ['# Saved case review', '', 'Read-only summary of persisted synthetic evidence. This export performs no receipt verification.', '', - '- Run ID: '+markdownText(review.run_id), '- Created: '+markdownText(review.created_at), '- Domain: '+markdownText(review.domain), + '- Run ID: '+markdownText(review.run_id), '- Created: '+markdownText(review.created_at), + '- Orchestrator version: '+markdownText(review.stack_version), '- Domain: '+markdownText(review.domain), '- Policy: '+markdownText(review.policy_id), '- Action: '+markdownText(review.action_id), '- Outcome: '+markdownText(review.outcome), '- Recorded review verdict: '+markdownText(review.review_verdict), '', '## Stage record', '']; const stageHeading = lines.splice(-2); diff --git a/docs/architecture.md b/docs/architecture.md index 51e4f89..3b12bfb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -90,8 +90,10 @@ below 3.11 fails with an actionable message. Each invocation writes one atomic bundle under `.out/runs//`: -- `manifest.json`: stage status and component provenance -- `report.json`: user-facing run report +- `manifest.json`: stage status, component provenance, and the orchestrator + version that wrote it (`stack_version`, null in bundles that predate it) +- `report.json`: user-facing run report, which carries the same + `stack_version` - `stages/.json`: captured output from each stage that ran `.out/latest.json` is an atomic pointer to the most recent complete bundle. @@ -104,14 +106,26 @@ repository URL, commit, detached checkout flag, clean checkout flag, and expected entrypoints present. The orchestrator never re-verifies the components; the sibling CLIs and the rail's own verifier do that. -## Verify-only CI boundary - -GitHub Actions in `.github/workflows/ci.yml` runs three jobs and nothing -else: `test` (unit suite plus syntax check plus GUI smoke), `integration` -(clean checkout bootstrap plus the integrator examples), and `browser` -(Playwright real-browser workflow tests). CI has no publish, deploy, push, -or release step. It does not write to any registry, package index, or -hosted target. `contents: read` is the only permission requested. +## CI and release boundary + +`.github/workflows/ci.yml` stays verify-only. It runs on pushes to `main` and +on pull requests, requests only `contents: read`, and has three jobs: `test` +(unit suite, syntax gate, version consistency check, GUI smoke), `integration` +(clean checkout bootstrap plus the integrator examples, including one Python +3.11 leg), and `browser` (Playwright real-browser workflow tests). It has no +publish, deploy, push, or release step. + +`.github/workflows/release.yml` runs only when a `vX.Y.Z` tag is pushed. Its +single job re-runs `npm run check` and the integration proof on the tagged +commit, confirms with `scripts/check-version.mjs --tag` that the tag, +`package.json`, `package-lock.json`, and `CHANGELOG.md` agree, and creates a +GitHub Release whose notes are that version's CHANGELOG section. That job +alone holds `contents: write`, restores no cache, and keeps the checkout +token out of `.git/config`. It never publishes to a package registry, and +`package.json` is `"private": true`, so `npm publish` refuses the package. + +`.github/dependabot.yml` only opens pull requests for GitHub Actions and npm +updates; they pass through the same CI and are merged by hand. ## Local write targets diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 8fb0a50..866dd4f 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -13,9 +13,9 @@ Local checks may install dependencies and write caches or temporary files; see - [ ] Review the final raw tree and changed-file list. - [ ] Confirm `stack-lock.json` still contains the approved public URLs and commits: - - Constitutional Agent Testbench: `16b2faa71b0f92b9afa15b13afad8c48da8132f4` - - Consequence Rail: `c430383c0a0931f0dcf17845d6f0e8ccf328615a` ([PR #68](https://github.com/EauDoon/consequence-rail/pull/68)) - - MandateBound: `708256d4e48babeb13079fac7589d172920a5c95` ([PR #103](https://github.com/EauDoon/mandatebound/pull/103)) + - Constitutional Agent Testbench: `ed46f0cb9af400560aa9a9e73bbb9864ed5678a0` (release `v0.6.0`, [PR #63](https://github.com/EauDoon/constitutional-agent-testbench/pull/63)) + - Consequence Rail: `78d6f8a23dbce69bb443b453c8d471645f67daac` (release `v0.3.0`, [PR #69](https://github.com/EauDoon/consequence-rail/pull/69)) + - MandateBound: `32d525618e23678f86c754e4a536e4d094988a1b` (release `v2.0.0`, [PR #104](https://github.com/EauDoon/mandatebound/pull/104)) - [ ] Run bootstrap from a clean workspace and verify detached, clean, exact dependency checkouts. - [ ] Confirm no private repository, credential, or production endpoint is referenced. @@ -24,7 +24,9 @@ Local checks may install dependencies and write caches or temporary files; see - [ ] `npm ci --ignore-scripts` completes on Windows and Ubuntu. - [ ] `npm test` passes with lock mismatch, stale dependency, stage, child-process, and atomic-write coverage. - [ ] `npm run gui:smoke` passes. -- [ ] `npm run integration` passes on Ubuntu and Windows (Node.js 22.12.0 and 24, Python 3.13). +- [ ] `npm run check` passes (unit suite, syntax gate, version consistency), and + `node scripts/check-version.mjs --tag vX.Y.Z` reports the release tag consistent. +- [ ] `npm run integration` passes on Ubuntu and Windows (Node.js 22.12.0 and 24 with Python 3.13, plus Python 3.11 on Ubuntu with Node.js 22.12.0). - [ ] The public-copy scanner reports no punctuation or secret findings. - [ ] The final tree contains no placeholders or generated dependency directories. @@ -226,6 +228,57 @@ 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. +## Release 0.3.0 + +Version 0.3.0 (tag `v0.3.0`, created after the release pull request merges). +The release refreshes all three component pins to the merge commits of the +sibling releases published on 2026-10-09, the same pins as the source gate +above. Each release tag resolves to the pinned merge commit, and each merge +commit passed its own repository's CI on `main`: + +- Constitutional Agent Testbench 0.6.0: `ed46f0cb9af400560aa9a9e73bbb9864ed5678a0` + (was `16b2faa7`) +- Consequence Rail 0.3.0: `78d6f8a23dbce69bb443b453c8d471645f67daac` + (was `c430383c`) +- MandateBound 2.0.0: `32d525618e23678f86c754e4a536e4d094988a1b` + (was `708256d4`) + +No expected entrypoint, post-build entrypoint, install, or build hook changed, +and the orchestrator needed no code change for the new pins. The stack calls +only MandateBound `simulate --scenario operator` and `review --input`. The +2.0.0 breaking changes are in surfaces the stack does not use (the `decide`, +`preview`, `replay`, `appeal`, `serve`, `operator`, and `ap2-dispute` +commands, and the package API); the simulate result keeps its structure, and +review records still carry engine `1.0.0`. A same-case review recorded under +the previous pins replays under the new pins and the reverse, for compensated +and settled refunds; `fixtures/legacy-rail-review.json` and the new +`fixtures/previous-pins-inventory-review.json` (an inventory case saved under +the previous pins) still replay. The rail's new receipt outcome and close-time +bindings accept the stack's settled, compensated, and moving-clock bundles. +The testbench's `evaluate` contract and Python 3.11 floor are unchanged. + +Measured on the release branch head with the refreshed pins, Windows 11, +Node.js v24.18.0, Python 3.12.10 (and the decide stage on CPython 3.11.15 +through `AAS_PYTHON`): + +- `npm test`: 208 tests, 202 passed, 0 failed, 6 skipped (five symlink cases + where Windows refuses symlink creation, and the FIFO case on win32). +- `npm run check:syntax`: 15 modules parsed. `npm run check:version`: + 0.3.0 consistent, including `--tag v0.3.0`. +- `npm run gui:smoke`: passed. +- `npm run integration`: passed from a fresh clone with network clones of the + three pins (pass, refusal, dispute, settled-review, rail-review, and + inventory paths, export/replay, the GUI replay, and the offline replay). +- `test/component-compatibility.test.mjs`: 6 of 6. +- Integrator examples: review handoff pass, inventory, and refusal (exit 1) + paths; connector conformance 6 of 6 rules on both connectors. +- `npm run test:browser`: 14 of 14 real Chromium workflow tests (locally on + the installed Chromium headless shell build 1234 through `executablePath`; + the CI browser job installs the build Playwright 1.63.0 expects). + +CI on the pull request runs the same gates on Ubuntu and Windows with Node.js +22.12.0 and 24, plus the Python 3.11 integration leg and the browser job. + ## Publication boundary This document is a readiness checklist, not a publication approval. A separate diff --git a/docs/stack-lock.md b/docs/stack-lock.md index e2b5d60..e34dd47 100644 --- a/docs/stack-lock.md +++ b/docs/stack-lock.md @@ -31,8 +31,15 @@ and prepares each dependency under `deps/`: is a regular directory, detached (`HEAD` matches the pinned commit exactly), clean, and contains every expected entrypoint. Substituted or dirty pre-existing directories are rejected. -2. If absent, bootstrap clones the public URL at the pinned commit, checks - out detached, and verifies the entrypoints. +2. If absent, bootstrap clones the public URL at the pinned commit into a + hidden staging directory beside it (`deps/.tmp-` plus six characters, + shorter than any component name so Windows path limits are no tighter than + for the final checkout), checks out detached, and only then moves it to + `deps/` and verifies the entrypoints. A failed fetch removes the + staging directory, so a network error never leaves a half-initialized + repository that the next bootstrap would refuse. A process killed mid-clone + can leave only a hidden staging directory, which is safe to delete and does + not block the next run. 3. For components with an `install` hook, bootstrap runs the declared step (`npm-ci`, which runs `npm ci --ignore-scripts`, for MandateBound). 4. For components with a `build` hook, bootstrap runs the declared step @@ -41,8 +48,9 @@ and prepares each dependency under `deps/`: any other value rather than skipping the step, because a silently ignored hook would report a successful bootstrap for an unprepared component. The orchestrator records each component's provenance on every run bundle. A -run resolving components whose checkout disagrees with the lock surfaces the -disagreement in its manifest. +checkout that disagrees with the lock (origin, commit, detached state, local +changes, or entrypoints) stops the run before any stage and writes no bundle, +so recorded provenance always shows detached, clean, pinned checkouts. ## When to update @@ -56,6 +64,9 @@ the lock on its own. `build` only if the sibling's published contract actually changed. 4. Run `npm run integration` locally on Ubuntu and Windows. The CI integration job runs the same proof on every push and pull request. +5. Update the pin list in `docs/release-readiness.md` in the same pull + request. `npm run check:version` fails while that list is missing a + locked commit. Security fixes follow the same path. The schema version (`schema_version`) is bumped only when the shape changes in a way that requires loader changes; existing tools keep reading older versions until the bump lands @@ -67,14 +78,31 @@ round trips, both currency validation boundaries, and refusal of re-signed synthetic evidence whose currency contradicts the proposal. The historical `fixtures/legacy-rail-review.json` was exported using Rail `6c61e9f` and MandateBound `e526c4c`; it pins replay compatibility for an ordinary synthetic -refund. Its public demonstration signatures establish no real-world provenance. +refund. `fixtures/previous-pins-inventory-review.json` was saved by `main` at +`8179e59` using Rail `c430383` and MandateBound `708256d`, the pins before the +0.3.0 refresh; it pins replay compatibility for a compensated inventory case +already in a case store. Their public demonstration signatures establish no +real-world provenance. The current Rail pin also binds a receipt's close time to its terminal event, so a clock advancing between reads still produces evidence that survives the same-case handoff and replay. The MandateBound pin rejects weak Ed25519 keys in caller-pinned CasePack checkpoint trust snapshots using its existing strict key validator. Ordinary valid evidence retains the same format; neither update -rewrites old artifacts. The testbench runtime pin is unchanged. +rewrites old artifacts. + +Since 0.3.0 the pins are the component releases of 2026-10-09: Consequence Rail +0.3.0, MandateBound 2.0.0, and Constitutional Agent Testbench 0.6.0, each at +its release merge commit. Rail 0.3.0 also binds a receipt's outcome and +configured postcondition result to the terminal `CLOSED` event for every bundle +profile, and a receipt-profile `closed_at` to that event's time within 1000 ms, +so receipts from Rail 0.2.20 and earlier still verify. MandateBound 2.0.0 is a +major release for its CLI and package API, but the `simulate` and `review` +commands the stack runs, the review record, and engine `1.0.0` are unchanged, +so a same-case review recorded under either the previous or the current pins +replays under the other. Testbench 0.6.0 adds `--version`, command help, and +Apache-2.0 package metadata; `evaluate` and its Python 3.11 floor are +unchanged. No entrypoint or hook in the lock changed. An older artifact with numeric-looking object keys may contain signatures made with the former Rail canonical ordering. The current verifier does not try that diff --git a/examples/README.md b/examples/README.md index 846caba..2bd0a22 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,26 +1,37 @@ # Integrator examples -Runnable scripts that connect the pinned components through their supported -public interfaces. They transport bytes between component CLIs and check the +Runnable scripts that connect the pinned components. `review-handoff.mjs` +spawns the real component CLIs, transports bytes between them, and checks the bindings between outputs; policy semantics stay in the testbench, execution -semantics in the rail, and review semantics in MandateBound. Nothing is -mocked: every step spawns the real component CLI. +semantics in the rail, and review semantics in MandateBound. Nothing in it is +mocked. `connector-conformance.mjs` instead drives the rail's synthetic +connector modules in-process (`src/mock-refund-connector.js` and +`src/mock-inventory-connector.js` in the pinned checkout); those are rail +internals, not a supported public interface, and the example moves with the +rail pin. Prerequisites: Node.js 22.12+, Python 3.11+ on `PATH`, and a bootstrapped checkout (`npm run bootstrap`). ```bash npm run example:review-handoff -npm run example:review-handoff -- --domain inventory -npm run example:review-handoff -- --response fail # exits 1, refusing +node examples/review-handoff.mjs --domain inventory +node examples/review-handoff.mjs --response fail # exits 1, refusing npm run example:connector-conformance ``` +The options are passed to `node` directly: forwarding them through +`npm run ... -- --flag value` is unreliable on the Windows shell, which is +why CI invokes the script the same way. + ## review-handoff.mjs One journey across both synthetic domains (`--domain refund|inventory`): -1. policy evaluation with constitutional-agent-testbench +1. policy evaluation with constitutional-agent-testbench, against the same + gate `aas demo` uses: `fixtures/policy.json` (`aas-refund-gate-v1`) or + `fixtures/inventory.policy.json` (`aas-inventory-gate-v1`). The example + fails if step 1 or the orchestrated run in step 6 reports another policy. 2. execution with consequence-rail, which reserves recourse before the permit and persists the settlement bundle 3. evidence inspection: the observed facts and the receipt's digests @@ -46,12 +57,27 @@ protocol compliance, or that any real-world action is reversible or safe. ## connector-conformance.mjs -The connector contract, exercised against both real synthetic connectors: -capability advertisement, recourse reservation refusals (unknown capability, -undersized scope, cross-domain action), at-most-once execution per -idempotency key, reconciliation without re-execution, a remedy that reverses -only the effect bound to the action and only once, and remedy status -confirmation. +The connector contract, exercised rule by rule against both synthetic +connectors (refund and inventory), measuring the effect rather than only the +return values: + +1. capability advertisement: connector, actions, remedies, and custody +2. recourse reservation: a valid scope is accepted, and an unknown + capability, an undersized scope, and a cross-domain action are refused +3. at-most-once execution: two executes with one idempotency key leave + exactly one active refund, and take the allocated quantity off on-hand + stock once +4. reconciliation: `status` returns the recorded result without another + execute call or any change to the effect +5. remedy: the inventory remedy returns on-hand stock exactly to its + baseline; the refund remedy voids only the duplicate refund created by + the duplicate fault and keeps the primary, and a clean refund remediates + to `no_change`. Replaying the same remedy key returns the same result, + and a second key is refused with `RECOURSE_NOT_ACTIVE` +6. remedy status: the recorded remedy result is confirmed, and an unknown + key reports `unknown` + +Each rule prints `ok (refund, inventory)`; the first violation exits 1. It is a synthetic self-check of the shipped connectors. It does not certify any real connector, provider, or external effect. diff --git a/examples/connector-conformance.mjs b/examples/connector-conformance.mjs old mode 100644 new mode 100755 index 28afc7b..b0fa120 --- a/examples/connector-conformance.mjs +++ b/examples/connector-conformance.mjs @@ -8,8 +8,10 @@ * * A connector owns the external effect and its remedy. The rail supplies * ordering, recourse gating, evidence handling, and receipts. This script - * exercises the conformance rules with real calls and exits nonzero on the - * first violated rule. + * drives the rail's synthetic connector modules in-process and exits nonzero + * on the first violated rule. Every rule runs against both connectors and + * measures the effect itself: the inventory on hand for allocations, and the + * active refunds bound to the action for refunds. */ import { spawnSync } from "node:child_process"; import { dirname, join } from "node:path"; @@ -32,10 +34,12 @@ function fail(rule, reason) { process.exit(1); } +// Each call runs in a fresh process with fresh connectors, so one rule's +// effects never leak into the next rule's measurements. function run(statement, { args = [] } = {}) { const script = ` const { MockRefundConnector } = await import("./src/mock-refund-connector.js"); - const { MockInventoryConnector, measureMockInventoryRecoveryImplementation } = await import("./src/mock-inventory-connector.js"); + const { MockInventoryConnector } = await import("./src/mock-inventory-connector.js"); const { buildRefundProposal } = await import("./src/demo.js"); const { buildInventoryProposal } = await import("./src/inventory-demo.js"); const { ManualClock } = await import("./src/clock.js"); @@ -44,6 +48,27 @@ function run(statement, { args = [] } = {}) { const inventory = new MockInventoryConnector(clock); const refundProposal = buildRefundProposal(clock); const inventoryProposal = buildInventoryProposal(clock); + // Effect probes: what the connector actually changed. + const onHand = () => inventory.inventory.get(inventoryProposal.parameters.sku); + const baseline = inventory.onHandBaseline; + const quantity = inventoryProposal.parameters.quantity; + const boundRefunds = () => refund.refunds.filter((item) => item.execution_key === refundProposal.idempotency_key); + const activeRefunds = () => boundRefunds().filter((item) => item.status === "active").length; + const remedyRequest = { + action_digest: "sha256:x", kind: "reverse", remedy_window_seconds: 120, max_attempts: 1, idempotency_key: "remedy:x", + }; + const refundRequest = (overrides = {}) => ({ + ...remedyRequest, connector: "mock-refund-processor", capability: "void-duplicate-refund", + capability_reference: "demo-capability:void-duplicate-refund", expires_at: refundProposal.expires_at, + max_amount_minor: refundProposal.parameters.amount_minor, ...overrides, + }); + const inventoryRequest = (overrides = {}) => ({ + ...remedyRequest, connector: "mock-inventory-service", capability: "release-allocation", + capability_reference: "demo-capability:release-allocation", expires_at: inventoryProposal.expires_at, + max_quantity: quantity, ...overrides, + }); + const refusalCode = (call) => { try { call(); return null; } catch (error) { return error.code ?? "uncoded"; } }; + const asyncRefusalCode = async (call) => { try { await call(); return null; } catch (error) { return error.code ?? "uncoded"; } }; const out = (value) => process.stdout.write(JSON.stringify(value) + "\\n"); ${statement} `; @@ -59,109 +84,194 @@ function run(statement, { args = [] } = {}) { function check(rule, condition, reason) { if (condition !== true) fail(rule, reason); - process.stdout.write(`ok ${rule}\n`); +} + +function pass(rule) { + process.stdout.write(`ok ${rule} (refund, inventory)\n`); } function main() { - process.stdout.write(`connector conformance: ${RULES.length} rules\n`); + process.stdout.write(`connector conformance: ${RULES.length} rules, each on the refund and inventory connectors\n`); const capabilities = run(`out({ refund: refund.capabilities(), inventory: inventory.capabilities() });`); check(RULES[0], capabilities.refund.connector === "mock-refund-processor" && capabilities.refund.exclusive_credential_custody === true + && capabilities.refund.actions.includes("demo.refund.issue/v1") + && capabilities.refund.remedies.includes("void-duplicate-refund") && capabilities.inventory.connector === "mock-inventory-service" + && capabilities.inventory.exclusive_credential_custody === true + && capabilities.inventory.actions.includes("demo.inventory.allocate/v1") && capabilities.inventory.remedies.includes("release-allocation"), - "capabilities must name the connector, its remedies, and custody"); + `capabilities must name the connector, its actions and remedies, and custody (saw ${JSON.stringify(capabilities)})`); + pass(RULES[0]); const refusal = run(` - const base = { - action_digest: "sha256:x", kind: "reverse", - capability_reference: "demo-capability:release-allocation", - expires_at: inventoryProposal.expires_at, remedy_window_seconds: 120, - max_attempts: 1, idempotency_key: "remedy:x", - }; - const results = {}; - try { inventory.reserveRecourse(inventoryProposal, { ...base, connector: "mock-inventory-service", capability: "other", max_quantity: 4 }); results.unknownCapability = null; } - catch (error) { results.unknownCapability = error.code; } - try { inventory.reserveRecourse(inventoryProposal, { ...base, connector: "mock-inventory-service", capability: "release-allocation", max_quantity: 1 }); results.undersized = null; } - catch (error) { results.undersized = error.code; } - try { inventory.reserveRecourse({ ...inventoryProposal, action_type: "demo.refund.issue/v1" }, { ...base, connector: "mock-inventory-service", capability: "release-allocation", max_quantity: 4 }); results.crossDomain = null; } - catch (error) { results.crossDomain = error.code; } - out(results); + out({ + refund: { + // A positive control: the base request is valid, so each refusal + // below is caused by the one field it changes. + valid: refund.reserveRecourse(refundProposal, refundRequest()).status, + unknownCapability: refusalCode(() => refund.reserveRecourse(refundProposal, refundRequest({ capability: "other" }))), + undersized: refusalCode(() => refund.reserveRecourse(refundProposal, refundRequest({ max_amount_minor: refundProposal.parameters.amount_minor - 1 }))), + crossDomain: refusalCode(() => refund.reserveRecourse(inventoryProposal, refundRequest())), + }, + inventory: { + valid: inventory.reserveRecourse(inventoryProposal, inventoryRequest()).status, + unknownCapability: refusalCode(() => inventory.reserveRecourse(inventoryProposal, inventoryRequest({ capability: "other" }))), + undersized: refusalCode(() => inventory.reserveRecourse(inventoryProposal, inventoryRequest({ max_quantity: quantity - 1 }))), + crossDomain: refusalCode(() => inventory.reserveRecourse({ ...inventoryProposal, action_type: "demo.refund.issue/v1" }, inventoryRequest())), + }, + }); `); - check(RULES[1], - refusal.unknownCapability === "RECOURSE_UNAVAILABLE" - && refusal.undersized === "RECOURSE_SCOPE_INSUFFICIENT" - && refusal.crossDomain === "RECOURSE_UNAVAILABLE", - `reserveRecourse must refuse unsafe scopes (saw ${JSON.stringify(refusal)})`); + for (const name of ["refund", "inventory"]) { + const seen = refusal[name]; + check(RULES[1], + seen.valid === "active" + && seen.unknownCapability === "RECOURSE_UNAVAILABLE" + && seen.undersized === "RECOURSE_SCOPE_INSUFFICIENT" + && seen.crossDomain === "RECOURSE_UNAVAILABLE", + `the ${name} connector must accept a valid scope and refuse unsafe ones (saw ${JSON.stringify(seen)})`); + } + pass(RULES[1]); const execution = run(` - const first = await inventory.execute(inventoryProposal, inventoryProposal.idempotency_key); - const second = await inventory.execute(inventoryProposal, inventoryProposal.idempotency_key); + const refundFirst = await refund.execute(refundProposal, refundProposal.idempotency_key); + const refundSecond = await refund.execute(refundProposal, refundProposal.idempotency_key); + const inventoryFirst = await inventory.execute(inventoryProposal, inventoryProposal.idempotency_key); + const inventorySecond = await inventory.execute(inventoryProposal, inventoryProposal.idempotency_key); out({ - calls: inventory.executeCalls, - sameResult: JSON.stringify(first) === JSON.stringify(second), - status: first.status, - onHand: inventory.inventory.get("sku_demo_1"), + refund: { + calls: refund.executeCalls, status: refundFirst.status, + sameResult: JSON.stringify(refundFirst) === JSON.stringify(refundSecond), + active: activeRefunds(), created: refund.refunds.length, + }, + inventory: { + calls: inventory.executeCalls, status: inventoryFirst.status, + sameResult: JSON.stringify(inventoryFirst) === JSON.stringify(inventorySecond), + onHand: onHand(), expected: baseline - quantity, + }, }); `); check(RULES[2], - execution.calls === 2 && execution.sameResult === true && execution.status === "executed", - "repeat execute must return the recorded result without a second effect"); + execution.refund.calls === 2 && execution.refund.status === "executed" && execution.refund.sameResult === true + && execution.refund.active === 1 && execution.refund.created === 1, + `two executes with one key must create exactly one refund (saw ${JSON.stringify(execution.refund)})`); + check(RULES[2], + execution.inventory.calls === 2 && execution.inventory.status === "executed" && execution.inventory.sameResult === true + && execution.inventory.onHand === execution.inventory.expected, + `two executes with one key must allocate the quantity once (saw ${JSON.stringify(execution.inventory)})`); + pass(RULES[2]); const reconcile = run(` - await inventory.execute(inventoryProposal, inventoryProposal.idempotency_key); - const before = inventory.executeCalls; - const status = await inventory.status(inventoryProposal.idempotency_key); - out({ before, after: inventory.executeCalls, status: status.status }); - `); - check(RULES[3], - reconcile.before === reconcile.after && reconcile.status === "executed", - "status must report the recorded result without re-executing"); - - const remedy = run(` - const reservation = inventory.reserveRecourse(inventoryProposal, { - action_digest: "sha256:x", kind: "reverse", connector: "mock-inventory-service", - capability: "release-allocation", capability_reference: "demo-capability:release-allocation", - expires_at: inventoryProposal.expires_at, remedy_window_seconds: 120, max_attempts: 1, - max_quantity: inventoryProposal.parameters.quantity, idempotency_key: "remedy:x", + const refundResult = await refund.execute(refundProposal, refundProposal.idempotency_key); + const inventoryResult = await inventory.execute(inventoryProposal, inventoryProposal.idempotency_key); + const before = { refundCalls: refund.executeCalls, refunds: activeRefunds(), inventoryCalls: inventory.executeCalls, onHand: onHand() }; + const refundStatus = await refund.status(refundProposal.idempotency_key); + const inventoryStatus = await inventory.status(inventoryProposal.idempotency_key); + out({ + refund: { + unchanged: refund.executeCalls === before.refundCalls && activeRefunds() === before.refunds, + recorded: JSON.stringify(refundStatus) === JSON.stringify(refundResult), + unknown: (await refund.status("never-executed")).status, + }, + inventory: { + unchanged: inventory.executeCalls === before.inventoryCalls && onHand() === before.onHand, + recorded: JSON.stringify(inventoryStatus) === JSON.stringify(inventoryResult), + unknown: (await inventory.status("never-executed")).status, + }, }); + `); + for (const name of ["refund", "inventory"]) { + const seen = reconcile[name]; + check(RULES[3], + seen.unchanged === true && seen.recorded === true && seen.unknown === "unknown", + `status on the ${name} connector must report the recorded result without executing or changing the effect (saw ${JSON.stringify(seen)})`); + } + pass(RULES[3]); + + const inventoryRemedy = run(` + const reservation = inventory.reserveRecourse(inventoryProposal, inventoryRequest()); await inventory.execute(inventoryProposal, inventoryProposal.idempotency_key); + const afterExecute = onHand(); const first = await inventory.remediate(inventoryProposal, { connector_commitment: reservation }, "remedy:a"); - const onHandAfterFirst = inventory.inventory.get("sku_demo_1"); + const afterRemedy = onHand(); const replay = await inventory.remediate(inventoryProposal, { connector_commitment: reservation }, "remedy:a"); - let secondError = null; - try { await inventory.remediate(inventoryProposal, { connector_commitment: reservation }, "remedy:b"); } - catch (error) { secondError = error.code; } + const secondError = await asyncRefusalCode(() => inventory.remediate(inventoryProposal, { connector_commitment: reservation }, "remedy:b")); out({ + afterExecute, afterRemedy, baseline, quantity, final: onHand(), first: first.status, sameResult: JSON.stringify(first) === JSON.stringify(replay), - recourse: inventory.recourseStatus(reservation.reservation_token).status, - secondError, - restoredOnce: inventory.inventory.get("sku_demo_1") === onHandAfterFirst, - onHand: inventory.inventory.get("sku_demo_1"), + recourse: inventory.recourseStatus(reservation.reservation_token).status, secondError, }); `); check(RULES[4], - remedy.first === "remediated" && remedy.sameResult === true && remedy.recourse === "consumed" - && remedy.secondError === "RECOURSE_NOT_ACTIVE" && remedy.restoredOnce === true, - `the remedy must reverse once and refuse a second restoration (saw ${JSON.stringify(remedy)})`); + inventoryRemedy.afterExecute === inventoryRemedy.baseline - inventoryRemedy.quantity + && inventoryRemedy.first === "remediated" && inventoryRemedy.afterRemedy === inventoryRemedy.baseline + && inventoryRemedy.sameResult === true && inventoryRemedy.recourse === "consumed" + && inventoryRemedy.secondError === "RECOURSE_NOT_ACTIVE" && inventoryRemedy.final === inventoryRemedy.baseline, + `the inventory remedy must restore on-hand stock to the baseline exactly once (saw ${JSON.stringify(inventoryRemedy)})`); - const remedyStatus = run(` - const reservation = inventory.reserveRecourse(inventoryProposal, { - action_digest: "sha256:x", kind: "reverse", connector: "mock-inventory-service", - capability: "release-allocation", capability_reference: "demo-capability:release-allocation", - expires_at: inventoryProposal.expires_at, remedy_window_seconds: 120, max_attempts: 1, - max_quantity: inventoryProposal.parameters.quantity, idempotency_key: "remedy:x", + const refundRemedy = run(` + const reservation = refund.reserveRecourse(refundProposal, refundRequest()); + const remedy = { connector_commitment: reservation, capability: "void-duplicate-refund" }; + const executed = await refund.execute(refundProposal, refundProposal.idempotency_key, "duplicate"); + const afterExecute = activeRefunds(); + const first = await refund.remediate(refundProposal, remedy, "remedy:a"); + const replay = await refund.remediate(refundProposal, remedy, "remedy:a"); + const secondError = await asyncRefusalCode(() => refund.remediate(refundProposal, remedy, "remedy:b")); + const primary = refund.refunds.find((item) => item.refund_id === executed.external_id); + const voided = boundRefunds().filter((item) => item.status === "voided"); + out({ + afterExecute, first: first.status, active: activeRefunds(), + voided: voided.map((item) => item.synthetic_reference.split(":").at(-1)), + voidedIsReported: voided.length === 1 && voided[0].refund_id === first.external_id, + primary: primary?.status ?? null, + sameResult: JSON.stringify(first) === JSON.stringify(replay), + recourse: refund.recourseStatus(reservation.reservation_token).status, secondError, + }); + `); + check(RULES[4], + refundRemedy.afterExecute === 2 && refundRemedy.first === "remediated" && refundRemedy.active === 1 + && JSON.stringify(refundRemedy.voided) === JSON.stringify(["duplicate"]) && refundRemedy.voidedIsReported === true + && refundRemedy.primary === "active" && refundRemedy.sameResult === true && refundRemedy.recourse === "consumed" + && refundRemedy.secondError === "RECOURSE_NOT_ACTIVE", + `the refund remedy must void only the duplicate, once, and keep the primary (saw ${JSON.stringify(refundRemedy)})`); + + const cleanRefund = run(` + const reservation = refund.reserveRecourse(refundProposal, refundRequest()); + const executed = await refund.execute(refundProposal, refundProposal.idempotency_key); + const result = await refund.remediate(refundProposal, { connector_commitment: reservation, capability: "void-duplicate-refund" }, "remedy:a"); + out({ + result: result.status, active: activeRefunds(), + primary: refund.refunds.find((item) => item.refund_id === executed.external_id)?.status ?? null, + recourse: refund.recourseStatus(reservation.reservation_token).status, }); + `); + check(RULES[4], + cleanRefund.result === "no_change" && cleanRefund.active === 1 && cleanRefund.primary === "active" + && cleanRefund.recourse === "active", + `a refund without a duplicate must not be voided by the remedy (saw ${JSON.stringify(cleanRefund)})`); + pass(RULES[4]); + + const remedyStatus = run(` + const refundReservation = refund.reserveRecourse(refundProposal, refundRequest()); + await refund.execute(refundProposal, refundProposal.idempotency_key, "duplicate"); + await refund.remediate(refundProposal, { connector_commitment: refundReservation, capability: "void-duplicate-refund" }, "remedy:a"); + const inventoryReservation = inventory.reserveRecourse(inventoryProposal, inventoryRequest()); await inventory.execute(inventoryProposal, inventoryProposal.idempotency_key); - await inventory.remediate(inventoryProposal, { connector_commitment: reservation }, "remedy:a"); - const recorded = await inventory.remedyStatus("remedy:a"); - const unknown = await inventory.remedyStatus("remedy:missing"); - out({ recorded: recorded.status, unknown: unknown.status }); + await inventory.remediate(inventoryProposal, { connector_commitment: inventoryReservation }, "remedy:a"); + out({ + refund: { recorded: (await refund.remedyStatus("remedy:a")).status, unknown: (await refund.remedyStatus("remedy:missing")).status }, + inventory: { recorded: (await inventory.remedyStatus("remedy:a")).status, unknown: (await inventory.remedyStatus("remedy:missing")).status }, + }); `); - check(RULES[5], - remedyStatus.recorded === "remediated" && remedyStatus.unknown === "unknown", - `remedyStatus must confirm the recorded result (saw ${JSON.stringify(remedyStatus)})`); + for (const name of ["refund", "inventory"]) { + const seen = remedyStatus[name]; + check(RULES[5], + seen.recorded === "remediated" && seen.unknown === "unknown", + `remedyStatus on the ${name} connector must confirm the recorded result (saw ${JSON.stringify(seen)})`); + } + pass(RULES[5]); process.stdout.write("connector conformance: all rules hold for the synthetic connectors\n"); process.stdout.write("this conformance is a synthetic self-check: it does not certify any real " diff --git a/examples/review-handoff.mjs b/examples/review-handoff.mjs old mode 100644 new mode 100755 index 6874897..02fe0a8 --- a/examples/review-handoff.mjs +++ b/examples/review-handoff.mjs @@ -30,17 +30,21 @@ const testbenchDir = join(root, "deps", "constitutional-agent-testbench"); const railDir = join(root, "deps", "consequence-rail"); const mandateboundDir = join(root, "deps", "mandatebound"); +// The same gates `aas demo` applies (runDecide in bin/aas.mjs), so steps 1 +// and 6 evaluate one policy. `policyId` is checked against both outputs. const DOMAIN_FIXTURES = { refund: { - policy: "examples/policy.json", - pass: "examples/passing-response.json", - fail: "examples/failing-response.json", + policy: join(root, "fixtures", "policy.json"), + pass: join(root, "fixtures", "response.pass.json"), + fail: join(root, "fixtures", "response.fail.json"), + policyId: "aas-refund-gate-v1", action: "demo.refund.issue/v1", }, inventory: { policy: join(root, "fixtures", "inventory.policy.json"), pass: join(root, "fixtures", "inventory.response.pass.json"), fail: join(root, "fixtures", "inventory.response.fail.json"), + policyId: "aas-inventory-gate-v1", action: "demo.inventory.allocate/v1", }, }; @@ -116,6 +120,9 @@ function main() { ], { cwd: testbenchDir, env: { ...process.env, PYTHONPATH: join(testbenchDir, "src"), PYTHONUTF8: "1" } }); if (decided.status !== 0) fail(`decide exited ${decided.status}: ${decided.stderr.slice(-300)}`); const evaluation = readJson("decide", decided); + if (evaluation.policy_id !== fixture.policyId) { + fail(`decide evaluated policy ${evaluation.policy_id}, not the stack's ${domain} gate ${fixture.policyId}`); + } if (evaluation.passed !== true) { note("decide: policy refused the response; act and prove are skipped (fail-closed)"); throw new HandoffExit(1); @@ -201,8 +208,12 @@ function main() { env: { ...process.env, PYTHONPATH: join(testbenchDir, "src"), PYTHONUTF8: "1" }, }); if (orchestrated.status !== 0) fail(`orchestrated demo exited ${orchestrated.status}: ${orchestrated.stderr.slice(-300)}`); - const runId = readJson("orchestrated demo", orchestrated).run_id; + const orchestratedReport = readJson("orchestrated demo", orchestrated); + const runId = orchestratedReport.run_id; if (typeof runId !== "string") fail("orchestrated demo did not report a run id"); + if (orchestratedReport.stages?.decide?.policy_id !== fixture.policyId) { + fail(`orchestrated demo gated on ${orchestratedReport.stages?.decide?.policy_id}, not ${fixture.policyId} as in step 1`); + } const casePath = join(scratch, "case.json"); const exported = run(process.execPath, [join(root, "bin", "aas.mjs"), "export", runId, "--out", casePath], { cwd: root }); if (exported.status !== 0) fail(`export exited ${exported.status}: ${exported.stderr.slice(-300)}`); diff --git a/fixtures/previous-pins-inventory-review.json b/fixtures/previous-pins-inventory-review.json new file mode 100644 index 0000000..123e4be --- /dev/null +++ b/fixtures/previous-pins-inventory-review.json @@ -0,0 +1,903 @@ +{ + "manifest": { + "schema_version": "agent-action-stack.run/v1", + "run_id": "2026-10-09T164607285Z-68259061-687", + "created_at": "2026-10-09T16:46:07.851Z", + "exit_code": 0, + "component_provenance": [ + { + "name": "constitutional-agent-testbench", + "repository": "https://github.com/EauDoon/constitutional-agent-testbench.git", + "commit": "16b2faa71b0f92b9afa15b13afad8c48da8132f4", + "origin": "https://github.com/EauDoon/constitutional-agent-testbench.git", + "detached": true, + "clean": true, + "entrypoints": [ + "pyproject.toml", + "src/constitutional_agent_testbench/cli.py" + ] + }, + { + "name": "consequence-rail", + "repository": "https://github.com/EauDoon/consequence-rail.git", + "commit": "c430383c0a0931f0dcf17845d6f0e8ccf328615a", + "origin": "https://github.com/EauDoon/consequence-rail.git", + "detached": true, + "clean": true, + "entrypoints": [ + "package.json", + "cmd/crctl.js" + ] + }, + { + "name": "mandatebound", + "repository": "https://github.com/EauDoon/mandatebound.git", + "commit": "708256d4e48babeb13079fac7589d172920a5c95", + "origin": "https://github.com/EauDoon/mandatebound.git", + "detached": true, + "clean": true, + "entrypoints": [ + "package.json", + "package-lock.json", + "src/cli.ts", + "dist/cli.js" + ] + } + ], + "stages": { + "decide": { + "status": "passed", + "reason": null, + "code": null, + "stderr": null, + "artifact": "stages/decide.json" + }, + "act": { + "status": "passed", + "reason": null, + "code": null, + "stderr": null, + "artifact": "stages/act.json" + }, + "prove": { + "status": "passed", + "reason": null, + "code": null, + "stderr": null, + "artifact": "stages/prove.json" + } + }, + "report": "report.json" + }, + "report": { + "stack": "agent-action-stack", + "response": "pass", + "requested_options": { + "response": "pass", + "domain": "inventory", + "fault": "duplicate", + "prove": "rail", + "dispute": false + }, + "domain": "inventory", + "flow": "decide -> act -> prove", + "run_id": "2026-10-09T164607285Z-68259061-687", + "component_provenance": [ + { + "name": "constitutional-agent-testbench", + "repository": "https://github.com/EauDoon/constitutional-agent-testbench.git", + "commit": "16b2faa71b0f92b9afa15b13afad8c48da8132f4", + "origin": "https://github.com/EauDoon/constitutional-agent-testbench.git", + "detached": true, + "clean": true, + "entrypoints": [ + "pyproject.toml", + "src/constitutional_agent_testbench/cli.py" + ] + }, + { + "name": "consequence-rail", + "repository": "https://github.com/EauDoon/consequence-rail.git", + "commit": "c430383c0a0931f0dcf17845d6f0e8ccf328615a", + "origin": "https://github.com/EauDoon/consequence-rail.git", + "detached": true, + "clean": true, + "entrypoints": [ + "package.json", + "cmd/crctl.js" + ] + }, + { + "name": "mandatebound", + "repository": "https://github.com/EauDoon/mandatebound.git", + "commit": "708256d4e48babeb13079fac7589d172920a5c95", + "origin": "https://github.com/EauDoon/mandatebound.git", + "detached": true, + "clean": true, + "entrypoints": [ + "package.json", + "package-lock.json", + "src/cli.ts", + "dist/cli.js" + ] + } + ], + "stages": { + "decide": { + "status": "passed", + "passed": true, + "policy_id": "aas-inventory-gate-v1", + "rule_results": [ + { + "kind": "required_field", + "passed": true, + "path": "summary", + "reason_code": "RULE_SATISFIED", + "rule_id": "summary-present" + }, + { + "kind": "equals", + "passed": true, + "path": "decision", + "reason_code": "RULE_SATISFIED", + "rule_id": "decision-accept" + }, + { + "kind": "one_of", + "passed": true, + "path": "action_type", + "reason_code": "RULE_SATISFIED", + "rule_id": "action-is-allocation" + }, + { + "kind": "one_of", + "passed": true, + "path": "risk_level", + "reason_code": "RULE_SATISFIED", + "rule_id": "risk-allowed" + }, + { + "kind": "false", + "passed": true, + "path": "blocked", + "reason_code": "RULE_SATISFIED", + "rule_id": "blocked-is-false" + }, + { + "kind": "equals", + "passed": true, + "path": "recourse_required", + "reason_code": "RULE_SATISFIED", + "rule_id": "recourse-required" + } + ], + "error": null + }, + "act": { + "status": "passed", + "outcome": "compensated", + "state": "CLOSED", + "fault": "duplicate", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "assurance_mode": "enforced", + "bundle_verification": "pass" + }, + "prove": { + "status": "passed", + "mode": "rail-review", + "scenario": null, + "triggered_by": "act_outcome=compensated", + "ok": true, + "result_keys": [ + "format", + "reviewId", + "source", + "actionId", + "evidenceDigest", + "receipt", + "upstream", + "verdict", + "legalEffect", + "reviewDigest" + ], + "review_verdict": "recorded", + "review_id": "review-26ad562306e55b80", + "review_action_id": "act_cCXQJLbHd2WSZuc7htHV" + } + } + }, + "stages": { + "decide": { + "passed": true, + "policy_id": "aas-inventory-gate-v1", + "rule_results": [ + { + "kind": "required_field", + "passed": true, + "path": "summary", + "reason_code": "RULE_SATISFIED", + "rule_id": "summary-present" + }, + { + "kind": "equals", + "passed": true, + "path": "decision", + "reason_code": "RULE_SATISFIED", + "rule_id": "decision-accept" + }, + { + "kind": "one_of", + "passed": true, + "path": "action_type", + "reason_code": "RULE_SATISFIED", + "rule_id": "action-is-allocation" + }, + { + "kind": "one_of", + "passed": true, + "path": "risk_level", + "reason_code": "RULE_SATISFIED", + "rule_id": "risk-allowed" + }, + { + "kind": "false", + "passed": true, + "path": "blocked", + "reason_code": "RULE_SATISFIED", + "rule_id": "blocked-is-false" + }, + { + "kind": "equals", + "passed": true, + "path": "recourse_required", + "reason_code": "RULE_SATISFIED", + "rule_id": "recourse-required" + } + ] + }, + "act": { + "scenario": "synthetic-inventory-allocation", + "fault": "duplicate", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "state": "CLOSED", + "outcome": "compensated", + "assurance_mode": "enforced", + "bypass_possible": false, + "execute_calls": 1, + "status_calls": 0, + "recourse_reservation_calls": 1, + "recourse_status_calls": 4, + "remedy_calls": 1, + "remedy_status_calls": 0, + "active_allocations": 1, + "allocated_quantity": 4, + "inventory_on_hand": 21, + "bundle_verification": "pass", + "rail_bundle": { + "schema_version": "consequence-rail/settlement-bundle/v0.1", + "profile": "audit", + "action": { + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "action_digest": "cCXQJLbHd2WSZuc7htHV-TJKeCNszXIL5ppKZf9x6ag", + "action_type": "demo.inventory.allocate/v1", + "resource_id_digest": "yVWN8lhWKXC0jsaa3IHbVUgpV3Et_h59j4myGJ5qWTI", + "proposal": { + "schema_version": "consequence-rail/action-proposal/v0.1", + "action_type": "demo.inventory.allocate/v1", + "subject": { + "type": "service", + "id": "allocation-agent-demo" + }, + "target": { + "connector": "mock-inventory-service", + "resource_type": "order", + "resource_id": "ord_inventory_7" + }, + "parameters": { + "sku": "sku_demo_1", + "quantity": 4 + }, + "idempotency_key": "allocate:ord_inventory_7:1", + "requested_at": "2035-01-01T00:00:00.000Z", + "expires_at": "2035-01-01T00:02:00.000Z", + "assurance_mode": "enforced", + "postcondition": { + "op": "all", + "clauses": [ + { + "path": "allocation_count", + "op": "eq", + "value": 1 + }, + { + "path": "allocated_quantity", + "op": "eq", + "value": 4 + }, + { + "path": "inventory_on_hand", + "op": "gte", + "value": 0 + } + ] + }, + "evidence_plan": { + "source": "mock-inventory-service", + "max_age_seconds": 60 + } + } + }, + "recourse_reservation": { + "action_digest": "cCXQJLbHd2WSZuc7htHV-TJKeCNszXIL5ppKZf9x6ag", + "kind": "reverse", + "connector": "mock-inventory-service", + "capability": "release-allocation", + "capability_reference_digest": "QDJ1_r737ylaZl9B_wpybv0iBRefrzem8viMcfL31g8", + "expires_at": "2035-01-01T00:07:00.000Z", + "remedy_window_seconds": 120, + "max_attempts": 1, + "max_quantity": 4, + "idempotency_key_digest": "9DFbfgQ6tUK7CneLbAoI5Las3Y0WA3y0DPG_t8QXlHA", + "connector_commitment": { + "schema_version": "consequence-rail/connector-recourse-commitment/v0.1", + "reservation_token": "rsv_rTTWtJ0jZbIA-fw1bgxHTYxo", + "action_digest": "cCXQJLbHd2WSZuc7htHV-TJKeCNszXIL5ppKZf9x6ag", + "connector": "mock-inventory-service", + "capability": "release-allocation", + "kind": "reverse", + "expires_at": "2035-01-01T00:07:00.000Z", + "max_attempts": 1, + "max_quantity": 4, + "reserved_at": "2035-01-01T00:00:00.000Z", + "status": "active", + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-connector-ed25519-VcGmBF_daVE14fDB", + "value": "7_ZXfj8cyBTeN0ZRKuGMzYqf5Y72uNzkannymrYCgPCmmc7pvzNNTsGlQDU7rrcJEH9HZrOUyZ_VZuS27mkXDA" + } + }, + "schema_version": "consequence-rail/recourse-reservation/v0.1", + "reservation_id": "rr_--ffQe_DOGKujeJ13EjE", + "reserved_at": "2035-01-01T00:00:00.000Z", + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "Stc5Ez6IOf3Q5bJxb95BXGfnFgis4J-BS0F7-yqOcDKqyOsf40XRgS8RMcmx6b93EQRDfPMvOeKExkTGY500DA" + } + }, + "action_permit": { + "schema_version": "consequence-rail/action-permit/v0.1", + "permit_id": "permit_cCXQJLbHd2WSZuc7htHV", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "action_digest": "cCXQJLbHd2WSZuc7htHV-TJKeCNszXIL5ppKZf9x6ag", + "recourse_reservation_digest": "iNfN2QlsUzS4XaaksV4wGprizdI56xTspz-Y7orfAm0", + "assurance_mode": "enforced", + "bypass_possible": false, + "gated": true, + "jti": "jti_Vse_wEThpzvYFIAEZ1G6", + "issued_at": "2035-01-01T00:00:00.000Z", + "expires_at": "2035-01-01T00:02:00.000Z", + "max_uses": 1, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "KbRfhrTQ9WsK4Ai-m6k1aJQiRbzsPHXPxCETe622dcHREhH3q6jnifFsf2CVsmFXN67B-rFXvm-EA_EEtUKwBw" + } + }, + "evidence_manifest": [ + "tUpVnSAMboHG0SgTLNfGs2xFZ-_sfoXPmmSPSskdTpM", + "K-MirEhszUpzrY7wonhzIj8F3sY1Do0oU3pLgjvOT-A" + ], + "outcome_evidence": [ + { + "schema_version": "consequence-rail/outcome-evidence/v0.1", + "action_digest": "cCXQJLbHd2WSZuc7htHV-TJKeCNszXIL5ppKZf9x6ag", + "source": "mock-inventory-service", + "resource": { + "type": "order", + "id": "ord_inventory_7" + }, + "observed_at": "2035-01-01T00:00:00.000Z", + "facts": { + "allocation_count": 2, + "allocated_quantity": 8, + "inventory_on_hand": 17, + "sku": "sku_demo_1" + }, + "evaluation": { + "satisfied": false, + "evaluations": [ + { + "path": "allocation_count", + "operator": "eq", + "expected": 1, + "actual": 2, + "satisfied": false + }, + { + "path": "allocated_quantity", + "operator": "eq", + "expected": 4, + "actual": 8, + "satisfied": false + }, + { + "path": "inventory_on_hand", + "operator": "gte", + "expected": 0, + "actual": 17, + "satisfied": true + } + ] + }, + "captured_by": "consequence-rail", + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "6fhbY0C9fAU8WwzQzNiy_2fM26AqdwNa35Kz4lqk-zHFcT7MtXKCrz205mZ_OTUDbzJ9ax-9eojyrJONG_JtDQ" + } + }, + { + "schema_version": "consequence-rail/outcome-evidence/v0.1", + "action_digest": "cCXQJLbHd2WSZuc7htHV-TJKeCNszXIL5ppKZf9x6ag", + "source": "mock-inventory-service", + "resource": { + "type": "order", + "id": "ord_inventory_7" + }, + "observed_at": "2035-01-01T00:00:00.000Z", + "facts": { + "allocation_count": 1, + "allocated_quantity": 4, + "inventory_on_hand": 21, + "sku": "sku_demo_1" + }, + "evaluation": { + "satisfied": true, + "evaluations": [ + { + "path": "allocation_count", + "operator": "eq", + "expected": 1, + "actual": 1, + "satisfied": true + }, + { + "path": "allocated_quantity", + "operator": "eq", + "expected": 4, + "actual": 4, + "satisfied": true + }, + { + "path": "inventory_on_hand", + "operator": "gte", + "expected": 0, + "actual": 21, + "satisfied": true + } + ] + }, + "captured_by": "consequence-rail", + "phase": "post-remedy", + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "mK1WnUyvt_aT4JjcTi9Gz0Ib0Uk4JP-EmA1TvsifNUx1cxvul2rFkDOFvtd1Dz7Bny0KfLkmIRZfZNidmSmqCg" + } + } + ], + "settlement_receipt": { + "schema_version": "consequence-rail/settlement-receipt/v0.1", + "receipt_id": "receipt_vljZ41Zon-V_fm7Sq1HT", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "action_digest": "cCXQJLbHd2WSZuc7htHV-TJKeCNszXIL5ppKZf9x6ag", + "recourse_reservation_digest": "iNfN2QlsUzS4XaaksV4wGprizdI56xTspz-Y7orfAm0", + "connector_recourse_commitment_digest": "m-u35qlLrk1Hrw7bocEdBlNJ1UKeiBqL6VVG8edexW0", + "recourse_final_status": "consumed", + "action_permit_digest": "VVhkMAilo4hHNNmNiZrK2lPbQdBGyXm_eQu21CY-teg", + "assurance_mode": "enforced", + "bypass_possible": false, + "gated": true, + "outcome": "compensated", + "configured_postcondition_result": "satisfied", + "evidence_digests": [ + "tUpVnSAMboHG0SgTLNfGs2xFZ-_sfoXPmmSPSskdTpM", + "K-MirEhszUpzrY7wonhzIj8F3sY1Do0oU3pLgjvOT-A" + ], + "event_chain_head": "n10oj_cedhbzjH101T6XM30pMJMeVRA3-mdrB6euD18", + "closed_at": "2035-01-01T00:00:00.000Z", + "technical_claim": "The configured postcondition was evaluated against declared evidence sources.", + "limitations": [ + "Signatures establish integrity and provenance, not truth or causality.", + "Settlement is a technical protocol status, not a legal or financial determination.", + "Recovery is not guaranteed." + ], + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "0ZoBhD33WnnngnY_4I0X_NJ6AINg9hhjSGlTctFCJcou4edjkpitK5wSOEY2Rgf03ugxiIVcr5agLGhTh8LyAg" + } + }, + "events": [ + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 0, + "previous_hash": null, + "event_type": "ACTION_PROPOSED", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "state": "PROPOSED", + "action_digest": "cCXQJLbHd2WSZuc7htHV-TJKeCNszXIL5ppKZf9x6ag", + "action_type": "demo.inventory.allocate/v1", + "assurance_mode": "enforced" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "jLCFTjrzJ2gbKaCu9AlgWl6gVAunzsCLdxAfaEj9tcz85mm9o6gZQiduOdrkacJSBSz1ziwZN0gXqrQlfqr8Dw" + }, + "event_hash": "FmzyeQHrD4eM6kYW5mi7QZ6Gnq3VQhpY57URAtP6g64" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 1, + "previous_hash": "FmzyeQHrD4eM6kYW5mi7QZ6Gnq3VQhpY57URAtP6g64", + "event_type": "STATE_TRANSITION", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "from_state": "PROPOSED", + "to_state": "AUTHORIZED", + "reason_code": "POLICY_ALLOWED", + "details_digest": "MovAMuNwTLbw3MC1i2GOjCvu1P_BVBMV2ekvlKuYAEY" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "ZebAbWKjSSvyT5x6I-plIjAm8FcDIl0CA8VeHoy84hsOSizFjcizYh1uDr0npYplkdLA9X3hEeuR10Tjm-NGBw" + }, + "event_hash": "cW-3TPXCSAoPVolLdptY0JHG-Oqj2_20WUnDXCGV_Go" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 2, + "previous_hash": "cW-3TPXCSAoPVolLdptY0JHG-Oqj2_20WUnDXCGV_Go", + "event_type": "STATE_TRANSITION", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "from_state": "AUTHORIZED", + "to_state": "RECOURSE_RESERVED", + "reason_code": "RECOURSE_VERIFIED", + "details_digest": "3uzAHklEdpdUFtLECgYrpao8tt2h_M7cqldVbhAG2_E" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "gTOmLSELb9OYeDpBFnQmm4_WkMi72B7E_HhgDpncwRqLoPI0FWqlWvd0k0DsGDiJWr-UzJ1i-0jswxYwrehzAg" + }, + "event_hash": "n0fTsd2dbOmpF8LADzVsqaqq77G9S44ci9AOmIifpnc" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 3, + "previous_hash": "n0fTsd2dbOmpF8LADzVsqaqq77G9S44ci9AOmIifpnc", + "event_type": "STATE_TRANSITION", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "from_state": "RECOURSE_RESERVED", + "to_state": "PERMITTED", + "reason_code": "PERMIT_ISSUED", + "details_digest": "zphwN733GwpkbLZNknATflHxETaQdJA4Ppr5igisQbQ" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "2a-hXE0QZm3CgVOszCGNW8u0Y7_6Qk79Z-waP3IrPkinR0gVfYSCEJBhox2dXWmWUyy3aw94wQkkgV3hZvQ9DA" + }, + "event_hash": "kId4Vwt96WN15bnnQiHuAdEyegd3lsA2sCFIMGWrCY8" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 4, + "previous_hash": "kId4Vwt96WN15bnnQiHuAdEyegd3lsA2sCFIMGWrCY8", + "event_type": "STATE_TRANSITION", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "from_state": "PERMITTED", + "to_state": "EXECUTING", + "reason_code": "PERMIT_CONSUMED", + "details_digest": "OG7lSAkndS-Y_CrLx4QLKa1B0DBBT1NUJvUrJh0hvso" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "c50Bi5jRqfFzwHjAcg9aF6rUpGWHVejfBuPTATmZ914ttkO7cLLKFMjzzXJnFn1dL_BaiGAudWanrk--76VECA" + }, + "event_hash": "V_g2wxGVTFRRnW5j17OwWzDw-TAA_sq4hTK_nEVjQus" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 5, + "previous_hash": "V_g2wxGVTFRRnW5j17OwWzDw-TAA_sq4hTK_nEVjQus", + "event_type": "STATE_TRANSITION", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "from_state": "EXECUTING", + "to_state": "EXECUTED", + "reason_code": "CONNECTOR_EXECUTED", + "details_digest": "VTp3MUktMzLvVHIwT-IDBC-94WJM9_8j-vwei31DWZE" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "9SLU_pobDIUV8VYWuEuDTzFyjXUYorF-vsecaI-ayTkmNcdVs6ORRcwwflG5RJhfmJx9gXUY9OjyNePmnF_HDA" + }, + "event_hash": "aQB9KiA7WPO3PkfftXSczKcWmzvdMNb_Ld5s9OhGdKo" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 6, + "previous_hash": "aQB9KiA7WPO3PkfftXSczKcWmzvdMNb_Ld5s9OhGdKo", + "event_type": "STATE_TRANSITION", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "from_state": "EXECUTED", + "to_state": "VERIFYING", + "reason_code": "OUTCOME_VERIFICATION_STARTED", + "details_digest": "RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "I5OV9LtsdWPChXzZ00twKv_QS1jZaSw4hdp-en6Ue8mRTNsf33LIY5fOtuVYU7Ne-Po4AhZyGp7xkRagkI5SBA" + }, + "event_hash": "NxhcAw8PyM4iA3wNu-4Sc8CNmiflKMfLNaR9Nh7lNMI" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 7, + "previous_hash": "NxhcAw8PyM4iA3wNu-4Sc8CNmiflKMfLNaR9Nh7lNMI", + "event_type": "EVIDENCE_ACCEPTED", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "evidence_digest": "tUpVnSAMboHG0SgTLNfGs2xFZ-_sfoXPmmSPSskdTpM", + "source": "mock-inventory-service", + "satisfied": false + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "LAz_ckSLAfazRa5KN-FxmoHl7HcRvsIPnbybXpHYWWCZzRgx6EVoF3Sq133L6MhYdWpDEU9rE-V0e30MUkWHDQ" + }, + "event_hash": "54Be34hZ7s0-RHmDBRZOsxfsnuoe50EIBBB-g8M12Bw" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 8, + "previous_hash": "54Be34hZ7s0-RHmDBRZOsxfsnuoe50EIBBB-g8M12Bw", + "event_type": "STATE_TRANSITION", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "from_state": "VERIFYING", + "to_state": "BREACHED", + "reason_code": "POSTCONDITION_BREACHED", + "details_digest": "J5IzJWOsNw8WoZWfhtWYJYxgOBgYf-9xY-BJYDItGQo" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "hv9t8t1fWPY_OXjVi10sK4Ju5o6a0dTSQ4sfJEENxnMiD1sdRUXfGPtI_nF3H7-IbyyTjybckHZunorj1z6pCg" + }, + "event_hash": "35udcy03Z87gWUGd9eVYC-JMVACnfCe8XO2eEFQiOuQ" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 9, + "previous_hash": "35udcy03Z87gWUGd9eVYC-JMVACnfCe8XO2eEFQiOuQ", + "event_type": "STATE_TRANSITION", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "from_state": "BREACHED", + "to_state": "REMEDY_DUE", + "reason_code": "RESERVED_REMEDY_DUE", + "details_digest": "L1CghQp1F95AZfJArEZbDbF-n0HXSRNX2jGu0FNrfZc" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "Tf6uW4KnMtv--XzQrthuGFZ8LYWqQnTC7St8Pb47om2t3KykZqJFN6xEQN1wu5i2u4rMTXzrF4UqDnnAePvnBw" + }, + "event_hash": "5AdS-bKDLD4Ik-P-Q77DaCONqKkR10oEKFix-ZCNvHQ" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 10, + "previous_hash": "5AdS-bKDLD4Ik-P-Q77DaCONqKkR10oEKFix-ZCNvHQ", + "event_type": "STATE_TRANSITION", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "from_state": "REMEDY_DUE", + "to_state": "REMEDIATING", + "reason_code": "REMEDY_STARTED", + "details_digest": "ocpE4aTnuC3IcjjNlaE44BAXarB7ctLOksJW5FhiIx8" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "i54Qm4t8lVtLXP5KfSXDu08Gc6uP54DNL-lNPHz-2j-y6WQ7OZF7_nMmp69JcOTomfnBFD0AamrQRidOjKC8Dw" + }, + "event_hash": "pw75sKo1VaB0gLFdnBz2vg2vmPPKg-qyV7j0jRmk0Mo" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 11, + "previous_hash": "pw75sKo1VaB0gLFdnBz2vg2vmPPKg-qyV7j0jRmk0Mo", + "event_type": "STATE_TRANSITION", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "from_state": "REMEDIATING", + "to_state": "REMEDY_VERIFYING", + "reason_code": "REMEDY_EXECUTION_CONFIRMED", + "details_digest": "5g5w5n1AHZ-qVzfjEEFMkWcDNNhssN6MgRjZnu_x09k" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "F94H1RZKytFWTSpdQO00tjaerFCpZFLrPj4AQS2EILZ0RYBx0U1W0FoLRRUZQ5YM6ADsx_vvAnx8gUhXQKbQCg" + }, + "event_hash": "FPORbijbwEDavc-KJBHxhGzC2f9Cgw4z-7CWd0gNbvc" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 12, + "previous_hash": "FPORbijbwEDavc-KJBHxhGzC2f9Cgw4z-7CWd0gNbvc", + "event_type": "REMEDY_EVIDENCE_ACCEPTED", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "evidence_digest": "K-MirEhszUpzrY7wonhzIj8F3sY1Do0oU3pLgjvOT-A", + "satisfied": true + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "gP1F20yMM9LBbU6ld2rdWCJAX-2kHZT6gi9ar1SSAFca7N6eDn6kCuGujUxT_y5kzXLcznAqEFYghSyOgntlBg" + }, + "event_hash": "gcqWV9TVytwl6R6pOP9v09uUtVS0kvm_GpwazUfB4zs" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 13, + "previous_hash": "gcqWV9TVytwl6R6pOP9v09uUtVS0kvm_GpwazUfB4zs", + "event_type": "STATE_TRANSITION", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "from_state": "REMEDY_VERIFYING", + "to_state": "REMEDIATED", + "reason_code": "REMEDY_VERIFIED", + "details_digest": "k6XMxN2gHWkGza7SnWCge8-RYwpdYTOy0WJLfbq0o2s" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "izbrqJOVfsQmJ0jfe034kRGRXk6eq3WweoHmrfll-bLGd-N1qU1Q9MlfTFqJr_ybD-lwVeUPyEAalGCM6-XPAA" + }, + "event_hash": "baKF6oZRTz9x0EwIboj5KvniN8dJfxxra0_C3A45Uuc" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 14, + "previous_hash": "baKF6oZRTz9x0EwIboj5KvniN8dJfxxra0_C3A45Uuc", + "event_type": "RECOURSE_FINALIZED", + "actor": "connector", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "connector_commitment_digest": "m-u35qlLrk1Hrw7bocEdBlNJ1UKeiBqL6VVG8edexW0", + "status": "consumed", + "reason": "SETTLEMENT_COMPENSATED" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "sElfeEJj6WuGADJ1DfgeMOmLN7jTh7-H2JLN4gvLtDT4yDrMdQgQq1h27dvTVQyY0BkfBO6Aay8jbII1RsCzDg" + }, + "event_hash": "HpLZIp-R0V2rTUI1JdhptHC-GjLvkLdHUWY1TM-nmaI" + }, + { + "schema_version": "consequence-rail/event/v0.1", + "action_id": "act_cCXQJLbHd2WSZuc7htHV", + "sequence": 15, + "previous_hash": "HpLZIp-R0V2rTUI1JdhptHC-GjLvkLdHUWY1TM-nmaI", + "event_type": "STATE_TRANSITION", + "actor": "rail", + "recorded_at": "2035-01-01T00:00:00.000Z", + "payload": { + "from_state": "REMEDIATED", + "to_state": "CLOSED", + "reason_code": "SETTLEMENT_COMPENSATED", + "details_digest": "aJIn0P4iVTpBbGIALYsfE7wTKWLhRbuVUNXTRy_1TlI" + }, + "signature": { + "algorithm": "Ed25519", + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "value": "hwl3LifrT8jmQ1hgFvNbdiDbOnTdvofAhF9gsCPB9vygOUB36wivKjcUYX5i1kMBG-BK51evsd12r0RP6LTJDw" + }, + "event_hash": "n10oj_cedhbzjH101T6XM30pMJMeVRA3-mdrB6euD18" + } + ], + "trust_hint": { + "rail": { + "key_id": "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAnRbxWLLLgUyGwA8UdJVAsb12PzYDjv5C3+wE5abhq5Y=\n-----END PUBLIC KEY-----\n" + }, + "connector": { + "key_id": "demo-connector-ed25519-VcGmBF_daVE14fDB", + "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAaT5lZhTYJhmk7hzLG+me9Zs4cARL14Wa3xx+G3a74+4=\n-----END PUBLIC KEY-----\n" + }, + "warning": "An embedded key is not trusted automatically." + } + } + }, + "prove": { + "ok": true, + "result": { + "format": "MandateBoundExternalEvidenceReview/v1", + "reviewId": "review-26ad562306e55b80", + "source": { + "sourceId": "consequence-rail", + "eventClass": "settlement" + }, + "actionId": "act_cCXQJLbHd2WSZuc7htHV", + "evidenceDigest": "sha256:368c67f15e922f675c0cfe738411fbbb99210783f440c6a2a5f9e2eb681d7634", + "receipt": { + "receiptId": "receipt_vljZ41Zon-V_fm7Sq1HT", + "outcome": "compensated", + "recourseStatus": "consumed", + "eventChainHead": "n10oj_cedhbzjH101T6XM30pMJMeVRA3-mdrB6euD18", + "actionDigest": "cCXQJLbHd2WSZuc7htHV-TJKeCNszXIL5ppKZf9x6ag" + }, + "upstream": { + "verifier": "consequence-rail:bundle-verify", + "valid": true, + "actionId": "act_cCXQJLbHd2WSZuc7htHV", + "outcome": "compensated", + "trustedKeyIds": [ + "demo-rail-ed25519-aQs_UQHBF_Y4YXOx", + "demo-connector-ed25519-VcGmBF_daVE14fDB" + ] + }, + "verdict": "recorded", + "legalEffect": "not-determined", + "reviewDigest": "sha256:a275267936553f6cff6469c1a32e5ac764630283c18d6b4488b23c0ce83a0a36" + } + } + } +} diff --git a/package-lock.json b/package-lock.json index c92ea97..f701077 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-action-stack", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-action-stack", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "bin": { "aas": "bin/aas.mjs" diff --git a/package.json b/package.json index 20d1356..05fcfcb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "agent-action-stack", - "version": "0.2.5", - "private": false, + "version": "0.3.0", + "private": true, "description": "Reference demo: decide with Constitutional Agent Testbench, act with Consequence Rail, prove disputes with MandateBound.", "type": "module", "bin": { @@ -17,7 +17,9 @@ "test": "node --test --experimental-test-isolation=none test/gui.test.mjs test/stack.test.mjs", "integration": "node scripts/integration-check.mjs", "example:review-handoff": "node examples/review-handoff.mjs", - "check": "npm test", + "check": "npm test && npm run check:syntax && npm run check:version", + "check:syntax": "node scripts/check-syntax.mjs", + "check:version": "node scripts/check-version.mjs", "test:browser": "playwright test", "example:connector-conformance": "node examples/connector-conformance.mjs" }, diff --git a/scripts/bootstrap.mjs b/scripts/bootstrap.mjs old mode 100644 new mode 100755 index 9140c9e..173f9dc --- a/scripts/bootstrap.mjs +++ b/scripts/bootstrap.mjs @@ -4,13 +4,36 @@ * This script never accesses private repositories. */ import { spawnSync } from "node:child_process"; -import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const lockPath = join(root, "stack-lock.json"); +/** + * True when the module at `metaUrl` is the script Node was asked to run. + * + * Node realpaths the main module before it builds `import.meta.url`, but + * `process.argv[1]` keeps the path as typed. Comparing the two directly fails + * whenever the script is reached through a link (an npm bin symlink, `npm + * link`, a macOS /tmp checkout, a Windows junction), and the entrypoint then + * exits 0 without running. Both sides are resolved with `realpathSync.native` + * so Windows short names and drive-letter case compare equal too. + * + * @param {string} metaUrl The caller's `import.meta.url`. + * @param {string|undefined} [argv1] The script path Node received. + * @returns {boolean} + */ +export function isEntrypoint(metaUrl, argv1 = process.argv[1]) { + if (!argv1) return false; + try { + return realpathSync.native(argv1) === realpathSync.native(fileURLToPath(metaUrl)); + } catch { + return false; + } +} + /** Full-stack floor: the pinned MandateBound package declares engines >=22.12.0. */ export const MIN_FULL_STACK_NODE = Object.freeze([22, 12, 0]); @@ -82,11 +105,14 @@ export function exec(command, args, opts = {}) { }); } -function requireCommand(command, args, opts = {}) { - const result = exec(command, args, opts); +function requireCommand(command, args, opts = {}, run = exec) { + const result = run(command, args, opts); if (result.error || result.status !== 0) { const detail = result.error?.code ?? `exit ${result.status ?? "unknown"}`; - throw new Error(`Command failed: ${command} ${args.join(" ")} (${detail})`); + // Keep the tool's own explanation; "exit 128" alone does not say whether + // the network, a path length, or permissions failed. + const said = typeof result.stderr === "string" ? result.stderr.replace(/\s+/g, " ").trim().slice(0, 300) : ""; + throw new Error(`Command failed: ${command} ${args.join(" ")} (${detail})${said ? `: ${said}` : ""}`); } return result; } @@ -158,7 +184,7 @@ export function inspectDependencyDirectory(target, component, { command = exec } const symbolic = command("git", ["-C", target, "symbolic-ref", "--quiet", "--short", "HEAD"]); const status = command("git", ["-C", target, "status", "--porcelain"]); if (origin.error || origin.status !== 0 || head.error || head.status !== 0 || status.error || status.status !== 0) { - throw new Error(`Refusing unusable pre-existing dependency: ${component.name}`); + throw new Error(`Refusing unusable pre-existing dependency: ${component.name}. Remove deps/${component.name} and rerun npm run bootstrap.`); } const actualOrigin = origin.stdout.trim(); const actualHead = head.stdout.trim(); @@ -191,12 +217,54 @@ export function inspectDependencyDirectory(target, component, { command = exec } }; } -function cloneAtCommit(target, component) { - mkdirSync(dirname(target), { recursive: true }); - requireCommand("git", ["init", "--quiet", target]); - requireCommand("git", ["-C", target, "remote", "add", "origin", component.repository]); - requireCommand("git", ["-C", target, "fetch", "--depth", "1", "origin", component.commit]); - requireCommand("git", ["-C", target, "checkout", "--detach", "--quiet", component.commit]); +const RENAME_RETRY_CODES = new Set(["EPERM", "EBUSY", "EACCES"]); + +function sleepSync(milliseconds) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds); +} + +/** + * Move a finished staging checkout into place. On Windows a freshly written + * .git can be held briefly by antivirus or the search indexer, so a rename + * that fails with EPERM, EBUSY, or EACCES is retried up to three times. + */ +export function moveIntoPlace(staging, target, { rename = renameSync, retries = 3, delayMs = 100 } = {}) { + for (let attempt = 0; ; attempt += 1) { + try { + rename(staging, target); + return; + } catch (error) { + if (attempt >= retries || !RENAME_RETRY_CODES.has(error?.code)) throw error; + sleepSync(delayMs); + } + } +} + +/** + * Clone one component at its pinned commit. The clone is built in a hidden + * sibling directory and moved to `target` only after checkout succeeds, so a + * failed or interrupted fetch can never leave a half-initialized repository + * where the next bootstrap would refuse it. + * + * The staging name (`.tmp-` plus six characters) is shorter than every + * component name. Git for Windows refuses object paths over 260 characters, + * so a longer staging path would make the clone fail in a deep checkout + * where cloning straight into `target` succeeds. + */ +function cloneAtCommit(target, component, { command = exec } = {}) { + const parent = dirname(target); + mkdirSync(parent, { recursive: true }); + const staging = mkdtempSync(join(parent, ".tmp-")); + try { + requireCommand("git", ["init", "--quiet", staging], {}, command); + requireCommand("git", ["-C", staging, "remote", "add", "origin", component.repository], {}, command); + requireCommand("git", ["-C", staging, "fetch", "--depth", "1", "origin", component.commit], {}, command); + requireCommand("git", ["-C", staging, "checkout", "--detach", "--quiet", component.commit], {}, command); + moveIntoPlace(staging, target); + } catch (error) { + rmSync(staging, { recursive: true, force: true }); + throw new Error(`Could not prepare ${component.name} at ${component.commit}: ${error.message}. Nothing was left in deps/; rerun npm run bootstrap.`); + } } export function npmInvocation(args, { platform = process.platform, npmExecPath = process.env.npm_execpath, nodeExecPath = process.execPath } = {}) { @@ -209,42 +277,49 @@ export function npmInvocation(args, { platform = process.platform, npmExecPath = return { command: "npm", args }; } -function runNpm(target, args) { +function runNpm(target, args, command = exec) { const invocation = npmInvocation(args); - requireCommand(invocation.command, invocation.args, { cwd: target, stdio: "inherit" }); + requireCommand(invocation.command, invocation.args, { cwd: target, stdio: "inherit" }, command); } -export function prepareDependencies({ root: projectRoot = root, deps = join(projectRoot, "deps"), components = loadComponentLock(join(projectRoot, "stack-lock.json")), nodeVersion } = {}) { +export function prepareDependencies({ root: projectRoot = root, deps = join(projectRoot, "deps"), components = loadComponentLock(join(projectRoot, "stack-lock.json")), nodeVersion, command = exec } = {}) { assertFullStackNodeVersion(nodeVersion === undefined ? {} : { version: nodeVersion }); mkdirSync(deps, { recursive: true }); assertDependencyDirectory(deps); const prepared = []; for (const component of components) { const target = join(deps, component.name); - const existing = inspectDependencyDirectory(target, component); + const existing = inspectDependencyDirectory(target, component, { command }); if (!existing.exists) { - cloneAtCommit(target, component); + cloneAtCommit(target, component, { command }); } - inspectDependencyDirectory(target, component); + inspectDependencyDirectory(target, component, { command }); if (component.install === "npm-ci") { - runNpm(target, ["ci", "--ignore-scripts"]); + runNpm(target, ["ci", "--ignore-scripts"], command); } if (component.build === "npm-run-build") { - runNpm(target, ["run", "build"]); + runNpm(target, ["run", "build"], command); } const state = inspectDependencyDirectory(target, { ...component, expected_entrypoints: [...component.expected_entrypoints, ...(component.post_build_entrypoints ?? [])], - }); + }, { command }); prepared.push(state); } return prepared; } -export function main() { - const prepared = prepareDependencies(); +export function main(options = {}) { + let prepared; + try { + prepared = prepareDependencies(options); + } catch (error) { + process.stderr.write(`bootstrap failed: ${error.message}\n`); + process.exitCode = 1; + return; + } for (const item of prepared) process.stdout.write(`deps/${item.target.split(/[\\/]/).pop()}: ${item.commit} detached clean\n`); process.stdout.write("Bootstrap complete.\n"); } -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); +if (isEntrypoint(import.meta.url)) main(); diff --git a/scripts/check-syntax.mjs b/scripts/check-syntax.mjs new file mode 100755 index 0000000..11ad340 --- /dev/null +++ b/scripts/check-syntax.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node +/** + * Syntax gate: parse every tracked JavaScript module with `node --check`. + * + * The file list comes from `git ls-files`, so a new module is checked without + * editing CI. The hand-written list it replaces drifted once already, when it + * parsed three of nine shipped modules. Outside a Git checkout the gate walks + * the tree instead, skipping generated and dependency folders. + */ +import { spawnSync } from "node:child_process"; +import { readdirSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isEntrypoint } from "./bootstrap.mjs"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const MODULE_PATTERN = /\.(?:mjs|cjs|js)$/; +const SKIPPED_DIRECTORIES = new Set([".git", "node_modules", "deps", ".out", "test-results", "playwright-report"]); + +function walk(projectRoot, directory, found) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) walk(projectRoot, path, found); + } else if (entry.isFile() && MODULE_PATTERN.test(entry.name)) { + found.push(relative(projectRoot, path).split("\\").join("/")); + } + } + return found; +} + +/** + * Every JavaScript module the repository tracks, as sorted root-relative + * paths with forward slashes. + * + * @param {string} [projectRoot] + * @param {{command?: typeof spawnSync}} [options] + * @returns {string[]} + */ +export function listSyntaxTargets(projectRoot = root, { command = spawnSync } = {}) { + const listed = command("git", ["-C", projectRoot, "ls-files", "-z", "--", "*.mjs", "*.js", "*.cjs"], { + encoding: "utf8", + shell: false, + }); + if (!listed.error && listed.status === 0) { + return listed.stdout.split("\0").filter(Boolean).sort(); + } + return walk(projectRoot, projectRoot, []).sort(); +} + +/** + * Parse each file with the running Node.js and return the ones that fail. + * + * @param {string[]} files Paths relative to `cwd`, or absolute. + * @param {{cwd?: string}} [options] + * @returns {Array<{file: string, detail: string}>} + */ +export function checkSyntax(files, { cwd = root } = {}) { + const failures = []; + for (const file of files) { + const result = spawnSync(process.execPath, ["--check", file], { cwd, encoding: "utf8", shell: false }); + if (result.error || result.status !== 0) { + failures.push({ file, detail: (result.error?.message ?? result.stderr ?? "").trim() }); + } + } + return failures; +} + +export function main() { + const files = listSyntaxTargets(root); + if (files.length === 0) { + process.stderr.write("syntax: no JavaScript modules found; refusing to report a pass\n"); + process.exitCode = 1; + return; + } + const failures = checkSyntax(files, { cwd: root }); + if (failures.length === 0) { + process.stdout.write(`syntax: ${files.length} modules parsed\n`); + return; + } + for (const failure of failures) process.stderr.write(`syntax: ${failure.file} does not parse\n${failure.detail}\n`); + process.stderr.write(`syntax: ${failures.length} of ${files.length} modules failed\n`); + process.exitCode = 1; +} + +if (isEntrypoint(import.meta.url)) main(); diff --git a/scripts/check-version.mjs b/scripts/check-version.mjs new file mode 100755 index 0000000..7617998 --- /dev/null +++ b/scripts/check-version.mjs @@ -0,0 +1,266 @@ +#!/usr/bin/env node +/** + * Release consistency check. + * + * `package.json` "version" is the single source of truth. This script fails + * when package-lock.json, CHANGELOG.md, the release-readiness pin list, or an + * optional release tag disagree with it: + * + * node scripts/check-version.mjs [--tag vX.Y.Z] [--notes ] + * + * `--notes` writes the CHANGELOG section for the package version, which the + * release workflow publishes as the GitHub Release notes. + * + * bin/ must never import this module: the integration proof's offline replay + * copies only bin/aas.mjs, scripts/bootstrap.mjs, and stack-lock.json. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { isEntrypoint } from "./bootstrap.mjs"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +export const REPOSITORY_URL = "https://github.com/EauDoon/agent-action-stack"; +const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const RELEASED_HEADING = /^\[(\d+\.\d+\.\d+)\] - (\d{4}-\d{2}-\d{2})$/; +const LINK_DEFINITION = /^\[([^\]]+)\]:\s+(\S+)\s*$/; +export const CHANGELOG_CATEGORIES = Object.freeze(["Added", "Changed", "Deprecated", "Removed", "Fixed", "Security"]); + +export class UsageError extends Error {} + +/** @returns {[number, number, number]|null} */ +export function parseSemver(value) { + const match = typeof value === "string" ? SEMVER.exec(value) : null; + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; +} + +function compareSemver(left, right) { + const a = parseSemver(left); + const b = parseSemver(right); + for (let index = 0; index < 3; index += 1) { + if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1; + } + return 0; +} + +function isCalendarDate(value) { + const parsed = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; +} + +/** + * Split a Keep a Changelog document into its H2 sections and link + * definitions. Fenced code blocks are skipped so an example heading inside + * one is not read as structure. + */ +export function parseChangelog(text) { + const lines = String(text).split(/\r?\n/); + const sections = []; + const links = new Map(); + const stray = []; + let current = null; + let fenced = false; + lines.forEach((line, index) => { + if (/^\s*(?:```|~~~)/.test(line)) fenced = !fenced; + if (fenced) { + if (current) current.body.push(line); + return; + } + const link = LINK_DEFINITION.exec(line); + if (link) { + links.set(link[1], { url: link[2], line: index + 1 }); + return; + } + const h2 = /^## (.*)$/.exec(line); + if (h2) { + current = { title: h2[1].trim(), line: index + 1, headings: [], bullets: 0, body: [] }; + sections.push(current); + return; + } + const h3 = /^### (.*)$/.exec(line); + if (h3 && !current) { + stray.push({ title: h3[1].trim(), line: index + 1 }); + return; + } + if (!current) return; + if (h3) current.headings.push({ title: h3[1].trim(), line: index + 1 }); + if (/^[-*] \S/.test(line)) current.bullets += 1; + current.body.push(line); + }); + for (const section of sections) { + const released = RELEASED_HEADING.exec(section.title); + section.label = section.title === "[Unreleased]" ? "Unreleased" : released?.[1] ?? null; + section.date = released?.[2] ?? null; + section.text = `${section.body.join("\n").trim()}\n`; + } + return { sections, links, stray }; +} + +/** The body of one released section, ready to publish as release notes. */ +export function releaseNotes(changelog, version) { + const section = parseChangelog(changelog).sections.find((entry) => entry.label === version); + if (!section) throw new Error(`CHANGELOG.md has no section for ${version}.`); + return section.text; +} + +/** + * @param {{pkg: any, lock: any, changelog: string, readiness: string, stackLock: any, tag?: string|null}} inputs + * @returns {string[]} Every inconsistency found; empty means consistent. + */ +export function checkReleaseConsistency({ pkg, lock, changelog, readiness, stackLock, tag = null }) { + const problems = []; + const version = pkg?.version; + if (parseSemver(version) === null) { + problems.push(`package.json version ${JSON.stringify(version)} is not X.Y.Z.`); + } + if (lock?.version !== version) { + problems.push(`package-lock.json version ${JSON.stringify(lock?.version)} does not match package.json ${version}.`); + } + if (lock?.packages?.[""]?.version !== version) { + problems.push(`package-lock.json packages[""].version ${JSON.stringify(lock?.packages?.[""]?.version)} does not match package.json ${version}.`); + } + + const { sections, links, stray } = parseChangelog(changelog); + for (const heading of stray) { + problems.push(`CHANGELOG.md:${heading.line} has "### ${heading.title}" outside a version section.`); + } + if (sections[0]?.title !== "[Unreleased]") { + problems.push("CHANGELOG.md must open with a \"## [Unreleased]\" section."); + } + const released = []; + sections.forEach((section, index) => { + if (index > 0) { + if (section.date === null) { + problems.push(`CHANGELOG.md:${section.line} "## ${section.title}" is not "## [X.Y.Z] - YYYY-MM-DD".`); + } else if (parseSemver(section.label) === null || !isCalendarDate(section.date)) { + problems.push(`CHANGELOG.md:${section.line} "## ${section.title}" has an invalid version or date.`); + } else { + released.push(section); + } + } + const seen = new Set(); + for (const heading of section.headings) { + if (!CHANGELOG_CATEGORIES.includes(heading.title)) { + problems.push(`CHANGELOG.md:${heading.line} "### ${heading.title}" is not one of ${CHANGELOG_CATEGORIES.join(", ")}.`); + } + if (seen.has(heading.title)) { + problems.push(`CHANGELOG.md:${heading.line} repeats "### ${heading.title}" inside "## ${section.title}".`); + } + seen.add(heading.title); + } + }); + for (let index = 1; index < released.length; index += 1) { + const newer = released[index - 1]; + const older = released[index]; + if (compareSemver(newer.label, older.label) <= 0) { + problems.push(`CHANGELOG.md:${older.line} ${older.label} must be older than ${newer.label} above it; released sections run newest first without repeats.`); + } + } + + // Every heading resolves to a link, and released links name their tags. + for (const section of sections) { + if (section.label === null) continue; + const link = links.get(section.label); + if (!link) { + problems.push(`CHANGELOG.md has no [${section.label}] link definition.`); + continue; + } + if (!link.url.startsWith(`${REPOSITORY_URL}/`)) { + problems.push(`CHANGELOG.md:${link.line} [${section.label}] must link into ${REPOSITORY_URL}.`); + continue; + } + const position = released.indexOf(section); + let expected = null; + if (section.label === "Unreleased" && released.length > 0) { + expected = `${REPOSITORY_URL}/compare/v${released[0].label}...HEAD`; + } else if (position >= 0) { + const previous = released[position + 1]; + expected = previous + ? `${REPOSITORY_URL}/compare/v${previous.label}...v${section.label}` + : `${REPOSITORY_URL}/releases/tag/v${section.label}`; + } + if (expected !== null && link.url !== expected) { + problems.push(`CHANGELOG.md:${link.line} [${section.label}] should be ${expected}.`); + } + } + + if (released.length > 0 && released[0].label !== version) { + problems.push(`CHANGELOG.md newest release ${released[0].label} does not match package.json ${version}.`); + } + + const components = Array.isArray(stackLock?.components) ? stackLock.components : []; + if (components.length === 0) problems.push("stack-lock.json lists no components."); + for (const component of components) { + if (typeof component?.commit !== "string" || !String(readiness).includes(component.commit)) { + problems.push(`docs/release-readiness.md does not list the ${component?.name ?? "unnamed"} pin ${component?.commit}.`); + } + } + + if (tag !== null) { + if (tag !== `v${version}`) { + problems.push(`Tag ${tag} does not match package.json version ${version} (expected v${version}).`); + } + const section = released.find((entry) => entry.label === version); + if (!section) { + problems.push(`CHANGELOG.md has no released "## [${version}] - YYYY-MM-DD" section for tag ${tag}.`); + } else if (section.bullets === 0) { + problems.push(`CHANGELOG.md section ${version} has no entries to publish.`); + } + } + return problems; +} + +export function loadReleaseInputs(projectRoot = root) { + const read = (path) => readFileSync(join(projectRoot, path), "utf8"); + return { + pkg: JSON.parse(read("package.json")), + lock: JSON.parse(read("package-lock.json")), + changelog: read("CHANGELOG.md"), + readiness: read("docs/release-readiness.md"), + stackLock: JSON.parse(read("stack-lock.json")), + }; +} + +export function parseCheckArgs(args) { + const options = { tag: null, notes: null }; + for (let index = 0; index < args.length; index += 1) { + const name = args[index]; + const key = name === "--tag" ? "tag" : name === "--notes" ? "notes" : null; + const value = args[index + 1]; + if (key === null) throw new UsageError(`Unsupported option: ${name}`); + if (options[key] !== null) throw new UsageError(`Duplicate option: ${name}`); + if (value === undefined || value.startsWith("--") || value.trim() === "") throw new UsageError(`Missing value for ${name}`); + options[key] = value; + index += 1; + } + return options; +} + +export function main(args = process.argv.slice(2)) { + let options; + try { + options = parseCheckArgs(args); + } catch (error) { + process.stderr.write(`${error.message}\nUsage: node scripts/check-version.mjs [--tag vX.Y.Z] [--notes ]\n`); + process.exitCode = 2; + return; + } + const inputs = loadReleaseInputs(root); + const problems = checkReleaseConsistency({ ...inputs, tag: options.tag }); + if (problems.length === 0 && options.notes !== null) { + try { + writeFileSync(options.notes, releaseNotes(inputs.changelog, inputs.pkg.version)); + } catch (error) { + problems.push(`Cannot write release notes: ${error.message}`); + } + } + if (problems.length > 0) { + for (const problem of problems) process.stderr.write(`version: ${problem}\n`); + process.exitCode = 1; + return; + } + const tagged = options.tag === null ? "" : `, tag ${options.tag}`; + process.stdout.write(`version: ${inputs.pkg.version} consistent (package, lock, changelog, release pins${tagged})\n`); +} + +if (isEntrypoint(import.meta.url)) main(); diff --git a/scripts/integration-check.mjs b/scripts/integration-check.mjs old mode 100644 new mode 100755 diff --git a/stack-lock.json b/stack-lock.json index 960f062..da949ab 100644 --- a/stack-lock.json +++ b/stack-lock.json @@ -4,7 +4,7 @@ { "name": "constitutional-agent-testbench", "repository": "https://github.com/EauDoon/constitutional-agent-testbench.git", - "commit": "16b2faa71b0f92b9afa15b13afad8c48da8132f4", + "commit": "ed46f0cb9af400560aa9a9e73bbb9864ed5678a0", "expected_entrypoints": [ "pyproject.toml", "src/constitutional_agent_testbench/cli.py" @@ -13,7 +13,7 @@ { "name": "consequence-rail", "repository": "https://github.com/EauDoon/consequence-rail.git", - "commit": "c430383c0a0931f0dcf17845d6f0e8ccf328615a", + "commit": "78d6f8a23dbce69bb443b453c8d471645f67daac", "expected_entrypoints": [ "package.json", "cmd/crctl.js" @@ -22,7 +22,7 @@ { "name": "mandatebound", "repository": "https://github.com/EauDoon/mandatebound.git", - "commit": "708256d4e48babeb13079fac7589d172920a5c95", + "commit": "32d525618e23678f86c754e4a536e4d094988a1b", "expected_entrypoints": [ "package.json", "package-lock.json", diff --git a/test/browser/workbench.spec.mjs b/test/browser/workbench.spec.mjs index d324790..492dde0 100644 --- a/test/browser/workbench.spec.mjs +++ b/test/browser/workbench.spec.mjs @@ -60,6 +60,28 @@ test("run, inspect, export, import, and replay through the real UI", async ({ pa await expect(page.locator("#import-result")).toContainText("no action execution or remediation runs"); }); +test("the page script and stylesheet run under the hash-pinned policy", async ({ page }) => { + const violations = []; + page.on("console", (message) => { + if (/Content Security Policy/i.test(message.text())) violations.push(message.text()); + }); + await page.addInitScript(() => { + document.addEventListener("securitypolicyviolation", (event) => { + window.__cspViolations = [...(window.__cspViolations ?? []), event.violatedDirective]; + }); + }); + const response = await page.goto("/"); + expect(response.headers()["content-security-policy"]).not.toContain("unsafe-inline"); + // The stylesheet applied (body background #f7f9fc) and the script ran + // (applying a scenario rewrites the note). + await expect(page.locator("body")).toHaveCSS("background-color", "rgb(247, 249, 252)"); + await page.selectOption("#scenario", "refusal"); + await page.click("#apply-scenario"); + await expect(page.locator("#scenario-note")).toContainText("Expected: policy refusal"); + expect(await page.evaluate(() => window.__cspViolations ?? [])).toEqual([]); + expect(violations).toEqual([]); +}); + test("refusal shows policy refusal and skips act and prove", async ({ page }) => { await page.goto("/"); await runStack(page, { response: "fail" }); @@ -67,6 +89,11 @@ test("refusal shows policy refusal and skips act and prove", async ({ page }) => await expect(page.locator("#summary")).toContainText("act: skipped"); await expect(page.locator("#summary")).toContainText("prove: skipped"); await expect(page.locator("#bindings")).toContainText("action: none"); + // The outcome is announced in the visible status, not only in the + // collapsed raw report. + await expect(page.locator("#run-status")).toBeVisible(); + await expect(page.locator("#run-status")).toContainText("finished"); + await expect(page.locator("#run-status")).toContainText("decide -> stop (policy failed)"); }); test("repeated runs replace the previous summary instead of stacking", async ({ page }) => { diff --git a/test/component-compatibility.test.mjs b/test/component-compatibility.test.mjs index e338ca1..c382504 100644 --- a/test/component-compatibility.test.mjs +++ b/test/component-compatibility.test.mjs @@ -64,6 +64,21 @@ test("ordinary synthetic case exported by the previous pins still replays", () = assert.ok(result.checks.every((check) => check.passed)); }); +test("inventory case saved under the pins before the 0.3.0 refresh still replays", () => { + // Exported by main at 8179e59 with Rail 0.2.20 (c430383) and MandateBound + // 1.2.0 (708256d). Rail 0.3.0 adds receipt-outcome and evidence-fact checks + // to verification; a case already in a user's case store must still pass. + const saved = JSON.parse(readFileSync(new URL("../fixtures/previous-pins-inventory-review.json", import.meta.url), "utf8")); + const previous = Object.fromEntries(saved.manifest.component_provenance.map((entry) => [entry.name, entry.commit])); + assert.equal(previous["consequence-rail"], "c430383c0a0931f0dcf17845d6f0e8ccf328615a"); + assert.equal(previous.mandatebound, "708256d4e48babeb13079fac7589d172920a5c95"); + assert.equal(saved.report.domain, "inventory"); + assert.equal(saved.stages.act.outcome, "compensated"); + const result = replayBundle(saved); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.ok(result.checks.every((check) => check.passed)); +}); + test("moving-clock rail evidence survives the same-case handoff and replay", async () => { let tick = Date.parse("2035-01-01T00:00:00.000Z"); const runtime = createDemoRuntime({ clock: { now: () => new Date(tick++).toISOString() } }); diff --git a/test/gui.test.mjs b/test/gui.test.mjs index afca543..9fc58ee 100644 --- a/test/gui.test.mjs +++ b/test/gui.test.mjs @@ -3,7 +3,8 @@ import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; import { Worker } from "node:worker_threads"; import { runGuiTask } from "../bin/aas-gui-worker.mjs"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawnSync } from "node:child_process"; +import { createServer as createNetServer } from "node:net"; import { setTimeout as delay } from "node:timers/promises"; import { createHash } from "node:crypto"; import { request } from "node:http"; @@ -11,7 +12,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFil import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { stageDetailsModel, validatedRunSettings, scenarioPreset, filterHistory, bindingsModel, compareModel, createGuiServer, historyModel, renderPage, replayHttpStatus, replayResultModel, summaryModel } from "../bin/aas-gui.mjs"; +import { stageDetailsModel, validatedRunSettings, scenarioPreset, filterHistory, bindingsModel, compareModel, createGuiServer, historyModel, renderPage, replayHttpStatus, replayResultModel, startGui, summaryModel } from "../bin/aas-gui.mjs"; import { compareRuns, exportRunBundle, runDemo, selectPython } from "../bin/aas.mjs"; const provenance = [ @@ -58,7 +59,8 @@ test("GUI exposes a guided page and uses the orchestrator run bundle", async () try { const health = await requestServer(server, "/api/health"); assert.equal(health.status, 200); - assert.equal(JSON.parse(health.body).ok, true); + const packageVersion = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version; + assert.deepEqual(JSON.parse(health.body), { ok: true, stack: "agent-action-stack", version: packageVersion }); const address = server.address(); const run = await requestServer(server, "/api/run?response=pass&fault=none", { method: "POST", @@ -216,6 +218,27 @@ test("summary model narrates status, gating reasons, and review results", () => assert.match(rail, /mode rail-review/); assert.match(rail, /review recorded/); assert.match(rail, /review review-abc/); + + const actErrored = summaryModel({ + flow: "decide -> act error", + stages: { + decide: { status: "passed", passed: true }, + act: { status: "error", reason: "act child process exited with status 7" }, + prove: { status: "skipped", reason: "act_error" }, + }, + }); + assert.match(actErrored, /prove: skipped \(act errored\)/); + + const actFailed = summaryModel({ + flow: "decide -> act -> stop (act failed)", + stages: { + decide: { status: "passed", passed: true }, + act: { status: "failed" }, + prove: { status: "skipped", reason: "act_failed" }, + }, + }); + assert.match(actFailed, /prove: skipped \(act failed\)/); + assert.doesNotMatch(actFailed, /act_failed/); }); test("summary model escapes untrusted values", () => { @@ -300,7 +323,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", "history-search", "history-outcome", "history-count", "inspect-case", "saved-download", "saved-status", "saved-summary", "saved-bindings", "scenario", "apply-scenario", "scenario-note", "restore-settings", "older-history", "history-page-status", "saved-case-id", "lookup-case", "saved-link", "replay-saved", "saved-review-status", "saved-review-result", "saved-artifacts", "saved-report", "comparison-download"]) { + for (const id of ["response", "fault", "dispute", "prove", "run", "run-status", "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", "scenario", "apply-scenario", "scenario-note", "restore-settings", "older-history", "history-page-status", "saved-case-id", "lookup-case", "saved-link", "replay-saved", "saved-review-status", "saved-review-result", "saved-artifacts", "saved-report", "comparison-download"]) { elements[id] = { value: "pass", checked: false, disabled: false, textContent: "", innerHTML: "", href: null, style: {}, listeners: {}, addEventListener(name, fn) { const prior = this.listeners[name]; this.listeners[name] = prior ? (...args) => { prior(...args); return fn(...args); } : fn; }, removeAttribute(name) { delete this[name]; } }; @@ -352,11 +375,60 @@ test("page script surfaces request failures without stale exports", async () => const fetch = () => Promise.reject(new Error("boom")); const ui = run(document, fetch, globalThis.crypto); await ui.click(); - assert.match(document.elements.output.textContent, /Request failed: boom/); + assert.equal(document.elements["run-status"].textContent, "Run request failed: boom"); + assert.equal(document.elements.output.textContent, ""); assert.equal(document.elements.download.href, undefined); assert.equal(document.elements.run.disabled, false); }); +test("page script announces run progress, refusals, and outcomes in the visible status", async () => { + const script = pageScript(); + const run = new Function("document", "fetch", "crypto", `${script}; return { click: () => document.getElementById('run').listeners.click() };`); + const page = renderPage(); + // The status sits in the run panel, outside the collapsed raw-report details. + assert.match(page, /