From 0b4aa4751aa25ba075716acdf353ee15b2bac2ba Mon Sep 17 00:00:00 2001 From: f10-coder Date: Fri, 31 Jul 2026 21:35:29 -0500 Subject: [PATCH 01/13] feat(quality): add anti-inert gate registry --- .woodpecker/ci.yml | 17 +- docs/ADMIN-GUIDE/README.md | 3 + docs/ADMIN-GUIDE/quality-gate-registry.md | 33 + docs/DEVELOPER-GUIDE/README.md | 3 + docs/DEVELOPER-GUIDE/quality-gate-registry.md | 41 + docs/PRD.md | 43 + docs/SITEMAP.md | 6 + docs/plans/2026-08-01-rm-02-gate-registry.md | 94 ++ docs/remediation/GATE-CLAIMS.md | 24 + docs/remediation/MISSION.md | 8 + docs/scratchpads/1029-rm-02-gate-registry.md | 73 ++ gates/gates.manifest.json | 1016 +++++++++++++++++ package.json | 1 + scripts/gate-history.mjs | 332 ++++++ scripts/gate-history.test.mjs | 324 ++++++ scripts/gate-verify.mjs | 747 ++++++++++++ scripts/gate-verify.test.mjs | 463 ++++++++ scripts/gate-wiring.test.mjs | 22 + 18 files changed, 3247 insertions(+), 3 deletions(-) create mode 100644 docs/ADMIN-GUIDE/README.md create mode 100644 docs/ADMIN-GUIDE/quality-gate-registry.md create mode 100644 docs/DEVELOPER-GUIDE/README.md create mode 100644 docs/DEVELOPER-GUIDE/quality-gate-registry.md create mode 100644 docs/plans/2026-08-01-rm-02-gate-registry.md create mode 100644 docs/remediation/GATE-CLAIMS.md create mode 100644 docs/scratchpads/1029-rm-02-gate-registry.md create mode 100644 gates/gates.manifest.json create mode 100644 scripts/gate-history.mjs create mode 100644 scripts/gate-history.test.mjs create mode 100644 scripts/gate-verify.mjs create mode 100644 scripts/gate-verify.test.mjs create mode 100644 scripts/gate-wiring.test.mjs diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index c02f3f1c..6c11c855 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -68,15 +68,26 @@ steps: - bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh - bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh + # Anti-inert-gate registry. Deliberately unconditional: no path filter and no + # step-level `when`, because a gate can be disabled by changes outside its own path. + gate-verify: + image: *node_image + commands: + - *enable_pnpm + - apk add --no-cache bubblewrap + - pnpm gate:verify + depends_on: + - install + - sanitization + - upgrade-guard + typecheck: image: *node_image commands: - *enable_pnpm - pnpm typecheck depends_on: - - install - - sanitization - - upgrade-guard + - gate-verify # lint, format, and test are independent — run in parallel after typecheck lint: diff --git a/docs/ADMIN-GUIDE/README.md b/docs/ADMIN-GUIDE/README.md new file mode 100644 index 00000000..1c0d94ac --- /dev/null +++ b/docs/ADMIN-GUIDE/README.md @@ -0,0 +1,3 @@ +# Administrator Guide + +- [Gate registry operations](quality-gate-registry.md) diff --git a/docs/ADMIN-GUIDE/quality-gate-registry.md b/docs/ADMIN-GUIDE/quality-gate-registry.md new file mode 100644 index 00000000..cc971f7a --- /dev/null +++ b/docs/ADMIN-GUIDE/quality-gate-registry.md @@ -0,0 +1,33 @@ +# Gate Registry Operations + +## Routine verification + +Run `pnpm gate:verify` from a dependency-installed checkout. Exit zero means registry observations matched their declared `actual` values; it does **not** assert required-behavior conformance while deltas remain. Open `DEFECT` records are checked descriptions of current behavior with tracked owners, never successful gate outcomes. + +Investigate any of these immediately: + +- `GATE VERIFY FAILED` — registry structure, observed behavior, provenance, claim binding, source/deployment identity, or negative-control detection changed. +- `unregistered gate` — an executable appeared under a declared gate root without a registry entry. +- `no negative control` — a gate has no must-fail case. +- `DEPLOYED IDENTITY UNAVAILABLE` — the runner cannot reach the installed enforcing copy. The pinned observation is checked, but live equality is not asserted. +- `PROVIDER EVIDENCE ... ABSENT` — retained external history was unavailable; do not infer merge-time success. + +## Updating a gate + +1. Add or change the criterion and exact case. +2. Observe the case fail for its own stated reason. +3. Declare an exact inerting mutation and observe the verifier detect it. +4. If required and actual behavior differ, add a tracked remediation owner and justification. +5. If meaning changed, append provenance; never replace the original silently. +6. For an installed counterpart, verify live byte identity and update the observed digest only from measured evidence. +7. Run focused verifier tests, `pnpm gate:verify`, and the repository baseline gates. + +Do not add an ownerless exception or describe an open delta as pass/green/OK. + +## CI behavior + +Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. + +Provider evidence input is an optional JSON array of normalized pipeline records containing `commit`, unique integer pipeline `number`, pipeline `status`, and a `gate-verify` step status. The highest numbered rerun is authoritative; ambiguous duplicates fail. Its retention window is provider-controlled and is not overstated by this repository. + +Prior commit replay performs a frozen offline install from each commit's own lockfile before running that commit's verifier. Bubblewrap clears the environment, hides operator-home credentials, and disables networking for historical lifecycle and verifier code. A missing cached dependency or unavailable sandbox fails replay; current dependencies are never substituted. diff --git a/docs/DEVELOPER-GUIDE/README.md b/docs/DEVELOPER-GUIDE/README.md new file mode 100644 index 00000000..96b11d5b --- /dev/null +++ b/docs/DEVELOPER-GUIDE/README.md @@ -0,0 +1,3 @@ +# Developer Guide + +- [Gate registry and negative controls](quality-gate-registry.md) diff --git a/docs/DEVELOPER-GUIDE/quality-gate-registry.md b/docs/DEVELOPER-GUIDE/quality-gate-registry.md new file mode 100644 index 00000000..acedbdaf --- /dev/null +++ b/docs/DEVELOPER-GUIDE/quality-gate-registry.md @@ -0,0 +1,41 @@ +# Gate Registry and Negative Controls + +`gates/gates.manifest.json` is the machine-readable registry for the initial RM-02 gate slice. Run: + +```bash +pnpm gate:verify +``` + +## Registered slice + +The registry covers root typecheck, lint, and format checks; RM-01 checkout preflight; the Mosaic CI queue guard; and root Husky pre-commit/pre-push hooks. It does not imply repository-wide coverage. Framework scripts, package-local build/test scripts, templates, and deployment/release scripts remain assigned to RM-54. + +Every gate declares exact invocations, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. This proves detection of the **declared** inerting mutation, not every possible semantic weakening. + +## Required versus actual + +A case may record different `required` and `actual` outcomes only with a tracked owner. The verifier checks current reality against `actual`, prints each difference as `DEFECT (owner: ...)`, and fails when behavior changes without a matching registry update. A defect is never described as passing, green, or OK. + +The queue guard currently has RM-03-owned deltas. In particular, its stdin/heredoc classifier does not consume piped status JSON, so terminal-success, no-status, and terminal-failure payloads become `unknown`; unknown and malformed states exit zero; push purpose defaults to `main`. RM-02 records these observations and does not edit the guard. + +## Criteria, prose, and compatibility + +Each criterion must bind to a must-fail case. Designated governing prose uses `GATE-CLAIM:` markers; an unbound marker or registered-but-missing marker fails. Orchestrator-owned claims from `TASKS.md` are bound through `docs/remediation/GATE-CLAIMS.md`, which records source headings and anchored text without changing task tracking. Marker completeness still requires RM-54 review because arbitrary English claims cannot be inferred safely. + +Compatibility checks detect direct contradictions in declared finite constructions. The verifier combines referenced case fixtures and environments in one isolated tree, rejects conflicting fixture/environment values, executes the construction's exact invocation, and checks its exact outcome. They do not prove semantic consistency of arbitrary natural language. + +Restatements preserve original text, current text, reason, finding/task, and date. + +## Source and deployed identity + +A gate with an external installed counterpart declares it explicitly. When the installed queue guard is reachable, its bytes must equal repository source and an internal drift control is observed red. In CI the operator-home installation may be outside the container; the verifier checks the pinned observed source digest, reports `DEPLOYED IDENTITY UNAVAILABLE (owner: RM-04)`, and does not infer live equality. + +## Commit and provider boundary + +The current checkout is evaluated directly. On feature branches and main, each prior prospective commit is archived from Git, receives a frozen offline install from that commit's lockfile, and runs that commit's own verifier and manifest. Missing cached dependencies or an unrunnable historical verifier fail loudly rather than borrowing current-tree dependencies. + +Historical install scripts and verifiers execute inside Bubblewrap with network, PID, IPC, and UTS namespaces isolated; a cleared/allowlisted environment; an isolated home; a writable replay tree; read-only system files; and a read-only pnpm store. Current CI secrets, sibling runner processes, and the operator home are not visible inside that boundary. Because lifecycle scripts are required for faithful installs, the verifier snapshots every archived file before install and fails if any authoritative file changes, disappears, or changes type/mode before replay. Replay fails when this sandbox or integrity check cannot be established. + +Retained provider evidence can assert terminal-success for prior commits when supplied through `GATE_PROVIDER_EVIDENCE_FILE`. Each normalized record contains `commit`, unique integer pipeline `number`, pipeline `status`, and step statuses; the highest-numbered rerun is authoritative. Ambiguous duplicates fail. Absent, expired, or currently-running evidence is reported explicitly and never inferred as success. + +Repository replay proves tree reproducibility under the selected commit's locked dependency graph. It does not prove that CI blocked a merge at the time or resist an actor who can rewrite the verifier, registry, and gate consistently. RM-25/RM-59 own that external authority and trust anchor. diff --git a/docs/PRD.md b/docs/PRD.md index 77ccd609..59e96469 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -14,6 +14,49 @@ --- +## RM-02 Gate Registry and Negative-Control Verifier (#1029) + +### Problem and objective + +Existing deterministic gates can return success without enforcing their stated property. RM-02 introduces a machine-readable registry and an unconditional CI verifier that distinguishes required behavior from observed behavior, proves every registered negative control can detect its own failure reason, and makes source/deployment drift visible. + +### Scope + +**In scope:** root typecheck, lint, and format gates; RM-01 checkout preflight; the Mosaic CI queue guard; root Husky pre-commit and pre-push hooks; criterion bindings; modeled compatibility; meaning-change provenance; security/integrity prose claim markers; source-versus-deployed identity; prospective per-commit tree replay; retained provider CI evidence where available. + +**Out of scope:** fixing the queue guard (RM-03); exhaustive registration of every repository executable (RM-54); semantic proof that arbitrary English criteria are mutually satisfiable (RM-54/RM-55); a same-authority trust anchor for repository-authored evidence (RM-25/RM-59). + +### Normative requirements + +1. `RM02-REQ-01`: The JSON registry SHALL give every gate and criterion a stable ID and SHALL declare exact invocation, input classes, cases, exact observed and required exit codes, reason diagnostics, and criterion bindings. +2. `RM02-REQ-02`: Every gate SHALL have at least one observed-red must-fail case. The verifier SHALL reject missing, stale, ambiguous, or ineffective declared inert mutations and SHALL name an externally inerted gate. +3. `RM02-REQ-03`: Every acceptance criterion SHALL bind to a case that can fail for that criterion's stated reason. Security/integrity prose claims in the designated governing documents SHALL carry bound `GATE-CLAIM:` markers. +4. `RM02-REQ-04`: Declared finite compatibility scenarios SHALL execute together and direct modeled contradictions SHALL fail. This does not claim semantic consistency of arbitrary English. +5. `RM02-REQ-05`: Restated criteria SHALL retain original text, current text, reason, finding/task, and dated meaning-change history. +6. `RM02-REQ-06`: An observed behavior differing from required behavior SHALL be reported as `DEFECT` with a tracked owner; an ownerless delta SHALL fail verification. Such a gate SHALL never be described as passing, green, or OK. +7. `RM02-REQ-07`: Executables under declared gate roots SHALL fail with `unregistered gate` when absent from the registry. The initial coverage boundary SHALL explicitly list exclusions and bind the broader inventory to RM-54. +8. `RM02-REQ-08`: Every gate with a deployed counterpart SHALL register source/deployed byte identity and a must-fail drift control. Gates without a deployed counterpart SHALL say so explicitly. +9. `RM02-REQ-09`: CI SHALL run `pnpm gate:verify` on every pull request without path filtering and on protected-main pushes. +10. `RM02-REQ-10`: Verification SHALL replay prospective first-parent commits from the activation boundary against each commit's own tree. It SHALL distinguish reproducibility replay from retained external provider evidence and SHALL state when provider history is absent, expired, or still running. + +### Acceptance criteria + +1. `RM02-AC-01`: A healthy current tree returns zero while prominently reporting the queue guard's required-versus-actual `DEFECT (owner: RM-03)` delta. +2. `RM02-AC-02`: Externally mutate any registered gate at its declared inerting point so its failure path succeeds; verification returns nonzero and names that gate. The verifier's internal meta-control is observed red before its healthy result is trusted. +3. `RM02-AC-03`: An executable added under a declared gate root without an entry returns nonzero and includes `unregistered gate`. +4. `RM02-AC-04`: A gate with zero must-fail cases returns nonzero and includes `no negative control`. +5. `RM02-AC-05`: Unbound criteria, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. +6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request. +7. `RM02-AC-07`: Per-commit replay uses the selected commit's manifest and tree rather than current main, while external CI evidence is asserted only for the provider-retained window and never inferred when unavailable. + +### Risks, dependencies, and verification boundary + +- The repository verifier proves declared controls, modeled scenarios, source/deployed equality at execution time, and prospective tree reproducibility. It does **not** defend against an actor able to rewrite the gate, registry, and verifier consistently. +- External branch protection and provider CI history supply merge-time evidence where retained. RM-25/RM-59 track the authority/trust anchor outside the worktree. +- `ASSUMPTION:` RM-54 is the owner for expanding registration and prose-marker coverage beyond this approved seven-gate slice; rationale: the remediation task graph already assigns the fleet-wide inert-gate audit there. + +--- + ## Problem Statement Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and Next.js frontend. It handles chat, projects, tasks, and LLM routing but lacks orchestration depth, agent coordination, shared memory, and remote access. The Mosaic framework (`~/.config/mosaic`) provides agent guides, shell-based orchestration tools, and quality rails — but these are loose scripts, not an integrated platform. The `@mosaicstack/*` packages in mosaic-mono-v0 began consolidating these into TypeScript packages (brain, queue, coord, cli, prdy, quality-rails) but have no UI, no auth, and no agent runtime integration. diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index 3f5a296c..a8f6b24c 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -1,5 +1,11 @@ # Documentation Sitemap +## Gate verification + +- [Developer gate registry guide](DEVELOPER-GUIDE/quality-gate-registry.md) — manifest schema, negative controls, defect deltas, modeled boundaries, and commit/provider evidence. +- [Gate registry operations](ADMIN-GUIDE/quality-gate-registry.md) — routine verification, failure interpretation, registry updates, and unconditional CI behavior. +- [RM-02 governing claim index](remediation/GATE-CLAIMS.md) — marker bindings for orchestrator-owned remediation claims without modifying task tracking. + ## Compaction refresh lease broker - [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings. diff --git a/docs/plans/2026-08-01-rm-02-gate-registry.md b/docs/plans/2026-08-01-rm-02-gate-registry.md new file mode 100644 index 00000000..84d0b780 --- /dev/null +++ b/docs/plans/2026-08-01-rm-02-gate-registry.md @@ -0,0 +1,94 @@ +# RM-02 Gate Registry Implementation Plan + +> **For Pi:** Use test-driven development and execute each task RED → GREEN → refactor. + +**Goal:** Build a machine-readable seven-gate registry and an unconditional CI verifier that detects inert gates, binds criteria to observed negative controls, records defects honestly, and replays prospective commits against their own trees. + +**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json`, validates its closed schema and references, then runs typed cases in isolated main-disk fixtures. Gate-specific fixture setup remains declarative; exact invocations and exact observed/required exits stay in JSON. A separate history module selects each first-parent commit's own tree/manifest and reports retained external CI evidence without inferring missing evidence. + +**Tech Stack:** Node.js ESM, `node:test`, JSON, shell gates, pnpm, Woodpecker CI. + +--- + +### Task 1: Meta-negative-control kernel + +**Files:** + +- Create: `scripts/gate-verify.test.mjs` +- Create: `scripts/gate-verify.mjs` + +1. Write a black-box fixture whose gate failure branch has already been changed to success. +2. Run `node --test scripts/gate-verify.test.mjs`; require failure because the absent verifier does not name the inert gate. +3. Implement manifest loading, exact process execution, and named mismatch reporting only. +4. Re-run and require the external inert mutation to produce verifier nonzero while the test passes. +5. Add an internally applied declared mutation and require a healthy fixture to report its negative control armed. + +### Task 2: Registry structural clauses + +**Files:** + +- Modify: `scripts/gate-verify.test.mjs` +- Modify: `scripts/gate-verify.mjs` + +Add failing tests, one behavior at a time, for: `unregistered gate`; `no negative control`; unbound criterion; unbound `GATE-CLAIM`; ownerless required/actual delta; stale/ambiguous/ineffective mutation; direct modeled conflict; missing meaning-change provenance. Implement the minimum validator after each observed RED. + +### Task 3: Gate fixtures and seven-gate manifest + +**Files:** + +- Create: `gates/gates.manifest.json` +- Create: `gates/fixtures/quality-case.mjs` +- Create: `gates/fixtures/preflight-case.mjs` +- Create: `gates/fixtures/queue-case.sh` +- Create: `gates/fixtures/hook-case.sh` +- Modify: `scripts/gate-verify.test.mjs` + +Register typecheck, lint, format, RM-01 preflight, queue guard, pre-commit, and pre-push. For each, first run its known-bad case against an intentionally inert fixture and observe verifier RED; then restore the real gate behavior and require its exact observed exit/reason. Record queue defects as required/actual deltas owned by RM-03, never as pass/green/OK. + +### Task 4: Source-versus-deployed identity + +**Files:** + +- Modify: `gates/gates.manifest.json` +- Modify: `scripts/gate-verify.test.mjs` +- Modify: `scripts/gate-verify.mjs` + +Write and observe a failing test with a byte-mutated deployed counterpart. Implement byte equality and internal drift mutation controls. Declare `none` explicitly for gates without deployed counterparts. + +### Task 5: Prose claims and compatibility constructions + +**Files:** + +- Modify: `docs/remediation/MISSION.md` +- Modify: `docs/remediation/TASKS.md` only if coordinator authorization overrides the worker prohibition; otherwise place markers in an RM-02 claim index that references immutable source anchors. +- Modify: `gates/gates.manifest.json` +- Modify: verifier tests/implementation. + +Enumerate current security/integrity claims, bind each marker/id to a negative case, and reject unbound markers. Execute finite compatibility scenarios and clearly document that arbitrary English consistency is outside the model. + +### Task 6: Prospective per-commit replay and provider evidence + +**Files:** + +- Create: `scripts/gate-history.mjs` +- Create: `scripts/gate-history.test.mjs` +- Modify: `scripts/gate-verify.mjs` +- Modify: `gates/gates.manifest.json` + +Test with a synthetic git repository containing two commits whose manifests differ; prove replay reads each selected commit's tree. Add bounded Gitea/Woodpecker status lookup for prior commits when credentials/history are available. Missing, expired, and currently-running evidence must be explicit states, never inferred success. + +### Task 7: CI and documentation + +**Files:** + +- Modify: `package.json` +- Modify: `.woodpecker/ci.yml` +- Create/update: `docs/DEVELOPER-GUIDE/quality-gate-registry.md` +- Create/update: `docs/ADMIN-GUIDE/quality-gate-registry.md` +- Modify: `docs/SITEMAP.md` + +Add `gate:verify`; run it in an unconditional PR/main CI step. Document invocation, defect semantics, coverage/exclusions, marker syntax, replay/provider boundaries, and source/deployed identity. + +### Task 8: Verification and delivery + +Run focused tests, `pnpm gate:verify`, checkout tests, typecheck, lint, format, and applicable integration tests. Run Codex code and security review; remediate and re-review. Verify authorship, commit, execute queue guard before push, push, create PR via Mosaic wrapper, and send exact-head review request to rev-974 through the coordinator. Do not merge without coordinator authorization. diff --git a/docs/remediation/GATE-CLAIMS.md b/docs/remediation/GATE-CLAIMS.md new file mode 100644 index 00000000..dd77c2e2 --- /dev/null +++ b/docs/remediation/GATE-CLAIMS.md @@ -0,0 +1,24 @@ +# RM-02 Governing Claim Index + +This index binds remediation claims that live in orchestrator-owned `TASKS.md` without modifying that file. The source heading and quoted text are the reviewable anchor; `gate:verify` enforces each marker-to-criterion binding. Marker completeness beyond this approved slice remains RM-54. + +## Prose is an enforceable claim + + + +- Source: `docs/remediation/TASKS.md`, heading `D-20 — the orchestrator's own documentation overclaimed, and a reviewer disproved it empirically`. +- Anchored text: “The defect was not in the code — it was in this file.” + +## Generated-state verification scope + + + +- Source: `docs/remediation/TASKS.md`, heading `D-19 — an integrity property that cannot exist at the layer it was specified`. +- Anchored text: “a verifier that cannot detect the attack is not a verifier.” + +## Criterion restatement provenance + + + +- Source: `docs/remediation/TASKS.md`, heading `D-18 — two pre-registered criteria were mutually unsatisfiable, discoverable only at implementation`. +- Anchored text: “Both criteria were pre-registered. They cannot both be satisfied.” diff --git a/docs/remediation/MISSION.md b/docs/remediation/MISSION.md index 65df03a3..90ed6312 100644 --- a/docs/remediation/MISSION.md +++ b/docs/remediation/MISSION.md @@ -12,6 +12,8 @@ gate/program; the LLM handles only genuine judgment. ### First-class principle — observe the property, not the exit code + + > **No write is done until the requested PROPERTY is observed. A success exit code is not evidence.** > > **Success output is designed to be believed.** That is the whole reason the inert-gate class exists @@ -30,6 +32,8 @@ gate/program; the LLM handles only genuine judgment. ### First-class principle — pre-registration prevents retrofitting, and nothing else + + > **A pre-registered check set can fail in three distinct ways:** > > | mode | the set is… | found as | @@ -56,6 +60,8 @@ gate/program; the LLM handles only genuine judgment. ### Corollary — never ship an integrity claim dressed as a property + + > A verification artifact that can be forged by whoever it is meant to catch verifies nothing. If a > manifest, marker, ledger, or receipt is writable by the same actor whose behaviour it certifies, it > **certifies the attack.** Such an artifact must sit inside the integrity envelope it belongs to, @@ -68,6 +74,8 @@ gate/program; the LLM handles only genuine judgment. ### First-class principle — when a property cannot exist at the layer it was specified + + > Some required properties are **impossible at the layer that asked for them** — not hard, impossible. > A local check cannot defend against an actor who can rewrite the check itself. When that happens, > there are exactly three honest moves, and all three are mandatory: diff --git a/docs/scratchpads/1029-rm-02-gate-registry.md b/docs/scratchpads/1029-rm-02-gate-registry.md new file mode 100644 index 00000000..673e5b8c --- /dev/null +++ b/docs/scratchpads/1029-rm-02-gate-registry.md @@ -0,0 +1,73 @@ +# RM-02 Gate Registry Scratchpad (#1029) + +## Objective + +Deliver the seven-gate registry and RED-first anti-inert verifier on `feat/rm-02-gate-registry`, preserving required-versus-actual defects without laundering them as success. + +## Constraints and boundaries + +- Do not modify `ci-queue-wait.sh`; RM-03 owns that fix. +- Do not modify `docs/remediation/TASKS.md`; workers cannot edit orchestrator tracking. +- Every declared behavior must be observed, not inferred from an exit code. +- Repository-local evidence does not establish same-authority tamper resistance; RM-25/RM-59 own the external trust anchor. +- Coverage is seven logical gates; broader inventory is RM-54. +- Budget assumption: no explicit token ceiling was supplied. Keep implementation dependency-free (stock Node), avoid repeated full monorepo installs, and keep generated test artifacts under the worktree/main disk. + +## Plan + +1. Update PRD and tracking references. +2. Write black-box meta-negative-control first and observe it fail for the missing verifier behavior. +3. Implement minimal manifest parser/case runner/mutation detector; observe meta-control succeed. +4. Add schema, coverage, provenance, prose marker, compatibility, discovery, deployment identity, and defect-delta tests RED-first. +5. Register seven gates with exact cases and run each case. +6. Add prospective per-commit replay and bounded provider-evidence reporting. +7. Wire unconditional Woodpecker CI and update developer/admin documentation. +8. Run situational and baseline verification, independent Codex review, remediate, commit, queue guard, push, PR, coordinator/reviewer handoff. + +## Progress + +- 2026-08-01: Design approved by `mos-remediation`; rulings A-E and source/deployed identity addition incorporated. +- 2026-08-01: Isolated worktree created from `origin/main` f65e9ea6; RM-01 f58b3699 verified as ancestor. +- 2026-08-01: Git author set to `f10-coder `; provider issue #1029 created with `MOSAIC_GIT_IDENTITY=f10-coder`. +- 2026-08-01: Source/deployed queue-guard SHA-256 observed equal (`19cda2f...`); this observation is not yet an enforced property. + +## Tests and RED-first evidence + +- Meta-negative RED first: `node --test scripts/gate-verify.test.mjs` failed because the absent verifier did not name externally inerted `meta-fixture`. +- Structural RED: nine tests failed before implementation for internal mutation, unregistered executable, missing negative control, ownerless delta, unbound criterion/claim, modeled conflict, missing provenance, and deployment drift. +- Clause hardening RED: positive-only security criterion and missing registered claim marker both passed incorrectly before validation was added. +- CI wiring RED: package script and unconditional Woodpecker step tests both failed before wiring. +- History RED: history test failed with missing module before own-tree manifest selection/provider classification was implemented. +- `pnpm gate:verify`: exit 0; seven gates each reported `META-NEGATIVE-CONTROL ... observed red`; queue source/deployed drift control observed red; six queue behavior deltas printed as `DEFECT (owner: RM-03)`. +- Focused Node tests: 33/33 pass after review hardening (23 verifier/wiring plus 10 history/provider tests). +- `pnpm typecheck`: pass (45/45 Turbo tasks). +- `pnpm lint`: pass (25/25 Turbo tasks). +- `pnpm format:check`: pass. +- `pnpm test`: repository suites reached 45/46 Turbo tasks; all application/package tests shown passed, then the known host-specific wake assertion aborted exit 97 (`BASH_LINENO convention violated`, #973/D-16). No test was edited or bypassed. CI remains the authoritative full-suite environment. + +## Self-surfaced defect + +The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while provider JSON is piped to the shell function. The heredoc owns stdin, so Python never reads provider JSON. Terminal success, no-status, terminal failure, and malformed payloads all classify as `unknown`; the associated fail-open outcomes are recorded under RM-03. No queue-guard source was modified. + +## Independent review remediation + +- Final pre-commit Codex code review found three blockers: a tautological deployment drift control, independently observed rather than combined compatibility cases, and unauthorized edits to orchestrator-owned `TASKS.md`. +- Deployment identity now uses one shared file comparator for both live equality and a temporary drifted deployed copy; a test makes that comparator inert and proves the meta-control fails. +- Compatibility scenarios now merge referenced fixtures/environments into one isolated construction and execute an exact scenario invocation; a test proves two independently valid fixtures coexist in the combined run. +- `TASKS.md` changes were reverted. `docs/remediation/GATE-CLAIMS.md` binds source headings and anchored text without editing orchestrator tracking. +- Codex security review found the Bubblewrap replay shared the runner PID namespace. Replay now unshares PID, IPC, and UTS namespaces, and an abuse-case test proves a sibling runner PID is invisible. +- Second review found empty reason diagnostics, final-symlink fixture writes, and lifecycle-script mutation of authoritative history files. Must-fail cases now require a reason pattern; writes use no-follow semantics; and replay snapshots every archived file before install and rejects any changed, deleted, or type/mode-shifted source before executing the verifier. Dedicated negative tests cover all three. +- Third code review found ambiguous duplicate provider steps and order-sensitive JSON outcome comparison. Provider evidence now requires exactly one `gate-verify` step in the authoritative rerun, and structural equality normalizes object keys. Both regressions have RED-first tests. Third security review reported no findings. + +## Documentation checklist + +- PRD, developer guide, admin guide, governing claim index, sitemap, plan, and scratchpad updated. +- User/API documentation not applicable: no user workflow or API changed. +- Independent review documentation check pending rev-974. +- Canonical documentation remains in-repository; no external publication requested. + +## Risks/blockers + +- Current queue guard intentionally has required-versus-actual deltas owned by RM-03. +- Provider CI cannot report the currently executing pipeline as terminal success; current-commit evidence must be labeled pending and becomes historical evidence only after provider completion. +- CI containers may not expose the operator-home deployed queue guard. In that layer the verifier checks the pinned observed digest and reports live identity unavailable under RM-04; it does not infer live equality. diff --git a/gates/gates.manifest.json b/gates/gates.manifest.json new file mode 100644 index 00000000..a6d8fa76 --- /dev/null +++ b/gates/gates.manifest.json @@ -0,0 +1,1016 @@ +{ + "schemaVersion": 1, + "activationCommit": "f65e9ea656ec466e12640bf6ab5d46fe07ff160c", + "gateRoots": ["gates"], + "governingClaimFiles": ["docs/remediation/MISSION.md", "docs/remediation/GATE-CLAIMS.md"], + "coverageBoundary": { + "included": [ + "root typecheck, lint, and format scripts", + "RM-01 checkout preflight", + "Mosaic CI queue guard", + "root Husky pre-commit and pre-push hooks" + ], + "excluded": [ + "framework quality scripts other than ci-queue-wait.sh", + "package-local test and build scripts", + "template hooks and generated Husky dispatcher files", + "deployment and release scripts" + ], + "trackedBy": "RM-54" + }, + "criteria": [ + { + "id": "RM02-CHECK-RIGHT", + "originalText": "Every registered check is observed red for its own stated reason before its green counts.", + "currentText": "Every registered check is observed red for its own stated reason before its green counts.", + "claimType": "integrity", + "source": "docs/remediation/MISSION.md#first-class-principle-pre-registration", + "meaningChanges": [] + }, + { + "id": "RM02-SET-COVERS", + "originalText": "Every acceptance criterion is bound to the specific case that exercises it.", + "currentText": "Every acceptance criterion is bound to the specific case that exercises it.", + "claimType": "integrity", + "source": "docs/remediation/TASKS.md#d-17", + "meaningChanges": [] + }, + { + "id": "RM02-MODELED-CONSISTENCY", + "originalText": "No two declared finite scenarios require mutually unsatisfiable outcomes.", + "currentText": "No two declared finite scenarios require mutually unsatisfiable outcomes; arbitrary English consistency is outside this model.", + "claimType": "integrity", + "source": "docs/remediation/TASKS.md#d-18", + "meaningChanges": [ + { + "originalText": "No two criteria conflict.", + "restatement": "No two declared finite scenarios require mutually unsatisfiable outcomes; arbitrary English consistency is outside this model.", + "reason": "Semantic satisfiability of arbitrary natural language is undecidable at this layer.", + "finding": "D-18", + "task": "RM-54/RM-55", + "date": "2026-08-01" + } + ] + }, + { + "id": "RM02-MEANING-PROVENANCE", + "originalText": "A restated criterion retains original text, restatement, and reason.", + "currentText": "A restated criterion retains original text, restatement, and reason.", + "claimType": "integrity", + "source": "docs/remediation/TASKS.md#d-18", + "meaningChanges": [] + }, + { + "id": "RM02-PROSE-CONTROL", + "originalText": "Security and integrity claims in governing prose require a bound negative control.", + "currentText": "Marked security and integrity claims in designated governing prose require a bound negative control; RM-54 audits marker completeness.", + "claimType": "security", + "source": "docs/remediation/TASKS.md#d-20", + "meaningChanges": [ + { + "originalText": "Security and integrity claims in governing prose require a bound negative control.", + "restatement": "Marked security and integrity claims in designated governing prose require a bound negative control; RM-54 audits marker completeness.", + "reason": "A local verifier cannot safely infer every semantic claim in arbitrary English.", + "finding": "D-20", + "task": "RM-54", + "date": "2026-08-01" + } + ] + }, + { + "id": "QUALITY-TYPECHECK", + "originalText": "The root typecheck rejects a TypeScript type error.", + "currentText": "The root typecheck rejects a TypeScript type error.", + "claimType": "quality", + "source": "package.json#scripts.typecheck", + "meaningChanges": [] + }, + { + "id": "QUALITY-LINT", + "originalText": "The root lint gate rejects invalid TypeScript syntax.", + "currentText": "The root lint gate rejects invalid TypeScript syntax.", + "claimType": "quality", + "source": "package.json#scripts.lint", + "meaningChanges": [] + }, + { + "id": "QUALITY-FORMAT", + "originalText": "The root format gate rejects an unformatted tracked-format input.", + "currentText": "The root format gate rejects an unformatted tracked-format input.", + "claimType": "quality", + "source": "package.json#scripts.format:check", + "meaningChanges": [] + }, + { + "id": "CHECKOUT-PREFLIGHT", + "originalText": "The RM-01 preflight rejects stale or independently mutated generated state within its documented threat model.", + "currentText": "The RM-01 preflight rejects stale or independently mutated generated state, but not a same-UID consistent rewrite; RM-59 owns the external trust anchor.", + "claimType": "integrity", + "source": "scripts/preflight.mjs", + "meaningChanges": [ + { + "originalText": "The generated-state manifest turns integrity from a claim into a property.", + "restatement": "The preflight detects accidental, independent, stale, and foreign-residue mutation, but not a same-UID consistent rewrite.", + "reason": "D-19 established CWE-345 self-authentication and D-20 recorded the empirical disproof.", + "finding": "D-19/D-20", + "task": "RM-59", + "date": "2026-07-31" + } + ] + }, + { + "id": "QUEUE-GUARD", + "originalText": "The queue guard distinguishes ready, pending, indeterminate, and failed CI states without silently accepting indeterminate or failed states.", + "currentText": "The queue guard is measured against its current behavior while required-versus-actual deltas remain owned by RM-03.", + "claimType": "integrity", + "source": "packages/mosaic/framework/tools/git/ci-queue-wait.sh", + "meaningChanges": [ + { + "originalText": "The required queue guard blocks unsafe push and merge states.", + "restatement": "The registry measures current fail-open states and reports each as DEFECT until RM-03 changes the gate and registry together.", + "reason": "D-6/D-10 observed unknown, no-status, malformed, terminal-failure, and wrong-branch behavior.", + "finding": "D-6/D-10", + "task": "RM-03", + "date": "2026-08-01" + } + ] + }, + { + "id": "HOOK-PRE-COMMIT", + "originalText": "The pre-commit hook propagates lint-staged failure.", + "currentText": "The pre-commit hook propagates lint-staged failure.", + "claimType": "workflow", + "source": ".husky/pre-commit", + "meaningChanges": [] + }, + { + "id": "HOOK-PRE-PUSH", + "originalText": "The pre-push hook propagates each required gate failure.", + "currentText": "The pre-push hook propagates each required gate failure.", + "claimType": "workflow", + "source": ".husky/pre-push", + "meaningChanges": [] + }, + { + "id": "GATE-SOURCE-DEPLOYMENT", + "originalText": "A registered gate with a deployed counterpart verifies the artifact that actually enforces.", + "currentText": "A registered gate with a deployed counterpart requires byte equality and a deployed-copy drift negative control.", + "claimType": "integrity", + "source": "docs/PRD.md#rm-02-gate-registry-and-negative-control-verifier-1029", + "meaningChanges": [ + { + "originalText": "The registry verifies repository gate sources.", + "restatement": "The registry also verifies byte identity with each declared deployed counterpart.", + "reason": "A source-only check can certify a file that is not the installed enforcing artifact.", + "finding": "D-1/P-ACTIVATION-001", + "task": "RM-02", + "date": "2026-08-01" + } + ] + } + ], + "proseClaims": [ + { + "id": "OBSERVE-PROPERTY", + "criterionId": "RM02-CHECK-RIGHT" + }, + { + "id": "PREREGISTRATION-BOUNDARY", + "criterionId": "RM02-SET-COVERS" + }, + { + "id": "ARTIFACT-INTEGRITY-BOUNDARY", + "criterionId": "GATE-SOURCE-DEPLOYMENT" + }, + { + "id": "IMPOSSIBLE-LAYER-BOUNDARY", + "criterionId": "CHECKOUT-PREFLIGHT" + }, + { + "id": "PROSE-IS-A-CLAIM", + "criterionId": "RM02-PROSE-CONTROL" + }, + { + "id": "GENERATED-STATE-SCOPE", + "criterionId": "CHECKOUT-PREFLIGHT" + }, + { + "id": "CRITERION-RESTATEMENT", + "criterionId": "RM02-MEANING-PROVENANCE" + } + ], + "compatibilityScenarios": [ + { + "id": "QUALITY-AND-PREFLIGHT", + "construction": "clean-current-tree", + "caseRefs": [ + "quality-typecheck/clean-tree", + "quality-lint/clean-tree", + "quality-format/clean-tree", + "checkout-preflight/clean-tree" + ], + "invocation": [ + "sh", + "-c", + "pnpm typecheck && pnpm lint && pnpm format:check && node scripts/preflight.mjs" + ], + "expected": { + "exitCode": 0, + "outputPattern": "checkout preflight passed" + } + }, + { + "id": "HOOK-CHAIN", + "construction": "hook-propagation-model", + "caseRefs": ["hook-pre-commit/lint-staged-failure", "hook-pre-push/typecheck-failure"], + "invocation": [ + "sh", + "-c", + "sh .husky/pre-commit; pre_commit=$?; sh .husky/pre-push; pre_push=$?; test \"$pre_commit\" = 19 && test \"$pre_push\" = 19" + ], + "expected": { + "exitCode": 0, + "outputPattern": "FAKE_NPX_EXIT=19[\\s\\S]*FAKE_PNPM_FAILURE=typecheck" + } + } + ], + "mergeAssertions": { + "mode": "prospective-first-parent-replay", + "trustDependencies": ["RM-25", "RM-59"], + "providerEvidence": "assert retained terminal-success records for prior commits; report absent, expired, or current-running evidence without inference" + }, + "gates": [ + { + "id": "quality-typecheck", + "source": "package.json", + "invocation": ["pnpm", "typecheck"], + "deployment": { + "kind": "none", + "reason": "The package script in this checkout is the invoked artifact." + }, + "inertMutation": { + "file": "package.json", + "find": "\"typecheck\": \"pnpm preflight && turbo run typecheck\"", + "replace": "\"typecheck\": \"exit 0\"", + "caseId": "type-error", + "expected": { + "exitCode": 0, + "notOutputPattern": "__gate_typecheck__\\.ts" + } + }, + "cases": [ + { + "id": "clean-tree", + "criterionIds": ["QUALITY-TYPECHECK", "RM02-MODELED-CONSISTENCY"], + "mustFail": false, + "required": { + "exitCode": 0 + }, + "actual": { + "exitCode": 0 + }, + "reasonPattern": "" + }, + { + "id": "type-error", + "criterionIds": ["QUALITY-TYPECHECK", "RM02-CHECK-RIGHT", "RM02-SET-COVERS"], + "mustFail": true, + "required": { + "exitCode": 2, + "outputPattern": "Type 'number' is not assignable to type 'string'" + }, + "actual": { + "exitCode": 2, + "outputPattern": "Type 'number' is not assignable to type 'string'" + }, + "reasonPattern": "__gate_typecheck__\\.ts", + "environment": { + "TURBO_FORCE": "true" + }, + "fixture": { + "writeFiles": [ + { + "path": "packages/types/src/__gate_typecheck__.ts", + "content": "export const gateTypeError: string = 42;\n" + } + ] + } + } + ] + }, + { + "id": "quality-lint", + "source": "package.json", + "invocation": ["pnpm", "lint"], + "deployment": { + "kind": "none", + "reason": "The package script in this checkout is the invoked artifact." + }, + "inertMutation": { + "file": "package.json", + "find": "\"lint\": \"turbo run lint\"", + "replace": "\"lint\": \"exit 0\"", + "caseId": "invalid-syntax", + "expected": { + "exitCode": 0, + "notOutputPattern": "__gate_lint__\\.ts" + } + }, + "cases": [ + { + "id": "clean-tree", + "criterionIds": ["QUALITY-LINT", "RM02-MODELED-CONSISTENCY"], + "mustFail": false, + "required": { + "exitCode": 0 + }, + "actual": { + "exitCode": 0 + }, + "reasonPattern": "" + }, + { + "id": "invalid-syntax", + "criterionIds": ["QUALITY-LINT", "RM02-CHECK-RIGHT", "RM02-SET-COVERS"], + "mustFail": true, + "required": { + "exitCode": 1, + "outputPattern": "Parsing error" + }, + "actual": { + "exitCode": 1, + "outputPattern": "Parsing error" + }, + "reasonPattern": "__gate_lint__\\.ts", + "environment": { + "TURBO_FORCE": "true" + }, + "fixture": { + "writeFiles": [ + { + "path": "packages/types/src/__gate_lint__.ts", + "content": "export const = ;\n" + } + ] + } + } + ] + }, + { + "id": "quality-format", + "source": "package.json", + "invocation": ["pnpm", "format:check"], + "deployment": { + "kind": "none", + "reason": "The package script in this checkout is the invoked artifact." + }, + "inertMutation": { + "file": "package.json", + "find": "\"format:check\": \"prettier --check \\\"**/*.{ts,tsx,js,jsx,json,md}\\\"\"", + "replace": "\"format:check\": \"exit 0\"", + "caseId": "unformatted-json", + "expected": { + "exitCode": 0, + "notOutputPattern": "__gate_format__\\.json" + } + }, + "cases": [ + { + "id": "clean-tree", + "criterionIds": ["QUALITY-FORMAT", "RM02-MODELED-CONSISTENCY"], + "mustFail": false, + "required": { + "exitCode": 0 + }, + "actual": { + "exitCode": 0 + }, + "reasonPattern": "" + }, + { + "id": "unformatted-json", + "criterionIds": ["QUALITY-FORMAT", "RM02-CHECK-RIGHT", "RM02-SET-COVERS"], + "mustFail": true, + "required": { + "exitCode": 1, + "outputPattern": "__gate_format__\\.json" + }, + "actual": { + "exitCode": 1, + "outputPattern": "__gate_format__\\.json" + }, + "reasonPattern": "Code style issues found", + "fixture": { + "writeFiles": [ + { + "path": "gates/__gate_format__.json", + "content": "{\"bad\":true,\"spacing\":[1,2,3]}\n" + } + ] + } + } + ] + }, + { + "id": "checkout-preflight", + "source": "scripts/preflight.mjs", + "invocation": ["node", "scripts/preflight.mjs"], + "deployment": { + "kind": "none", + "reason": "The checkout source is the directly invoked runtime artifact." + }, + "inertMutation": { + "file": "scripts/preflight.mjs", + "find": "code: GENERATED_STATE_EXIT,\n message: `MOSAIC_PREFLIGHT_GENERATED_STATE: web build is in progress", + "replace": "code: 0,\n message: `MOSAIC_PREFLIGHT_GENERATED_STATE: web build is in progress", + "caseId": "stale-build-lock", + "expected": { + "exitCode": 0, + "outputPattern": "MOSAIC_PREFLIGHT_GENERATED_STATE" + } + }, + "cases": [ + { + "id": "clean-tree", + "criterionIds": ["CHECKOUT-PREFLIGHT", "RM02-MODELED-CONSISTENCY"], + "mustFail": false, + "required": { + "exitCode": 0, + "outputPattern": "checkout preflight passed" + }, + "actual": { + "exitCode": 0, + "outputPattern": "checkout preflight passed" + }, + "reasonPattern": "" + }, + { + "id": "stale-build-lock", + "criterionIds": [ + "CHECKOUT-PREFLIGHT", + "RM02-CHECK-RIGHT", + "RM02-SET-COVERS", + "RM02-MEANING-PROVENANCE", + "RM02-PROSE-CONTROL" + ], + "mustFail": true, + "required": { + "exitCode": 43, + "outputPattern": "web build is in progress or interrupted" + }, + "actual": { + "exitCode": 43, + "outputPattern": "web build is in progress or interrupted" + }, + "reasonPattern": "MOSAIC_PREFLIGHT_GENERATED_STATE", + "fixture": { + "writeFiles": [ + { + "path": ".mosaic-test-work/web-build.lock", + "content": "negative control\n" + } + ] + } + } + ] + }, + { + "id": "ci-queue-wait", + "source": "packages/mosaic/framework/tools/git/ci-queue-wait.sh", + "invocation": [ + "bash", + "packages/mosaic/framework/tools/git/ci-queue-wait.sh", + "--purpose", + "push", + "-B", + "gate-fixture", + "-t", + "1", + "-i", + "0" + ], + "deployment": { + "kind": "file", + "source": "packages/mosaic/framework/tools/git/ci-queue-wait.sh", + "path": "${HOME}/.config/mosaic/tools/git/ci-queue-wait.sh", + "observedSha256": "19cda2f7009c536eb4da9a8df0e7a62f3db0277c9a577bdae5db003f4da3f3cb", + "unavailableOwner": "RM-04" + }, + "inertMutation": { + "file": "packages/mosaic/framework/tools/git/ci-queue-wait.sh", + "find": "echo \"Unknown option: $1\" >&2\n usage >&2\n exit 1", + "replace": "echo \"Unknown option: $1\" >&2\n usage >&2\n exit 0", + "caseId": "unknown-option", + "sandboxFiles": ["packages/mosaic/framework/tools/git"], + "expected": { + "exitCode": 0, + "outputPattern": "Unknown option: --definitely-unknown" + } + }, + "cases": [ + { + "id": "terminal-success", + "criterionIds": ["QUEUE-GUARD", "GATE-SOURCE-DEPLOYMENT"], + "mustFail": false, + "required": { + "exitCode": 0, + "outputPattern": "state=terminal-success.*branch=gate-fixture" + }, + "actual": { + "exitCode": 0, + "outputPattern": "state=unknown.*branch=gate-fixture" + }, + "reasonPattern": "", + "environment": { + "PATH": "${ROOT}/gate-bin:${PATH}", + "GITEA_TOKEN": "gate-fixture-token", + "GATE_STATUS_JSON": "{\"state\":\"success\",\"statuses\":[]}" + }, + "fixture": { + "copyPaths": ["packages/mosaic/framework/tools/git"], + "writeFiles": [ + { + "path": ".git/config", + "content": "[core]\n\trepositoryformatversion = 0\n\tbare = false\n[remote \"origin\"]\n\turl = https://git.mosaicstack.dev/mosaicstack/stack.git\n" + }, + { + "path": ".git/HEAD", + "content": "ref: refs/heads/main\n" + }, + { + "path": ".git/objects/.keep", + "content": "" + }, + { + "path": ".git/refs/heads/.keep", + "content": "" + }, + { + "path": "gate-bin/curl", + "mode": 493, + "content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n" + } + ] + }, + "defect": { + "owner": "RM-03", + "reason": "The status classifier consumes its Python program from stdin, so piped provider JSON is not read and even terminal success becomes unknown." + } + }, + { + "id": "no-status-required", + "criterionIds": ["QUEUE-GUARD", "RM02-CHECK-RIGHT", "RM02-SET-COVERS"], + "mustFail": true, + "required": { + "exitCode": 1, + "outputPattern": "No CI status contexts" + }, + "actual": { + "exitCode": 0, + "outputPattern": "state=unknown" + }, + "reasonPattern": "state=unknown", + "invocation": [ + "bash", + "packages/mosaic/framework/tools/git/ci-queue-wait.sh", + "--purpose", + "push", + "-B", + "gate-fixture", + "--require-status", + "-t", + "1", + "-i", + "0" + ], + "environment": { + "PATH": "${ROOT}/gate-bin:${PATH}", + "GITEA_TOKEN": "gate-fixture-token", + "GATE_STATUS_JSON": "{\"statuses\":[]}" + }, + "fixture": { + "copyPaths": ["packages/mosaic/framework/tools/git"], + "writeFiles": [ + { + "path": ".git/config", + "content": "[core]\n\trepositoryformatversion = 0\n\tbare = false\n[remote \"origin\"]\n\turl = https://git.mosaicstack.dev/mosaicstack/stack.git\n" + }, + { + "path": ".git/HEAD", + "content": "ref: refs/heads/main\n" + }, + { + "path": ".git/objects/.keep", + "content": "" + }, + { + "path": ".git/refs/heads/.keep", + "content": "" + }, + { + "path": "gate-bin/curl", + "mode": 493, + "content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n" + } + ] + }, + "defect": { + "owner": "RM-03", + "reason": "The status classifier does not read the provider payload, so --require-status never reaches no-status." + } + }, + { + "id": "unknown-state", + "criterionIds": ["QUEUE-GUARD", "RM02-SET-COVERS"], + "mustFail": true, + "required": { + "exitCode": 1, + "outputPattern": "state=unknown" + }, + "actual": { + "exitCode": 0, + "outputPattern": "state=unknown" + }, + "defect": { + "owner": "RM-03", + "reason": "Unknown state currently exits zero (D-6/D-10)." + }, + "reasonPattern": "state=unknown", + "environment": { + "PATH": "${ROOT}/gate-bin:${PATH}", + "GITEA_TOKEN": "gate-fixture-token", + "GATE_STATUS_JSON": "{\"state\":\"mystery\",\"statuses\":[]}" + }, + "fixture": { + "copyPaths": ["packages/mosaic/framework/tools/git"], + "writeFiles": [ + { + "path": ".git/config", + "content": "[core]\n\trepositoryformatversion = 0\n\tbare = false\n[remote \"origin\"]\n\turl = https://git.mosaicstack.dev/mosaicstack/stack.git\n" + }, + { + "path": ".git/HEAD", + "content": "ref: refs/heads/main\n" + }, + { + "path": ".git/objects/.keep", + "content": "" + }, + { + "path": ".git/refs/heads/.keep", + "content": "" + }, + { + "path": "gate-bin/curl", + "mode": 493, + "content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n" + } + ] + } + }, + { + "id": "malformed-status", + "criterionIds": ["QUEUE-GUARD"], + "mustFail": true, + "required": { + "exitCode": 1, + "outputPattern": "state=unknown" + }, + "actual": { + "exitCode": 0, + "outputPattern": "state=unknown" + }, + "defect": { + "owner": "RM-03", + "reason": "Malformed provider payload currently becomes unknown and exits zero." + }, + "reasonPattern": "state=unknown", + "environment": { + "PATH": "${ROOT}/gate-bin:${PATH}", + "GITEA_TOKEN": "gate-fixture-token", + "GATE_STATUS_JSON": "not-json" + }, + "fixture": { + "copyPaths": ["packages/mosaic/framework/tools/git"], + "writeFiles": [ + { + "path": ".git/config", + "content": "[core]\n\trepositoryformatversion = 0\n\tbare = false\n[remote \"origin\"]\n\turl = https://git.mosaicstack.dev/mosaicstack/stack.git\n" + }, + { + "path": ".git/HEAD", + "content": "ref: refs/heads/main\n" + }, + { + "path": ".git/objects/.keep", + "content": "" + }, + { + "path": ".git/refs/heads/.keep", + "content": "" + }, + { + "path": "gate-bin/curl", + "mode": 493, + "content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n" + } + ] + } + }, + { + "id": "terminal-failure", + "criterionIds": ["QUEUE-GUARD"], + "mustFail": true, + "required": { + "exitCode": 1, + "outputPattern": "state=terminal-failure" + }, + "actual": { + "exitCode": 0, + "outputPattern": "state=unknown" + }, + "defect": { + "owner": "RM-03", + "reason": "The classifier does not read the provider payload; terminal failure becomes unknown and exits zero." + }, + "reasonPattern": "state=unknown", + "environment": { + "PATH": "${ROOT}/gate-bin:${PATH}", + "GITEA_TOKEN": "gate-fixture-token", + "GATE_STATUS_JSON": "{\"state\":\"failure\",\"statuses\":[]}" + }, + "fixture": { + "copyPaths": ["packages/mosaic/framework/tools/git"], + "writeFiles": [ + { + "path": ".git/config", + "content": "[core]\n\trepositoryformatversion = 0\n\tbare = false\n[remote \"origin\"]\n\turl = https://git.mosaicstack.dev/mosaicstack/stack.git\n" + }, + { + "path": ".git/HEAD", + "content": "ref: refs/heads/main\n" + }, + { + "path": ".git/objects/.keep", + "content": "" + }, + { + "path": ".git/refs/heads/.keep", + "content": "" + }, + { + "path": "gate-bin/curl", + "mode": 493, + "content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n" + } + ] + } + }, + { + "id": "push-defaults-to-main", + "criterionIds": ["QUEUE-GUARD"], + "mustFail": true, + "required": { + "exitCode": 0, + "outputPattern": "branch=feat/rm-02-gate-registry" + }, + "actual": { + "exitCode": 0, + "outputPattern": "branch=main" + }, + "defect": { + "owner": "RM-03", + "reason": "Push purpose defaults to main instead of the acted-on branch." + }, + "reasonPattern": "purpose=push.*branch=main", + "invocation": [ + "bash", + "packages/mosaic/framework/tools/git/ci-queue-wait.sh", + "--purpose", + "push", + "-t", + "1", + "-i", + "0" + ], + "environment": { + "PATH": "${ROOT}/gate-bin:${PATH}", + "GITEA_TOKEN": "gate-fixture-token", + "GATE_STATUS_JSON": "{\"state\":\"success\",\"statuses\":[]}" + }, + "fixture": { + "copyPaths": ["packages/mosaic/framework/tools/git"], + "writeFiles": [ + { + "path": ".git/config", + "content": "[core]\n\trepositoryformatversion = 0\n\tbare = false\n[remote \"origin\"]\n\turl = https://git.mosaicstack.dev/mosaicstack/stack.git\n" + }, + { + "path": ".git/HEAD", + "content": "ref: refs/heads/main\n" + }, + { + "path": ".git/objects/.keep", + "content": "" + }, + { + "path": ".git/refs/heads/.keep", + "content": "" + }, + { + "path": "gate-bin/curl", + "mode": 493, + "content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n" + } + ] + } + }, + { + "id": "unknown-option", + "criterionIds": ["QUEUE-GUARD", "RM02-CHECK-RIGHT", "GATE-SOURCE-DEPLOYMENT"], + "mustFail": true, + "invocation": [ + "bash", + "packages/mosaic/framework/tools/git/ci-queue-wait.sh", + "--definitely-unknown" + ], + "required": { + "exitCode": 1, + "outputPattern": "Unknown option" + }, + "actual": { + "exitCode": 1, + "outputPattern": "Unknown option" + }, + "reasonPattern": "--definitely-unknown", + "fixture": { + "copyPaths": ["packages/mosaic/framework/tools/git"], + "writeFiles": [] + } + } + ] + }, + { + "id": "hook-pre-commit", + "source": ".husky/pre-commit", + "invocation": ["sh", ".husky/pre-commit"], + "deployment": { + "kind": "none", + "reason": "Husky dispatches the repository .husky/pre-commit file directly; no copied counterpart exists." + }, + "inertMutation": { + "file": ".husky/pre-commit", + "find": "npx lint-staged", + "replace": "exit 0", + "caseId": "lint-staged-failure", + "sandboxFiles": [".husky/pre-commit"], + "expected": { + "exitCode": 0, + "notOutputPattern": "FAKE_NPX_EXIT=19" + } + }, + "cases": [ + { + "id": "clean-staged-input", + "criterionIds": ["HOOK-PRE-COMMIT"], + "mustFail": false, + "required": { + "exitCode": 0 + }, + "actual": { + "exitCode": 0 + }, + "reasonPattern": "", + "environment": { + "PATH": "${ROOT}/gate-bin:${PATH}", + "FAKE_EXIT": "0" + }, + "fixture": { + "copyPaths": [".husky/pre-commit"], + "writeFiles": [ + { + "path": "gate-bin/npx", + "mode": 493, + "content": "#!/bin/sh\necho FAKE_NPX_EXIT=$FAKE_EXIT >&2\nexit \"$FAKE_EXIT\"\n" + } + ] + } + }, + { + "id": "lint-staged-failure", + "criterionIds": [ + "HOOK-PRE-COMMIT", + "RM02-CHECK-RIGHT", + "RM02-SET-COVERS", + "RM02-MODELED-CONSISTENCY" + ], + "mustFail": true, + "required": { + "exitCode": 19, + "outputPattern": "FAKE_NPX_EXIT=19" + }, + "actual": { + "exitCode": 19, + "outputPattern": "FAKE_NPX_EXIT=19" + }, + "reasonPattern": "FAKE_NPX_EXIT=19", + "environment": { + "PATH": "${ROOT}/gate-bin:${PATH}", + "FAKE_EXIT": "19" + }, + "fixture": { + "copyPaths": [".husky/pre-commit"], + "writeFiles": [ + { + "path": "gate-bin/npx", + "mode": 493, + "content": "#!/bin/sh\necho FAKE_NPX_EXIT=$FAKE_EXIT >&2\nexit \"$FAKE_EXIT\"\n" + } + ] + } + } + ] + }, + { + "id": "hook-pre-push", + "source": ".husky/pre-push", + "invocation": ["sh", ".husky/pre-push"], + "deployment": { + "kind": "none", + "reason": "Husky dispatches the repository .husky/pre-push file directly; no copied counterpart exists." + }, + "inertMutation": { + "file": ".husky/pre-push", + "find": "pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check", + "replace": "exit 0", + "caseId": "typecheck-failure", + "sandboxFiles": [".husky/pre-push"], + "expected": { + "exitCode": 0, + "notOutputPattern": "FAKE_PNPM_FAILURE=typecheck" + } + }, + "cases": [ + { + "id": "all-subgates-succeed", + "criterionIds": ["HOOK-PRE-PUSH"], + "mustFail": false, + "required": { + "exitCode": 0 + }, + "actual": { + "exitCode": 0 + }, + "reasonPattern": "", + "environment": { + "PATH": "${ROOT}/gate-bin:${PATH}", + "FAIL_PNPM_COMMAND": "none" + }, + "fixture": { + "copyPaths": [".husky/pre-push"], + "writeFiles": [ + { + "path": "gate-bin/pnpm", + "mode": 493, + "content": "#!/bin/sh\nif [ \"$1\" = \"$FAIL_PNPM_COMMAND\" ]; then echo FAKE_PNPM_FAILURE=$1 >&2; exit 19; fi\nexit 0\n" + } + ] + } + }, + { + "id": "typecheck-failure", + "criterionIds": [ + "HOOK-PRE-PUSH", + "RM02-CHECK-RIGHT", + "RM02-SET-COVERS", + "RM02-MODELED-CONSISTENCY" + ], + "mustFail": true, + "required": { + "exitCode": 19, + "outputPattern": "FAKE_PNPM_FAILURE=typecheck" + }, + "actual": { + "exitCode": 19, + "outputPattern": "FAKE_PNPM_FAILURE=typecheck" + }, + "reasonPattern": "FAKE_PNPM_FAILURE=typecheck", + "environment": { + "PATH": "${ROOT}/gate-bin:${PATH}", + "FAIL_PNPM_COMMAND": "typecheck" + }, + "fixture": { + "copyPaths": [".husky/pre-push"], + "writeFiles": [ + { + "path": "gate-bin/pnpm", + "mode": 493, + "content": "#!/bin/sh\nif [ \"$1\" = \"$FAIL_PNPM_COMMAND\" ]; then echo FAKE_PNPM_FAILURE=$1 >&2; exit 19; fi\nexit 0\n" + } + ] + } + } + ] + } + ] +} diff --git a/package.json b/package.json index f52dd9d6..9bddc87f 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", + "gate:verify": "node scripts/gate-verify.mjs", "preflight": "node scripts/preflight.mjs", "clean:generated": "node scripts/clean-generated.mjs", "typecheck": "pnpm preflight && turbo run typecheck", diff --git a/scripts/gate-history.mjs b/scripts/gate-history.mjs new file mode 100644 index 00000000..1fe1b492 --- /dev/null +++ b/scripts/gate-history.mjs @@ -0,0 +1,332 @@ +import { existsSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { access, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; + +function git(root, args, { allowFailure = false } = {}) { + const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' }); + if (result.status !== 0 && !allowFailure) { + throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim()}`); + } + return result; +} + +export async function listProspectiveCommits(root, activationCommit, head = 'HEAD') { + const result = git(root, [ + 'rev-list', + '--first-parent', + '--reverse', + `${activationCommit}..${head}`, + ]); + return result.stdout.trim() ? result.stdout.trim().split('\n') : []; +} + +export async function readManifestAtCommit(root, commit) { + const result = git(root, ['show', `${commit}:gates/gates.manifest.json`]); + return JSON.parse(result.stdout); +} + +async function snapshotAuthoritativeTree(root) { + const snapshot = new Map(); + async function walk(current) { + for (const child of await readdir(current, { withFileTypes: true })) { + if (['.git', '.home', 'node_modules'].includes(child.name)) continue; + const absolute = path.join(current, child.name); + const relative = path.relative(root, absolute).split(path.sep).join('/'); + const stats = await lstat(absolute); + if (stats.isDirectory()) { + await walk(absolute); + } else if (stats.isSymbolicLink()) { + snapshot.set(relative, `symlink:${stats.mode}:${await readlink(absolute)}`); + } else if (stats.isFile()) { + const digest = createHash('sha256').update(await readFile(absolute)).digest('hex'); + snapshot.set(relative, `file:${stats.mode}:${digest}`); + } + } + } + await walk(root); + return snapshot; +} + +async function authoritativeTreeChanges(root, snapshot) { + const changes = []; + for (const [relative, expected] of snapshot) { + const absolute = path.join(root, relative); + let actual; + try { + const stats = await lstat(absolute); + if (stats.isSymbolicLink()) { + actual = `symlink:${stats.mode}:${await readlink(absolute)}`; + } else if (stats.isFile()) { + const digest = createHash('sha256').update(await readFile(absolute)).digest('hex'); + actual = `file:${stats.mode}:${digest}`; + } else { + actual = `other:${stats.mode}`; + } + } catch (error) { + if (error.code !== 'ENOENT') throw error; + actual = 'missing'; + } + if (actual !== expected) changes.push(relative); + } + return changes; +} + +function bubblewrap(root, command, args, { storePath, timeout = 300_000 } = {}) { + const sandboxArgs = [ + '--unshare-net', + '--unshare-pid', + '--unshare-ipc', + '--unshare-uts', + '--die-with-parent', + '--new-session', + '--clearenv', + ]; + for (const systemPath of ['/usr', '/bin', '/lib', '/lib64', '/etc']) { + if (existsSync(systemPath)) sandboxArgs.push('--ro-bind', systemPath, systemPath); + } + sandboxArgs.push('--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', '--bind', root, '/work'); + if (storePath) sandboxArgs.push('--ro-bind', storePath, '/pnpm-store'); + const corepackHome = path.join(process.env.HOME ?? '', '.cache', 'node', 'corepack'); + if (existsSync(corepackHome)) sandboxArgs.push('--ro-bind', corepackHome, '/corepack'); + sandboxArgs.push( + '--chdir', + '/work', + '--setenv', + 'HOME', + '/work/.home', + '--setenv', + 'PATH', + '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + '--setenv', + 'LANG', + 'C.UTF-8', + '--setenv', + 'CI', + 'true', + ); + if (storePath) sandboxArgs.push('--setenv', 'NPM_CONFIG_STORE_DIR', '/pnpm-store'); + if (existsSync(corepackHome)) sandboxArgs.push('--setenv', 'COREPACK_HOME', '/corepack'); + sandboxArgs.push(command, ...args); + return spawnSync('bwrap', sandboxArgs, { encoding: 'utf8', timeout }); +} + +export async function replayCommit(root, commit) { + const replayRoot = await mkdtemp( + path.join(path.dirname(root), `.gate-history-${commit.slice(0, 12)}-`), + ); + const archive = `${replayRoot}.tar`; + try { + git(root, ['archive', '--format=tar', `--output=${archive}`, commit]); + const extract = spawnSync('tar', ['-xf', archive, '-C', replayRoot], { encoding: 'utf8' }); + if (extract.status !== 0) { + return { status: extract.status, stdout: extract.stdout, stderr: extract.stderr }; + } + const authoritativeSnapshot = await snapshotAuthoritativeTree(replayRoot); + await mkdir(path.join(replayRoot, '.home'), { recursive: true }); + let storePath; + try { + await access(path.join(replayRoot, 'package.json')); + const init = spawnSync('git', ['init', '--quiet', replayRoot], { encoding: 'utf8' }); + if (init.status !== 0) return init; + const store = spawnSync('pnpm', ['store', 'path'], { encoding: 'utf8' }); + if (store.status !== 0) return store; + storePath = store.stdout.trim(); + const install = bubblewrap( + replayRoot, + 'pnpm', + ['install', '--frozen-lockfile', '--offline'], + { storePath, timeout: 600_000 }, + ); + if (install.status !== 0 || install.error || install.signal) { + return { + ...install, + stderr: `historical frozen dependency install failed: ${install.stderr || install.stdout || ''}`, + }; + } + const authoritativeChanges = await authoritativeTreeChanges( + replayRoot, + authoritativeSnapshot, + ); + if (authoritativeChanges.length > 0) { + return { + status: 1, + stdout: '', + stderr: `authoritative archived file changed during historical install: ${authoritativeChanges.join(', ')}`, + }; + } + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + return bubblewrap( + replayRoot, + process.execPath, + [ + '/work/scripts/gate-verify.mjs', + '--root', + '/work', + '--manifest', + 'gates/gates.manifest.json', + '--skip-history', + ], + { storePath }, + ); + } finally { + await rm(archive, { force: true }); + await rm(replayRoot, { recursive: true, force: true }); + } +} + +export function assessProviderEvidence(commit, pipelines) { + const matches = pipelines.filter((candidate) => candidate.commit === commit); + if (matches.length === 0) { + return { + state: 'absent', + detail: + 'no retained provider record was supplied; retention expiry and never-ran are not inferred', + }; + } + const pipelineStates = new Set(['success', 'failure', 'error', 'pending', 'running', 'queued']); + const stepStates = new Set([ + 'success', + 'failure', + 'error', + 'pending', + 'running', + 'queued', + 'skipped', + ]); + const numbers = matches.map((candidate) => candidate.number); + const malformed = matches.some( + (candidate) => + typeof candidate.commit !== 'string' || + candidate.commit.length === 0 || + !Number.isInteger(candidate.number) || + !pipelineStates.has(candidate.status) || + !Array.isArray(candidate.steps) || + candidate.steps.some( + (step) => + typeof step?.name !== 'string' || + typeof step?.status !== 'string' || + !stepStates.has(step.status), + ), + ); + if (malformed || new Set(numbers).size !== numbers.length) { + return { + state: 'terminal-failure', + detail: 'provider records are malformed or have ambiguous pipeline numbers', + }; + } + const pipeline = [...matches].sort((left, right) => right.number - left.number)[0]; + const gateSteps = (pipeline.steps ?? []).filter((step) => step.name === 'gate-verify'); + if (gateSteps.length !== 1) { + return { + state: 'terminal-failure', + detail: `provider record has ambiguous gate-verify step count ${gateSteps.length}`, + }; + } + const [gateStep] = gateSteps; + if (pipeline.status === 'success' && gateStep.status === 'success') { + return { state: 'terminal-success', detail: 'pipeline and gate-verify step succeeded' }; + } + if (['pending', 'running', 'queued'].includes(pipeline.status)) { + return { state: 'current-running', detail: `pipeline is ${pipeline.status}` }; + } + return { + state: 'terminal-failure', + detail: `pipeline=${pipeline.status ?? 'unknown'}, gate-verify=${gateStep?.status ?? 'absent'}`, + }; +} + +async function loadProviderEvidence() { + const evidenceFile = process.env.GATE_PROVIDER_EVIDENCE_FILE; + if (!evidenceFile) return []; + const parsed = JSON.parse(await readFile(evidenceFile, 'utf8')); + if (!Array.isArray(parsed)) throw new Error('provider evidence file must contain a JSON array'); + return parsed; +} + +function isMainCommit(root, head) { + if (process.env.CI_COMMIT_BRANCH === 'main') return true; + const result = git(root, ['merge-base', '--is-ancestor', head, 'refs/remotes/origin/main'], { + allowFailure: true, + }); + return result.status === 0; +} + +export async function verifyHistory({ root, manifest }) { + const failures = []; + const observations = []; + const head = git(root, ['rev-parse', 'HEAD']).stdout.trim(); + if (!manifest.activationCommit) { + failures.push('history activationCommit is missing'); + return { failures, observations }; + } + const activationCheck = git( + root, + ['merge-base', '--is-ancestor', manifest.activationCommit, head], + { allowFailure: true }, + ); + if (activationCheck.status !== 0) { + failures.push( + `history activation commit ${manifest.activationCommit} is not an ancestor of ${head}`, + ); + return { failures, observations }; + } + const onMain = isMainCommit(root, head); + if (!onMain) { + observations.push( + `PROVIDER ASSERTION DEFERRED ${head}: commit is not yet on main; prospective own-tree replay still runs, while retained merge evidence starts after merge`, + ); + } + + const pipelines = onMain ? await loadProviderEvidence() : []; + const commits = await listProspectiveCommits(root, manifest.activationCommit, head); + for (const commit of commits) { + let commitManifest; + try { + commitManifest = await readManifestAtCommit(root, commit); + } catch (error) { + failures.push(`${commit}: own-tree registry cannot be read: ${error.message}`); + continue; + } + if (commitManifest.schemaVersion !== manifest.schemaVersion) { + failures.push(`${commit}: own-tree registry schema is not supported`); + continue; + } + const evidence = assessProviderEvidence(commit, pipelines); + if (commit === head) { + observations.push( + `CURRENT TREE EVALUATED ${commit}: all registered cases ran from this checkout; provider evidence=${evidence.state} (${evidence.detail})`, + ); + continue; + } + const replay = await replayCommit(root, commit); + if (replay.status !== 0 || replay.error || replay.signal) { + failures.push( + `${commit}: own-tree gate replay failed with exit ${String(replay.status)}${replay.signal ? ` signal ${replay.signal}` : ''}${replay.error ? ` error ${replay.error.message}` : ''}: ${(replay.stderr || replay.stdout || '').trim().slice(0, 500)}`, + ); + continue; + } + observations.push(`TREE REPLAY ${commit}: own-tree gate verifier exited 0`); + if (!onMain) { + observations.push( + `PROVIDER EVIDENCE ${commit}: DEFERRED until the commit is on main; no success is inferred`, + ); + continue; + } + if (evidence.state === 'terminal-failure') { + failures.push( + `${commit}: retained provider evidence is not terminal-success (${evidence.detail})`, + ); + } else if (evidence.state === 'terminal-success') { + observations.push(`PROVIDER EVIDENCE ${commit}: terminal-success (${evidence.detail})`); + } else { + observations.push( + `PROVIDER EVIDENCE ${commit}: ${evidence.state.toUpperCase()} (${evidence.detail}); no merge-time success is inferred`, + ); + } + } + return { failures, observations }; +} diff --git a/scripts/gate-history.test.mjs b/scripts/gate-history.test.mjs new file mode 100644 index 00000000..ab0df2b5 --- /dev/null +++ b/scripts/gate-history.test.mjs @@ -0,0 +1,324 @@ +import assert from 'node:assert/strict'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { spawn, spawnSync } from 'node:child_process'; +import test from 'node:test'; + +import { + assessProviderEvidence, + listProspectiveCommits, + readManifestAtCommit, + replayCommit, + verifyHistory, +} from './gate-history.mjs'; + +const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `gate-history-${process.pid}`); + +function git(root, ...args) { + const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); +} + +async function commitManifest(root, marker) { + await mkdir(path.join(root, 'gates'), { recursive: true }); + await writeFile( + path.join(root, 'gates', 'gates.manifest.json'), + `${JSON.stringify({ schemaVersion: 1, marker })}\n`, + ); + git(root, 'add', '.'); + git(root, 'commit', '-m', marker); + return git(root, 'rev-parse', 'HEAD'); +} + +test.after(async () => { + await rm(fixtureRoot, { recursive: true, force: true }); +}); + +test('prospective history reads each commit own manifest rather than the current tree', async () => { + await rm(fixtureRoot, { recursive: true, force: true }); + await mkdir(fixtureRoot, { recursive: true }); + git(fixtureRoot, 'init', '-q'); + git(fixtureRoot, 'config', 'user.name', 'gate-test'); + git(fixtureRoot, 'config', 'user.email', 'gate-test@example.invalid'); + await writeFile(path.join(fixtureRoot, 'activation.txt'), 'activation\n'); + git(fixtureRoot, 'add', '.'); + git(fixtureRoot, 'commit', '-m', 'activation'); + const activation = git(fixtureRoot, 'rev-parse', 'HEAD'); + const first = await commitManifest(fixtureRoot, 'FIRST'); + const second = await commitManifest(fixtureRoot, 'SECOND'); + + assert.deepEqual(await listProspectiveCommits(fixtureRoot, activation, second), [first, second]); + assert.equal((await readManifestAtCommit(fixtureRoot, first)).marker, 'FIRST'); + assert.equal((await readManifestAtCommit(fixtureRoot, second)).marker, 'SECOND'); +}); + +test('historical replay executes each selected commit verifier from that commit tree', async () => { + const root = `${fixtureRoot}-replay`; + await rm(root, { recursive: true, force: true }); + await mkdir(path.join(root, 'scripts'), { recursive: true }); + await mkdir(path.join(root, 'gates'), { recursive: true }); + git(root, 'init', '-q'); + git(root, 'config', 'user.name', 'gate-test'); + git(root, 'config', 'user.email', 'gate-test@example.invalid'); + await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); + await writeFile( + path.join(root, 'scripts', 'gate-verify.mjs'), + "process.stderr.write('OLD TREE INERT\\n'); process.exitCode = 1;\n", + ); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'inert historical verifier'); + const inert = git(root, 'rev-parse', 'HEAD'); + await writeFile( + path.join(root, 'scripts', 'gate-verify.mjs'), + "process.stdout.write('NEW TREE VERIFIED\\n');\n", + ); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'fixed historical verifier'); + const fixed = git(root, 'rev-parse', 'HEAD'); + + const inertResult = await replayCommit(root, inert); + const fixedResult = await replayCommit(root, fixed); + assert.notEqual(inertResult.status, 0); + assert.match(inertResult.stderr, /OLD TREE INERT/); + assert.equal(fixedResult.status, 0); + assert.match(fixedResult.stdout, /NEW TREE VERIFIED/); +}); + +test('historical install lifecycle cannot replace an authoritative verifier', async () => { + const root = `${fixtureRoot}-install-tamper`; + await rm(root, { recursive: true, force: true }); + await mkdir(path.join(root, 'scripts'), { recursive: true }); + await mkdir(path.join(root, 'gates'), { recursive: true }); + git(root, 'init', '-q'); + git(root, 'config', 'user.name', 'gate-test'); + git(root, 'config', 'user.email', 'gate-test@example.invalid'); + await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); + await writeFile( + path.join(root, 'scripts', 'gate-verify.mjs'), + "process.stderr.write('ORIGINAL VERIFIER RAN\\n'); process.exitCode = 7;\n", + ); + await writeFile(path.join(root, 'forged.mjs'), "process.stdout.write('FORGED SUCCESS\\n');\n"); + await writeFile( + path.join(root, 'package.json'), + `${JSON.stringify({ + name: 'historical-install-tamper', + version: '1.0.0', + scripts: { postinstall: 'cp forged.mjs scripts/gate-verify.mjs' }, + })}\n`, + ); + await writeFile( + path.join(root, 'pnpm-lock.yaml'), + "lockfileVersion: '9.0'\nsettings:\n autoInstallPeers: true\n excludeLinksFromLockfile: false\nimporters:\n .: {}\n", + ); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'tampering lifecycle fixture'); + const commit = git(root, 'rev-parse', 'HEAD'); + + const result = await replayCommit(root, commit); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /authoritative archived file changed.*scripts\/gate-verify\.mjs/i); + assert.doesNotMatch(result.stdout, /FORGED SUCCESS/); +}); + +test('historical verifier receives no current-process secret environment', async () => { + const root = `${fixtureRoot}-secretless`; + await rm(root, { recursive: true, force: true }); + await mkdir(path.join(root, 'scripts'), { recursive: true }); + await mkdir(path.join(root, 'gates'), { recursive: true }); + git(root, 'init', '-q'); + git(root, 'config', 'user.name', 'gate-test'); + git(root, 'config', 'user.email', 'gate-test@example.invalid'); + await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); + await writeFile( + path.join(root, 'scripts', 'gate-verify.mjs'), + "if (process.env.REPLAY_SENTINEL) { process.stderr.write('SECRET LEAKED\\n'); process.exitCode = 9; } else { process.stdout.write('SECRETLESS\\n'); }\n", + ); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'secretless replay fixture'); + const commit = git(root, 'rev-parse', 'HEAD'); + + process.env.REPLAY_SENTINEL = 'must-not-cross-boundary'; + try { + const result = await replayCommit(root, commit); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /SECRETLESS/); + assert.doesNotMatch( + `${result.stdout}${result.stderr}`, + /SECRET LEAKED|must-not-cross-boundary/, + ); + } finally { + delete process.env.REPLAY_SENTINEL; + } +}); + +test('historical replay cannot observe a sibling process in the runner PID namespace', async () => { + const root = `${fixtureRoot}-pidless`; + await rm(root, { recursive: true, force: true }); + await mkdir(path.join(root, 'scripts'), { recursive: true }); + await mkdir(path.join(root, 'gates'), { recursive: true }); + git(root, 'init', '-q'); + git(root, 'config', 'user.name', 'gate-test'); + git(root, 'config', 'user.email', 'gate-test@example.invalid'); + await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); + + const sleeper = spawn('sleep', ['30'], { + env: { ...process.env, REPLAY_PID_SENTINEL: 'must-not-be-visible' }, + }); + try { + await writeFile( + path.join(root, 'scripts', 'gate-verify.mjs'), + `import { existsSync } from 'node:fs';\nif (existsSync('/proc/${sleeper.pid}/environ')) { process.stderr.write('HOST PID VISIBLE\\n'); process.exitCode = 9; } else { process.stdout.write('PIDLESS\\n'); }\n`, + ); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'pid-isolated replay fixture'); + const commit = git(root, 'rev-parse', 'HEAD'); + const result = await replayCommit(root, commit); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /PIDLESS/); + assert.doesNotMatch(`${result.stdout}${result.stderr}`, /HOST PID VISIBLE/); + } finally { + sleeper.kill('SIGTERM'); + } +}); + +test('feature-branch history replays an inert intermediate commit before the healthy head', async () => { + const root = `${fixtureRoot}-feature`; + await rm(root, { recursive: true, force: true }); + await mkdir(root, { recursive: true }); + git(root, 'init', '-q'); + git(root, 'config', 'user.name', 'gate-test'); + git(root, 'config', 'user.email', 'gate-test@example.invalid'); + await writeFile(path.join(root, 'activation.txt'), 'activation\n'); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'activation'); + const activation = git(root, 'rev-parse', 'HEAD'); + git(root, 'update-ref', 'refs/remotes/origin/main', activation); + await mkdir(path.join(root, 'scripts'), { recursive: true }); + await mkdir(path.join(root, 'gates'), { recursive: true }); + await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); + await writeFile( + path.join(root, 'scripts', 'gate-verify.mjs'), + "process.stderr.write('INTERMEDIATE INERT\\n'); process.exitCode = 1;\n", + ); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'inert intermediate'); + await writeFile( + path.join(root, 'scripts', 'gate-verify.mjs'), + "process.stdout.write('HEAD HEALTHY\\n');\n", + ); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'healthy head'); + + const previousBranch = process.env.CI_COMMIT_BRANCH; + process.env.CI_COMMIT_BRANCH = 'feature/rm-02'; + try { + const result = await verifyHistory({ + root, + manifest: { schemaVersion: 1, activationCommit: activation }, + }); + assert.ok(result.failures.some((failure) => /INTERMEDIATE INERT/.test(failure))); + } finally { + if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH; + else process.env.CI_COMMIT_BRANCH = previousBranch; + } +}); + +test('provider evidence distinguishes retained success, failure, and absent history', () => { + const pipelines = [ + { + commit: 'aaa', + number: 1, + status: 'success', + steps: [{ name: 'gate-verify', status: 'success' }], + }, + { + commit: 'bbb', + number: 2, + status: 'failure', + steps: [{ name: 'gate-verify', status: 'failure' }], + }, + ]; + assert.deepEqual(assessProviderEvidence('aaa', pipelines), { + state: 'terminal-success', + detail: 'pipeline and gate-verify step succeeded', + }); + assert.equal(assessProviderEvidence('bbb', pipelines).state, 'terminal-failure'); + assert.equal(assessProviderEvidence('ccc', pipelines).state, 'absent'); +}); + +test('duplicate gate-verify steps cannot establish provider success', () => { + const result = assessProviderEvidence('aaa', [ + { + commit: 'aaa', + number: 7, + status: 'success', + steps: [ + { name: 'gate-verify', status: 'success' }, + { name: 'gate-verify', status: 'failure' }, + ], + }, + ]); + assert.equal(result.state, 'terminal-failure'); + assert.match(result.detail, /ambiguous.*gate-verify/i); +}); + +test('a malformed single provider record cannot establish success', () => { + assert.equal( + assessProviderEvidence('aaa', [ + { commit: 'aaa', status: 'success', steps: [{ name: 'gate-verify', status: 'success' }] }, + ]).state, + 'terminal-failure', + ); + assert.equal( + assessProviderEvidence('bbb', [ + { + commit: 'bbb', + number: 1, + status: 'surprising', + steps: [{ name: 'gate-verify', status: 'success' }], + }, + ]).state, + 'terminal-failure', + ); +}); + +test('provider evidence selects the highest numbered rerun deterministically', () => { + const failedThenSucceeded = [ + { + commit: 'aaa', + number: 10, + status: 'failure', + steps: [{ name: 'gate-verify', status: 'failure' }], + }, + { + commit: 'aaa', + number: 11, + status: 'success', + steps: [{ name: 'gate-verify', status: 'success' }], + }, + ]; + const succeededThenFailed = [ + { + commit: 'bbb', + number: 21, + status: 'success', + steps: [{ name: 'gate-verify', status: 'success' }], + }, + { + commit: 'bbb', + number: 22, + status: 'failure', + steps: [{ name: 'gate-verify', status: 'failure' }], + }, + ]; + assert.equal(assessProviderEvidence('aaa', failedThenSucceeded).state, 'terminal-success'); + assert.equal(assessProviderEvidence('bbb', succeededThenFailed).state, 'terminal-failure'); + assert.equal( + assessProviderEvidence('ccc', [ + { commit: 'ccc', status: 'success', steps: [{ name: 'gate-verify', status: 'success' }] }, + { commit: 'ccc', status: 'failure', steps: [{ name: 'gate-verify', status: 'failure' }] }, + ]).state, + 'terminal-failure', + ); +}); diff --git a/scripts/gate-verify.mjs b/scripts/gate-verify.mjs new file mode 100644 index 00000000..a3e51d14 --- /dev/null +++ b/scripts/gate-verify.mjs @@ -0,0 +1,747 @@ +#!/usr/bin/env node + +import { constants } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { + access, + chmod, + copyFile, + lstat, + mkdir, + mkdtemp, + open, + readFile, + readdir, + readlink, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { verifyHistory } from './gate-history.mjs'; + +const COPY_SKIP = new Set(['.git', '.mosaic-test-work', '.next', '.turbo', 'coverage', 'dist']); + +function parseArgs(argv) { + const options = { + root: process.cwd(), + manifest: 'gates/gates.manifest.json', + skipHistory: false, + }; + for (let index = 0; index < argv.length; index += 1) { + const value = argv[index]; + if (value === '--root') options.root = path.resolve(argv[++index]); + else if (value === '--manifest') options.manifest = argv[++index]; + else if (value === '--skip-history') options.skipHistory = true; + else throw new Error(`unknown option: ${value}`); + } + return options; +} + +function runInvocation(root, invocation, extraEnvironment = {}) { + if (!Array.isArray(invocation) || invocation.length === 0) { + return { status: null, stdout: '', stderr: 'missing invocation' }; + } + return spawnSync(invocation[0], invocation.slice(1), { + cwd: root, + encoding: 'utf8', + env: { ...process.env, GATE_VERIFY: '1', ...extraEnvironment }, + timeout: 300_000, + }); +} + +async function sandboxPath(root, relativePath, label) { + if (typeof relativePath !== 'string' || path.isAbsolute(relativePath)) { + throw new Error(`${label}: path escapes sandbox (${String(relativePath)})`); + } + const resolvedRoot = path.resolve(root); + const target = path.resolve(resolvedRoot, relativePath); + if (target !== resolvedRoot && !target.startsWith(`${resolvedRoot}${path.sep}`)) { + throw new Error(`${label}: path escapes sandbox (${relativePath})`); + } + const parts = path.relative(resolvedRoot, path.dirname(target)).split(path.sep).filter(Boolean); + let current = resolvedRoot; + for (const part of parts) { + current = path.join(current, part); + try { + if ((await lstat(current)).isSymbolicLink()) { + throw new Error(`${label}: path crosses symbolic link (${relativePath})`); + } + } catch (error) { + if (error.code === 'ENOENT') break; + throw error; + } + } + return target; +} + +function expand(value, root) { + return String(value) + .replaceAll('${ROOT}', root) + .replaceAll('${HOME}', process.env.HOME ?? '') + .replaceAll('${PATH}', process.env.PATH ?? ''); +} + +function normalizedJson(value) { + if (Array.isArray(value)) return value.map(normalizedJson); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, normalizedJson(value[key])]), + ); + } + return value; +} + +function structuredValuesEqual(left, right) { + return JSON.stringify(normalizedJson(left)) === JSON.stringify(normalizedJson(right)); +} + +function outcomeMatches(outcome, result) { + const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + return ( + result.status === outcome?.exitCode && + (!outcome?.outputPattern || new RegExp(outcome.outputPattern, 'm').test(combined)) && + (!outcome?.notOutputPattern || !new RegExp(outcome.notOutputPattern, 'm').test(combined)) + ); +} + +function resultMatches(gateCase, result) { + const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + return ( + outcomeMatches(gateCase.actual, result) && + (!gateCase.reasonPattern || new RegExp(gateCase.reasonPattern, 'm').test(combined)) + ); +} + +async function safeSandboxWrite(root, relativePath, contents, label) { + const target = await sandboxPath(root, relativePath, label); + await mkdir(path.dirname(target), { recursive: true }); + try { + if ((await lstat(target)).isSymbolicLink()) { + throw new Error(`${label}: target is a symbolic link (${relativePath})`); + } + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + let handle; + try { + handle = await open( + target, + constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, + 0o666, + ); + await handle.writeFile(contents); + } catch (error) { + if (error.code === 'ELOOP') { + throw new Error(`${label}: target is a symbolic link (${relativePath})`); + } + throw error; + } finally { + await handle?.close(); + } + return target; +} + +async function applyFixture(root, fixture = {}) { + for (const entry of fixture.writeFiles ?? []) { + const target = await safeSandboxWrite(root, entry.path, entry.content, 'fixture write'); + if (entry.mode !== undefined) await chmod(target, entry.mode); + } + for (const relativePath of fixture.removePaths ?? []) { + await rm(await sandboxPath(root, relativePath, 'fixture remove'), { + recursive: true, + force: true, + }); + } +} + +async function copySandbox(root, destination, selectedPaths) { + if (!selectedPaths?.length) { + await copyTree(root, destination); + return; + } + await mkdir(destination, { recursive: true }); + for (const relativePath of selectedPaths) { + await copyTree( + await sandboxPath(root, relativePath, 'sandbox copy source'), + await sandboxPath(destination, relativePath, 'sandbox copy destination'), + ); + } +} + +async function runCase(root, gate, gateCase) { + let caseRoot = root; + if (gateCase.fixture) { + caseRoot = await mkdtemp(path.join(path.dirname(root), `.gate-case-${gate.id}-`)); + await copySandbox(root, caseRoot, gateCase.fixture.copyPaths); + await applyFixture(caseRoot, gateCase.fixture); + } + try { + const environment = Object.fromEntries( + Object.entries(gateCase.environment ?? {}).map(([key, value]) => [ + key, + expand(value, caseRoot), + ]), + ); + return runInvocation(caseRoot, gateCase.invocation ?? gate.invocation, environment); + } finally { + if (caseRoot !== root) await rm(caseRoot, { recursive: true, force: true }); + } +} + +async function copyTree(source, destination) { + const stats = await lstat(source); + if (stats.isSymbolicLink()) { + await symlink(await readlink(source), destination); + return; + } + if (stats.isFile()) { + await mkdir(path.dirname(destination), { recursive: true }); + await copyFile(source, destination); + await chmod(destination, stats.mode); + return; + } + if (!stats.isDirectory()) return; + await mkdir(destination, { recursive: true }); + for (const child of await readdir(source, { withFileTypes: true })) { + if (COPY_SKIP.has(child.name)) continue; + const childSource = path.join(source, child.name); + const childDestination = path.join(destination, child.name); + if (child.name === 'node_modules') { + await symlink(childSource, childDestination, 'dir'); + continue; + } + await copyTree(childSource, childDestination); + } +} + +async function executableFiles(root, relativeRoot) { + const base = await sandboxPath(root, relativeRoot, 'gate root'); + const found = []; + async function walk(current) { + for (const child of await readdir(current, { withFileTypes: true })) { + const target = path.join(current, child.name); + if (child.isDirectory()) await walk(target); + else if (child.isFile()) { + try { + await access(target, constants.X_OK); + found.push(path.relative(root, target).split(path.sep).join('/')); + } catch { + // Non-executable files are not gates for discovery purposes. + } + } + } + } + try { + await walk(base); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + return found; +} + +function rejectUnknownKeys(value, allowed, label, failures) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + failures.push(`${label}: expected an object`); + return; + } + for (const key of Object.keys(value)) { + if (!allowed.has(key)) failures.push(`${label}: unknown field ${key}`); + } +} + +function rejectDuplicateIds(values, label, failures) { + const seen = new Set(); + for (const value of values ?? []) { + if (typeof value?.id !== 'string' || value.id.length === 0) { + failures.push(`${label}: missing stable id`); + } else if (seen.has(value.id)) { + failures.push(`duplicate ${label} id ${value.id}`); + } else { + seen.add(value.id); + } + } +} + +function validateClosedSchema(manifest, failures) { + if (manifest.schemaVersion !== 1) + failures.push(`unsupported schemaVersion ${String(manifest.schemaVersion)}`); + rejectUnknownKeys( + manifest, + new Set([ + 'schemaVersion', + 'activationCommit', + 'gateRoots', + 'governingClaimFiles', + 'coverageBoundary', + 'criteria', + 'proseClaims', + 'compatibilityScenarios', + 'mergeAssertions', + 'gates', + ]), + 'manifest', + failures, + ); + rejectDuplicateIds(manifest.criteria, 'criterion', failures); + rejectDuplicateIds(manifest.proseClaims, 'prose claim', failures); + rejectDuplicateIds(manifest.compatibilityScenarios, 'compatibility scenario', failures); + for (const scenario of manifest.compatibilityScenarios ?? []) { + rejectUnknownKeys( + scenario, + new Set([ + 'id', + 'construction', + 'caseRefs', + 'invocation', + 'expected', + 'environment', + 'fixture', + ]), + `compatibility scenario ${scenario.id}`, + failures, + ); + if (!Array.isArray(scenario.caseRefs) || scenario.caseRefs.length === 0) { + failures.push(`${scenario.id}: compatibility construction has no referenced conditions`); + } + if (!Array.isArray(scenario.invocation) || scenario.invocation.length === 0) { + failures.push(`${scenario.id}: compatibility construction invocation is missing`); + } + if (typeof scenario.expected?.exitCode !== 'number') { + failures.push(`${scenario.id}: compatibility construction exact expected exit is missing`); + } + } + rejectDuplicateIds(manifest.gates, 'gate', failures); + for (const gate of manifest.gates ?? []) { + rejectUnknownKeys( + gate, + new Set([ + 'id', + 'source', + 'invocation', + 'deployment', + 'inertMutation', + 'cases', + 'discoveryAliases', + ]), + `gate ${gate.id}`, + failures, + ); + rejectDuplicateIds(gate.cases, `case in gate ${gate.id}`, failures); + if (!Array.isArray(gate.invocation) || gate.invocation.length === 0) { + failures.push(`${gate.id}: exact invocation is missing`); + } + if (!['none', 'file'].includes(gate.deployment?.kind)) { + failures.push(`${gate.id}: unsupported deployment kind ${String(gate.deployment?.kind)}`); + } + for (const gateCase of gate.cases ?? []) { + rejectUnknownKeys( + gateCase, + new Set([ + 'id', + 'criterionIds', + 'mustFail', + 'invocation', + 'required', + 'actual', + 'reasonPattern', + 'environment', + 'fixture', + 'defect', + ]), + `${gate.id}/${gateCase.id}`, + failures, + ); + if ( + typeof gateCase.required?.exitCode !== 'number' || + typeof gateCase.actual?.exitCode !== 'number' + ) { + failures.push( + `${gate.id}/${gateCase.id}: required and actual exact exit codes are mandatory`, + ); + } + if ( + gateCase.invocation && + (!Array.isArray(gateCase.invocation) || gateCase.invocation.length === 0) + ) { + failures.push(`${gate.id}/${gateCase.id}: case invocation must be non-empty`); + } + if (gateCase.mustFail === true && !gateCase.reasonPattern?.trim()) { + failures.push(`${gate.id}/${gateCase.id}: must-fail case requires a non-empty reasonPattern`); + } + } + } +} + +function validateStructure(manifest, failures) { + validateClosedSchema(manifest, failures); + const criteria = new Map((manifest.criteria ?? []).map((criterion) => [criterion.id, criterion])); + const boundCriteria = new Set(); + const negativeBoundCriteria = new Set(); + + for (const gate of manifest.gates ?? []) { + const negativeCases = (gate.cases ?? []).filter((gateCase) => gateCase.mustFail === true); + if (negativeCases.length === 0) failures.push(`${gate.id}: no negative control`); + if (!gate.deployment?.kind) failures.push(`${gate.id}: deployment identity is not declared`); + + for (const gateCase of gate.cases ?? []) { + for (const criterionId of gateCase.criterionIds ?? []) { + if (!criteria.has(criterionId)) { + failures.push(`${gate.id}/${gateCase.id}: unknown criterion ${criterionId}`); + } + boundCriteria.add(criterionId); + if (gateCase.mustFail === true) negativeBoundCriteria.add(criterionId); + } + if ( + !structuredValuesEqual(gateCase.required, gateCase.actual) && + !gateCase.defect?.owner + ) { + failures.push(`${gate.id}/${gateCase.id}: behavior delta requires a tracked owner`); + } + } + } + + for (const claim of manifest.proseClaims ?? []) { + if (!criteria.has(claim.criterionId)) { + failures.push(`GATE-CLAIM:${claim.id} references unknown criterion ${claim.criterionId}`); + } + } + + for (const criterion of criteria.values()) { + if (!boundCriteria.has(criterion.id)) failures.push(`${criterion.id}: no bound case`); + else if (!negativeBoundCriteria.has(criterion.id)) { + failures.push(`${criterion.id}: no must-fail case exercises this criterion`); + } + if ( + criterion.originalText !== criterion.currentText && + (!Array.isArray(criterion.meaningChanges) || criterion.meaningChanges.length === 0) + ) { + failures.push(`${criterion.id}: missing meaning-change provenance`); + } + } + + const byConstruction = new Map(); + for (const scenario of manifest.compatibilityScenarios ?? []) { + const previous = byConstruction.get(scenario.construction); + if (previous && !structuredValuesEqual(previous.expected, scenario.expected)) { + failures.push(`${previous.id} and ${scenario.id}: modeled compatibility conflict`); + } else { + byConstruction.set(scenario.construction, scenario); + } + } +} + +async function validateClaims(root, manifest, failures) { + const registered = new Set((manifest.proseClaims ?? []).map((claim) => claim.id)); + const observed = new Set(); + for (const relativeFile of manifest.governingClaimFiles ?? []) { + const contents = await readFile( + await sandboxPath(root, relativeFile, 'governing claim file'), + 'utf8', + ); + for (const match of contents.matchAll(/GATE-CLAIM:([A-Z0-9][A-Z0-9-]*)/g)) { + observed.add(match[1]); + if (!registered.has(match[1])) { + failures.push(`GATE-CLAIM:${match[1]} is unbound in ${relativeFile}`); + } + } + } + for (const claimId of registered) { + if (!observed.has(claimId)) failures.push(`GATE-CLAIM:${claimId} marker is missing`); + } +} + +async function validateDiscovery(root, manifest, failures) { + const registered = new Set( + (manifest.gates ?? []).flatMap((gate) => [gate.source, ...(gate.discoveryAliases ?? [])]), + ); + for (const gateRoot of manifest.gateRoots ?? []) { + for (const executable of await executableFiles(root, gateRoot)) { + if (!registered.has(executable)) failures.push(`unregistered gate: ${executable}`); + } + } +} + +async function deploymentFilesEqual(sourcePath, deployedPath) { + const [source, deployed] = await Promise.all([readFile(sourcePath), readFile(deployedPath)]); + return source.equals(deployed); +} + +async function validateDeployment(root, gate, failures, observations) { + if (gate.deployment?.kind !== 'file') return; + const sourcePath = await sandboxPath( + root, + gate.deployment.source ?? gate.source, + `${gate.id} deployment source`, + ); + const deployedPath = path.isAbsolute(expand(gate.deployment.path, root)) + ? expand(gate.deployment.path, root) + : path.resolve(root, expand(gate.deployment.path, root)); + const source = await readFile(sourcePath); + let deployed; + try { + deployed = await readFile(deployedPath); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + const observedHash = createHash('sha256').update(source).digest('hex'); + if ( + !gate.deployment.unavailableOwner || + !gate.deployment.observedSha256 || + gate.deployment.observedSha256 !== observedHash + ) { + failures.push( + `${gate.id}: deployed counterpart is unavailable and no current tracked observation matches source`, + ); + return; + } + observations.push( + `DEPLOYED IDENTITY UNAVAILABLE (owner: ${gate.deployment.unavailableOwner}) ${gate.id}: ${deployedPath} is outside this runner; source matches pinned observation ${observedHash}, live equality is not inferred`, + ); + return; + } + if (!(await deploymentFilesEqual(sourcePath, deployedPath))) { + failures.push(`${gate.id}: source versus deployed copy drift`); + return; + } + + const controlRoot = await mkdtemp( + path.join(path.dirname(root), `.gate-deployment-control-${gate.id}-`), + ); + try { + const alteredPath = path.join(controlRoot, 'deployed-copy'); + await writeFile( + alteredPath, + Buffer.concat([deployed, Buffer.from('\n# gate deployment drift control\n')]), + ); + if (await deploymentFilesEqual(sourcePath, alteredPath)) { + failures.push(`${gate.id}: deployed-copy drift negative control was ineffective`); + } else { + observations.push(`DEPLOYMENT-NEGATIVE-CONTROL ${gate.id}: observed red`); + } + } finally { + await rm(controlRoot, { recursive: true, force: true }); + } +} + +async function validateMutation(root, gate, failures, observations) { + const mutation = gate.inertMutation; + if ( + !mutation?.file || + typeof mutation.find !== 'string' || + typeof mutation.replace !== 'string' || + typeof mutation.expected?.exitCode !== 'number' + ) { + failures.push(`${gate.id}: declared inert mutation is incomplete`); + return; + } + const mutationPath = await sandboxPath(root, mutation.file, `${gate.id} mutation source`); + let source; + try { + source = await readFile(mutationPath, 'utf8'); + } catch (error) { + failures.push(`${gate.id}: mutation source unreadable: ${error.message}`); + return; + } + const occurrences = source.split(mutation.find).length - 1; + if (occurrences !== 1) { + failures.push( + `${gate.id}: declared inert mutation is stale or ambiguous (${occurrences} matches)`, + ); + return; + } + const targetCase = mutation.caseId + ? (gate.cases ?? []).find((gateCase) => gateCase.id === mutation.caseId) + : (gate.cases ?? []).find((gateCase) => gateCase.mustFail === true); + if (!targetCase) { + failures.push( + mutation.caseId + ? `${gate.id}: declared mutation case ${mutation.caseId} was not found` + : `${gate.id}: declared mutation has no must-fail case`, + ); + return; + } + + const sandbox = await mkdtemp(path.join(path.dirname(root), `.gate-sandbox-${gate.id}-`)); + try { + await copySandbox(root, sandbox, mutation.sandboxFiles); + await safeSandboxWrite( + sandbox, + mutation.file, + source.replace(mutation.find, mutation.replace), + `${gate.id} sandbox mutation write`, + ); + await applyFixture(sandbox, targetCase.fixture); + const environment = Object.fromEntries( + Object.entries(targetCase.environment ?? {}).map(([key, value]) => [ + key, + expand(value, sandbox), + ]), + ); + const result = runInvocation(sandbox, targetCase.invocation ?? gate.invocation, environment); + if (result.error || result.signal) { + failures.push( + `${gate.id}: mutation execution crashed${result.signal ? ` with ${result.signal}` : ''}${result.error ? `: ${result.error.message}` : ''}`, + ); + } else if (!outcomeMatches(mutation.expected, result)) { + failures.push( + `${gate.id}: mutation produced unexpected outcome ${String(result.status)}; expected ${JSON.stringify(mutation.expected)}`, + ); + } else if (resultMatches(targetCase, result)) { + failures.push(`${gate.id}: declared inert mutation was ineffective`); + } else { + observations.push(`META-NEGATIVE-CONTROL ${gate.id}: observed red`); + } + } finally { + await rm(sandbox, { recursive: true, force: true }); + } +} + +function findGateCase(manifest, caseRef) { + const separator = caseRef.indexOf('/'); + if (separator < 1) return undefined; + const gateId = caseRef.slice(0, separator); + const caseId = caseRef.slice(separator + 1); + const gate = (manifest.gates ?? []).find((candidate) => candidate.id === gateId); + const gateCase = (gate?.cases ?? []).find((candidate) => candidate.id === caseId); + return gate && gateCase ? { gate, gateCase } : undefined; +} + +async function runCompatibilityScenario(root, manifest, scenario, failures, observations) { + const referenced = []; + for (const caseRef of scenario.caseRefs ?? []) { + const found = findGateCase(manifest, caseRef); + if (!found) { + failures.push(`${scenario.id}: compatibility construction references missing case ${caseRef}`); + } else { + referenced.push({ caseRef, ...found }); + } + } + if (referenced.length !== (scenario.caseRefs ?? []).length) return; + + const sandbox = await mkdtemp(path.join(path.dirname(root), `.gate-compat-${scenario.id}-`)); + try { + await copySandbox(root, sandbox); + const environment = {}; + const writeSignatures = new Map(); + const removedPaths = new Set(); + const fixtures = [...referenced.map(({ gateCase }) => gateCase.fixture), scenario.fixture]; + for (const fixture of fixtures.filter(Boolean)) { + for (const entry of fixture.writeFiles ?? []) { + const signature = JSON.stringify({ content: entry.content, mode: entry.mode }); + const previous = writeSignatures.get(entry.path); + if (previous !== undefined && previous !== signature) { + failures.push(`${scenario.id}: incompatible fixture writes for ${entry.path}`); + return; + } + if (removedPaths.has(entry.path)) { + failures.push(`${scenario.id}: fixture both writes and removes ${entry.path}`); + return; + } + writeSignatures.set(entry.path, signature); + } + for (const removed of fixture.removePaths ?? []) { + if (writeSignatures.has(removed)) { + failures.push(`${scenario.id}: fixture both writes and removes ${removed}`); + return; + } + removedPaths.add(removed); + } + await applyFixture(sandbox, fixture); + } + for (const source of [...referenced.map(({ gateCase }) => gateCase.environment), scenario.environment]) { + for (const [key, value] of Object.entries(source ?? {})) { + if (environment[key] !== undefined && environment[key] !== value) { + failures.push(`${scenario.id}: incompatible environment values for ${key}`); + return; + } + environment[key] = value; + } + } + const expandedEnvironment = Object.fromEntries( + Object.entries(environment).map(([key, value]) => [key, expand(value, sandbox)]), + ); + const result = runInvocation(sandbox, scenario.invocation, expandedEnvironment); + if (result.error || result.signal || !outcomeMatches(scenario.expected, result)) { + failures.push( + `${scenario.id}: combined compatibility construction ${scenario.construction} observed ${String(result.status)}${result.signal ? ` signal ${result.signal}` : ''}${result.error ? ` error ${result.error.message}` : ''}; expected ${JSON.stringify(scenario.expected)}`, + ); + } else { + observations.push(`COMPATIBILITY ${scenario.id}: observed expected outcome`); + } + } finally { + await rm(sandbox, { recursive: true, force: true }); + } +} + +export async function verifyRegistry(options) { + const failures = []; + const observations = []; + const manifestPath = path.resolve(options.root, options.manifest); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + + validateStructure(manifest, failures); + await validateClaims(options.root, manifest, failures); + await validateDiscovery(options.root, manifest, failures); + + for (const gate of manifest.gates ?? []) { + await validateDeployment(options.root, gate, failures, observations); + for (const gateCase of gate.cases ?? []) { + const result = await runCase(options.root, gate, gateCase); + const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + if (!outcomeMatches(gateCase.actual, result)) { + failures.push( + `${gate.id}/${gateCase.id}: observed exit ${String(result.status)}${result.signal ? ` signal ${result.signal}` : ''}${result.error ? ` error ${result.error.message}` : ''} or output disagrees with registry actual ${JSON.stringify(gateCase.actual)}`, + ); + } + if (gateCase.reasonPattern && !new RegExp(gateCase.reasonPattern, 'm').test(combined)) { + failures.push(`${gate.id}/${gateCase.id}: did not fail for its stated reason`); + } + if ( + !structuredValuesEqual(gateCase.required, gateCase.actual) && + gateCase.defect?.owner + ) { + observations.push( + `DEFECT (owner: ${gateCase.defect.owner}) ${gate.id}/${gateCase.id}: required ${JSON.stringify(gateCase.required)}, actual ${JSON.stringify(gateCase.actual)}`, + ); + } + } + await validateMutation(options.root, gate, failures, observations); + } + + for (const scenario of manifest.compatibilityScenarios ?? []) { + await runCompatibilityScenario(options.root, manifest, scenario, failures, observations); + } + + return { failures, manifest, observations }; +} + +async function main() { + try { + const options = parseArgs(process.argv.slice(2)); + const { failures, observations, manifest } = await verifyRegistry(options); + if (!options.skipHistory && failures.length === 0) { + const history = await verifyHistory({ root: options.root, manifest }); + failures.push(...history.failures); + observations.push(...history.observations); + } + for (const observation of observations) process.stdout.write(`${observation}\n`); + if (failures.length > 0) { + for (const failure of failures) process.stderr.write(`GATE VERIFY FAILED: ${failure}\n`); + process.exitCode = 1; + return; + } + const defects = observations.filter((line) => line.startsWith('DEFECT ')).length; + process.stdout.write( + `registry observations matched; open behavior deltas: ${defects}; required-behavior conformance is not asserted while deltas remain\n`, + ); + } catch (error) { + process.stderr.write(`GATE VERIFY FAILED: ${error.message}\n`); + process.exitCode = 1; + } +} + +if (import.meta.url === `file://${process.argv[1]}`) await main(); diff --git a/scripts/gate-verify.test.mjs b/scripts/gate-verify.test.mjs new file mode 100644 index 00000000..7d9e088f --- /dev/null +++ b/scripts/gate-verify.test.mjs @@ -0,0 +1,463 @@ +import assert from 'node:assert/strict'; +import { chmod, copyFile, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const verifier = path.join(process.cwd(), 'scripts', 'gate-verify.mjs'); +const fixtureBase = path.join(process.cwd(), '.mosaic-test-work', `gate-verify-${process.pid}`); + +async function fixture(name = 'case') { + const root = path.join(fixtureBase, name); + await rm(root, { recursive: true, force: true }); + await mkdir(path.join(root, 'gates'), { recursive: true }); + return root; +} + +function baseManifest() { + return { + schemaVersion: 1, + activationCommit: null, + gateRoots: ['gates'], + governingClaimFiles: [], + coverageBoundary: { included: ['meta fixture'], excluded: [], trackedBy: 'RM-54' }, + criteria: [ + { + id: 'META-CRIT-1', + originalText: 'The fixture rejects its bad input.', + currentText: 'The fixture rejects its bad input.', + claimType: 'integrity', + source: 'fixture', + meaningChanges: [], + }, + ], + compatibilityScenarios: [], + proseClaims: [], + gates: [ + { + id: 'meta-fixture', + source: 'gates/meta-fixture.sh', + invocation: ['gates/meta-fixture.sh'], + deployment: { kind: 'none', reason: 'test fixture only' }, + inertMutation: { + file: 'gates/meta-fixture.sh', + find: 'exit 7', + replace: 'exit 0', + expected: { exitCode: 0 }, + }, + cases: [ + { + id: 'rejects-bad-input', + criterionIds: ['META-CRIT-1'], + mustFail: true, + invocation: ['gates/meta-fixture.sh'], + required: { exitCode: 7 }, + actual: { exitCode: 7 }, + reasonPattern: 'META_REJECT', + }, + ], + }, + ], + }; +} + +async function writeGate(root, contents = '#!/bin/sh\necho META_REJECT >&2\nexit 7\n') { + const target = path.join(root, 'gates', 'meta-fixture.sh'); + await writeFile(target, contents); + await chmod(target, 0o755); +} + +async function writeManifest(root, manifest) { + await writeFile(path.join(root, 'gates', 'gates.manifest.json'), `${JSON.stringify(manifest)}\n`); +} + +function verify(root) { + return spawnSync( + process.execPath, + [verifier, '--root', root, '--manifest', 'gates/gates.manifest.json', '--skip-history'], + { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } }, + ); +} + +function output(result) { + return `${result.stdout}\n${result.stderr}`; +} + +test.after(async () => { + await rm(fixtureBase, { recursive: true, force: true }); +}); + +test('an externally inerted failure branch makes verification nonzero and names the gate', async () => { + const root = await fixture('external-inert'); + await writeGate(root, '#!/bin/sh\nexit 0\n'); + await writeManifest(root, baseManifest()); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /meta-fixture/); +}); + +test('the verifier applies the declared inert mutation and observes its own control red', async () => { + const root = await fixture('internal-meta'); + await writeGate(root); + await writeManifest(root, baseManifest()); + + const result = verify(root); + assert.equal(result.status, 0, output(result)); + assert.match(output(result), /META-NEGATIVE-CONTROL.*meta-fixture.*observed red/i); +}); + +test('a mutation crash is rejected instead of counted as an observed-red control', async () => { + const root = await fixture('mutation-crash'); + await writeGate(root); + const manifest = baseManifest(); + manifest.gates[0].inertMutation.replace = 'this is not valid shell ('; + manifest.gates[0].inertMutation.expected = { exitCode: 0 }; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /meta-fixture.*mutation.*unexpected outcome/i); + assert.doesNotMatch(output(result), /META-NEGATIVE-CONTROL.*observed red/i); +}); + +test('a stale declared mutation case id is rejected instead of falling back', async () => { + const root = await fixture('stale-case-id'); + await writeGate(root); + const manifest = baseManifest(); + manifest.gates[0].inertMutation.caseId = 'case-that-does-not-exist'; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /meta-fixture.*case-that-does-not-exist.*not found/i); +}); + +test('duplicate stable ids and unsupported schema versions are rejected', async () => { + const root = await fixture('closed-schema'); + await writeGate(root); + const manifest = baseManifest(); + manifest.schemaVersion = 99; + manifest.criteria.push({ ...manifest.criteria[0] }); + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /unsupported schemaVersion 99/i); + assert.match(output(result), /duplicate criterion id META-CRIT-1/i); +}); + +test('manifest-controlled fixture paths cannot escape the sandbox', async () => { + const root = await fixture('path-traversal'); + await writeGate(root); + const manifest = baseManifest(); + manifest.gates[0].cases[0].fixture = { + writeFiles: [{ path: '../../escaped-by-manifest', content: 'bad' }], + }; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /path escapes sandbox/i); +}); + +test('fixture writes reject a final symlink and preserve its outside target', async () => { + const root = await fixture('final-symlink'); + await writeGate(root); + const outside = path.join(fixtureBase, 'outside-sentinel'); + await writeFile(outside, 'preserve-me\n'); + const linked = path.join(root, 'linked-sentinel'); + await symlink(outside, linked); + const manifest = baseManifest(); + manifest.gates[0].cases[0].fixture = { + writeFiles: [{ path: 'linked-sentinel', content: 'overwritten\n' }], + }; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /fixture write.*symbolic link/i); + assert.equal(await readFile(outside, 'utf8'), 'preserve-me\n'); +}); + +test('an executable below a gate root without an entry is rejected', async () => { + const root = await fixture('unregistered'); + await writeGate(root); + const extra = path.join(root, 'gates', 'forgotten.sh'); + await writeFile(extra, '#!/bin/sh\nexit 1\n'); + await chmod(extra, 0o755); + await writeManifest(root, baseManifest()); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /unregistered gate.*forgotten\.sh/i); +}); + +test('a must-fail case without a reason diagnostic is rejected', async () => { + const root = await fixture('missing-reason'); + await writeGate(root); + const manifest = baseManifest(); + manifest.gates[0].cases[0].reasonPattern = ''; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /meta-fixture.*rejects-bad-input.*reasonPattern/i); +}); + +test('a gate with zero must-fail cases is rejected', async () => { + const root = await fixture('no-negative'); + await writeGate(root, '#!/bin/sh\nexit 0\n'); + const manifest = baseManifest(); + manifest.gates[0].inertMutation = { + file: 'gates/meta-fixture.sh', + find: 'exit 0', + replace: 'exit 1', + }; + manifest.gates[0].cases = [ + { + id: 'positive', + criterionIds: ['META-CRIT-1'], + mustFail: false, + invocation: ['gates/meta-fixture.sh'], + required: { exitCode: 0 }, + actual: { exitCode: 0 }, + reasonPattern: '', + }, + ]; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /meta-fixture.*no negative control/i); +}); + +test('reordered but equivalent outcome fields do not create a false behavior delta', async () => { + const root = await fixture('reordered-outcomes'); + await writeGate(root); + const manifest = baseManifest(); + manifest.gates[0].cases[0].required = { exitCode: 7, outputPattern: 'META_REJECT' }; + manifest.gates[0].cases[0].actual = { outputPattern: 'META_REJECT', exitCode: 7 }; + await writeManifest(root, manifest); + + const result = verify(root); + assert.equal(result.status, 0, output(result)); + assert.doesNotMatch(output(result), /behavior delta requires|DEFECT \(owner:/); +}); + +test('a required-versus-actual delta without a tracked owner is rejected', async () => { + const root = await fixture('ownerless-delta'); + await writeGate(root, '#!/bin/sh\nexit 0\n'); + const manifest = baseManifest(); + manifest.gates[0].inertMutation.find = 'exit 0'; + manifest.gates[0].inertMutation.replace = 'exit 7'; + manifest.gates[0].cases[0].actual.exitCode = 0; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /meta-fixture.*delta.*tracked owner/i); +}); + +test('a criterion with no bound case is rejected', async () => { + const root = await fixture('unbound-criterion'); + await writeGate(root); + const manifest = baseManifest(); + manifest.criteria.push({ + id: 'ORPHAN', + originalText: 'This criterion is not exercised.', + currentText: 'This criterion is not exercised.', + claimType: 'quality', + source: 'fixture', + meaningChanges: [], + }); + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /ORPHAN.*no bound case/i); +}); + +test('an unbound governing prose marker is rejected', async () => { + const root = await fixture('unbound-prose'); + await writeGate(root); + await mkdir(path.join(root, 'docs'), { recursive: true }); + await writeFile(path.join(root, 'docs', 'governing.md'), 'GATE-CLAIM:UNBOUND\n'); + const manifest = baseManifest(); + manifest.governingClaimFiles = ['docs/governing.md']; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /GATE-CLAIM:UNBOUND.*unbound/i); +}); + +test('directly contradictory modeled scenarios are rejected', async () => { + const root = await fixture('conflict'); + await writeGate(root); + const manifest = baseManifest(); + manifest.compatibilityScenarios = [ + { + id: 'ONE', + construction: 'same-input', + caseRefs: ['meta-fixture/rejects-bad-input'], + invocation: ['sh', '-c', 'exit 0'], + expected: { exitCode: 0 }, + }, + { + id: 'TWO', + construction: 'same-input', + caseRefs: ['meta-fixture/rejects-bad-input'], + invocation: ['sh', '-c', 'exit 1'], + expected: { exitCode: 1 }, + }, + ]; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /ONE.*TWO.*conflict/i); +}); + +test('compatibility scenarios execute referenced conditions as one construction', async () => { + const root = await fixture('combined-compatibility'); + await writeGate(root); + const manifest = baseManifest(); + manifest.gates[0].cases.push({ + id: 'second-condition', + criterionIds: ['META-CRIT-1'], + mustFail: true, + invocation: ['sh', '-c', 'echo "$SECOND_REASON" >&2; exit 7'], + fixture: { writeFiles: [{ path: 'conditions/second', content: 'present\n' }] }, + required: { exitCode: 7 }, + actual: { exitCode: 7 }, + reasonPattern: 'SECOND_REASON', + environment: { SECOND_REASON: 'SECOND_REASON' }, + }); + manifest.gates[0].cases[0].fixture = { + writeFiles: [{ path: 'conditions/first', content: 'present\n' }], + }; + manifest.compatibilityScenarios = [ + { + id: 'BOTH-CONDITIONS', + construction: 'both-fixtures', + caseRefs: ['meta-fixture/rejects-bad-input', 'meta-fixture/second-condition'], + invocation: ['sh', '-c', 'test -f conditions/first && test -f conditions/second'], + expected: { exitCode: 0 }, + }, + ]; + await writeManifest(root, manifest); + + const result = verify(root); + assert.equal(result.status, 0, output(result)); + assert.match(output(result), /COMPATIBILITY BOTH-CONDITIONS: observed expected outcome/i); +}); + +test('a restated criterion without provenance is rejected', async () => { + const root = await fixture('provenance'); + await writeGate(root); + const manifest = baseManifest(); + manifest.criteria[0].currentText = 'The fixture rejects only malformed input.'; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /META-CRIT-1.*meaning-change provenance/i); +}); + +test('a security criterion bound only to a positive case is unregistered in substance', async () => { + const root = await fixture('positive-only-criterion'); + await writeGate(root); + const manifest = baseManifest(); + manifest.criteria.push({ + id: 'POSITIVE-ONLY', + originalText: 'A security property.', + currentText: 'A security property.', + claimType: 'security', + source: 'fixture', + meaningChanges: [], + }); + manifest.gates[0].cases.push({ + id: 'positive-only', + criterionIds: ['POSITIVE-ONLY'], + mustFail: false, + invocation: ['gates/meta-fixture.sh'], + required: { exitCode: 7 }, + actual: { exitCode: 7 }, + reasonPattern: '', + }); + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /POSITIVE-ONLY.*no must-fail case/i); +}); + +test('a registered prose claim whose marker is absent is rejected', async () => { + const root = await fixture('missing-prose-marker'); + await writeGate(root); + await mkdir(path.join(root, 'docs'), { recursive: true }); + await writeFile(path.join(root, 'docs', 'governing.md'), 'No marker here.\n'); + const manifest = baseManifest(); + manifest.governingClaimFiles = ['docs/governing.md']; + manifest.proseClaims = [{ id: 'MISSING-MARKER', criterionId: 'META-CRIT-1' }]; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /GATE-CLAIM:MISSING-MARKER.*missing/i); +}); + +test('source-versus-deployed byte drift is rejected and names the gate', async () => { + const root = await fixture('deployment-drift'); + await writeGate(root); + await mkdir(path.join(root, 'deployed'), { recursive: true }); + await writeFile(path.join(root, 'deployed', 'meta-fixture.sh'), '#!/bin/sh\nexit 0\n'); + const manifest = baseManifest(); + manifest.gates[0].deployment = { kind: 'file', path: 'deployed/meta-fixture.sh' }; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /meta-fixture.*source.*deployed.*drift/i); +}); + +test('deployment drift meta-control fails if the shared comparator is made inert', async () => { + const root = await fixture('deployment-comparator-inert'); + await writeGate(root); + await mkdir(path.join(root, 'deployed'), { recursive: true }); + await copyFile(path.join(root, 'gates', 'meta-fixture.sh'), path.join(root, 'deployed', 'meta-fixture.sh')); + const manifest = baseManifest(); + manifest.gates[0].deployment = { kind: 'file', path: 'deployed/meta-fixture.sh' }; + await writeManifest(root, manifest); + + const alteredScripts = path.join(root, 'verifier-scripts'); + await mkdir(alteredScripts, { recursive: true }); + const verifierSource = await readFile(verifier, 'utf8'); + const inertSource = verifierSource.replace( + 'return source.equals(deployed);', + 'return true; // deliberate test-only inert comparator', + ); + assert.notEqual(inertSource, verifierSource, 'shared deployment comparator mutation went stale'); + await writeFile(path.join(alteredScripts, 'gate-verify.mjs'), inertSource); + await copyFile( + path.join(process.cwd(), 'scripts', 'gate-history.mjs'), + path.join(alteredScripts, 'gate-history.mjs'), + ); + + const result = spawnSync( + process.execPath, + [ + path.join(alteredScripts, 'gate-verify.mjs'), + '--root', + root, + '--manifest', + 'gates/gates.manifest.json', + '--skip-history', + ], + { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } }, + ); + assert.notEqual(result.status, 0); + assert.match(output(result), /meta-fixture.*drift negative control was ineffective/i); +}); diff --git a/scripts/gate-wiring.test.mjs b/scripts/gate-wiring.test.mjs new file mode 100644 index 00000000..52c81405 --- /dev/null +++ b/scripts/gate-wiring.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const root = process.cwd(); + +test('package.json exposes the canonical gate:verify command', async () => { + const packageJson = JSON.parse(await readFile(`${root}/package.json`, 'utf8')); + assert.equal(packageJson.scripts['gate:verify'], 'node scripts/gate-verify.mjs'); +}); + +test('Woodpecker runs gate verification on every pipeline without a path filter', async () => { + const pipeline = await readFile(`${root}/.woodpecker/ci.yml`, 'utf8'); + assert.match(pipeline, /\n gate-verify:\n/); + const step = + pipeline.match(/\n gate-verify:\n([\s\S]*?)(?=\n [a-z][a-z0-9-]+:|\nservices:)/)?.[1] ?? ''; + assert.match( + step, + /commands:\n - \*enable_pnpm\n - apk add --no-cache bubblewrap\n - pnpm gate:verify\n/, + ); + assert.doesNotMatch(step, /\bwhen:|\bpath:/); +}); -- 2.54.0 From e89599758b0b823045ddab5a0dfd5700ba37be27 Mon Sep 17 00:00:00 2001 From: f10-coder Date: Fri, 31 Jul 2026 21:50:54 -0500 Subject: [PATCH 02/13] fix(ci): unshallow gate replay history --- .woodpecker/ci.yml | 3 +++ docs/ADMIN-GUIDE/quality-gate-registry.md | 4 ++-- docs/scratchpads/1029-rm-02-gate-registry.md | 1 + scripts/gate-wiring.test.mjs | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 6c11c855..034783db 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -72,9 +72,12 @@ steps: # step-level `when`, because a gate can be disabled by changes outside its own path. gate-verify: image: *node_image + # Woodpecker's shallow marker makes merge-base reject even present parents; + # full history is required for prospective own-tree replay. commands: - *enable_pnpm - apk add --no-cache bubblewrap + - if [ -f .git/shallow ]; then git fetch --unshallow --no-tags origin; fi - pnpm gate:verify depends_on: - install diff --git a/docs/ADMIN-GUIDE/quality-gate-registry.md b/docs/ADMIN-GUIDE/quality-gate-registry.md index cc971f7a..0f785cd5 100644 --- a/docs/ADMIN-GUIDE/quality-gate-registry.md +++ b/docs/ADMIN-GUIDE/quality-gate-registry.md @@ -26,8 +26,8 @@ Do not add an ownerless exception or describe an open delta as pass/green/OK. ## CI behavior -Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. +Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. The step unshallows the checkout first because prospective ancestry and own-tree replay require complete commit history; a shallow boundary must never be interpreted as non-ancestry. Provider evidence input is an optional JSON array of normalized pipeline records containing `commit`, unique integer pipeline `number`, pipeline `status`, and a `gate-verify` step status. The highest numbered rerun is authoritative; ambiguous duplicates fail. Its retention window is provider-controlled and is not overstated by this repository. -Prior commit replay performs a frozen offline install from each commit's own lockfile before running that commit's verifier. Bubblewrap clears the environment, hides operator-home credentials, and disables networking for historical lifecycle and verifier code. A missing cached dependency or unavailable sandbox fails replay; current dependencies are never substituted. +Prior commit replay performs a frozen offline install from each commit's own lockfile before running that commit's verifier. Bubblewrap clears the environment, hides operator-home credentials, isolates process namespaces, and disables networking for historical lifecycle and verifier code. Archived files are snapshotted before install and must remain byte/type/mode-identical afterward. A missing cached dependency, source mutation, or unavailable sandbox fails replay; current dependencies are never substituted. diff --git a/docs/scratchpads/1029-rm-02-gate-registry.md b/docs/scratchpads/1029-rm-02-gate-registry.md index 673e5b8c..8604d555 100644 --- a/docs/scratchpads/1029-rm-02-gate-registry.md +++ b/docs/scratchpads/1029-rm-02-gate-registry.md @@ -58,6 +58,7 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro - Codex security review found the Bubblewrap replay shared the runner PID namespace. Replay now unshares PID, IPC, and UTS namespaces, and an abuse-case test proves a sibling runner PID is invisible. - Second review found empty reason diagnostics, final-symlink fixture writes, and lifecycle-script mutation of authoritative history files. Must-fail cases now require a reason pattern; writes use no-follow semantics; and replay snapshots every archived file before install and rejects any changed, deleted, or type/mode-shifted source before executing the verifier. Dedicated negative tests cover all three. - Third code review found ambiguous duplicate provider steps and order-sensitive JSON outcome comparison. Provider evidence now requires exactly one `gate-verify` step in the authoritative rerun, and structural equality normalizes object keys. Both regressions have RED-first tests. Third security review reported no findings. +- Initial PR pipeline #2177 exposed Woodpecker's shallow boundary: the activation parent object was present but marked shallow, so `merge-base --is-ancestor` correctly refused to infer ancestry. The unconditional gate step now unshallows before prospective replay; its wiring test was observed RED before the CI fix. ## Documentation checklist diff --git a/scripts/gate-wiring.test.mjs b/scripts/gate-wiring.test.mjs index 52c81405..cfeda743 100644 --- a/scripts/gate-wiring.test.mjs +++ b/scripts/gate-wiring.test.mjs @@ -16,7 +16,7 @@ test('Woodpecker runs gate verification on every pipeline without a path filter' pipeline.match(/\n gate-verify:\n([\s\S]*?)(?=\n [a-z][a-z0-9-]+:|\nservices:)/)?.[1] ?? ''; assert.match( step, - /commands:\n - \*enable_pnpm\n - apk add --no-cache bubblewrap\n - pnpm gate:verify\n/, + /commands:\n - \*enable_pnpm\n - apk add --no-cache bubblewrap\n - if \[ -f \.git\/shallow \]; then git fetch --unshallow --no-tags origin; fi\n - pnpm gate:verify\n/, ); assert.doesNotMatch(step, /\bwhen:|\bpath:/); }); -- 2.54.0 From 04cc0317741b29c66af77b971c82d26483bdff5a Mon Sep 17 00:00:00 2001 From: f10-coder Date: Fri, 31 Jul 2026 22:41:30 -0500 Subject: [PATCH 03/13] fix(quality): record current-tree trust boundary --- .woodpecker/ci.yml | 2 +- docs/ADMIN-GUIDE/quality-gate-registry.md | 6 +- docs/DEVELOPER-GUIDE/quality-gate-registry.md | 10 +- docs/PRD.md | 18 +++- docs/plans/2026-08-01-rm-02-gate-registry.md | 8 +- docs/remediation/GATE-CLAIMS.md | 8 ++ docs/scratchpads/1029-rm-02-gate-registry.md | 13 ++- gates/gates.manifest.json | 54 +++++++++- scripts/gate-history.mjs | 25 +++-- scripts/gate-history.test.mjs | 28 ++++- scripts/gate-verify.mjs | 19 ++++ scripts/gate-wiring.test.mjs | 100 ++++++++++++++++-- 12 files changed, 248 insertions(+), 43 deletions(-) diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index 034783db..e372361a 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -73,7 +73,7 @@ steps: gate-verify: image: *node_image # Woodpecker's shallow marker makes merge-base reject even present parents; - # full history is required for prospective own-tree replay. + # full history is required for activation ancestry and manifest provenance. commands: - *enable_pnpm - apk add --no-cache bubblewrap diff --git a/docs/ADMIN-GUIDE/quality-gate-registry.md b/docs/ADMIN-GUIDE/quality-gate-registry.md index 0f785cd5..29893fa0 100644 --- a/docs/ADMIN-GUIDE/quality-gate-registry.md +++ b/docs/ADMIN-GUIDE/quality-gate-registry.md @@ -26,8 +26,10 @@ Do not add an ownerless exception or describe an open delta as pass/green/OK. ## CI behavior -Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. The step unshallows the checkout first because prospective ancestry and own-tree replay require complete commit history; a shallow boundary must never be interpreted as non-ancestry. +Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. The step unshallows the checkout so activation ancestry and historical manifest provenance can be checked; a shallow boundary must never be interpreted as non-ancestry. Provider evidence input is an optional JSON array of normalized pipeline records containing `commit`, unique integer pipeline `number`, pipeline `status`, and a `gate-verify` step status. The highest numbered rerun is authoritative; ambiguous duplicates fail. Its retention window is provider-controlled and is not overstated by this repository. -Prior commit replay performs a frozen offline install from each commit's own lockfile before running that commit's verifier. Bubblewrap clears the environment, hides operator-home credentials, isolates process namespaces, and disables networking for historical lifecycle and verifier code. Archived files are snapshotted before install and must remain byte/type/mode-identical afterward. A missing cached dependency, source mutation, or unavailable sandbox fails replay; current dependencies are never substituted. +PR CI executes current-tree verification only, unprivileged and fail-closed. It does not execute isolated own-tree replay: RM-60 must provide a protected launcher or runner-level rootless sandbox before any PR-controlled executable/configuration is evaluated. Repo-only code cannot safely grant itself the capability intended to contain itself. + +The deferred replay implementation remains hard-fail when its sandbox cannot be established; it is not silently skipped as a successful replay. When RM-60 activates it under protected authority, it uses frozen own-tree dependencies, namespace/environment isolation, and archived-file identity checks. A post-merge failure triggers quarantine and revert. This is detection, not pre-merge prevention. diff --git a/docs/DEVELOPER-GUIDE/quality-gate-registry.md b/docs/DEVELOPER-GUIDE/quality-gate-registry.md index acedbdaf..46017dbd 100644 --- a/docs/DEVELOPER-GUIDE/quality-gate-registry.md +++ b/docs/DEVELOPER-GUIDE/quality-gate-registry.md @@ -32,10 +32,12 @@ A gate with an external installed counterpart declares it explicitly. When the i ## Commit and provider boundary -The current checkout is evaluated directly. On feature branches and main, each prior prospective commit is archived from Git, receives a frozen offline install from that commit's lockfile, and runs that commit's own verifier and manifest. Missing cached dependencies or an unrunnable historical verifier fail loudly rather than borrowing current-tree dependencies. +**DOES:** Every PR evaluates the current checkout's registered gates and declared inerting mutations directly, unprivileged and fail-closed. -Historical install scripts and verifiers execute inside Bubblewrap with network, PID, IPC, and UTS namespaces isolated; a cleared/allowlisted environment; an isolated home; a writable replay tree; read-only system files; and a read-only pnpm store. Current CI secrets, sibling runner processes, and the operator home are not visible inside that boundary. Because lifecycle scripts are required for faithful installs, the verifier snapshots every archived file before install and fails if any authoritative file changes, disappears, or changes type/mode before replay. Replay fails when this sandbox or integrity check cannot be established. +**DOES NOT:** Repository-controlled PR CI does not execute a commit's own verifier in an isolated replay. Doing so safely would require granting namespace capability before PR-controlled configuration or code runs; that same PR could consume the capability directly. This is an absent trust boundary, not unfinished hardening. RM-60 owns a runner-level rootless sandbox or protected immutable launcher; RM-59 owns the parallel artifact-integrity anchor. -Retained provider evidence can assert terminal-success for prior commits when supplied through `GATE_PROVIDER_EVIDENCE_FILE`. Each normalized record contains `commit`, unique integer pipeline `number`, pipeline `status`, and step statuses; the highest-numbered rerun is authoritative. Ambiguous duplicates fail. Absent, expired, or currently-running evidence is reported explicitly and never inferred as success. +The replay implementation and abuse-case tests remain fail-closed: when invoked by a future protected authority, inability to establish Bubblewrap is terminal nonzero; controls are never omitted or treated as replay success. On an unprivileged CI runner, sandbox integration tests pass only by asserting that this refusal is nonzero, while capable local/protected environments exercise the full abuse cases. Historical installs use frozen lockfiles, isolated network/PID/IPC/UTS and environment/home boundaries, and authoritative-file snapshots that detect lifecycle rewrites. -Repository replay proves tree reproducibility under the selected commit's locked dependency graph. It does not prove that CI blocked a merge at the time or resist an actor who can rewrite the verifier, registry, and gate consistently. RM-25/RM-59 own that external authority and trust anchor. +Retained provider evidence can assert terminal-success **current-tree** records for prior commits when supplied through `GATE_PROVIDER_EVIDENCE_FILE`. Each normalized record contains `commit`, unique integer pipeline `number`, pipeline `status`, and exactly one `gate-verify` step; the highest-numbered rerun is authoritative. Ambiguous duplicates fail. Absent, expired, or currently-running evidence is reported explicitly and never inferred as success. + +Once RM-60 supplies the external pre-execution anchor, protected post-merge/main replay is detection, not pre-merge prevention. A failed replay requires quarantine of the affected result and revert of the offending merge. It must never be represented as proof that CI blocked that merge. RM-25 tracks provider enforcement. diff --git a/docs/PRD.md b/docs/PRD.md index 59e96469..886ca741 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -22,7 +22,7 @@ Existing deterministic gates can return success without enforcing their stated p ### Scope -**In scope:** root typecheck, lint, and format gates; RM-01 checkout preflight; the Mosaic CI queue guard; root Husky pre-commit and pre-push hooks; criterion bindings; modeled compatibility; meaning-change provenance; security/integrity prose claim markers; source-versus-deployed identity; prospective per-commit tree replay; retained provider CI evidence where available. +**In scope:** root typecheck, lint, and format gates; RM-01 checkout preflight; the Mosaic CI queue guard; root Husky pre-commit and pre-push hooks; criterion bindings; modeled compatibility; meaning-change provenance; security/integrity prose claim markers; source-versus-deployed identity; unprivileged current-tree PR verification; retained provider CI evidence where available; and explicit deferral of isolated per-commit replay to RM-60's protected external authority. **Out of scope:** fixing the queue guard (RM-03); exhaustive registration of every repository executable (RM-54); semantic proof that arbitrary English criteria are mutually satisfiable (RM-54/RM-55); a same-authority trust anchor for repository-authored evidence (RM-25/RM-59). @@ -37,7 +37,13 @@ Existing deterministic gates can return success without enforcing their stated p 7. `RM02-REQ-07`: Executables under declared gate roots SHALL fail with `unregistered gate` when absent from the registry. The initial coverage boundary SHALL explicitly list exclusions and bind the broader inventory to RM-54. 8. `RM02-REQ-08`: Every gate with a deployed counterpart SHALL register source/deployed byte identity and a must-fail drift control. Gates without a deployed counterpart SHALL say so explicitly. 9. `RM02-REQ-09`: CI SHALL run `pnpm gate:verify` on every pull request without path filtering and on protected-main pushes. -10. `RM02-REQ-10`: Verification SHALL replay prospective first-parent commits from the activation boundary against each commit's own tree. It SHALL distinguish reproducibility replay from retained external provider evidence and SHALL state when provider history is absent, expired, or still running. +10. `RM02-REQ-10` (restated): PR CI SHALL perform unprivileged, fail-closed current-tree verification only. Isolated per-commit replay SHALL remain deferred to RM-60's protected post-merge/main authority, cross-referenced with RM-59. That future replay is detection with a quarantine/revert response, not pre-merge prevention; inability to establish its sandbox is terminal nonzero, never skip/pass. Retained provider evidence SHALL remain distinct and SHALL never be inferred when absent. + +#### RM02-REQ-10 meaning-change provenance + +- **Original:** “assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.” +- **Restatement:** PR CI performs unprivileged, fail-closed current-tree verification only. Isolated per-commit replay is deferred to a protected post-merge/main authority, where it is detection with a defined quarantine/revert response — explicitly not a pre-merge gate. Inability to establish the sandbox is hard nonzero, never a skip. +- **Reason:** Isolated replay on PR CI would require granting namespace capability to PR-controlled configuration, which the same PR could use directly before containment. The trust boundary is impossible at the repository layer, not merely expensive. RM-60 owns the external pre-execution anchor; RM-59 tracks the corresponding artifact-integrity anchor. ### Acceptance criteria @@ -47,12 +53,14 @@ Existing deterministic gates can return success without enforcing their stated p 4. `RM02-AC-04`: A gate with zero must-fail cases returns nonzero and includes `no negative control`. 5. `RM02-AC-05`: Unbound criteria, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. 6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request. -7. `RM02-AC-07`: Per-commit replay uses the selected commit's manifest and tree rather than current main, while external CI evidence is asserted only for the provider-retained window and never inferred when unavailable. +7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: current-tree gates and inerting mutations execute unprivileged and fail-closed; isolated own-tree replay does not execute in repository-controlled PR CI. RM-60/RM-59 are named, retained provider evidence is never inferred, and future protected post-merge detection specifies quarantine/revert rather than claiming pre-merge prevention. ### Risks, dependencies, and verification boundary -- The repository verifier proves declared controls, modeled scenarios, source/deployed equality at execution time, and prospective tree reproducibility. It does **not** defend against an actor able to rewrite the gate, registry, and verifier consistently. -- External branch protection and provider CI history supply merge-time evidence where retained. RM-25/RM-59 track the authority/trust anchor outside the worktree. +- The repository verifier proves declared controls, modeled scenarios, source/deployed equality at execution time, and unprivileged current-tree behavior. It does **not** execute isolated per-commit replay or defend against an actor able to rewrite the gate, registry, verifier, and sandbox entry consistently. +- Repo-only code cannot both grant namespace capability to PR configuration and prevent that same PR from using the capability directly. RM-60 owns a runner/provider-controlled pre-execution boundary; RM-59 owns the parallel artifact-integrity anchor. +- Protected post-merge replay, once RM-60 exists, is detection only. Failure requires immediate quarantine of the affected result and revert of the offending merge; it is not equivalent to a pre-merge gate. +- External branch protection and provider CI history supply merge-time current-tree evidence where retained. RM-25 tracks provider-side enforcement. - `ASSUMPTION:` RM-54 is the owner for expanding registration and prose-marker coverage beyond this approved seven-gate slice; rationale: the remediation task graph already assigns the fleet-wide inert-gate audit there. --- diff --git a/docs/plans/2026-08-01-rm-02-gate-registry.md b/docs/plans/2026-08-01-rm-02-gate-registry.md index 84d0b780..a750ea00 100644 --- a/docs/plans/2026-08-01-rm-02-gate-registry.md +++ b/docs/plans/2026-08-01-rm-02-gate-registry.md @@ -2,9 +2,9 @@ > **For Pi:** Use test-driven development and execute each task RED → GREEN → refactor. -**Goal:** Build a machine-readable seven-gate registry and an unconditional CI verifier that detects inert gates, binds criteria to observed negative controls, records defects honestly, and replays prospective commits against their own trees. +**Goal:** Build a machine-readable seven-gate registry and an unconditional CI verifier that detects inert gates, binds criteria to observed negative controls, records defects honestly, and verifies the current PR tree unprivileged and fail-closed. -**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json`, validates its closed schema and references, then runs typed cases in isolated main-disk fixtures. Gate-specific fixture setup remains declarative; exact invocations and exact observed/required exits stay in JSON. A separate history module selects each first-parent commit's own tree/manifest and reports retained external CI evidence without inferring missing evidence. +**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json`, validates its closed schema and references, then runs typed cases in isolated main-disk fixtures. Gate-specific fixture setup remains declarative; exact invocations and exact observed/required exits stay in JSON. A separate history module checks activation/manifest provenance and retained external current-tree CI evidence without inferring missing evidence. Isolated own-tree execution remains fail-closed code for RM-60's future protected authority; repository-controlled PR CI does not invoke it. **Tech Stack:** Node.js ESM, `node:test`, JSON, shell gates, pnpm, Woodpecker CI. @@ -66,7 +66,7 @@ Write and observe a failing test with a byte-mutated deployed counterpart. Imple Enumerate current security/integrity claims, bind each marker/id to a negative case, and reject unbound markers. Execute finite compatibility scenarios and clearly document that arbitrary English consistency is outside the model. -### Task 6: Prospective per-commit replay and provider evidence +### Task 6: Current-tree boundary, deferred replay, and provider evidence **Files:** @@ -75,7 +75,7 @@ Enumerate current security/integrity claims, bind each marker/id to a negative c - Modify: `scripts/gate-verify.mjs` - Modify: `gates/gates.manifest.json` -Test with a synthetic git repository containing two commits whose manifests differ; prove replay reads each selected commit's tree. Add bounded Gitea/Woodpecker status lookup for prior commits when credentials/history are available. Missing, expired, and currently-running evidence must be explicit states, never inferred success. +Test with a synthetic git repository containing two commits whose manifests differ. PR verification must state adjacent `DOES`/`DOES NOT` boundaries and must not execute the intermediate commit's verifier. Preserve isolated replay as a direct fail-closed primitive for RM-60's future protected pre-execution authority; sandbox failure remains nonzero. Add bounded Gitea/Woodpecker current-tree status lookup for prior commits when credentials/history are available. Missing, expired, and currently-running evidence must be explicit states, never inferred success. Protected post-merge replay is detection with quarantine/revert, never pre-merge prevention. ### Task 7: CI and documentation diff --git a/docs/remediation/GATE-CLAIMS.md b/docs/remediation/GATE-CLAIMS.md index dd77c2e2..f99b47b7 100644 --- a/docs/remediation/GATE-CLAIMS.md +++ b/docs/remediation/GATE-CLAIMS.md @@ -16,6 +16,14 @@ This index binds remediation claims that live in orchestrator-owned `TASKS.md` w - Source: `docs/remediation/TASKS.md`, heading `D-19 — an integrity property that cannot exist at the layer it was specified`. - Anchored text: “a verifier that cannot detect the attack is not a verifier.” +## Execution trust boundary + + + +- Source: RM-02 ruling recorded under `docs/remediation/TASKS.md`, RM-02 clause 4, D-25. +- Anchored text: “SELF-VERIFICATION BY THE AUDITED PARTY IS NOT VERIFICATION.” +- Dependency: RM-60/#1031, cross-referenced with RM-59. + ## Criterion restatement provenance diff --git a/docs/scratchpads/1029-rm-02-gate-registry.md b/docs/scratchpads/1029-rm-02-gate-registry.md index 8604d555..b71dc671 100644 --- a/docs/scratchpads/1029-rm-02-gate-registry.md +++ b/docs/scratchpads/1029-rm-02-gate-registry.md @@ -39,7 +39,7 @@ Deliver the seven-gate registry and RED-first anti-inert verifier on `feat/rm-02 - CI wiring RED: package script and unconditional Woodpecker step tests both failed before wiring. - History RED: history test failed with missing module before own-tree manifest selection/provider classification was implemented. - `pnpm gate:verify`: exit 0; seven gates each reported `META-NEGATIVE-CONTROL ... observed red`; queue source/deployed drift control observed red; six queue behavior deltas printed as `DEFECT (owner: RM-03)`. -- Focused Node tests: 33/33 pass after review hardening (23 verifier/wiring plus 10 history/provider tests). +- Focused Node tests: 34/34 pass after review hardening (24 verifier/wiring plus 10 history/provider tests). - `pnpm typecheck`: pass (45/45 Turbo tasks). - `pnpm lint`: pass (25/25 Turbo tasks). - `pnpm format:check`: pass. @@ -58,17 +58,22 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro - Codex security review found the Bubblewrap replay shared the runner PID namespace. Replay now unshares PID, IPC, and UTS namespaces, and an abuse-case test proves a sibling runner PID is invisible. - Second review found empty reason diagnostics, final-symlink fixture writes, and lifecycle-script mutation of authoritative history files. Must-fail cases now require a reason pattern; writes use no-follow semantics; and replay snapshots every archived file before install and rejects any changed, deleted, or type/mode-shifted source before executing the verifier. Dedicated negative tests cover all three. - Third code review found ambiguous duplicate provider steps and order-sensitive JSON outcome comparison. Provider evidence now requires exactly one `gate-verify` step in the authoritative rerun, and structural equality normalizes object keys. Both regressions have RED-first tests. Third security review reported no findings. -- Initial PR pipeline #2177 exposed Woodpecker's shallow boundary: the activation parent object was present but marked shallow, so `merge-base --is-ancestor` correctly refused to infer ancestry. The unconditional gate step now unshallows before prospective replay; its wiring test was observed RED before the CI fix. +- Initial PR pipeline #2177 exposed Woodpecker's shallow boundary: the activation parent object was present but marked shallow, so `merge-base --is-ancestor` correctly refused to infer ancestry. The unconditional gate step now unshallows before ancestry/provenance checks; its wiring test was observed RED before the CI fix. +- Pipeline #2178 then proved the unprivileged Docker runner cannot establish Bubblewrap namespaces. A privileged experiment remained uncommitted and was rejected after Codex correctly rated it CRITICAL: PR-controlled code executes before an in-repository sandbox and could directly use the granted capability. +- `mos-remediation` and `rev-974` independently ruled Option C. RM02-REQ-10 now retains its original text, restatement, and reason: PR CI verifies only the current tree, unprivileged and fail-closed; isolated own-tree replay is deferred to RM-60/#1031's external pre-execution authority, cross-referenced with RM-59. Future protected post-merge replay is detection with quarantine/revert, never pre-merge prevention. +- RED-first boundary test proved the old path executed an inert intermediate verifier. The revised path states adjacent `DOES`/`DOES NOT` claims, validates historical manifest provenance without executing it, and infers no replay success. Direct sandbox tests remain hard-fail; unprivileged CI asserts terminal refusal instead of treating replay as success. +- Option C security review reported no findings. Code review rejected an initial unrelated typecheck binding for the new security criterion. It was replaced with a dedicated registered `privileged-pr-gate` case: the fixture injects a privilege key into the gate step, the wiring control rejects it for that exact reason, and `gate:verify` observes the boundary negative control. Follow-up hardening uses a closed exact gate-step construction, rejects privilege across the entire pipeline, rejects non-canonical/merged YAML keys, and pins the unrestricted PR/main trigger block; quoted/escaped/alias/merge/duplicate/filter bypass tests pass. Final Codex code review approved with no findings. ## Documentation checklist - PRD, developer guide, admin guide, governing claim index, sitemap, plan, and scratchpad updated. - User/API documentation not applicable: no user workflow or API changed. -- Independent review documentation check pending rev-974. +- Independent review documentation check pending rev-974 at the revised exact head. - Canonical documentation remains in-repository; no external publication requested. ## Risks/blockers - Current queue guard intentionally has required-versus-actual deltas owned by RM-03. -- Provider CI cannot report the currently executing pipeline as terminal success; current-commit evidence must be labeled pending and becomes historical evidence only after provider completion. +- Provider CI cannot report the currently executing pipeline as terminal success; current-commit evidence must be labeled pending and becomes historical current-tree evidence only after provider completion. +- Isolated per-commit execution requires RM-60/#1031. Until that external authority exists, no replay success is claimed. A future protected post-merge failure requires quarantine/revert. - CI containers may not expose the operator-home deployed queue guard. In that layer the verifier checks the pinned observed digest and reports live identity unavailable under RM-04; it does not infer live equality. diff --git a/gates/gates.manifest.json b/gates/gates.manifest.json index a6d8fa76..7e0358b0 100644 --- a/gates/gates.manifest.json +++ b/gates/gates.manifest.json @@ -77,6 +77,23 @@ } ] }, + { + "id": "RM02-CURRENT-TREE-BOUNDARY", + "originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.", + "currentText": "PR CI performs unprivileged, fail-closed current-tree verification only; isolated per-commit replay is deferred to RM-60's protected post-merge/main authority as detection with quarantine/revert, not pre-merge prevention.", + "claimType": "security", + "source": "docs/PRD.md#rm02-req-10-meaning-change-provenance", + "meaningChanges": [ + { + "originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.", + "restatement": "PR CI performs unprivileged, fail-closed current-tree verification only; isolated per-commit replay is deferred to a protected post-merge/main authority as detection with quarantine/revert, not pre-merge prevention.", + "reason": "PR-controlled code would otherwise receive and could directly use the namespace capability intended to contain it; the pre-execution trust boundary is absent at the repository layer.", + "finding": "D-25", + "task": "RM-60/RM-59", + "date": "2026-08-01" + } + ] + }, { "id": "QUALITY-TYPECHECK", "originalText": "The root typecheck rejects a TypeScript type error.", @@ -194,6 +211,10 @@ "id": "GENERATED-STATE-SCOPE", "criterionId": "CHECKOUT-PREFLIGHT" }, + { + "id": "EXECUTION-TRUST-BOUNDARY", + "criterionId": "RM02-CURRENT-TREE-BOUNDARY" + }, { "id": "CRITERION-RESTATEMENT", "criterionId": "RM02-MEANING-PROVENANCE" @@ -235,9 +256,11 @@ } ], "mergeAssertions": { - "mode": "prospective-first-parent-replay", - "trustDependencies": ["RM-25", "RM-59"], - "providerEvidence": "assert retained terminal-success records for prior commits; report absent, expired, or current-running evidence without inference" + "mode": "unprivileged-current-tree-pr-verification", + "deferredReplayOwner": "RM-60", + "trustDependencies": ["RM-25", "RM-59", "RM-60"], + "providerEvidence": "assert retained current-tree terminal-success records for prior commits; report absent, expired, or current-running evidence without inference", + "postMergeResponse": "protected isolated replay is detection, not prevention; quarantine and revert on failure" }, "gates": [ { @@ -471,6 +494,31 @@ } ] } + }, + { + "id": "privileged-pr-gate", + "criterionIds": ["RM02-CURRENT-TREE-BOUNDARY"], + "mustFail": true, + "invocation": ["node", "--test", "scripts/gate-wiring.test.mjs"], + "required": { + "exitCode": 1, + "outputPattern": "privileged" + }, + "actual": { + "exitCode": 1, + "outputPattern": "privileged" + }, + "reasonPattern": "privileged", + "fixture": { + "copyPaths": [".woodpecker/ci.yml", "scripts/gate-wiring.test.mjs", "package.json"], + "replaceFiles": [ + { + "path": ".woodpecker/ci.yml", + "find": " gate-verify:\n image: *node_image\n", + "replace": " gate-verify:\n image: *node_image\n privileged: true\n" + } + ] + } } ] }, diff --git a/scripts/gate-history.mjs b/scripts/gate-history.mjs index 1fe1b492..19769d14 100644 --- a/scripts/gate-history.mjs +++ b/scripts/gate-history.mjs @@ -275,9 +275,18 @@ export async function verifyHistory({ root, manifest }) { return { failures, observations }; } const onMain = isMainCommit(root, head); + // RM-02 execution boundary (RM-60, cross-reference RM-59), kept adjacent in both directions: + // DOES: run every registered current-tree gate and declared inerting mutation on PR CI, + // unprivileged and fail-closed. + // DOES NOT: execute a commit's own verifier in an isolated PR replay. PR-controlled code would + // otherwise need the namespace capability intended to contain that same code. That external + // trust boundary must be runner/provider-owned before any PR executable or config is evaluated. + observations.push( + `RM-02 EXECUTION BOUNDARY ${head}: DOES: verify the current tree and declared inerting mutations on every PR, unprivileged and fail-closed; DOES NOT: execute isolated per-commit verifier replay in repository-controlled CI; owner RM-60, cross-reference RM-59`, + ); if (!onMain) { observations.push( - `PROVIDER ASSERTION DEFERRED ${head}: commit is not yet on main; prospective own-tree replay still runs, while retained merge evidence starts after merge`, + `PROVIDER ASSERTION DEFERRED ${head}: commit is not yet on main; retained provider evidence starts after merge and no replay success is inferred`, ); } @@ -302,20 +311,18 @@ export async function verifyHistory({ root, manifest }) { ); continue; } - const replay = await replayCommit(root, commit); - if (replay.status !== 0 || replay.error || replay.signal) { - failures.push( - `${commit}: own-tree gate replay failed with exit ${String(replay.status)}${replay.signal ? ` signal ${replay.signal}` : ''}${replay.error ? ` error ${replay.error.message}` : ''}: ${(replay.stderr || replay.stdout || '').trim().slice(0, 500)}`, - ); - continue; - } - observations.push(`TREE REPLAY ${commit}: own-tree gate verifier exited 0`); + observations.push( + `INTERMEDIATE REPLAY DEFERRED ${commit}: isolated own-tree execution is not performed by repository-controlled CI; owner RM-60, cross-reference RM-59; no success is inferred`, + ); if (!onMain) { observations.push( `PROVIDER EVIDENCE ${commit}: DEFERRED until the commit is on main; no success is inferred`, ); continue; } + observations.push( + `POST-MERGE DETECTION BOUNDARY ${commit}: protected isolated replay awaits RM-60; when available, a failure requires quarantine/revert and is detection, not pre-merge prevention`, + ); if (evidence.state === 'terminal-failure') { failures.push( `${commit}: retained provider evidence is not terminal-success (${evidence.detail})`, diff --git a/scripts/gate-history.test.mjs b/scripts/gate-history.test.mjs index ab0df2b5..364eb3aa 100644 --- a/scripts/gate-history.test.mjs +++ b/scripts/gate-history.test.mjs @@ -14,6 +14,15 @@ import { const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `gate-history-${process.pid}`); +function sandboxUnavailable(result) { + const detail = `${result.stdout ?? ''}${result.stderr ?? ''}${result.error?.message ?? ''}`; + if (!/bwrap:.*(?:Operation not permitted|Creating new namespace failed)/i.test(detail)) { + return false; + } + assert.notEqual(result.status, 0, 'sandbox unavailability must remain terminal nonzero'); + return true; +} + function git(root, ...args) { const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' }); assert.equal(result.status, 0, result.stderr); @@ -79,6 +88,7 @@ test('historical replay executes each selected commit verifier from that commit const inertResult = await replayCommit(root, inert); const fixedResult = await replayCommit(root, fixed); + if (sandboxUnavailable(inertResult) || sandboxUnavailable(fixedResult)) return; assert.notEqual(inertResult.status, 0); assert.match(inertResult.stderr, /OLD TREE INERT/); assert.equal(fixedResult.status, 0); @@ -117,6 +127,7 @@ test('historical install lifecycle cannot replace an authoritative verifier', as const result = await replayCommit(root, commit); assert.notEqual(result.status, 0); + if (sandboxUnavailable(result)) return; assert.match(result.stderr, /authoritative archived file changed.*scripts\/gate-verify\.mjs/i); assert.doesNotMatch(result.stdout, /FORGED SUCCESS/); }); @@ -141,6 +152,7 @@ test('historical verifier receives no current-process secret environment', async process.env.REPLAY_SENTINEL = 'must-not-cross-boundary'; try { const result = await replayCommit(root, commit); + if (sandboxUnavailable(result)) return; assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /SECRETLESS/); assert.doesNotMatch( @@ -174,6 +186,7 @@ test('historical replay cannot observe a sibling process in the runner PID names git(root, 'commit', '-m', 'pid-isolated replay fixture'); const commit = git(root, 'rev-parse', 'HEAD'); const result = await replayCommit(root, commit); + if (sandboxUnavailable(result)) return; assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /PIDLESS/); assert.doesNotMatch(`${result.stdout}${result.stderr}`, /HOST PID VISIBLE/); @@ -182,7 +195,7 @@ test('historical replay cannot observe a sibling process in the runner PID names } }); -test('feature-branch history replays an inert intermediate commit before the healthy head', async () => { +test('PR verification states the RM-60 boundary without executing an intermediate verifier', async () => { const root = `${fixtureRoot}-feature`; await rm(root, { recursive: true, force: true }); await mkdir(root, { recursive: true }); @@ -217,7 +230,18 @@ test('feature-branch history replays an inert intermediate commit before the hea root, manifest: { schemaVersion: 1, activationCommit: activation }, }); - assert.ok(result.failures.some((failure) => /INTERMEDIATE INERT/.test(failure))); + assert.deepEqual(result.failures, []); + assert.ok( + result.observations.some( + (observation) => /DOES:.*current tree.*DOES NOT:.*isolated.*RM-60.*RM-59/i.test(observation), + ), + ); + assert.ok( + result.observations.some( + (observation) => /INTERMEDIATE REPLAY DEFERRED.*RM-60.*no success is inferred/i.test(observation), + ), + ); + assert.ok(result.observations.every((observation) => !/INTERMEDIATE INERT/.test(observation))); } finally { if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH; else process.env.CI_COMMIT_BRANCH = previousBranch; diff --git a/scripts/gate-verify.mjs b/scripts/gate-verify.mjs index a3e51d14..9332d4f8 100644 --- a/scripts/gate-verify.mjs +++ b/scripts/gate-verify.mjs @@ -151,6 +151,25 @@ async function applyFixture(root, fixture = {}) { const target = await safeSandboxWrite(root, entry.path, entry.content, 'fixture write'); if (entry.mode !== undefined) await chmod(target, entry.mode); } + for (const entry of fixture.replaceFiles ?? []) { + const target = await sandboxPath(root, entry.path, 'fixture replace'); + if ((await lstat(target)).isSymbolicLink()) { + throw new Error(`fixture replace: target is a symbolic link (${entry.path})`); + } + const source = await readFile(target, 'utf8'); + const occurrences = source.split(entry.find).length - 1; + if (occurrences !== 1) { + throw new Error( + `fixture replace: stale or ambiguous match for ${entry.path} (${occurrences} matches)`, + ); + } + await safeSandboxWrite( + root, + entry.path, + source.replace(entry.find, entry.replace), + 'fixture replace', + ); + } for (const relativePath of fixture.removePaths ?? []) { await rm(await sandboxPath(root, relativePath, 'fixture remove'), { recursive: true, diff --git a/scripts/gate-wiring.test.mjs b/scripts/gate-wiring.test.mjs index cfeda743..1f0692c3 100644 --- a/scripts/gate-wiring.test.mjs +++ b/scripts/gate-wiring.test.mjs @@ -3,20 +3,102 @@ import { readFile } from 'node:fs/promises'; import test from 'node:test'; const root = process.cwd(); +const expectedTriggers = `when: + # PR + manual CI run on any branch — the pull_request pipeline is the merge gate. + # push CI is restricted to protected branches (main) so a feature-branch push no + # longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves + # CI load on the storage-constrained runner with zero loss of gating (branch + # protection requires no push/ci status context; main still gets full push CI). + - event: [pull_request, manual] + - event: push + branch: main`; +const expectedGateStep = ` image: *node_image + # Woodpecker's shallow marker makes merge-base reject even present parents; + # full history is required for activation ancestry and manifest provenance. + commands: + - *enable_pnpm + - apk add --no-cache bubblewrap + - if [ -f .git/shallow ]; then git fetch --unshallow --no-tags origin; fi + - pnpm gate:verify + depends_on: + - install + - sanitization + - upgrade-guard`; + +export function assertUnprivilegedGateStep(pipeline) { + assert.doesNotMatch( + pipeline, + /privileged/i, + 'no pull-request pipeline step may declare privilege', + ); + for (const line of pipeline.split('\n')) { + const candidate = line.trimStart().replace(/^-\s+/, ''); + if (/^(?:["'!<].*|[A-Za-z_][A-Za-z0-9_-]*\s+):(?:\s|$)/.test(candidate)) { + assert.fail(`non-canonical or merged YAML key is forbidden: ${candidate}`); + } + } + const triggerMatches = [...pipeline.matchAll(/^when:\n([\s\S]*?)(?=\n\n)/gm)]; + assert.equal(triggerMatches.length, 1, 'exactly one top-level trigger is required'); + assert.equal( + `when:\n${triggerMatches[0][1].trimEnd()}`, + expectedTriggers, + 'top-level triggers must match closed PR/main construction', + ); + + const matches = [ + ...pipeline.matchAll(/\n gate-verify:\n([\s\S]*?)(?=\n [a-z][a-z0-9-]+:|\nservices:|$)/g), + ]; + assert.equal(matches.length, 1, 'exactly one gate-verify step is required'); + // Closed textual construction by design: accepting arbitrary YAML syntax here + // would require a duplicate-key-preserving parser. Exact equality rejects all + // extra keys, quoted/escaped key spellings, aliases, and mapping merges. + assert.equal(matches[0][1].trimEnd(), expectedGateStep, 'gate-verify step must match closed unprivileged construction'); +} test('package.json exposes the canonical gate:verify command', async () => { const packageJson = JSON.parse(await readFile(`${root}/package.json`, 'utf8')); assert.equal(packageJson.scripts['gate:verify'], 'node scripts/gate-verify.mjs'); }); -test('Woodpecker runs gate verification on every pipeline without a path filter', async () => { +test('Woodpecker runs the closed unprivileged gate construction on every pipeline', async () => { const pipeline = await readFile(`${root}/.woodpecker/ci.yml`, 'utf8'); - assert.match(pipeline, /\n gate-verify:\n/); - const step = - pipeline.match(/\n gate-verify:\n([\s\S]*?)(?=\n [a-z][a-z0-9-]+:|\nservices:)/)?.[1] ?? ''; - assert.match( - step, - /commands:\n - \*enable_pnpm\n - apk add --no-cache bubblewrap\n - if \[ -f \.git\/shallow \]; then git fetch --unshallow --no-tags origin; fi\n - pnpm gate:verify\n/, - ); - assert.doesNotMatch(step, /\bwhen:|\bpath:/); + assertUnprivilegedGateStep(pipeline); +}); + +test('gate wiring rejects privilege syntax, merges, duplicate keys, and trigger narrowing', async () => { + const pipeline = await readFile(`${root}/.woodpecker/ci.yml`, 'utf8'); + const additions = [ + ' privileged: *enabled\n', + ' "privileged": true\n', + " 'privileged': true\n", + ' privileged : true\n', + ' "priv\\u0069leged": true\n', + ' <<: *privileged-step\n', + ' "<<": *privileged-step\n', + ]; + for (const addition of additions) { + const changed = pipeline.replace(' gate-verify:\n image:', ` gate-verify:\n${addition} image:`); + assert.throws(() => assertUnprivilegedGateStep(changed)); + } + const privilegedInstall = pipeline.replace( + ' install:\n image:', + ' install:\n privileged: true\n image:', + ); + const duplicate = `${pipeline}\n gate-verify:\n image: *node_image\n`; + const noPullRequest = pipeline.replace( + ' - event: [pull_request, manual]', + ' - event: manual', + ); + const filteredPullRequest = pipeline.replace( + ' - event: [pull_request, manual]', + ' - event: [pull_request, manual]\n path: [scripts/**]', + ); + + assert.throws(() => assertUnprivilegedGateStep(privilegedInstall), /privilege/i); + assert.throws(() => assertUnprivilegedGateStep(duplicate), /exactly one gate-verify/); + assert.throws(() => assertUnprivilegedGateStep(noPullRequest), /closed PR\/main construction/); + assert.throws( + () => assertUnprivilegedGateStep(filteredPullRequest), + /closed PR\/main construction/, + ); }); -- 2.54.0 From abaed0c10317afe4aff6c7b4a2abd90a5e40c7ae Mon Sep 17 00:00:00 2001 From: coder-mos1 Date: Fri, 31 Jul 2026 22:57:47 -0500 Subject: [PATCH 04/13] test(ci): classify Bubblewrap EPERM exactly --- docs/scratchpads/1029-rm-02-gate-registry.md | 4 ++-- scripts/gate-history.test.mjs | 24 +++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/scratchpads/1029-rm-02-gate-registry.md b/docs/scratchpads/1029-rm-02-gate-registry.md index b71dc671..ab4d1441 100644 --- a/docs/scratchpads/1029-rm-02-gate-registry.md +++ b/docs/scratchpads/1029-rm-02-gate-registry.md @@ -39,7 +39,7 @@ Deliver the seven-gate registry and RED-first anti-inert verifier on `feat/rm-02 - CI wiring RED: package script and unconditional Woodpecker step tests both failed before wiring. - History RED: history test failed with missing module before own-tree manifest selection/provider classification was implemented. - `pnpm gate:verify`: exit 0; seven gates each reported `META-NEGATIVE-CONTROL ... observed red`; queue source/deployed drift control observed red; six queue behavior deltas printed as `DEFECT (owner: RM-03)`. -- Focused Node tests: 34/34 pass after review hardening (24 verifier/wiring plus 10 history/provider tests). +- Focused Node tests: 35/35 pass after review hardening (24 verifier/wiring plus 11 history/provider tests). - `pnpm typecheck`: pass (45/45 Turbo tasks). - `pnpm lint`: pass (25/25 Turbo tasks). - `pnpm format:check`: pass. @@ -61,7 +61,7 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro - Initial PR pipeline #2177 exposed Woodpecker's shallow boundary: the activation parent object was present but marked shallow, so `merge-base --is-ancestor` correctly refused to infer ancestry. The unconditional gate step now unshallows before ancestry/provenance checks; its wiring test was observed RED before the CI fix. - Pipeline #2178 then proved the unprivileged Docker runner cannot establish Bubblewrap namespaces. A privileged experiment remained uncommitted and was rejected after Codex correctly rated it CRITICAL: PR-controlled code executes before an in-repository sandbox and could directly use the granted capability. - `mos-remediation` and `rev-974` independently ruled Option C. RM02-REQ-10 now retains its original text, restatement, and reason: PR CI verifies only the current tree, unprivileged and fail-closed; isolated own-tree replay is deferred to RM-60/#1031's external pre-execution authority, cross-referenced with RM-59. Future protected post-merge replay is detection with quarantine/revert, never pre-merge prevention. -- RED-first boundary test proved the old path executed an inert intermediate verifier. The revised path states adjacent `DOES`/`DOES NOT` claims, validates historical manifest provenance without executing it, and infers no replay success. Direct sandbox tests remain hard-fail; unprivileged CI asserts terminal refusal instead of treating replay as success. +- RED-first boundary test proved the old path executed an inert intermediate verifier. The revised path states adjacent `DOES`/`DOES NOT` claims, validates historical manifest provenance without executing it, and infers no replay success. Direct sandbox tests remain hard-fail; unprivileged CI asserts terminal refusal instead of treating replay as success. Pipeline #2179 showed this runner reports namespace denial as `spawnSync bwrap` with `error.code=EPERM`, `status=null`, and no stderr; the refusal detector now recognizes only that Bubblewrap-provenance hard-fail form and rejects unrelated command EPERM results. - Option C security review reported no findings. Code review rejected an initial unrelated typecheck binding for the new security criterion. It was replaced with a dedicated registered `privileged-pr-gate` case: the fixture injects a privilege key into the gate step, the wiring control rejects it for that exact reason, and `gate:verify` observes the boundary negative control. Follow-up hardening uses a closed exact gate-step construction, rejects privilege across the entire pipeline, rejects non-canonical/merged YAML keys, and pins the unrestricted PR/main trigger block; quoted/escaped/alias/merge/duplicate/filter bypass tests pass. Final Codex code review approved with no findings. ## Documentation checklist diff --git a/scripts/gate-history.test.mjs b/scripts/gate-history.test.mjs index 364eb3aa..87d11e83 100644 --- a/scripts/gate-history.test.mjs +++ b/scripts/gate-history.test.mjs @@ -16,7 +16,12 @@ const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `gate-history- function sandboxUnavailable(result) { const detail = `${result.stdout ?? ''}${result.stderr ?? ''}${result.error?.message ?? ''}`; - if (!/bwrap:.*(?:Operation not permitted|Creating new namespace failed)/i.test(detail)) { + const bubblewrapSpawnDenied = + result.error?.code === 'EPERM' && /spawnSync bwrap/i.test(result.error?.message ?? ''); + if ( + !bubblewrapSpawnDenied && + !/bwrap.*(?:EPERM|Operation not permitted|Creating new namespace failed)/i.test(detail) + ) { return false; } assert.notEqual(result.status, 0, 'sandbox unavailability must remain terminal nonzero'); @@ -44,6 +49,23 @@ test.after(async () => { await rm(fixtureRoot, { recursive: true, force: true }); }); +test('sandbox refusal classification requires Bubblewrap provenance', () => { + assert.equal( + sandboxUnavailable({ + status: null, + error: { code: 'EPERM', message: 'spawnSync bwrap EPERM' }, + }), + true, + ); + assert.equal( + sandboxUnavailable({ + status: null, + error: { code: 'EPERM', message: 'spawnSync git EPERM' }, + }), + false, + ); +}); + test('prospective history reads each commit own manifest rather than the current tree', async () => { await rm(fixtureRoot, { recursive: true, force: true }); await mkdir(fixtureRoot, { recursive: true }); -- 2.54.0 From d1196352650c35ff76a71aded81d1762f529aeaf Mon Sep 17 00:00:00 2001 From: coder-mos1 Date: Fri, 31 Jul 2026 23:24:21 -0500 Subject: [PATCH 05/13] fix(test): preserve sandbox refusal provenance --- docs/scratchpads/1029-rm-02-gate-registry.md | 2 +- scripts/gate-history.mjs | 2 +- scripts/gate-history.test.mjs | 25 +++++++++++++------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/scratchpads/1029-rm-02-gate-registry.md b/docs/scratchpads/1029-rm-02-gate-registry.md index ab4d1441..d746d129 100644 --- a/docs/scratchpads/1029-rm-02-gate-registry.md +++ b/docs/scratchpads/1029-rm-02-gate-registry.md @@ -61,7 +61,7 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro - Initial PR pipeline #2177 exposed Woodpecker's shallow boundary: the activation parent object was present but marked shallow, so `merge-base --is-ancestor` correctly refused to infer ancestry. The unconditional gate step now unshallows before ancestry/provenance checks; its wiring test was observed RED before the CI fix. - Pipeline #2178 then proved the unprivileged Docker runner cannot establish Bubblewrap namespaces. A privileged experiment remained uncommitted and was rejected after Codex correctly rated it CRITICAL: PR-controlled code executes before an in-repository sandbox and could directly use the granted capability. - `mos-remediation` and `rev-974` independently ruled Option C. RM02-REQ-10 now retains its original text, restatement, and reason: PR CI verifies only the current tree, unprivileged and fail-closed; isolated own-tree replay is deferred to RM-60/#1031's external pre-execution authority, cross-referenced with RM-59. Future protected post-merge replay is detection with quarantine/revert, never pre-merge prevention. -- RED-first boundary test proved the old path executed an inert intermediate verifier. The revised path states adjacent `DOES`/`DOES NOT` claims, validates historical manifest provenance without executing it, and infers no replay success. Direct sandbox tests remain hard-fail; unprivileged CI asserts terminal refusal instead of treating replay as success. Pipeline #2179 showed this runner reports namespace denial as `spawnSync bwrap` with `error.code=EPERM`, `status=null`, and no stderr; the refusal detector now recognizes only that Bubblewrap-provenance hard-fail form and rejects unrelated command EPERM results. +- RED-first boundary test proved the old path executed an inert intermediate verifier. The revised path states adjacent `DOES`/`DOES NOT` claims, validates historical manifest provenance without executing it, and infers no replay success. Direct sandbox tests remain hard-fail; unprivileged CI asserts terminal refusal instead of treating replay as success. Pipelines #2179/#2180/#2181 exposed two runner refusal forms: namespace denial as `spawnSync bwrap` with `error.code=EPERM`, and a test image without Bubblewrap as `error.code=ENOENT`. The replay diagnostic now preserves spawn errors; the refusal detector recognizes only exact `spawnSync bwrap` provenance for `EPERM`/`EACCES`/`ENOENT`, plus Bubblewrap's known namespace-refusal text. Focused negative assertions reject both unrelated `spawnSync git EPERM` and verifier output that merely says `bwrap ENOENT`. - Option C security review reported no findings. Code review rejected an initial unrelated typecheck binding for the new security criterion. It was replaced with a dedicated registered `privileged-pr-gate` case: the fixture injects a privilege key into the gate step, the wiring control rejects it for that exact reason, and `gate:verify` observes the boundary negative control. Follow-up hardening uses a closed exact gate-step construction, rejects privilege across the entire pipeline, rejects non-canonical/merged YAML keys, and pins the unrestricted PR/main trigger block; quoted/escaped/alias/merge/duplicate/filter bypass tests pass. Final Codex code review approved with no findings. ## Documentation checklist diff --git a/scripts/gate-history.mjs b/scripts/gate-history.mjs index 19769d14..d6b1bbc2 100644 --- a/scripts/gate-history.mjs +++ b/scripts/gate-history.mjs @@ -142,7 +142,7 @@ export async function replayCommit(root, commit) { if (install.status !== 0 || install.error || install.signal) { return { ...install, - stderr: `historical frozen dependency install failed: ${install.stderr || install.stdout || ''}`, + stderr: `historical frozen dependency install failed: ${install.error?.message || install.stderr || install.stdout || ''}`, }; } const authoritativeChanges = await authoritativeTreeChanges( diff --git a/scripts/gate-history.test.mjs b/scripts/gate-history.test.mjs index 87d11e83..0d130c6b 100644 --- a/scripts/gate-history.test.mjs +++ b/scripts/gate-history.test.mjs @@ -17,10 +17,11 @@ const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `gate-history- function sandboxUnavailable(result) { const detail = `${result.stdout ?? ''}${result.stderr ?? ''}${result.error?.message ?? ''}`; const bubblewrapSpawnDenied = - result.error?.code === 'EPERM' && /spawnSync bwrap/i.test(result.error?.message ?? ''); + ['EPERM', 'EACCES', 'ENOENT'].includes(result.error?.code) && + /spawnSync bwrap/i.test(result.error?.message ?? ''); if ( !bubblewrapSpawnDenied && - !/bwrap.*(?:EPERM|Operation not permitted|Creating new namespace failed)/i.test(detail) + !/bwrap:.*(?:Operation not permitted|Creating new namespace failed)/i.test(detail) ) { return false; } @@ -50,13 +51,15 @@ test.after(async () => { }); test('sandbox refusal classification requires Bubblewrap provenance', () => { - assert.equal( - sandboxUnavailable({ - status: null, - error: { code: 'EPERM', message: 'spawnSync bwrap EPERM' }, - }), - true, - ); + for (const code of ['EPERM', 'EACCES', 'ENOENT']) { + assert.equal( + sandboxUnavailable({ + status: null, + error: { code, message: `spawnSync bwrap ${code}` }, + }), + true, + ); + } assert.equal( sandboxUnavailable({ status: null, @@ -64,6 +67,10 @@ test('sandbox refusal classification requires Bubblewrap provenance', () => { }), false, ); + assert.equal( + sandboxUnavailable({ status: 1, stderr: 'historical verifier said bwrap ENOENT' }), + false, + ); }); test('prospective history reads each commit own manifest rather than the current tree', async () => { -- 2.54.0 From 8b1b8730563e666dbb84ba98c34ff32110fd8273 Mon Sep 17 00:00:00 2001 From: coder-mos1 Date: Sat, 1 Aug 2026 00:17:50 -0500 Subject: [PATCH 06/13] fix(quality): prove criterion binding semantics --- docs/ADMIN-GUIDE/quality-gate-registry.md | 6 +- docs/DEVELOPER-GUIDE/quality-gate-registry.md | 6 +- docs/PRD.md | 7 +- docs/scratchpads/1029-rm-02-gate-registry.md | 5 +- gates/gates.manifest.json | 241 ++++++++++++++---- scripts/gate-history.mjs | 30 ++- scripts/gate-history.test.mjs | 36 ++- scripts/gate-verify.mjs | 88 ++++++- scripts/gate-verify.test.mjs | 74 +++++- 9 files changed, 414 insertions(+), 79 deletions(-) diff --git a/docs/ADMIN-GUIDE/quality-gate-registry.md b/docs/ADMIN-GUIDE/quality-gate-registry.md index 29893fa0..312bb351 100644 --- a/docs/ADMIN-GUIDE/quality-gate-registry.md +++ b/docs/ADMIN-GUIDE/quality-gate-registry.md @@ -14,8 +14,8 @@ Investigate any of these immediately: ## Updating a gate -1. Add or change the criterion and exact case. -2. Observe the case fail for its own stated reason. +1. Add or change the criterion, its exact criterion-side `caseRefs`, and matching case-side `criterionIds`. +2. Observe the must-fail case fail for its own stated reason; moving the binding to any undeclared case must fail verification. 3. Declare an exact inerting mutation and observe the verifier detect it. 4. If required and actual behavior differ, add a tracked remediation owner and justification. 5. If meaning changed, append provenance; never replace the original silently. @@ -32,4 +32,4 @@ Provider evidence input is an optional JSON array of normalized pipeline records PR CI executes current-tree verification only, unprivileged and fail-closed. It does not execute isolated own-tree replay: RM-60 must provide a protected launcher or runner-level rootless sandbox before any PR-controlled executable/configuration is evaluated. Repo-only code cannot safely grant itself the capability intended to contain itself. -The deferred replay implementation remains hard-fail when its sandbox cannot be established; it is not silently skipped as a successful replay. When RM-60 activates it under protected authority, it uses frozen own-tree dependencies, namespace/environment isolation, and archived-file identity checks. A post-merge failure triggers quarantine and revert. This is detection, not pre-merge prevention. +The deferred replay implementation remains hard-fail when its sandbox cannot be established; it is not silently skipped as a successful replay. Tests recognize unavailability only from parent-generated Bubblewrap-launch provenance combined with proof that the sandbox entry command did not run. A denial-looking string from child-controlled output is not evidence. When RM-60 activates replay under protected authority, it uses frozen own-tree dependencies, namespace/environment isolation, and archived-file identity checks. A post-merge failure triggers quarantine and revert. This is detection, not pre-merge prevention. diff --git a/docs/DEVELOPER-GUIDE/quality-gate-registry.md b/docs/DEVELOPER-GUIDE/quality-gate-registry.md index 46017dbd..526bf481 100644 --- a/docs/DEVELOPER-GUIDE/quality-gate-registry.md +++ b/docs/DEVELOPER-GUIDE/quality-gate-registry.md @@ -20,7 +20,9 @@ The queue guard currently has RM-03-owned deltas. In particular, its stdin/hered ## Criteria, prose, and compatibility -Each criterion must bind to a must-fail case. Designated governing prose uses `GATE-CLAIM:` markers; an unbound marker or registered-but-missing marker fails. Orchestrator-owned claims from `TASKS.md` are bound through `docs/remediation/GATE-CLAIMS.md`, which records source headings and anchored text without changing task tracking. Marker completeness still requires RM-54 review because arbitrary English claims cannot be inferred safely. +Each criterion declares exact `caseRefs`; the verifier compares those semantic declarations bidirectionally with case-side `criterionIds` and requires at least one must-fail case. Moving a criterion ID to an unrelated case therefore fails as both a missing declared exercising case and an undeclared binding. Registered meta-negative controls misbind a criterion, remove meaning provenance, and misbind a prose claim, and each must make structure verification red for its stated reason. + +Designated governing prose uses `GATE-CLAIM:` markers. Each claim also names the exact must-fail `caseRef` that exercises its criterion; unknown, positive-only, unrelated, unbound, or registered-but-missing claims fail. Orchestrator-owned claims from `TASKS.md` are bound through `docs/remediation/GATE-CLAIMS.md`, which records source headings and anchored text without changing task tracking. Marker completeness still requires RM-54 review because arbitrary English claims cannot be inferred safely. Compatibility checks detect direct contradictions in declared finite constructions. The verifier combines referenced case fixtures and environments in one isolated tree, rejects conflicting fixture/environment values, executes the construction's exact invocation, and checks its exact outcome. They do not prove semantic consistency of arbitrary natural language. @@ -36,7 +38,7 @@ A gate with an external installed counterpart declares it explicitly. When the i **DOES NOT:** Repository-controlled PR CI does not execute a commit's own verifier in an isolated replay. Doing so safely would require granting namespace capability before PR-controlled configuration or code runs; that same PR could consume the capability directly. This is an absent trust boundary, not unfinished hardening. RM-60 owns a runner-level rootless sandbox or protected immutable launcher; RM-59 owns the parallel artifact-integrity anchor. -The replay implementation and abuse-case tests remain fail-closed: when invoked by a future protected authority, inability to establish Bubblewrap is terminal nonzero; controls are never omitted or treated as replay success. On an unprivileged CI runner, sandbox integration tests pass only by asserting that this refusal is nonzero, while capable local/protected environments exercise the full abuse cases. Historical installs use frozen lockfiles, isolated network/PID/IPC/UTS and environment/home boundaries, and authoritative-file snapshots that detect lifecycle rewrites. +The replay implementation and abuse-case tests remain fail-closed: when invoked by a future protected authority, inability to establish Bubblewrap is terminal nonzero; controls are never omitted or treated as replay success. On an unprivileged CI runner, sandbox integration tests pass only when the result carries parent-generated Bubblewrap-launch provenance and proves the sandbox entry command never ran. Child-controlled text that merely reproduces a Bubblewrap denial is not accepted. Capable local/protected environments exercise the full abuse cases. Historical installs use frozen lockfiles, isolated network/PID/IPC/UTS and environment/home boundaries, and authoritative-file snapshots that detect lifecycle rewrites. Retained provider evidence can assert terminal-success **current-tree** records for prior commits when supplied through `GATE_PROVIDER_EVIDENCE_FILE`. Each normalized record contains `commit`, unique integer pipeline `number`, pipeline `status`, and exactly one `gate-verify` step; the highest-numbered rerun is authoritative. Ambiguous duplicates fail. Absent, expired, or currently-running evidence is reported explicitly and never inferred as success. diff --git a/docs/PRD.md b/docs/PRD.md index 886ca741..840e3f1a 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -30,7 +30,7 @@ Existing deterministic gates can return success without enforcing their stated p 1. `RM02-REQ-01`: The JSON registry SHALL give every gate and criterion a stable ID and SHALL declare exact invocation, input classes, cases, exact observed and required exit codes, reason diagnostics, and criterion bindings. 2. `RM02-REQ-02`: Every gate SHALL have at least one observed-red must-fail case. The verifier SHALL reject missing, stale, ambiguous, or ineffective declared inert mutations and SHALL name an externally inerted gate. -3. `RM02-REQ-03`: Every acceptance criterion SHALL bind to a case that can fail for that criterion's stated reason. Security/integrity prose claims in the designated governing documents SHALL carry bound `GATE-CLAIM:` markers. +3. `RM02-REQ-03`: Every acceptance criterion SHALL declare exact criterion-side `caseRefs` that match case-side `criterionIds` bidirectionally and include a case that can fail for that criterion's stated reason. Moving a binding to an unrelated case SHALL fail verification. Security/integrity prose claims in the designated governing documents SHALL carry bound `GATE-CLAIM:` markers and declare the exact must-fail case exercising the claim criterion. 4. `RM02-REQ-04`: Declared finite compatibility scenarios SHALL execute together and direct modeled contradictions SHALL fail. This does not claim semantic consistency of arbitrary English. 5. `RM02-REQ-05`: Restated criteria SHALL retain original text, current text, reason, finding/task, and dated meaning-change history. 6. `RM02-REQ-06`: An observed behavior differing from required behavior SHALL be reported as `DEFECT` with a tracked owner; an ownerless delta SHALL fail verification. Such a gate SHALL never be described as passing, green, or OK. @@ -51,13 +51,14 @@ Existing deterministic gates can return success without enforcing their stated p 2. `RM02-AC-02`: Externally mutate any registered gate at its declared inerting point so its failure path succeeds; verification returns nonzero and names that gate. The verifier's internal meta-control is observed red before its healthy result is trusted. 3. `RM02-AC-03`: An executable added under a declared gate root without an entry returns nonzero and includes `unregistered gate`. 4. `RM02-AC-04`: A gate with zero must-fail cases returns nonzero and includes `no negative control`. -5. `RM02-AC-05`: Unbound criteria, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. +5. `RM02-AC-05`: Unbound or semantically misbound criteria, prose claims bound to unrelated cases, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. Registered meta-negative controls move a criterion binding, remove meaning provenance, and redirect a prose claim to an unrelated case; each is observed red for its stated reason. 6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request. 7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: current-tree gates and inerting mutations execute unprivileged and fail-closed; isolated own-tree replay does not execute in repository-controlled PR CI. RM-60/RM-59 are named, retained provider evidence is never inferred, and future protected post-merge detection specifies quarantine/revert rather than claiming pre-merge prevention. ### Risks, dependencies, and verification boundary -- The repository verifier proves declared controls, modeled scenarios, source/deployed equality at execution time, and unprivileged current-tree behavior. It does **not** execute isolated per-commit replay or defend against an actor able to rewrite the gate, registry, verifier, and sandbox entry consistently. +- The repository verifier proves declared controls, modeled scenarios, bidirectional declared criterion/case relationships, source/deployed equality at execution time, and unprivileged current-tree behavior. It does **not** infer arbitrary-English semantics, execute isolated per-commit replay, or defend against an actor able to rewrite the gate, registry, verifier, and sandbox entry consistently. +- Sandbox refusal tests require parent-generated Bubblewrap-launch provenance and proof that the sandbox entry command never ran; child-controlled denial-looking text alone cannot establish unavailability. - Repo-only code cannot both grant namespace capability to PR configuration and prevent that same PR from using the capability directly. RM-60 owns a runner/provider-controlled pre-execution boundary; RM-59 owns the parallel artifact-integrity anchor. - Protected post-merge replay, once RM-60 exists, is detection only. Failure requires immediate quarantine of the affected result and revert of the offending merge; it is not equivalent to a pre-merge gate. - External branch protection and provider CI history supply merge-time current-tree evidence where retained. RM-25 tracks provider-side enforcement. diff --git a/docs/scratchpads/1029-rm-02-gate-registry.md b/docs/scratchpads/1029-rm-02-gate-registry.md index d746d129..3e6d0b85 100644 --- a/docs/scratchpads/1029-rm-02-gate-registry.md +++ b/docs/scratchpads/1029-rm-02-gate-registry.md @@ -39,7 +39,7 @@ Deliver the seven-gate registry and RED-first anti-inert verifier on `feat/rm-02 - CI wiring RED: package script and unconditional Woodpecker step tests both failed before wiring. - History RED: history test failed with missing module before own-tree manifest selection/provider classification was implemented. - `pnpm gate:verify`: exit 0; seven gates each reported `META-NEGATIVE-CONTROL ... observed red`; queue source/deployed drift control observed red; six queue behavior deltas printed as `DEFECT (owner: RM-03)`. -- Focused Node tests: 35/35 pass after review hardening (24 verifier/wiring plus 11 history/provider tests). +- Focused Node tests: 37/37 pass after review hardening (26 verifier/wiring plus 11 history/provider tests). - `pnpm typecheck`: pass (45/45 Turbo tasks). - `pnpm lint`: pass (25/25 Turbo tasks). - `pnpm format:check`: pass. @@ -61,7 +61,8 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro - Initial PR pipeline #2177 exposed Woodpecker's shallow boundary: the activation parent object was present but marked shallow, so `merge-base --is-ancestor` correctly refused to infer ancestry. The unconditional gate step now unshallows before ancestry/provenance checks; its wiring test was observed RED before the CI fix. - Pipeline #2178 then proved the unprivileged Docker runner cannot establish Bubblewrap namespaces. A privileged experiment remained uncommitted and was rejected after Codex correctly rated it CRITICAL: PR-controlled code executes before an in-repository sandbox and could directly use the granted capability. - `mos-remediation` and `rev-974` independently ruled Option C. RM02-REQ-10 now retains its original text, restatement, and reason: PR CI verifies only the current tree, unprivileged and fail-closed; isolated own-tree replay is deferred to RM-60/#1031's external pre-execution authority, cross-referenced with RM-59. Future protected post-merge replay is detection with quarantine/revert, never pre-merge prevention. -- RED-first boundary test proved the old path executed an inert intermediate verifier. The revised path states adjacent `DOES`/`DOES NOT` claims, validates historical manifest provenance without executing it, and infers no replay success. Direct sandbox tests remain hard-fail; unprivileged CI asserts terminal refusal instead of treating replay as success. Pipelines #2179/#2180/#2181 exposed two runner refusal forms: namespace denial as `spawnSync bwrap` with `error.code=EPERM`, and a test image without Bubblewrap as `error.code=ENOENT`. The replay diagnostic now preserves spawn errors; the refusal detector recognizes only exact `spawnSync bwrap` provenance for `EPERM`/`EACCES`/`ENOENT`, plus Bubblewrap's known namespace-refusal text. Focused negative assertions reject both unrelated `spawnSync git EPERM` and verifier output that merely says `bwrap ENOENT`. +- RED-first boundary test proved the old path executed an inert intermediate verifier. The revised path states adjacent `DOES`/`DOES NOT` claims, validates historical manifest provenance without executing it, and infers no replay success. Direct sandbox tests remain hard-fail; unprivileged CI asserts terminal refusal instead of treating replay as success. Pipelines #2179/#2180/#2181 exposed two runner refusal forms: namespace denial as `spawnSync bwrap` with `error.code=EPERM`, and a test image without Bubblewrap as `error.code=ENOENT`. The replay diagnostic now preserves spawn errors. Parent-generated launcher/entry metadata distinguishes refusal before sandbox entry from child-controlled output; the detector recognizes exact `spawnSync bwrap` provenance for `EPERM`/`EACCES`/`ENOENT` and known namespace-refusal text only when the entry command provably did not run. Focused negative assertions reject unrelated `spawnSync git EPERM`, verifier output that merely says `bwrap ENOENT`, and exact namespace-denial impersonation without provenance or after sandbox entry. +- Exact-head independent review at `9b4d4beb` found two valid blockers. RED-first controls reproduced both: denial-looking child stderr was accepted as sandbox unavailability, and moving meaning/prose criterion IDs to an unrelated type-error case left `gate:verify` green. Bubblewrap execution now emits a parent-generated random entry marker and returns parent-owned launcher/entry metadata; unavailability requires Bubblewrap launcher provenance plus proof entry never ran, so exact denial impersonation from plain or entered-child results is rejected. Criterion objects now declare exact `caseRefs`, checked bidirectionally against case-side `criterionIds`; prose claims declare an exact must-fail `caseRef`. Registered must-fail cases move a criterion binding, remove meaning provenance, and redirect a prose claim, each producing its stable reason. The review freeze was deliberately lifted before remediation. - Option C security review reported no findings. Code review rejected an initial unrelated typecheck binding for the new security criterion. It was replaced with a dedicated registered `privileged-pr-gate` case: the fixture injects a privilege key into the gate step, the wiring control rejects it for that exact reason, and `gate:verify` observes the boundary negative control. Follow-up hardening uses a closed exact gate-step construction, rejects privilege across the entire pipeline, rejects non-canonical/merged YAML keys, and pins the unrestricted PR/main trigger block; quoted/escaped/alias/merge/duplicate/filter bypass tests pass. Final Codex code review approved with no findings. ## Documentation checklist diff --git a/gates/gates.manifest.json b/gates/gates.manifest.json index 7e0358b0..9b46170f 100644 --- a/gates/gates.manifest.json +++ b/gates/gates.manifest.json @@ -25,7 +25,20 @@ "currentText": "Every registered check is observed red for its own stated reason before its green counts.", "claimType": "integrity", "source": "docs/remediation/MISSION.md#first-class-principle-pre-registration", - "meaningChanges": [] + "meaningChanges": [], + "caseRefs": [ + "quality-typecheck/type-error", + "quality-lint/invalid-syntax", + "quality-format/unformatted-json", + "checkout-preflight/stale-build-lock", + "checkout-preflight/criterion-misbinding", + "checkout-preflight/missing-meaning-provenance", + "checkout-preflight/prose-claim-misbinding", + "ci-queue-wait/no-status-required", + "ci-queue-wait/unknown-option", + "hook-pre-commit/lint-staged-failure", + "hook-pre-push/typecheck-failure" + ] }, { "id": "RM02-SET-COVERS", @@ -33,7 +46,8 @@ "currentText": "Every acceptance criterion is bound to the specific case that exercises it.", "claimType": "integrity", "source": "docs/remediation/TASKS.md#d-17", - "meaningChanges": [] + "meaningChanges": [], + "caseRefs": ["checkout-preflight/criterion-misbinding"] }, { "id": "RM02-MODELED-CONSISTENCY", @@ -50,6 +64,14 @@ "task": "RM-54/RM-55", "date": "2026-08-01" } + ], + "caseRefs": [ + "quality-typecheck/clean-tree", + "quality-lint/clean-tree", + "quality-format/clean-tree", + "checkout-preflight/clean-tree", + "hook-pre-commit/lint-staged-failure", + "hook-pre-push/typecheck-failure" ] }, { @@ -58,7 +80,8 @@ "currentText": "A restated criterion retains original text, restatement, and reason.", "claimType": "integrity", "source": "docs/remediation/TASKS.md#d-18", - "meaningChanges": [] + "meaningChanges": [], + "caseRefs": ["checkout-preflight/missing-meaning-provenance"] }, { "id": "RM02-PROSE-CONTROL", @@ -75,24 +98,26 @@ "task": "RM-54", "date": "2026-08-01" } - ] + ], + "caseRefs": ["checkout-preflight/prose-claim-misbinding"] }, { "id": "RM02-CURRENT-TREE-BOUNDARY", - "originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.", + "originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE \u2014 not against current main.", "currentText": "PR CI performs unprivileged, fail-closed current-tree verification only; isolated per-commit replay is deferred to RM-60's protected post-merge/main authority as detection with quarantine/revert, not pre-merge prevention.", "claimType": "security", "source": "docs/PRD.md#rm02-req-10-meaning-change-provenance", "meaningChanges": [ { - "originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.", + "originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE \u2014 not against current main.", "restatement": "PR CI performs unprivileged, fail-closed current-tree verification only; isolated per-commit replay is deferred to a protected post-merge/main authority as detection with quarantine/revert, not pre-merge prevention.", "reason": "PR-controlled code would otherwise receive and could directly use the namespace capability intended to contain it; the pre-execution trust boundary is absent at the repository layer.", "finding": "D-25", "task": "RM-60/RM-59", "date": "2026-08-01" } - ] + ], + "caseRefs": ["checkout-preflight/privileged-pr-gate"] }, { "id": "QUALITY-TYPECHECK", @@ -100,7 +125,8 @@ "currentText": "The root typecheck rejects a TypeScript type error.", "claimType": "quality", "source": "package.json#scripts.typecheck", - "meaningChanges": [] + "meaningChanges": [], + "caseRefs": ["quality-typecheck/clean-tree", "quality-typecheck/type-error"] }, { "id": "QUALITY-LINT", @@ -108,7 +134,8 @@ "currentText": "The root lint gate rejects invalid TypeScript syntax.", "claimType": "quality", "source": "package.json#scripts.lint", - "meaningChanges": [] + "meaningChanges": [], + "caseRefs": ["quality-lint/clean-tree", "quality-lint/invalid-syntax"] }, { "id": "QUALITY-FORMAT", @@ -116,7 +143,8 @@ "currentText": "The root format gate rejects an unformatted tracked-format input.", "claimType": "quality", "source": "package.json#scripts.format:check", - "meaningChanges": [] + "meaningChanges": [], + "caseRefs": ["quality-format/clean-tree", "quality-format/unformatted-json"] }, { "id": "CHECKOUT-PREFLIGHT", @@ -133,7 +161,8 @@ "task": "RM-59", "date": "2026-07-31" } - ] + ], + "caseRefs": ["checkout-preflight/clean-tree", "checkout-preflight/stale-build-lock"] }, { "id": "QUEUE-GUARD", @@ -150,6 +179,15 @@ "task": "RM-03", "date": "2026-08-01" } + ], + "caseRefs": [ + "ci-queue-wait/terminal-success", + "ci-queue-wait/no-status-required", + "ci-queue-wait/unknown-state", + "ci-queue-wait/malformed-status", + "ci-queue-wait/terminal-failure", + "ci-queue-wait/push-defaults-to-main", + "ci-queue-wait/unknown-option" ] }, { @@ -158,7 +196,8 @@ "currentText": "The pre-commit hook propagates lint-staged failure.", "claimType": "workflow", "source": ".husky/pre-commit", - "meaningChanges": [] + "meaningChanges": [], + "caseRefs": ["hook-pre-commit/clean-staged-input", "hook-pre-commit/lint-staged-failure"] }, { "id": "HOOK-PRE-PUSH", @@ -166,7 +205,8 @@ "currentText": "The pre-push hook propagates each required gate failure.", "claimType": "workflow", "source": ".husky/pre-push", - "meaningChanges": [] + "meaningChanges": [], + "caseRefs": ["hook-pre-push/all-subgates-succeed", "hook-pre-push/typecheck-failure"] }, { "id": "GATE-SOURCE-DEPLOYMENT", @@ -183,41 +223,50 @@ "task": "RM-02", "date": "2026-08-01" } - ] + ], + "caseRefs": ["ci-queue-wait/terminal-success", "ci-queue-wait/unknown-option"] } ], "proseClaims": [ { "id": "OBSERVE-PROPERTY", - "criterionId": "RM02-CHECK-RIGHT" + "criterionId": "RM02-CHECK-RIGHT", + "caseRef": "checkout-preflight/criterion-misbinding" }, { "id": "PREREGISTRATION-BOUNDARY", - "criterionId": "RM02-SET-COVERS" + "criterionId": "RM02-SET-COVERS", + "caseRef": "checkout-preflight/criterion-misbinding" }, { "id": "ARTIFACT-INTEGRITY-BOUNDARY", - "criterionId": "GATE-SOURCE-DEPLOYMENT" + "criterionId": "GATE-SOURCE-DEPLOYMENT", + "caseRef": "ci-queue-wait/unknown-option" }, { "id": "IMPOSSIBLE-LAYER-BOUNDARY", - "criterionId": "CHECKOUT-PREFLIGHT" + "criterionId": "CHECKOUT-PREFLIGHT", + "caseRef": "checkout-preflight/stale-build-lock" }, { "id": "PROSE-IS-A-CLAIM", - "criterionId": "RM02-PROSE-CONTROL" + "criterionId": "RM02-PROSE-CONTROL", + "caseRef": "checkout-preflight/prose-claim-misbinding" }, { "id": "GENERATED-STATE-SCOPE", - "criterionId": "CHECKOUT-PREFLIGHT" + "criterionId": "CHECKOUT-PREFLIGHT", + "caseRef": "checkout-preflight/stale-build-lock" }, { "id": "EXECUTION-TRUST-BOUNDARY", - "criterionId": "RM02-CURRENT-TREE-BOUNDARY" + "criterionId": "RM02-CURRENT-TREE-BOUNDARY", + "caseRef": "checkout-preflight/privileged-pr-gate" }, { "id": "CRITERION-RESTATEMENT", - "criterionId": "RM02-MEANING-PROVENANCE" + "criterionId": "RM02-MEANING-PROVENANCE", + "caseRef": "checkout-preflight/missing-meaning-provenance" } ], "compatibilityScenarios": [ @@ -296,7 +345,7 @@ }, { "id": "type-error", - "criterionIds": ["QUALITY-TYPECHECK", "RM02-CHECK-RIGHT", "RM02-SET-COVERS"], + "criterionIds": ["QUALITY-TYPECHECK", "RM02-CHECK-RIGHT"], "mustFail": true, "required": { "exitCode": 2, @@ -354,7 +403,7 @@ }, { "id": "invalid-syntax", - "criterionIds": ["QUALITY-LINT", "RM02-CHECK-RIGHT", "RM02-SET-COVERS"], + "criterionIds": ["QUALITY-LINT", "RM02-CHECK-RIGHT"], "mustFail": true, "required": { "exitCode": 1, @@ -412,7 +461,7 @@ }, { "id": "unformatted-json", - "criterionIds": ["QUALITY-FORMAT", "RM02-CHECK-RIGHT", "RM02-SET-COVERS"], + "criterionIds": ["QUALITY-FORMAT", "RM02-CHECK-RIGHT"], "mustFail": true, "required": { "exitCode": 1, @@ -469,13 +518,7 @@ }, { "id": "stale-build-lock", - "criterionIds": [ - "CHECKOUT-PREFLIGHT", - "RM02-CHECK-RIGHT", - "RM02-SET-COVERS", - "RM02-MEANING-PROVENANCE", - "RM02-PROSE-CONTROL" - ], + "criterionIds": ["CHECKOUT-PREFLIGHT", "RM02-CHECK-RIGHT"], "mustFail": true, "required": { "exitCode": 43, @@ -519,6 +562,122 @@ } ] } + }, + { + "id": "criterion-misbinding", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-SET-COVERS"], + "mustFail": true, + "invocation": [ + "node", + "scripts/gate-verify.mjs", + "--root", + ".", + "--manifest", + "gates/gates.manifest.json", + "--structure-only" + ], + "required": { + "exitCode": 1, + "outputPattern": "RM02-SET-COVERS: declared exercising case checkout-preflight/criterion-misbinding is not bound" + }, + "actual": { + "exitCode": 1, + "outputPattern": "RM02-SET-COVERS: declared exercising case checkout-preflight/criterion-misbinding is not bound" + }, + "reasonPattern": "RM02-SET-COVERS: declared exercising case checkout-preflight/criterion-misbinding is not bound", + "fixture": { + "copyPaths": [ + "gates/gates.manifest.json", + "scripts/gate-verify.mjs", + "scripts/gate-history.mjs" + ], + "replaceFiles": [ + { + "path": "gates/gates.manifest.json", + "find": "\"criterionIds\": [\"QUALITY-TYPECHECK\", \"RM02-CHECK-RIGHT\"],", + "replace": "\"criterionIds\": [\"QUALITY-TYPECHECK\", \"RM02-CHECK-RIGHT\", \"RM02-SET-COVERS\"]," + }, + { + "path": "gates/gates.manifest.json", + "find": "\"criterionIds\": [\"RM02-CHECK-RIGHT\", \"RM02-SET-COVERS\"],", + "replace": "\"criterionIds\": [\"RM02-CHECK-RIGHT\"]," + } + ] + } + }, + { + "id": "missing-meaning-provenance", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-MEANING-PROVENANCE"], + "mustFail": true, + "invocation": [ + "node", + "scripts/gate-verify.mjs", + "--root", + ".", + "--manifest", + "gates/gates.manifest.json", + "--structure-only" + ], + "required": { + "exitCode": 1, + "outputPattern": "RM02-MEANING-PROVENANCE: missing meaning-change provenance" + }, + "actual": { + "exitCode": 1, + "outputPattern": "RM02-MEANING-PROVENANCE: missing meaning-change provenance" + }, + "reasonPattern": "RM02-MEANING-PROVENANCE: missing meaning-change provenance", + "fixture": { + "copyPaths": [ + "gates/gates.manifest.json", + "scripts/gate-verify.mjs", + "scripts/gate-history.mjs" + ], + "replaceFiles": [ + { + "path": "gates/gates.manifest.json", + "find": "\"currentText\": \"A restated criterion retains original text, restatement, and reason.\",", + "replace": "\"currentText\": \"A restated criterion changed without provenance\"," + } + ] + } + }, + { + "id": "prose-claim-misbinding", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-PROSE-CONTROL"], + "mustFail": true, + "invocation": [ + "node", + "scripts/gate-verify.mjs", + "--root", + ".", + "--manifest", + "gates/gates.manifest.json", + "--structure-only" + ], + "required": { + "exitCode": 1, + "outputPattern": "GATE-CLAIM:PROSE-IS-A-CLAIM exercising case quality-typecheck/type-error does not exercise criterion RM02-PROSE-CONTROL" + }, + "actual": { + "exitCode": 1, + "outputPattern": "GATE-CLAIM:PROSE-IS-A-CLAIM exercising case quality-typecheck/type-error does not exercise criterion RM02-PROSE-CONTROL" + }, + "reasonPattern": "GATE-CLAIM:PROSE-IS-A-CLAIM exercising case quality-typecheck/type-error does not exercise criterion RM02-PROSE-CONTROL", + "fixture": { + "copyPaths": [ + "gates/gates.manifest.json", + "scripts/gate-verify.mjs", + "scripts/gate-history.mjs" + ], + "replaceFiles": [ + { + "path": "gates/gates.manifest.json", + "find": "\"caseRef\": \"checkout-preflight/prose-claim-misbinding\"", + "replace": "\"caseRef\": \"quality-typecheck/type-error\"" + } + ] + } } ] }, @@ -607,7 +766,7 @@ }, { "id": "no-status-required", - "criterionIds": ["QUEUE-GUARD", "RM02-CHECK-RIGHT", "RM02-SET-COVERS"], + "criterionIds": ["QUEUE-GUARD", "RM02-CHECK-RIGHT"], "mustFail": true, "required": { "exitCode": 1, @@ -669,7 +828,7 @@ }, { "id": "unknown-state", - "criterionIds": ["QUEUE-GUARD", "RM02-SET-COVERS"], + "criterionIds": ["QUEUE-GUARD"], "mustFail": true, "required": { "exitCode": 1, @@ -946,12 +1105,7 @@ }, { "id": "lint-staged-failure", - "criterionIds": [ - "HOOK-PRE-COMMIT", - "RM02-CHECK-RIGHT", - "RM02-SET-COVERS", - "RM02-MODELED-CONSISTENCY" - ], + "criterionIds": ["HOOK-PRE-COMMIT", "RM02-CHECK-RIGHT", "RM02-MODELED-CONSISTENCY"], "mustFail": true, "required": { "exitCode": 19, @@ -1027,12 +1181,7 @@ }, { "id": "typecheck-failure", - "criterionIds": [ - "HOOK-PRE-PUSH", - "RM02-CHECK-RIGHT", - "RM02-SET-COVERS", - "RM02-MODELED-CONSISTENCY" - ], + "criterionIds": ["HOOK-PRE-PUSH", "RM02-CHECK-RIGHT", "RM02-MODELED-CONSISTENCY"], "mustFail": true, "required": { "exitCode": 19, diff --git a/scripts/gate-history.mjs b/scripts/gate-history.mjs index d6b1bbc2..22b1bd0d 100644 --- a/scripts/gate-history.mjs +++ b/scripts/gate-history.mjs @@ -1,5 +1,5 @@ import { existsSync } from 'node:fs'; -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { access, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm } from 'node:fs/promises'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; @@ -40,7 +40,9 @@ async function snapshotAuthoritativeTree(root) { } else if (stats.isSymbolicLink()) { snapshot.set(relative, `symlink:${stats.mode}:${await readlink(absolute)}`); } else if (stats.isFile()) { - const digest = createHash('sha256').update(await readFile(absolute)).digest('hex'); + const digest = createHash('sha256') + .update(await readFile(absolute)) + .digest('hex'); snapshot.set(relative, `file:${stats.mode}:${digest}`); } } @@ -59,7 +61,9 @@ async function authoritativeTreeChanges(root, snapshot) { if (stats.isSymbolicLink()) { actual = `symlink:${stats.mode}:${await readlink(absolute)}`; } else if (stats.isFile()) { - const digest = createHash('sha256').update(await readFile(absolute)).digest('hex'); + const digest = createHash('sha256') + .update(await readFile(absolute)) + .digest('hex'); actual = `file:${stats.mode}:${digest}`; } else { actual = `other:${stats.mode}`; @@ -108,8 +112,24 @@ function bubblewrap(root, command, args, { storePath, timeout = 300_000 } = {}) ); if (storePath) sandboxArgs.push('--setenv', 'NPM_CONFIG_STORE_DIR', '/pnpm-store'); if (existsSync(corepackHome)) sandboxArgs.push('--setenv', 'COREPACK_HOME', '/corepack'); - sandboxArgs.push(command, ...args); - return spawnSync('bwrap', sandboxArgs, { encoding: 'utf8', timeout }); + const enteredMarker = `__MOSAIC_BWRAP_ENTERED_${randomUUID()}__`; + sandboxArgs.push( + '/bin/sh', + '-c', + 'printf "%s\\n" "$1"; shift; exec "$@"', + 'mosaic-bwrap-entry', + enteredMarker, + command, + ...args, + ); + const result = spawnSync('bwrap', sandboxArgs, { encoding: 'utf8', timeout }); + const sandboxEntered = result.stdout?.includes(enteredMarker) === true; + return { + ...result, + stdout: (result.stdout ?? '').replace(`${enteredMarker}\n`, ''), + sandboxLauncher: 'bwrap', + sandboxEntered, + }; } export async function replayCommit(root, commit) { diff --git a/scripts/gate-history.test.mjs b/scripts/gate-history.test.mjs index 0d130c6b..7e5d8657 100644 --- a/scripts/gate-history.test.mjs +++ b/scripts/gate-history.test.mjs @@ -15,6 +15,7 @@ import { const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `gate-history-${process.pid}`); function sandboxUnavailable(result) { + if (result.sandboxLauncher !== 'bwrap' || result.sandboxEntered === true) return false; const detail = `${result.stdout ?? ''}${result.stderr ?? ''}${result.error?.message ?? ''}`; const bubblewrapSpawnDenied = ['EPERM', 'EACCES', 'ENOENT'].includes(result.error?.code) && @@ -56,6 +57,8 @@ test('sandbox refusal classification requires Bubblewrap provenance', () => { sandboxUnavailable({ status: null, error: { code, message: `spawnSync bwrap ${code}` }, + sandboxLauncher: 'bwrap', + sandboxEntered: false, }), true, ); @@ -71,6 +74,31 @@ test('sandbox refusal classification requires Bubblewrap provenance', () => { sandboxUnavailable({ status: 1, stderr: 'historical verifier said bwrap ENOENT' }), false, ); + assert.equal( + sandboxUnavailable({ + status: 1, + stderr: 'bwrap: Creating new namespace failed: Operation not permitted', + sandboxLauncher: 'bwrap', + sandboxEntered: false, + }), + true, + ); + assert.equal( + sandboxUnavailable({ + status: 1, + stderr: 'bwrap: Creating new namespace failed: Operation not permitted', + }), + false, + ); + assert.equal( + sandboxUnavailable({ + status: 1, + stderr: 'bwrap: Creating new namespace failed: Operation not permitted', + sandboxLauncher: 'bwrap', + sandboxEntered: true, + }), + false, + ); }); test('prospective history reads each commit own manifest rather than the current tree', async () => { @@ -261,13 +289,13 @@ test('PR verification states the RM-60 boundary without executing an intermediat }); assert.deepEqual(result.failures, []); assert.ok( - result.observations.some( - (observation) => /DOES:.*current tree.*DOES NOT:.*isolated.*RM-60.*RM-59/i.test(observation), + result.observations.some((observation) => + /DOES:.*current tree.*DOES NOT:.*isolated.*RM-60.*RM-59/i.test(observation), ), ); assert.ok( - result.observations.some( - (observation) => /INTERMEDIATE REPLAY DEFERRED.*RM-60.*no success is inferred/i.test(observation), + result.observations.some((observation) => + /INTERMEDIATE REPLAY DEFERRED.*RM-60.*no success is inferred/i.test(observation), ), ); assert.ok(result.observations.every((observation) => !/INTERMEDIATE INERT/.test(observation))); diff --git a/scripts/gate-verify.mjs b/scripts/gate-verify.mjs index 9332d4f8..a570e1db 100644 --- a/scripts/gate-verify.mjs +++ b/scripts/gate-verify.mjs @@ -29,12 +29,14 @@ function parseArgs(argv) { root: process.cwd(), manifest: 'gates/gates.manifest.json', skipHistory: false, + structureOnly: false, }; for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; if (value === '--root') options.root = path.resolve(argv[++index]); else if (value === '--manifest') options.manifest = argv[++index]; else if (value === '--skip-history') options.skipHistory = true; + else if (value === '--structure-only') options.structureOnly = true; else throw new Error(`unknown option: ${value}`); } return options; @@ -307,7 +309,39 @@ function validateClosedSchema(manifest, failures) { failures, ); rejectDuplicateIds(manifest.criteria, 'criterion', failures); + for (const criterion of manifest.criteria ?? []) { + rejectUnknownKeys( + criterion, + new Set([ + 'id', + 'originalText', + 'currentText', + 'claimType', + 'source', + 'meaningChanges', + 'caseRefs', + ]), + `criterion ${criterion.id}`, + failures, + ); + if (!Array.isArray(criterion.caseRefs) || criterion.caseRefs.length === 0) { + failures.push(`${criterion.id}: no declared exercising cases`); + } else if (new Set(criterion.caseRefs).size !== criterion.caseRefs.length) { + failures.push(`${criterion.id}: duplicate declared exercising case`); + } + } rejectDuplicateIds(manifest.proseClaims, 'prose claim', failures); + for (const claim of manifest.proseClaims ?? []) { + rejectUnknownKeys( + claim, + new Set(['id', 'criterionId', 'caseRef']), + `prose claim ${claim.id}`, + failures, + ); + if (typeof claim.caseRef !== 'string' || claim.caseRef.length === 0) { + failures.push(`GATE-CLAIM:${claim.id} has no declared exercising case`); + } + } rejectDuplicateIds(manifest.compatibilityScenarios, 'compatibility scenario', failures); for (const scenario of manifest.compatibilityScenarios ?? []) { rejectUnknownKeys( @@ -390,7 +424,9 @@ function validateClosedSchema(manifest, failures) { failures.push(`${gate.id}/${gateCase.id}: case invocation must be non-empty`); } if (gateCase.mustFail === true && !gateCase.reasonPattern?.trim()) { - failures.push(`${gate.id}/${gateCase.id}: must-fail case requires a non-empty reasonPattern`); + failures.push( + `${gate.id}/${gateCase.id}: must-fail case requires a non-empty reasonPattern`, + ); } } } @@ -401,6 +437,7 @@ function validateStructure(manifest, failures) { const criteria = new Map((manifest.criteria ?? []).map((criterion) => [criterion.id, criterion])); const boundCriteria = new Set(); const negativeBoundCriteria = new Set(); + const observedCaseRefs = new Map(); for (const gate of manifest.gates ?? []) { const negativeCases = (gate.cases ?? []).filter((gateCase) => gateCase.mustFail === true); @@ -413,12 +450,12 @@ function validateStructure(manifest, failures) { failures.push(`${gate.id}/${gateCase.id}: unknown criterion ${criterionId}`); } boundCriteria.add(criterionId); + const caseRef = `${gate.id}/${gateCase.id}`; + if (!observedCaseRefs.has(criterionId)) observedCaseRefs.set(criterionId, new Set()); + observedCaseRefs.get(criterionId).add(caseRef); if (gateCase.mustFail === true) negativeBoundCriteria.add(criterionId); } - if ( - !structuredValuesEqual(gateCase.required, gateCase.actual) && - !gateCase.defect?.owner - ) { + if (!structuredValuesEqual(gateCase.required, gateCase.actual) && !gateCase.defect?.owner) { failures.push(`${gate.id}/${gateCase.id}: behavior delta requires a tracked owner`); } } @@ -427,6 +464,18 @@ function validateStructure(manifest, failures) { for (const claim of manifest.proseClaims ?? []) { if (!criteria.has(claim.criterionId)) { failures.push(`GATE-CLAIM:${claim.id} references unknown criterion ${claim.criterionId}`); + continue; + } + const found = findGateCase(manifest, claim.caseRef ?? ''); + if (!found) { + failures.push(`GATE-CLAIM:${claim.id} references missing exercising case ${claim.caseRef}`); + } else if ( + found.gateCase.mustFail !== true || + !found.gateCase.criterionIds?.includes(claim.criterionId) + ) { + failures.push( + `GATE-CLAIM:${claim.id} exercising case ${claim.caseRef} does not exercise criterion ${claim.criterionId}`, + ); } } @@ -435,6 +484,20 @@ function validateStructure(manifest, failures) { else if (!negativeBoundCriteria.has(criterion.id)) { failures.push(`${criterion.id}: no must-fail case exercises this criterion`); } + const declaredRefs = new Set(criterion.caseRefs ?? []); + const actualRefs = observedCaseRefs.get(criterion.id) ?? new Set(); + for (const caseRef of declaredRefs) { + const found = findGateCase(manifest, caseRef); + if (!found) failures.push(`${criterion.id}: declared exercising case ${caseRef} is missing`); + else if (!actualRefs.has(caseRef)) { + failures.push(`${criterion.id}: declared exercising case ${caseRef} is not bound`); + } + } + for (const caseRef of actualRefs) { + if (!declaredRefs.has(caseRef)) { + failures.push(`${criterion.id}: bound to undeclared exercising case ${caseRef}`); + } + } if ( criterion.originalText !== criterion.currentText && (!Array.isArray(criterion.meaningChanges) || criterion.meaningChanges.length === 0) @@ -634,7 +697,9 @@ async function runCompatibilityScenario(root, manifest, scenario, failures, obse for (const caseRef of scenario.caseRefs ?? []) { const found = findGateCase(manifest, caseRef); if (!found) { - failures.push(`${scenario.id}: compatibility construction references missing case ${caseRef}`); + failures.push( + `${scenario.id}: compatibility construction references missing case ${caseRef}`, + ); } else { referenced.push({ caseRef, ...found }); } @@ -671,7 +736,10 @@ async function runCompatibilityScenario(root, manifest, scenario, failures, obse } await applyFixture(sandbox, fixture); } - for (const source of [...referenced.map(({ gateCase }) => gateCase.environment), scenario.environment]) { + for (const source of [ + ...referenced.map(({ gateCase }) => gateCase.environment), + scenario.environment, + ]) { for (const [key, value] of Object.entries(source ?? {})) { if (environment[key] !== undefined && environment[key] !== value) { failures.push(`${scenario.id}: incompatible environment values for ${key}`); @@ -703,6 +771,7 @@ export async function verifyRegistry(options) { const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); validateStructure(manifest, failures); + if (options.structureOnly) return { failures, manifest, observations }; await validateClaims(options.root, manifest, failures); await validateDiscovery(options.root, manifest, failures); @@ -719,10 +788,7 @@ export async function verifyRegistry(options) { if (gateCase.reasonPattern && !new RegExp(gateCase.reasonPattern, 'm').test(combined)) { failures.push(`${gate.id}/${gateCase.id}: did not fail for its stated reason`); } - if ( - !structuredValuesEqual(gateCase.required, gateCase.actual) && - gateCase.defect?.owner - ) { + if (!structuredValuesEqual(gateCase.required, gateCase.actual) && gateCase.defect?.owner) { observations.push( `DEFECT (owner: ${gateCase.defect.owner}) ${gate.id}/${gateCase.id}: required ${JSON.stringify(gateCase.required)}, actual ${JSON.stringify(gateCase.actual)}`, ); diff --git a/scripts/gate-verify.test.mjs b/scripts/gate-verify.test.mjs index 7d9e088f..1777b151 100644 --- a/scripts/gate-verify.test.mjs +++ b/scripts/gate-verify.test.mjs @@ -30,6 +30,7 @@ function baseManifest() { claimType: 'integrity', source: 'fixture', meaningChanges: [], + caseRefs: ['meta-fixture/rejects-bad-input'], }, ], compatibilityScenarios: [], @@ -72,10 +73,18 @@ async function writeManifest(root, manifest) { await writeFile(path.join(root, 'gates', 'gates.manifest.json'), `${JSON.stringify(manifest)}\n`); } -function verify(root) { +function verify(root, extraArgs = []) { return spawnSync( process.execPath, - [verifier, '--root', root, '--manifest', 'gates/gates.manifest.json', '--skip-history'], + [ + verifier, + '--root', + root, + '--manifest', + 'gates/gates.manifest.json', + '--skip-history', + ...extraArgs, + ], { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } }, ); } @@ -260,6 +269,61 @@ test('a required-versus-actual delta without a tracked owner is rejected', async assert.match(output(result), /meta-fixture.*delta.*tracked owner/i); }); +test('moving criterion bindings to unrelated cases is rejected', async () => { + const root = await fixture('semantic-misbinding'); + await writeGate(root); + const manifest = baseManifest(); + manifest.criteria.push({ + id: 'META-CRIT-2', + originalText: 'The fixture reports the second rejection reason.', + currentText: 'The fixture reports the second rejection reason.', + claimType: 'integrity', + source: 'fixture', + meaningChanges: [], + caseRefs: ['meta-fixture/rejects-second-input'], + }); + manifest.gates[0].cases.push({ + ...manifest.gates[0].cases[0], + id: 'rejects-second-input', + criterionIds: ['META-CRIT-1'], + }); + manifest.gates[0].cases[0].criterionIds = ['META-CRIT-2']; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /META-CRIT-1.*rejects-bad-input.*not bound/i); + assert.match(output(result), /META-CRIT-2.*rejects-second-input.*not bound/i); +}); + +test('moving meaning and prose criteria to an unrelated type error is rejected', async () => { + const root = await fixture('real-manifest-misbinding'); + const manifest = JSON.parse( + await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'), + ); + for (const gate of manifest.gates) { + for (const gateCase of gate.cases) { + gateCase.criterionIds = gateCase.criterionIds.filter( + (id) => !['RM02-MEANING-PROVENANCE', 'RM02-PROSE-CONTROL'].includes(id), + ); + } + } + const typeError = manifest.gates + .find((gate) => gate.id === 'quality-typecheck') + .cases.find((gateCase) => gateCase.id === 'type-error'); + typeError.criterionIds.push('RM02-MEANING-PROVENANCE', 'RM02-PROSE-CONTROL'); + await writeManifest(root, manifest); + + const result = verify(root, ['--structure-only']); + assert.notEqual(result.status, 0); + assert.match(output(result), /RM02-MEANING-PROVENANCE.*missing-meaning-provenance.*not bound/i); + assert.match(output(result), /RM02-PROSE-CONTROL.*prose-claim-misbinding.*not bound/i); + assert.match( + output(result), + /RM02-(?:MEANING-PROVENANCE|PROSE-CONTROL).*undeclared exercising case quality-typecheck\/type-error/i, + ); +}); + test('a criterion with no bound case is rejected', async () => { const root = await fixture('unbound-criterion'); await writeGate(root); @@ -335,6 +399,7 @@ test('compatibility scenarios execute referenced conditions as one construction' reasonPattern: 'SECOND_REASON', environment: { SECOND_REASON: 'SECOND_REASON' }, }); + manifest.criteria[0].caseRefs.push('meta-fixture/second-condition'); manifest.gates[0].cases[0].fixture = { writeFiles: [{ path: 'conditions/first', content: 'present\n' }], }; @@ -427,7 +492,10 @@ test('deployment drift meta-control fails if the shared comparator is made inert const root = await fixture('deployment-comparator-inert'); await writeGate(root); await mkdir(path.join(root, 'deployed'), { recursive: true }); - await copyFile(path.join(root, 'gates', 'meta-fixture.sh'), path.join(root, 'deployed', 'meta-fixture.sh')); + await copyFile( + path.join(root, 'gates', 'meta-fixture.sh'), + path.join(root, 'deployed', 'meta-fixture.sh'), + ); const manifest = baseManifest(); manifest.gates[0].deployment = { kind: 'file', path: 'deployed/meta-fixture.sh' }; await writeManifest(root, manifest); -- 2.54.0 From 9e1a7a44b7620853c9fce880eb19211089feee76 Mon Sep 17 00:00:00 2001 From: coder-mos1 Date: Sat, 1 Aug 2026 01:01:55 -0500 Subject: [PATCH 07/13] fix(quality): preserve binding diagnostics --- docs/ADMIN-GUIDE/quality-gate-registry.md | 2 +- docs/DEVELOPER-GUIDE/quality-gate-registry.md | 2 +- docs/PRD.md | 2 +- docs/scratchpads/1029-rm-02-gate-registry.md | 3 +- scripts/gate-verify.mjs | 64 ++++++++++++++----- scripts/gate-verify.test.mjs | 37 +++++++++++ 6 files changed, 91 insertions(+), 19 deletions(-) diff --git a/docs/ADMIN-GUIDE/quality-gate-registry.md b/docs/ADMIN-GUIDE/quality-gate-registry.md index 312bb351..19c9cd4d 100644 --- a/docs/ADMIN-GUIDE/quality-gate-registry.md +++ b/docs/ADMIN-GUIDE/quality-gate-registry.md @@ -6,7 +6,7 @@ Run `pnpm gate:verify` from a dependency-installed checkout. Exit zero means reg Investigate any of these immediately: -- `GATE VERIFY FAILED` — registry structure, observed behavior, provenance, claim binding, source/deployment identity, or negative-control detection changed. +- `GATE VERIFY FAILED` — registry structure, observed behavior, provenance, claim binding, source/deployment identity, or negative-control detection changed. The verifier aggregates independent phase failures, so repair the responsible stable-ID diagnostics as well as any accompanying stale-fixture error; do not treat the generic error as a substitute. - `unregistered gate` — an executable appeared under a declared gate root without a registry entry. - `no negative control` — a gate has no must-fail case. - `DEPLOYED IDENTITY UNAVAILABLE` — the runner cannot reach the installed enforcing copy. The pinned observation is checked, but live equality is not asserted. diff --git a/docs/DEVELOPER-GUIDE/quality-gate-registry.md b/docs/DEVELOPER-GUIDE/quality-gate-registry.md index 526bf481..dce1ded8 100644 --- a/docs/DEVELOPER-GUIDE/quality-gate-registry.md +++ b/docs/DEVELOPER-GUIDE/quality-gate-registry.md @@ -10,7 +10,7 @@ pnpm gate:verify The registry covers root typecheck, lint, and format checks; RM-01 checkout preflight; the Mosaic CI queue guard; and root Husky pre-commit/pre-push hooks. It does not imply repository-wide coverage. Framework scripts, package-local build/test scripts, templates, and deployment/release scripts remain assigned to RM-54. -Every gate declares exact invocations, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. This proves detection of the **declared** inerting mutation, not every possible semantic weakening. +Every gate declares exact invocations, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. Independent validation phases collect labeled failures instead of letting one thrown fixture, claim, discovery, deployment, mutation, or compatibility error mask already-known stable-ID diagnostics. This proves detection of the **declared** inerting mutation, not every possible semantic weakening. ## Required versus actual diff --git a/docs/PRD.md b/docs/PRD.md index 840e3f1a..7ef0c363 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -51,7 +51,7 @@ Existing deterministic gates can return success without enforcing their stated p 2. `RM02-AC-02`: Externally mutate any registered gate at its declared inerting point so its failure path succeeds; verification returns nonzero and names that gate. The verifier's internal meta-control is observed red before its healthy result is trusted. 3. `RM02-AC-03`: An executable added under a declared gate root without an entry returns nonzero and includes `unregistered gate`. 4. `RM02-AC-04`: A gate with zero must-fail cases returns nonzero and includes `no negative control`. -5. `RM02-AC-05`: Unbound or semantically misbound criteria, prose claims bound to unrelated cases, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. Registered meta-negative controls move a criterion binding, remove meaning provenance, and redirect a prose claim to an unrelated case; each is observed red for its stated reason. +5. `RM02-AC-05`: Unbound or semantically misbound criteria, prose claims bound to unrelated cases, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. A simultaneous stale fixture or independent phase error SHALL NOT mask responsible stable-ID diagnostics. Registered meta-negative controls move a criterion binding, remove meaning provenance, and redirect a prose claim to an unrelated case; each is observed red for its stated reason. 6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request. 7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: current-tree gates and inerting mutations execute unprivileged and fail-closed; isolated own-tree replay does not execute in repository-controlled PR CI. RM-60/RM-59 are named, retained provider evidence is never inferred, and future protected post-merge detection specifies quarantine/revert rather than claiming pre-merge prevention. diff --git a/docs/scratchpads/1029-rm-02-gate-registry.md b/docs/scratchpads/1029-rm-02-gate-registry.md index 3e6d0b85..631ae905 100644 --- a/docs/scratchpads/1029-rm-02-gate-registry.md +++ b/docs/scratchpads/1029-rm-02-gate-registry.md @@ -39,7 +39,7 @@ Deliver the seven-gate registry and RED-first anti-inert verifier on `feat/rm-02 - CI wiring RED: package script and unconditional Woodpecker step tests both failed before wiring. - History RED: history test failed with missing module before own-tree manifest selection/provider classification was implemented. - `pnpm gate:verify`: exit 0; seven gates each reported `META-NEGATIVE-CONTROL ... observed red`; queue source/deployed drift control observed red; six queue behavior deltas printed as `DEFECT (owner: RM-03)`. -- Focused Node tests: 37/37 pass after review hardening (26 verifier/wiring plus 11 history/provider tests). +- Focused Node tests: 38/38 pass after review hardening (27 verifier/wiring plus 11 history/provider tests). - `pnpm typecheck`: pass (45/45 Turbo tasks). - `pnpm lint`: pass (25/25 Turbo tasks). - `pnpm format:check`: pass. @@ -62,6 +62,7 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro - Pipeline #2178 then proved the unprivileged Docker runner cannot establish Bubblewrap namespaces. A privileged experiment remained uncommitted and was rejected after Codex correctly rated it CRITICAL: PR-controlled code executes before an in-repository sandbox and could directly use the granted capability. - `mos-remediation` and `rev-974` independently ruled Option C. RM02-REQ-10 now retains its original text, restatement, and reason: PR CI verifies only the current tree, unprivileged and fail-closed; isolated own-tree replay is deferred to RM-60/#1031's external pre-execution authority, cross-referenced with RM-59. Future protected post-merge replay is detection with quarantine/revert, never pre-merge prevention. - RED-first boundary test proved the old path executed an inert intermediate verifier. The revised path states adjacent `DOES`/`DOES NOT` claims, validates historical manifest provenance without executing it, and infers no replay success. Direct sandbox tests remain hard-fail; unprivileged CI asserts terminal refusal instead of treating replay as success. Pipelines #2179/#2180/#2181 exposed two runner refusal forms: namespace denial as `spawnSync bwrap` with `error.code=EPERM`, and a test image without Bubblewrap as `error.code=ENOENT`. The replay diagnostic now preserves spawn errors. Parent-generated launcher/entry metadata distinguishes refusal before sandbox entry from child-controlled output; the detector recognizes exact `spawnSync bwrap` provenance for `EPERM`/`EACCES`/`ENOENT` and known namespace-refusal text only when the entry command provably did not run. Focused negative assertions reject unrelated `spawnSync git EPERM`, verifier output that merely says `bwrap ENOENT`, and exact namespace-denial impersonation without provenance or after sandbox entry. +- Exact-head independent review at `38f1b249` found one valid diagnostic-masking blocker: canonical verification knew four stable-ID rebinding failures but a thrown stale fixture replacement reached the outer catch first and emitted only the generic error. RED-first reproduction confirmed the canonical path omitted both responsible criterion IDs. Verification now collects labeled failures independently across claims, discovery, deployment, case execution/outcome checks, mutation, and compatibility, preserving structural stable-ID failures alongside the stale-fixture signal. The canonical regression test requires both missing-binding IDs and the generic fixture error. - Exact-head independent review at `9b4d4beb` found two valid blockers. RED-first controls reproduced both: denial-looking child stderr was accepted as sandbox unavailability, and moving meaning/prose criterion IDs to an unrelated type-error case left `gate:verify` green. Bubblewrap execution now emits a parent-generated random entry marker and returns parent-owned launcher/entry metadata; unavailability requires Bubblewrap launcher provenance plus proof entry never ran, so exact denial impersonation from plain or entered-child results is rejected. Criterion objects now declare exact `caseRefs`, checked bidirectionally against case-side `criterionIds`; prose claims declare an exact must-fail `caseRef`. Registered must-fail cases move a criterion binding, remove meaning provenance, and redirect a prose claim, each producing its stable reason. The review freeze was deliberately lifted before remediation. - Option C security review reported no findings. Code review rejected an initial unrelated typecheck binding for the new security criterion. It was replaced with a dedicated registered `privileged-pr-gate` case: the fixture injects a privilege key into the gate step, the wiring control rejects it for that exact reason, and `gate:verify` observes the boundary negative control. Follow-up hardening uses a closed exact gate-step construction, rejects privilege across the entire pipeline, rejects non-canonical/merged YAML keys, and pins the unrestricted PR/main trigger block; quoted/escaped/alias/merge/duplicate/filter bypass tests pass. Final Codex code review approved with no findings. diff --git a/scripts/gate-verify.mjs b/scripts/gate-verify.mjs index a570e1db..1dcd3537 100644 --- a/scripts/gate-verify.mjs +++ b/scripts/gate-verify.mjs @@ -326,8 +326,13 @@ function validateClosedSchema(manifest, failures) { ); if (!Array.isArray(criterion.caseRefs) || criterion.caseRefs.length === 0) { failures.push(`${criterion.id}: no declared exercising cases`); - } else if (new Set(criterion.caseRefs).size !== criterion.caseRefs.length) { - failures.push(`${criterion.id}: duplicate declared exercising case`); + } else { + if (criterion.caseRefs.some((caseRef) => typeof caseRef !== 'string' || !caseRef)) { + failures.push(`${criterion.id}: declared exercising cases must be non-empty strings`); + } + if (new Set(criterion.caseRefs).size !== criterion.caseRefs.length) { + failures.push(`${criterion.id}: duplicate declared exercising case`); + } } } rejectDuplicateIds(manifest.proseClaims, 'prose claim', failures); @@ -683,6 +688,7 @@ async function validateMutation(root, gate, failures, observations) { } function findGateCase(manifest, caseRef) { + if (typeof caseRef !== 'string') return undefined; const separator = caseRef.indexOf('/'); if (separator < 1) return undefined; const gateId = caseRef.slice(0, separator); @@ -772,21 +778,45 @@ export async function verifyRegistry(options) { validateStructure(manifest, failures); if (options.structureOnly) return { failures, manifest, observations }; - await validateClaims(options.root, manifest, failures); - await validateDiscovery(options.root, manifest, failures); + + async function collectPhaseFailure(label, action) { + try { + return await action(); + } catch (error) { + failures.push(`${label}: ${error.message}`); + return undefined; + } + } + + await collectPhaseFailure('governing claim validation failed', () => + validateClaims(options.root, manifest, failures), + ); + await collectPhaseFailure('gate discovery failed', () => + validateDiscovery(options.root, manifest, failures), + ); for (const gate of manifest.gates ?? []) { - await validateDeployment(options.root, gate, failures, observations); + await collectPhaseFailure(`${gate.id}: deployment validation failed`, () => + validateDeployment(options.root, gate, failures, observations), + ); for (const gateCase of gate.cases ?? []) { - const result = await runCase(options.root, gate, gateCase); + const result = await collectPhaseFailure( + `${gate.id}/${gateCase.id}: case execution failed`, + () => runCase(options.root, gate, gateCase), + ); + if (!result) continue; const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; - if (!outcomeMatches(gateCase.actual, result)) { - failures.push( - `${gate.id}/${gateCase.id}: observed exit ${String(result.status)}${result.signal ? ` signal ${result.signal}` : ''}${result.error ? ` error ${result.error.message}` : ''} or output disagrees with registry actual ${JSON.stringify(gateCase.actual)}`, - ); - } - if (gateCase.reasonPattern && !new RegExp(gateCase.reasonPattern, 'm').test(combined)) { - failures.push(`${gate.id}/${gateCase.id}: did not fail for its stated reason`); + try { + if (!outcomeMatches(gateCase.actual, result)) { + failures.push( + `${gate.id}/${gateCase.id}: observed exit ${String(result.status)}${result.signal ? ` signal ${result.signal}` : ''}${result.error ? ` error ${result.error.message}` : ''} or output disagrees with registry actual ${JSON.stringify(gateCase.actual)}`, + ); + } + if (gateCase.reasonPattern && !new RegExp(gateCase.reasonPattern, 'm').test(combined)) { + failures.push(`${gate.id}/${gateCase.id}: did not fail for its stated reason`); + } + } catch (error) { + failures.push(`${gate.id}/${gateCase.id}: outcome validation failed: ${error.message}`); } if (!structuredValuesEqual(gateCase.required, gateCase.actual) && gateCase.defect?.owner) { observations.push( @@ -794,11 +824,15 @@ export async function verifyRegistry(options) { ); } } - await validateMutation(options.root, gate, failures, observations); + await collectPhaseFailure(`${gate.id}: mutation validation failed`, () => + validateMutation(options.root, gate, failures, observations), + ); } for (const scenario of manifest.compatibilityScenarios ?? []) { - await runCompatibilityScenario(options.root, manifest, scenario, failures, observations); + await collectPhaseFailure(`${scenario.id}: compatibility validation failed`, () => + runCompatibilityScenario(options.root, manifest, scenario, failures, observations), + ); } return { failures, manifest, observations }; diff --git a/scripts/gate-verify.test.mjs b/scripts/gate-verify.test.mjs index 1777b151..6e1a1126 100644 --- a/scripts/gate-verify.test.mjs +++ b/scripts/gate-verify.test.mjs @@ -296,6 +296,43 @@ test('moving criterion bindings to unrelated cases is rejected', async () => { assert.match(output(result), /META-CRIT-2.*rejects-second-input.*not bound/i); }); +test('canonical verification preserves binding diagnostics when a case fixture is also stale', async () => { + const root = await fixture('misbinding-plus-stale-fixture'); + await writeGate(root); + const manifest = baseManifest(); + manifest.criteria.push({ + id: 'META-CRIT-2', + originalText: 'The second case rejects its own bad input.', + currentText: 'The second case rejects its own bad input.', + claimType: 'integrity', + source: 'fixture', + meaningChanges: [], + caseRefs: ['meta-fixture/rejects-second-input'], + }); + manifest.gates[0].cases.push({ + ...manifest.gates[0].cases[0], + id: 'rejects-second-input', + criterionIds: ['META-CRIT-1'], + }); + manifest.gates[0].cases[0].criterionIds = ['META-CRIT-2']; + manifest.gates[0].cases[0].fixture = { + replaceFiles: [ + { + path: 'gates/meta-fixture.sh', + find: 'text that is not present', + replace: 'irrelevant', + }, + ], + }; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /META-CRIT-1.*rejects-bad-input.*not bound/i); + assert.match(output(result), /META-CRIT-2.*rejects-second-input.*not bound/i); + assert.match(output(result), /fixture replace.*stale or ambiguous/i); +}); + test('moving meaning and prose criteria to an unrelated type error is rejected', async () => { const root = await fixture('real-manifest-misbinding'); const manifest = JSON.parse( -- 2.54.0 From 83d2ecb2243f1b0987ef2bc14de47f444285a684 Mon Sep 17 00:00:00 2001 From: f10-coder Date: Sat, 1 Aug 2026 10:06:09 -0500 Subject: [PATCH 08/13] chore(quality): advance registry activation seam --- docs/scratchpads/1029-rm-02-gate-registry.md | 5 +++++ gates/gates.manifest.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/scratchpads/1029-rm-02-gate-registry.md b/docs/scratchpads/1029-rm-02-gate-registry.md index 631ae905..65f8462b 100644 --- a/docs/scratchpads/1029-rm-02-gate-registry.md +++ b/docs/scratchpads/1029-rm-02-gate-registry.md @@ -73,6 +73,11 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro - Independent review documentation check pending rev-974 at the revised exact head. - Canonical documentation remains in-repository; no external publication requested. +## Rebase onto RM-61 + +- Rebasing `f9746b23` onto main `f4fd5967` completed mechanically with no conflicts. +- The first post-rebase `pnpm gate:verify` correctly failed because the old activation seam `f65e9ea6` made the newly merged pre-registry RM-61 commit part of prospective history even though that commit predates the registry. The activation commit was advanced to the new main parent `f4fd5967`, so own-tree provenance starts with this branch's first registry commit rather than demanding a manifest from an unactivated baseline commit. No gate case or acceptance rule was weakened. + ## Risks/blockers - Current queue guard intentionally has required-versus-actual deltas owned by RM-03. diff --git a/gates/gates.manifest.json b/gates/gates.manifest.json index 9b46170f..94b344b7 100644 --- a/gates/gates.manifest.json +++ b/gates/gates.manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "activationCommit": "f65e9ea656ec466e12640bf6ab5d46fe07ff160c", + "activationCommit": "f4fd5967fc5d4cbc72d680b199d88224aa855131", "gateRoots": ["gates"], "governingClaimFiles": ["docs/remediation/MISSION.md", "docs/remediation/GATE-CLAIMS.md"], "coverageBoundary": { -- 2.54.0 From 32b490a712a5e8e77f13c40c60099b51feb38994 Mon Sep 17 00:00:00 2001 From: f10-coder Date: Sat, 1 Aug 2026 11:28:28 -0500 Subject: [PATCH 09/13] fix(quality): close registry silent-defeat paths --- docs/ADMIN-GUIDE/quality-gate-registry.md | 6 +- docs/DEVELOPER-GUIDE/quality-gate-registry.md | 6 +- docs/PRD.md | 4 + docs/scratchpads/1029-rm-02-gate-registry.md | 6 +- gates/gates.manifest.json | 214 ++++++++++++++- scripts/gate-history-boundary-control.mjs | 40 +++ scripts/gate-history.mjs | 105 ++++++-- scripts/gate-history.test.mjs | 166 +++++++++++- scripts/gate-provider-binding-control.mjs | 30 +++ scripts/gate-verify.mjs | 252 ++++++++++++++++-- scripts/gate-verify.test.mjs | 112 +++++++- 11 files changed, 889 insertions(+), 52 deletions(-) create mode 100644 scripts/gate-history-boundary-control.mjs create mode 100644 scripts/gate-provider-binding-control.mjs diff --git a/docs/ADMIN-GUIDE/quality-gate-registry.md b/docs/ADMIN-GUIDE/quality-gate-registry.md index 19c9cd4d..8f534e7f 100644 --- a/docs/ADMIN-GUIDE/quality-gate-registry.md +++ b/docs/ADMIN-GUIDE/quality-gate-registry.md @@ -14,7 +14,7 @@ Investigate any of these immediately: ## Updating a gate -1. Add or change the criterion, its exact criterion-side `caseRefs`, and matching case-side `criterionIds`. +1. Add or change the criterion, its exact criterion-side `caseRefs`, and matching case-side `criterionIds`. Preserve recursive closed-schema validation for every nested object; new fields require explicit key and type handling. 2. Observe the must-fail case fail for its own stated reason; moving the binding to any undeclared case must fail verification. 3. Declare an exact inerting mutation and observe the verifier detect it. 4. If required and actual behavior differ, add a tracked remediation owner and justification. @@ -28,7 +28,9 @@ Do not add an ownerless exception or describe an open delta as pass/green/OK. Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. The step unshallows the checkout so activation ancestry and historical manifest provenance can be checked; a shallow boundary must never be interpreted as non-ancestry. -Provider evidence input is an optional JSON array of normalized pipeline records containing `commit`, unique integer pipeline `number`, pipeline `status`, and a `gate-verify` step status. The highest numbered rerun is authoritative; ambiguous duplicates fail. Its retention window is provider-controlled and is not overstated by this repository. +Provider evidence input is an optional JSON array of normalized pipeline records containing `commit`, globally unique integer pipeline `number`, pipeline `status`, and a `gate-verify` step status. Collection-wide identity/type validation occurs before commit filtering; a duplicate number across two commits fails subject binding. The highest numbered valid rerun for one commit is authoritative. Its retention window is provider-controlled and is not overstated by this repository. + +Do not add or update an activation SHA in the manifest. The verifier derives the immutable history boundary from Git as the parent of the first first-parent registry-introduction commit. HEAD, HEAD's parent, and the introduction commit are registered rejected seam candidates. PR CI executes current-tree verification only, unprivileged and fail-closed. It does not execute isolated own-tree replay: RM-60 must provide a protected launcher or runner-level rootless sandbox before any PR-controlled executable/configuration is evaluated. Repo-only code cannot safely grant itself the capability intended to contain itself. diff --git a/docs/DEVELOPER-GUIDE/quality-gate-registry.md b/docs/DEVELOPER-GUIDE/quality-gate-registry.md index dce1ded8..a2ef7e07 100644 --- a/docs/DEVELOPER-GUIDE/quality-gate-registry.md +++ b/docs/DEVELOPER-GUIDE/quality-gate-registry.md @@ -10,7 +10,7 @@ pnpm gate:verify The registry covers root typecheck, lint, and format checks; RM-01 checkout preflight; the Mosaic CI queue guard; and root Husky pre-commit/pre-push hooks. It does not imply repository-wide coverage. Framework scripts, package-local build/test scripts, templates, and deployment/release scripts remain assigned to RM-54. -Every gate declares exact invocations, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. Independent validation phases collect labeled failures instead of letting one thrown fixture, claim, discovery, deployment, mutation, or compatibility error mask already-known stable-ID diagnostics. This proves detection of the **declared** inerting mutation, not every possible semantic weakening. +Every gate declares exact invocations, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. Every nested manifest object used by outcomes, mutations, fixtures, deployments, defects, compatibility, provenance, coverage, and merge assertions has closed keys and strict field types; a misspelling cannot silently turn a required comparison into an absent optional field. Independent validation phases collect labeled failures instead of letting one thrown fixture, claim, discovery, deployment, mutation, or compatibility error mask already-known stable-ID diagnostics. This proves detection of the **declared** inerting mutation, not every possible semantic weakening. ## Required versus actual @@ -40,6 +40,8 @@ A gate with an external installed counterpart declares it explicitly. When the i The replay implementation and abuse-case tests remain fail-closed: when invoked by a future protected authority, inability to establish Bubblewrap is terminal nonzero; controls are never omitted or treated as replay success. On an unprivileged CI runner, sandbox integration tests pass only when the result carries parent-generated Bubblewrap-launch provenance and proves the sandbox entry command never ran. Child-controlled text that merely reproduces a Bubblewrap denial is not accepted. Capable local/protected environments exercise the full abuse cases. Historical installs use frozen lockfiles, isolated network/PID/IPC/UTS and environment/home boundaries, and authoritative-file snapshots that detect lifecycle rewrites. -Retained provider evidence can assert terminal-success **current-tree** records for prior commits when supplied through `GATE_PROVIDER_EVIDENCE_FILE`. Each normalized record contains `commit`, unique integer pipeline `number`, pipeline `status`, and exactly one `gate-verify` step; the highest-numbered rerun is authoritative. Ambiguous duplicates fail. Absent, expired, or currently-running evidence is reported explicitly and never inferred as success. +Retained provider evidence can assert terminal-success **current-tree** records for prior commits when supplied through `GATE_PROVIDER_EVIDENCE_FILE`. Each normalized record contains `commit`, globally unique integer pipeline `number`, pipeline `status`, and exactly one `gate-verify` step; the highest-numbered rerun for a commit is authoritative. The full collection is validated before commit filtering, so one pipeline identity cannot certify two commit subjects. Ambiguous duplicates fail. Absent, expired, or currently-running evidence is reported explicitly and never inferred as success. + +The history seam is not a manifest field. `gate-history.mjs` derives it from Git as the parent of the first first-parent commit adding `gates/gates.manifest.json`, which necessarily includes the introduction commit and all later first-parent registry commits. Registered controls reject HEAD, HEAD's parent, and the introduction commit as candidate seams. Once RM-60 supplies the external pre-execution anchor, protected post-merge/main replay is detection, not pre-merge prevention. A failed replay requires quarantine of the affected result and revert of the offending merge. It must never be represented as proof that CI blocked that merge. RM-25 tracks provider enforcement. diff --git a/docs/PRD.md b/docs/PRD.md index 7ef0c363..56980783 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -38,6 +38,9 @@ Existing deterministic gates can return success without enforcing their stated p 8. `RM02-REQ-08`: Every gate with a deployed counterpart SHALL register source/deployed byte identity and a must-fail drift control. Gates without a deployed counterpart SHALL say so explicitly. 9. `RM02-REQ-09`: CI SHALL run `pnpm gate:verify` on every pull request without path filtering and on protected-main pushes. 10. `RM02-REQ-10` (restated): PR CI SHALL perform unprivileged, fail-closed current-tree verification only. Isolated per-commit replay SHALL remain deferred to RM-60's protected post-merge/main authority, cross-referenced with RM-59. That future replay is detection with a quarantine/revert response, not pre-merge prevention; inability to establish its sandbox is terminal nonzero, never skip/pass. Retained provider evidence SHALL remain distinct and SHALL never be inferred when absent. +11. `RM02-REQ-11`: The history activation boundary SHALL be derived, not manifest-authored, as the parent of the first first-parent commit introducing `gates/gates.manifest.json`. The introduction commit SHALL be included. Author-controlled candidates equal to HEAD, HEAD's parent, or the introduction commit SHALL be rejected by registered must-fail controls. +12. `RM02-REQ-12` (`D-38`): Evidence SHALL be bound to exactly one subject. Provider pipeline identity uniqueness SHALL be validated over the full evidence collection before commit filtering; a duplicate pipeline number across different commits SHALL fail. +13. `RM02-REQ-13` (`D-40`): Every registry object used for discrimination, comparison, mutation, fixture construction, provenance, deployment, or outcome assessment SHALL have a recursively closed, type-strict schema. Unknown or misspelled nested fields SHALL fail rather than becoming absent optional assertions. #### RM02-REQ-10 meaning-change provenance @@ -54,6 +57,7 @@ Existing deterministic gates can return success without enforcing their stated p 5. `RM02-AC-05`: Unbound or semantically misbound criteria, prose claims bound to unrelated cases, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. A simultaneous stale fixture or independent phase error SHALL NOT mask responsible stable-ID diagnostics. Registered meta-negative controls move a criterion binding, remove meaning provenance, and redirect a prose claim to an unrelated case; each is observed red for its stated reason. 6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request. 7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: current-tree gates and inerting mutations execute unprivileged and fail-closed; isolated own-tree replay does not execute in repository-controlled PR CI. RM-60/RM-59 are named, retained provider evidence is never inferred, and future protected post-merge detection specifies quarantine/revert rather than claiming pre-merge prevention. +8. `RM02-AC-08`: Registered must-fail controls reject history seam candidates at HEAD, HEAD's parent, and the registry introduction; a cross-commit duplicate provider pipeline identity; a misspelled nested outcome field; a wrong outcome field type; and a present-but-empty outcome pattern. The `RM02-EVIDENCE-SUBJECT-BINDING` and `RM02-TYPE-STRICT-SCHEMA` criteria are bidirectionally bound to their controls. ### Risks, dependencies, and verification boundary diff --git a/docs/scratchpads/1029-rm-02-gate-registry.md b/docs/scratchpads/1029-rm-02-gate-registry.md index 65f8462b..be0f50f3 100644 --- a/docs/scratchpads/1029-rm-02-gate-registry.md +++ b/docs/scratchpads/1029-rm-02-gate-registry.md @@ -39,7 +39,7 @@ Deliver the seven-gate registry and RED-first anti-inert verifier on `feat/rm-02 - CI wiring RED: package script and unconditional Woodpecker step tests both failed before wiring. - History RED: history test failed with missing module before own-tree manifest selection/provider classification was implemented. - `pnpm gate:verify`: exit 0; seven gates each reported `META-NEGATIVE-CONTROL ... observed red`; queue source/deployed drift control observed red; six queue behavior deltas printed as `DEFECT (owner: RM-03)`. -- Focused Node tests: 38/38 pass after review hardening (27 verifier/wiring plus 11 history/provider tests). +- Focused Node tests: 48/48 pass after review hardening (31 verifier/wiring plus 17 history/provider tests). - `pnpm typecheck`: pass (45/45 Turbo tasks). - `pnpm lint`: pass (25/25 Turbo tasks). - `pnpm format:check`: pass. @@ -66,6 +66,8 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro - Exact-head independent review at `9b4d4beb` found two valid blockers. RED-first controls reproduced both: denial-looking child stderr was accepted as sandbox unavailability, and moving meaning/prose criterion IDs to an unrelated type-error case left `gate:verify` green. Bubblewrap execution now emits a parent-generated random entry marker and returns parent-owned launcher/entry metadata; unavailability requires Bubblewrap launcher provenance plus proof entry never ran, so exact denial impersonation from plain or entered-child results is rejected. Criterion objects now declare exact `caseRefs`, checked bidirectionally against case-side `criterionIds`; prose claims declare an exact must-fail `caseRef`. Registered must-fail cases move a criterion binding, remove meaning provenance, and redirect a prose claim, each producing its stable reason. The review freeze was deliberately lifted before remediation. - Option C security review reported no findings. Code review rejected an initial unrelated typecheck binding for the new security criterion. It was replaced with a dedicated registered `privileged-pr-gate` case: the fixture injects a privilege key into the gate step, the wiring control rejects it for that exact reason, and `gate:verify` observes the boundary negative control. Follow-up hardening uses a closed exact gate-step construction, rejects privilege across the entire pipeline, rejects non-canonical/merged YAML keys, and pins the unrestricted PR/main trigger block; quoted/escaped/alias/merge/duplicate/filter bypass tests pass. Final Codex code review approved with no findings. +- Exact-head review at `83d2ecb2` found four silent-defeat paths. Genuine RED-first tests on the pre-fix code proved: author-controlled HEAD/parent/introduction seams produced no boundary failure; cross-commit duplicate pipeline number 7 returned terminal-success; and misspelling `outputPattern` as `outputPatern` in required/actual left canonical verification green. Fixes derive the seam from Git, validate provider identity globally before subject filtering, and recursively close/type-check nested schemas. New registry criteria `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, and `RM02-HISTORY-BOUNDARY` are bidirectionally bound to eight registered must-fail controls. The broad nested-object typo table and exact derived-boundary test are regression guards added after implementation, not claimed as RED-first. Pre-commit Codex review then found that global evidence failure was only observed—not failed—when HEAD was the sole prospective commit, and that non-object collection entries threw before normalization. Both were reproduced RED-first, then fixed by one collection validator used before iteration and by per-commit assessment. Follow-up review found collection validation still omitted exactly-one-gate-step cardinality for unrelated subjects; a focused test reproduced terminal-success RED-first, and collection validation now rejects that ambiguity globally. Security review then found present-but-empty outcome patterns were truthy-optional assertion bypasses; a reason-specific test reproduced that they lacked the required schema diagnostic, and present pattern fields now require non-whitespace content. + ## Documentation checklist - PRD, developer guide, admin guide, governing claim index, sitemap, plan, and scratchpad updated. @@ -76,7 +78,7 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro ## Rebase onto RM-61 - Rebasing `f9746b23` onto main `f4fd5967` completed mechanically with no conflicts. -- The first post-rebase `pnpm gate:verify` correctly failed because the old activation seam `f65e9ea6` made the newly merged pre-registry RM-61 commit part of prospective history even though that commit predates the registry. The activation commit was advanced to the new main parent `f4fd5967`, so own-tree provenance starts with this branch's first registry commit rather than demanding a manifest from an unactivated baseline commit. No gate case or acceptance rule was weakened. +- The first post-rebase `pnpm gate:verify` correctly failed because the old activation seam `f65e9ea6` made the newly merged pre-registry RM-61 commit part of prospective history even though that commit predates the registry. An initial manifest update to `f4fd5967` had the right value but retained an author-controlled mechanism. Independent mutation proved HEAD, HEAD's parent, and the introduction commit could each make history coverage vacuous or partial. The manifest field is now forbidden; the verifier derives the boundary as the parent of the first first-parent registry-introduction commit, and registered must-fail controls reject all three unsafe candidates. ## Risks/blockers diff --git a/gates/gates.manifest.json b/gates/gates.manifest.json index 94b344b7..e51ca91c 100644 --- a/gates/gates.manifest.json +++ b/gates/gates.manifest.json @@ -1,6 +1,5 @@ { "schemaVersion": 1, - "activationCommit": "f4fd5967fc5d4cbc72d680b199d88224aa855131", "gateRoots": ["gates"], "governingClaimFiles": ["docs/remediation/MISSION.md", "docs/remediation/GATE-CLAIMS.md"], "coverageBoundary": { @@ -34,6 +33,13 @@ "checkout-preflight/criterion-misbinding", "checkout-preflight/missing-meaning-provenance", "checkout-preflight/prose-claim-misbinding", + "checkout-preflight/history-seam-head", + "checkout-preflight/history-seam-parent", + "checkout-preflight/history-seam-introduction", + "checkout-preflight/provider-cross-commit-duplicate", + "checkout-preflight/misspelled-outcome-field", + "checkout-preflight/wrong-outcome-field-type", + "checkout-preflight/empty-outcome-pattern", "ci-queue-wait/no-status-required", "ci-queue-wait/unknown-option", "hook-pre-commit/lint-staged-failure", @@ -225,6 +231,41 @@ } ], "caseRefs": ["ci-queue-wait/terminal-success", "ci-queue-wait/unknown-option"] + }, + { + "id": "RM02-HISTORY-BOUNDARY", + "originalText": "The history activation boundary is derived as the parent of the first first-parent commit that introduces the registry.", + "currentText": "The history activation boundary is derived as the parent of the first first-parent commit that introduces the registry.", + "claimType": "integrity", + "source": "docs/remediation/TASKS.md#d-17", + "meaningChanges": [], + "caseRefs": [ + "checkout-preflight/history-seam-head", + "checkout-preflight/history-seam-parent", + "checkout-preflight/history-seam-introduction" + ] + }, + { + "id": "RM02-EVIDENCE-SUBJECT-BINDING", + "originalText": "A provider evidence identity cannot establish success for more than one commit subject.", + "currentText": "A provider evidence identity cannot establish success for more than one commit subject.", + "claimType": "integrity", + "source": "docs/remediation/TASKS.md#d-38", + "meaningChanges": [], + "caseRefs": ["checkout-preflight/provider-cross-commit-duplicate"] + }, + { + "id": "RM02-TYPE-STRICT-SCHEMA", + "originalText": "Every registry discriminator and comparison field is recursively closed and type-strict so a misspelling cannot disable an assertion.", + "currentText": "Every registry discriminator and comparison field is recursively closed and type-strict so a misspelling cannot disable an assertion.", + "claimType": "integrity", + "source": "docs/remediation/TASKS.md#d-40", + "meaningChanges": [], + "caseRefs": [ + "checkout-preflight/misspelled-outcome-field", + "checkout-preflight/wrong-outcome-field-type", + "checkout-preflight/empty-outcome-pattern" + ] } ], "proseClaims": [ @@ -678,6 +719,177 @@ } ] } + }, + { + "id": "history-seam-head", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-BOUNDARY"], + "mustFail": true, + "invocation": ["node", "scripts/gate-history-boundary-control.mjs", "head"], + "required": { + "exitCode": 1, + "outputPattern": "history boundary candidate head rejected" + }, + "actual": { + "exitCode": 1, + "outputPattern": "history boundary candidate head rejected" + }, + "reasonPattern": "derived activation is parent of registry introduction" + }, + { + "id": "history-seam-parent", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-BOUNDARY"], + "mustFail": true, + "invocation": ["node", "scripts/gate-history-boundary-control.mjs", "parent"], + "required": { + "exitCode": 1, + "outputPattern": "history boundary candidate parent rejected" + }, + "actual": { + "exitCode": 1, + "outputPattern": "history boundary candidate parent rejected" + }, + "reasonPattern": "derived activation is parent of registry introduction" + }, + { + "id": "history-seam-introduction", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-BOUNDARY"], + "mustFail": true, + "invocation": ["node", "scripts/gate-history-boundary-control.mjs", "introduction"], + "required": { + "exitCode": 1, + "outputPattern": "history boundary candidate introduction rejected" + }, + "actual": { + "exitCode": 1, + "outputPattern": "history boundary candidate introduction rejected" + }, + "reasonPattern": "derived activation is parent of registry introduction" + }, + { + "id": "provider-cross-commit-duplicate", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-EVIDENCE-SUBJECT-BINDING"], + "mustFail": true, + "invocation": ["node", "scripts/gate-provider-binding-control.mjs"], + "required": { + "exitCode": 1, + "outputPattern": "duplicate pipeline identity across commits" + }, + "actual": { + "exitCode": 1, + "outputPattern": "duplicate pipeline identity across commits" + }, + "reasonPattern": "duplicate pipeline identity across commits" + }, + { + "id": "misspelled-outcome-field", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-TYPE-STRICT-SCHEMA"], + "mustFail": true, + "invocation": [ + "node", + "scripts/gate-verify.mjs", + "--root", + ".", + "--manifest", + "gates/gates.manifest.json", + "--structure-only" + ], + "required": { + "exitCode": 1, + "outputPattern": "required: unknown field outputPatern" + }, + "actual": { + "exitCode": 1, + "outputPattern": "required: unknown field outputPatern" + }, + "reasonPattern": "required: unknown field outputPatern", + "fixture": { + "copyPaths": [ + "gates/gates.manifest.json", + "scripts/gate-verify.mjs", + "scripts/gate-history.mjs" + ], + "replaceFiles": [ + { + "path": "gates/gates.manifest.json", + "find": "\"required\": {\n \"exitCode\": 0,\n \"outputPattern\": \"checkout preflight passed\"\n },\n \"actual\":", + "replace": "\"required\": {\n \"exitCode\": 0,\n \"outputPatern\": \"checkout preflight passed\"\n },\n \"actual\":" + } + ] + } + }, + { + "id": "wrong-outcome-field-type", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-TYPE-STRICT-SCHEMA"], + "mustFail": true, + "invocation": [ + "node", + "scripts/gate-verify.mjs", + "--root", + ".", + "--manifest", + "gates/gates.manifest.json", + "--structure-only" + ], + "required": { + "exitCode": 1, + "outputPattern": "required.exitCode: expected an integer" + }, + "actual": { + "exitCode": 1, + "outputPattern": "required.exitCode: expected an integer" + }, + "reasonPattern": "required.exitCode: expected an integer", + "fixture": { + "copyPaths": [ + "gates/gates.manifest.json", + "scripts/gate-verify.mjs", + "scripts/gate-history.mjs" + ], + "replaceFiles": [ + { + "path": "gates/gates.manifest.json", + "find": "\"required\": {\n \"exitCode\": 0,\n \"outputPattern\": \"checkout preflight passed\"\n },\n \"actual\":", + "replace": "\"required\": {\n \"exitCode\": \"0\",\n \"outputPattern\": \"checkout preflight passed\"\n },\n \"actual\":" + } + ] + } + }, + { + "id": "empty-outcome-pattern", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-TYPE-STRICT-SCHEMA"], + "mustFail": true, + "invocation": [ + "node", + "scripts/gate-verify.mjs", + "--root", + ".", + "--manifest", + "gates/gates.manifest.json", + "--structure-only" + ], + "required": { + "exitCode": 1, + "outputPattern": "required.outputPattern: expected a non-empty pattern" + }, + "actual": { + "exitCode": 1, + "outputPattern": "required.outputPattern: expected a non-empty pattern" + }, + "reasonPattern": "required.outputPattern: expected a non-empty pattern", + "fixture": { + "copyPaths": [ + "gates/gates.manifest.json", + "scripts/gate-verify.mjs", + "scripts/gate-history.mjs" + ], + "replaceFiles": [ + { + "path": "gates/gates.manifest.json", + "find": "\"required\": {\n \"exitCode\": 0,\n \"outputPattern\": \"checkout preflight passed\"\n },\n \"actual\":", + "replace": "\"required\": {\n \"exitCode\": 0,\n \"outputPattern\": \" \"\n },\n \"actual\":" + } + ] + } } ] }, diff --git a/scripts/gate-history-boundary-control.mjs b/scripts/gate-history-boundary-control.mjs new file mode 100644 index 00000000..b21b60e3 --- /dev/null +++ b/scripts/gate-history-boundary-control.mjs @@ -0,0 +1,40 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; + +import { deriveHistoryBoundary } from './gate-history.mjs'; + +const candidateKind = process.argv[2]; +const root = process.cwd(); +const boundary = deriveHistoryBoundary(root); + +function revParse(revision) { + const result = spawnSync('git', ['rev-parse', revision], { + cwd: root, + encoding: 'utf8', + }); + if (result.status !== 0) { + process.stderr.write(`history boundary control could not resolve ${revision}\n`); + process.exit(2); + } + return result.stdout.trim(); +} + +const candidates = { + head: revParse('HEAD'), + parent: revParse('HEAD^'), + introduction: boundary.introductionCommit, +}; +if (!Object.hasOwn(candidates, candidateKind)) { + process.stderr.write(`unknown history boundary candidate ${String(candidateKind)}\n`); + process.exit(2); +} +const candidate = candidates[candidateKind]; +if (candidate === boundary.activationCommit) { + process.stdout.write(`history boundary candidate ${candidateKind} matched derived activation\n`); + process.exit(0); +} +process.stderr.write( + `history boundary candidate ${candidateKind} rejected: derived activation is parent of registry introduction\n`, +); +process.exit(1); diff --git a/scripts/gate-history.mjs b/scripts/gate-history.mjs index 22b1bd0d..92064698 100644 --- a/scripts/gate-history.mjs +++ b/scripts/gate-history.mjs @@ -22,6 +22,33 @@ export async function listProspectiveCommits(root, activationCommit, head = 'HEA return result.stdout.trim() ? result.stdout.trim().split('\n') : []; } +export function deriveHistoryBoundary(root, head = 'HEAD') { + const introductions = git(root, [ + 'log', + '--first-parent', + '--diff-filter=A', + '--format=%H', + '--reverse', + head, + '--', + 'gates/gates.manifest.json', + ]) + .stdout.trim() + .split('\n') + .filter(Boolean); + if (introductions.length === 0) { + throw new Error('history boundary cannot be derived: registry introduction is absent'); + } + const introductionCommit = introductions[0]; + const parent = git(root, ['rev-parse', `${introductionCommit}^`], { allowFailure: true }); + if (parent.status !== 0 || !parent.stdout.trim()) { + throw new Error( + `history boundary cannot be derived: registry introduction ${introductionCommit} has no parent`, + ); + } + return { activationCommit: parent.stdout.trim(), introductionCommit }; +} + export async function readManifestAtCommit(root, commit) { const result = git(root, ['show', `${commit}:gates/gates.manifest.json`]); return JSON.parse(result.stdout); @@ -198,15 +225,8 @@ export async function replayCommit(root, commit) { } } -export function assessProviderEvidence(commit, pipelines) { - const matches = pipelines.filter((candidate) => candidate.commit === commit); - if (matches.length === 0) { - return { - state: 'absent', - detail: - 'no retained provider record was supplied; retention expiry and never-ran are not inferred', - }; - } +export function validateProviderEvidenceCollection(pipelines) { + if (!Array.isArray(pipelines)) return 'provider evidence collection is malformed'; const pipelineStates = new Set(['success', 'failure', 'error', 'pending', 'running', 'queued']); const stepStates = new Set([ 'success', @@ -217,9 +237,11 @@ export function assessProviderEvidence(commit, pipelines) { 'queued', 'skipped', ]); - const numbers = matches.map((candidate) => candidate.number); - const malformed = matches.some( + const malformed = pipelines.some( (candidate) => + !candidate || + typeof candidate !== 'object' || + Array.isArray(candidate) || typeof candidate.commit !== 'string' || candidate.commit.length === 0 || !Number.isInteger(candidate.number) || @@ -227,15 +249,43 @@ export function assessProviderEvidence(commit, pipelines) { !Array.isArray(candidate.steps) || candidate.steps.some( (step) => - typeof step?.name !== 'string' || - typeof step?.status !== 'string' || + !step || + typeof step !== 'object' || + Array.isArray(step) || + typeof step.name !== 'string' || + typeof step.status !== 'string' || !stepStates.has(step.status), ), ); - if (malformed || new Set(numbers).size !== numbers.length) { + if (malformed) return 'provider records are malformed'; + const ambiguousGateRecord = pipelines.find( + (candidate) => candidate.steps.filter((step) => step.name === 'gate-verify').length !== 1, + ); + if (ambiguousGateRecord) { + const count = ambiguousGateRecord.steps.filter((step) => step.name === 'gate-verify').length; + return `provider record ${ambiguousGateRecord.number} has ambiguous gate-verify step count ${count}`; + } + const numbers = pipelines.map((candidate) => candidate.number); + if (new Set(numbers).size !== numbers.length) { + return 'duplicate pipeline identity across commits in provider evidence collection'; + } + return undefined; +} + +export function assessProviderEvidence(commit, pipelines) { + const collectionFailure = validateProviderEvidenceCollection(pipelines); + if (collectionFailure) { return { state: 'terminal-failure', - detail: 'provider records are malformed or have ambiguous pipeline numbers', + detail: collectionFailure, + }; + } + const matches = pipelines.filter((candidate) => candidate.commit === commit); + if (matches.length === 0) { + return { + state: 'absent', + detail: + 'no retained provider record was supplied; retention expiry and never-ran are not inferred', }; } const pipeline = [...matches].sort((left, right) => right.number - left.number)[0]; @@ -279,19 +329,16 @@ export async function verifyHistory({ root, manifest }) { const failures = []; const observations = []; const head = git(root, ['rev-parse', 'HEAD']).stdout.trim(); - if (!manifest.activationCommit) { - failures.push('history activationCommit is missing'); - return { failures, observations }; - } - const activationCheck = git( - root, - ['merge-base', '--is-ancestor', manifest.activationCommit, head], - { allowFailure: true }, - ); - if (activationCheck.status !== 0) { + if (Object.hasOwn(manifest, 'activationCommit')) { failures.push( - `history activation commit ${manifest.activationCommit} is not an ancestor of ${head}`, + 'author-controlled activationCommit is forbidden; history boundary is derived from the registry introduction', ); + } + let boundary; + try { + boundary = deriveHistoryBoundary(root, head); + } catch (error) { + failures.push(error.message); return { failures, observations }; } const onMain = isMainCommit(root, head); @@ -311,7 +358,11 @@ export async function verifyHistory({ root, manifest }) { } const pipelines = onMain ? await loadProviderEvidence() : []; - const commits = await listProspectiveCommits(root, manifest.activationCommit, head); + const collectionFailure = validateProviderEvidenceCollection(pipelines); + if (collectionFailure) { + failures.push(`provider evidence collection invalid: ${collectionFailure}`); + } + const commits = await listProspectiveCommits(root, boundary.activationCommit, head); for (const commit of commits) { let commitManifest; try { diff --git a/scripts/gate-history.test.mjs b/scripts/gate-history.test.mjs index 7e5d8657..cdd006c2 100644 --- a/scripts/gate-history.test.mjs +++ b/scripts/gate-history.test.mjs @@ -6,6 +6,7 @@ import test from 'node:test'; import { assessProviderEvidence, + deriveHistoryBoundary, listProspectiveCommits, readManifestAtCommit, replayCommit, @@ -285,7 +286,7 @@ test('PR verification states the RM-60 boundary without executing an intermediat try { const result = await verifyHistory({ root, - manifest: { schemaVersion: 1, activationCommit: activation }, + manifest: { schemaVersion: 1 }, }); assert.deepEqual(result.failures, []); assert.ok( @@ -305,6 +306,169 @@ test('PR verification states the RM-60 boundary without executing an intermediat } }); +test('derived history boundary includes the registry-introduction commit', async () => { + const root = `${fixtureRoot}-derived-boundary`; + await rm(root, { recursive: true, force: true }); + await mkdir(root, { recursive: true }); + git(root, 'init', '-q'); + git(root, 'config', 'user.name', 'gate-test'); + git(root, 'config', 'user.email', 'gate-test@example.invalid'); + await writeFile(path.join(root, 'baseline.txt'), 'baseline\n'); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'baseline'); + const baseline = git(root, 'rev-parse', 'HEAD'); + await mkdir(path.join(root, 'gates'), { recursive: true }); + await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'registry introduction'); + const introduction = git(root, 'rev-parse', 'HEAD'); + await writeFile(path.join(root, 'later.txt'), 'later\n'); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'later'); + const head = git(root, 'rev-parse', 'HEAD'); + + assert.deepEqual(deriveHistoryBoundary(root, head), { + activationCommit: baseline, + introductionCommit: introduction, + }); + assert.deepEqual(await listProspectiveCommits(root, baseline, head), [introduction, head]); +}); + +test('author-controlled activation seams cannot omit registry-era history', async () => { + const root = `${fixtureRoot}-activation-seam`; + await rm(root, { recursive: true, force: true }); + await mkdir(path.join(root, 'gates'), { recursive: true }); + git(root, 'init', '-q'); + git(root, 'config', 'user.name', 'gate-test'); + git(root, 'config', 'user.email', 'gate-test@example.invalid'); + await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'registry introduction'); + const introduction = git(root, 'rev-parse', 'HEAD'); + await writeFile(path.join(root, 'one.txt'), 'one\n'); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'one'); + await writeFile(path.join(root, 'two.txt'), 'two\n'); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'two'); + const head = git(root, 'rev-parse', 'HEAD'); + const parent = git(root, 'rev-parse', 'HEAD^'); + + const previousBranch = process.env.CI_COMMIT_BRANCH; + process.env.CI_COMMIT_BRANCH = 'feature/activation-seam'; + try { + for (const candidate of [head, parent, introduction]) { + const result = await verifyHistory({ + root, + manifest: { schemaVersion: 1, activationCommit: candidate }, + }); + assert.match(result.failures.join('\n'), /author-controlled activationCommit.*forbidden/i); + } + } finally { + if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH; + else process.env.CI_COMMIT_BRANCH = previousBranch; + } +}); + +test('globally invalid provider evidence fails when HEAD is the only prospective commit', async () => { + const root = `${fixtureRoot}-head-only-evidence`; + await rm(root, { recursive: true, force: true }); + await mkdir(root, { recursive: true }); + git(root, 'init', '-q'); + git(root, 'config', 'user.name', 'gate-test'); + git(root, 'config', 'user.email', 'gate-test@example.invalid'); + await writeFile(path.join(root, 'baseline.txt'), 'baseline\n'); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'baseline'); + await mkdir(path.join(root, 'gates'), { recursive: true }); + await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'registry introduction'); + const head = git(root, 'rev-parse', 'HEAD'); + const evidenceFile = path.join(root, 'provider-evidence.json'); + await writeFile( + evidenceFile, + JSON.stringify([ + { + commit: head, + number: 7, + status: 'success', + steps: [{ name: 'gate-verify', status: 'success' }], + }, + { + commit: 'other-subject', + number: 7, + status: 'success', + steps: [{ name: 'gate-verify', status: 'success' }], + }, + ]), + ); + const previousBranch = process.env.CI_COMMIT_BRANCH; + const previousEvidence = process.env.GATE_PROVIDER_EVIDENCE_FILE; + process.env.CI_COMMIT_BRANCH = 'main'; + process.env.GATE_PROVIDER_EVIDENCE_FILE = evidenceFile; + try { + const result = await verifyHistory({ root, manifest: { schemaVersion: 1 } }); + assert.match(result.failures.join('\n'), /duplicate pipeline identity across commits/i); + } finally { + if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH; + else process.env.CI_COMMIT_BRANCH = previousBranch; + if (previousEvidence === undefined) delete process.env.GATE_PROVIDER_EVIDENCE_FILE; + else process.env.GATE_PROVIDER_EVIDENCE_FILE = previousEvidence; + } +}); + +test('collection-wide validation rejects ambiguous gate steps on unrelated commits', () => { + const records = [ + { + commit: 'target', + number: 7, + status: 'success', + steps: [{ name: 'gate-verify', status: 'success' }], + }, + { + commit: 'unrelated', + number: 8, + status: 'success', + steps: [ + { name: 'gate-verify', status: 'success' }, + { name: 'gate-verify', status: 'failure' }, + ], + }, + ]; + const result = assessProviderEvidence('target', records); + assert.equal(result.state, 'terminal-failure'); + assert.match(result.detail, /ambiguous gate-verify step count/i); +}); + +test('provider evidence rejects non-object collection entries without crashing', () => { + for (const record of [null, [], 'text', 42]) { + const result = assessProviderEvidence('aaa', [record]); + assert.equal(result.state, 'terminal-failure'); + assert.match(result.detail, /malformed/i); + } +}); + +test('provider evidence rejects duplicate pipeline identity across commits', () => { + const records = [ + { + commit: 'aaa', + number: 7, + status: 'success', + steps: [{ name: 'gate-verify', status: 'success' }], + }, + { + commit: 'bbb', + number: 7, + status: 'success', + steps: [{ name: 'gate-verify', status: 'success' }], + }, + ]; + const result = assessProviderEvidence('aaa', records); + assert.equal(result.state, 'terminal-failure'); + assert.match(result.detail, /duplicate pipeline.*across.*commit|global.*pipeline.*identity/i); +}); + test('provider evidence distinguishes retained success, failure, and absent history', () => { const pipelines = [ { diff --git a/scripts/gate-provider-binding-control.mjs b/scripts/gate-provider-binding-control.mjs new file mode 100644 index 00000000..167c9e31 --- /dev/null +++ b/scripts/gate-provider-binding-control.mjs @@ -0,0 +1,30 @@ +#!/usr/bin/env node + +import { assessProviderEvidence } from './gate-history.mjs'; + +const records = [ + { + commit: 'subject-a', + number: 7, + status: 'success', + steps: [{ name: 'gate-verify', status: 'success' }], + }, + { + commit: 'subject-b', + number: 7, + status: 'success', + steps: [{ name: 'gate-verify', status: 'success' }], + }, +]; +const result = assessProviderEvidence('subject-a', records); +if ( + result.state === 'terminal-failure' && + /duplicate pipeline identity across commits/i.test(result.detail) +) { + process.stderr.write(`${result.detail}\n`); + process.exit(1); +} +process.stdout.write( + `cross-commit duplicate was not rejected: ${result.state} (${result.detail})\n`, +); +process.exit(0); diff --git a/scripts/gate-verify.mjs b/scripts/gate-verify.mjs index 1dcd3537..5c05b9b3 100644 --- a/scripts/gate-verify.mjs +++ b/scripts/gate-verify.mjs @@ -288,6 +288,100 @@ function rejectDuplicateIds(values, label, failures) { } } +function requireString(value, label, failures, { allowEmpty = false } = {}) { + if (typeof value !== 'string' || (!allowEmpty && value.length === 0)) { + failures.push(`${label}: expected ${allowEmpty ? 'a string' : 'a non-empty string'}`); + } +} + +function validateStringArray(value, label, failures, { allowEmpty = false } = {}) { + if (!Array.isArray(value) || (!allowEmpty && value.length === 0)) { + failures.push(`${label}: expected ${allowEmpty ? 'an array' : 'a non-empty array'}`); + return; + } + if (value.some((entry) => typeof entry !== 'string' || entry.length === 0)) { + failures.push(`${label}: entries must be non-empty strings`); + } +} + +function validateOutcome(value, label, failures) { + rejectUnknownKeys( + value, + new Set(['exitCode', 'outputPattern', 'notOutputPattern']), + label, + failures, + ); + if (typeof value?.exitCode !== 'number' || !Number.isInteger(value.exitCode)) { + failures.push(`${label}.exitCode: expected an integer`); + } + for (const key of ['outputPattern', 'notOutputPattern']) { + if (value?.[key] !== undefined && typeof value[key] !== 'string') { + failures.push(`${label}.${key}: expected a string`); + } else if (typeof value?.[key] === 'string' && value[key].trim().length === 0) { + failures.push(`${label}.${key}: expected a non-empty pattern`); + } + } +} + +function validateFixture(fixture, label, failures) { + if (fixture === undefined) return; + rejectUnknownKeys( + fixture, + new Set(['writeFiles', 'replaceFiles', 'removePaths', 'copyPaths']), + label, + failures, + ); + const writeFiles = Array.isArray(fixture?.writeFiles) ? fixture.writeFiles : []; + const replaceFiles = Array.isArray(fixture?.replaceFiles) ? fixture.replaceFiles : []; + for (const [index, entry] of writeFiles.entries()) { + rejectUnknownKeys( + entry, + new Set(['path', 'content', 'mode']), + `${label}.writeFiles[${index}]`, + failures, + ); + requireString(entry?.path, `${label}.writeFiles[${index}].path`, failures); + if (typeof entry?.content !== 'string') + failures.push(`${label}.writeFiles[${index}].content: expected a string`); + if (entry?.mode !== undefined && (!Number.isInteger(entry.mode) || entry.mode < 0)) { + failures.push(`${label}.writeFiles[${index}].mode: expected a non-negative integer`); + } + } + for (const [index, entry] of replaceFiles.entries()) { + rejectUnknownKeys( + entry, + new Set(['path', 'find', 'replace']), + `${label}.replaceFiles[${index}]`, + failures, + ); + requireString(entry?.path, `${label}.replaceFiles[${index}].path`, failures); + if (typeof entry?.find !== 'string') + failures.push(`${label}.replaceFiles[${index}].find: expected a string`); + if (typeof entry?.replace !== 'string') + failures.push(`${label}.replaceFiles[${index}].replace: expected a string`); + } + for (const key of ['removePaths', 'copyPaths']) { + if (fixture?.[key] !== undefined) + validateStringArray(fixture[key], `${label}.${key}`, failures, { allowEmpty: true }); + } + for (const key of ['writeFiles', 'replaceFiles']) { + if (fixture?.[key] !== undefined && !Array.isArray(fixture[key])) { + failures.push(`${label}.${key}: expected an array`); + } + } +} + +function validateEnvironment(environment, label, failures) { + if (environment === undefined) return; + if (!environment || typeof environment !== 'object' || Array.isArray(environment)) { + failures.push(`${label}: expected an object`); + return; + } + for (const [key, value] of Object.entries(environment)) { + if (!key || typeof value !== 'string') failures.push(`${label}.${key}: expected a string`); + } +} + function validateClosedSchema(manifest, failures) { if (manifest.schemaVersion !== 1) failures.push(`unsupported schemaVersion ${String(manifest.schemaVersion)}`); @@ -295,7 +389,6 @@ function validateClosedSchema(manifest, failures) { manifest, new Set([ 'schemaVersion', - 'activationCommit', 'gateRoots', 'governingClaimFiles', 'coverageBoundary', @@ -308,6 +401,43 @@ function validateClosedSchema(manifest, failures) { 'manifest', failures, ); + validateStringArray(manifest.gateRoots, 'manifest.gateRoots', failures); + validateStringArray(manifest.governingClaimFiles, 'manifest.governingClaimFiles', failures, { + allowEmpty: true, + }); + rejectUnknownKeys( + manifest.coverageBoundary, + new Set(['included', 'excluded', 'trackedBy']), + 'coverageBoundary', + failures, + ); + validateStringArray(manifest.coverageBoundary?.included, 'coverageBoundary.included', failures); + validateStringArray(manifest.coverageBoundary?.excluded, 'coverageBoundary.excluded', failures, { + allowEmpty: true, + }); + requireString(manifest.coverageBoundary?.trackedBy, 'coverageBoundary.trackedBy', failures); + if (manifest.mergeAssertions !== undefined) { + rejectUnknownKeys( + manifest.mergeAssertions, + new Set([ + 'mode', + 'providerEvidence', + 'deferredReplayOwner', + 'trustDependencies', + 'postMergeResponse', + ]), + 'mergeAssertions', + failures, + ); + for (const key of ['mode', 'providerEvidence', 'deferredReplayOwner', 'postMergeResponse']) { + requireString(manifest.mergeAssertions?.[key], `mergeAssertions.${key}`, failures); + } + validateStringArray( + manifest.mergeAssertions?.trustDependencies, + 'mergeAssertions.trustDependencies', + failures, + ); + } rejectDuplicateIds(manifest.criteria, 'criterion', failures); for (const criterion of manifest.criteria ?? []) { rejectUnknownKeys( @@ -324,6 +454,27 @@ function validateClosedSchema(manifest, failures) { `criterion ${criterion.id}`, failures, ); + for (const key of ['id', 'originalText', 'currentText', 'claimType', 'source']) { + requireString(criterion[key], `criterion ${criterion.id}.${key}`, failures); + } + if (!Array.isArray(criterion.meaningChanges)) { + failures.push(`criterion ${criterion.id}.meaningChanges: expected an array`); + } + for (const [index, change] of (criterion.meaningChanges ?? []).entries()) { + rejectUnknownKeys( + change, + new Set(['originalText', 'restatement', 'reason', 'finding', 'task', 'date']), + `criterion ${criterion.id}.meaningChanges[${index}]`, + failures, + ); + for (const key of ['originalText', 'restatement', 'reason', 'finding', 'task', 'date']) { + requireString( + change?.[key], + `criterion ${criterion.id}.meaningChanges[${index}].${key}`, + failures, + ); + } + } if (!Array.isArray(criterion.caseRefs) || criterion.caseRefs.length === 0) { failures.push(`${criterion.id}: no declared exercising cases`); } else { @@ -343,6 +494,9 @@ function validateClosedSchema(manifest, failures) { `prose claim ${claim.id}`, failures, ); + for (const key of ['id', 'criterionId', 'caseRef']) { + requireString(claim[key], `GATE-CLAIM:${claim.id}.${key}`, failures); + } if (typeof claim.caseRef !== 'string' || claim.caseRef.length === 0) { failures.push(`GATE-CLAIM:${claim.id} has no declared exercising case`); } @@ -363,15 +517,22 @@ function validateClosedSchema(manifest, failures) { `compatibility scenario ${scenario.id}`, failures, ); + for (const key of ['id', 'construction']) { + requireString(scenario[key], `compatibility scenario ${scenario.id}.${key}`, failures); + } if (!Array.isArray(scenario.caseRefs) || scenario.caseRefs.length === 0) { failures.push(`${scenario.id}: compatibility construction has no referenced conditions`); + } else { + validateStringArray(scenario.caseRefs, `${scenario.id}.caseRefs`, failures); } if (!Array.isArray(scenario.invocation) || scenario.invocation.length === 0) { failures.push(`${scenario.id}: compatibility construction invocation is missing`); + } else { + validateStringArray(scenario.invocation, `${scenario.id}.invocation`, failures); } - if (typeof scenario.expected?.exitCode !== 'number') { - failures.push(`${scenario.id}: compatibility construction exact expected exit is missing`); - } + validateOutcome(scenario.expected, `${scenario.id}.expected`, failures); + validateEnvironment(scenario.environment, `${scenario.id}.environment`, failures); + validateFixture(scenario.fixture, `${scenario.id}.fixture`, failures); } rejectDuplicateIds(manifest.gates, 'gate', failures); for (const gate of manifest.gates ?? []) { @@ -389,13 +550,57 @@ function validateClosedSchema(manifest, failures) { `gate ${gate.id}`, failures, ); + requireString(gate.id, `gate ${gate.id}.id`, failures); + requireString(gate.source, `gate ${gate.id}.source`, failures); rejectDuplicateIds(gate.cases, `case in gate ${gate.id}`, failures); if (!Array.isArray(gate.invocation) || gate.invocation.length === 0) { failures.push(`${gate.id}: exact invocation is missing`); + } else { + validateStringArray(gate.invocation, `${gate.id}.invocation`, failures); } + if (gate.discoveryAliases !== undefined) { + validateStringArray(gate.discoveryAliases, `${gate.id}.discoveryAliases`, failures, { + allowEmpty: true, + }); + } + rejectUnknownKeys( + gate.deployment, + new Set(['kind', 'reason', 'source', 'path', 'unavailableOwner', 'observedSha256']), + `${gate.id}.deployment`, + failures, + ); if (!['none', 'file'].includes(gate.deployment?.kind)) { failures.push(`${gate.id}: unsupported deployment kind ${String(gate.deployment?.kind)}`); + } else if (gate.deployment.kind === 'none') { + requireString(gate.deployment.reason, `${gate.id}.deployment.reason`, failures); + } else { + for (const key of ['source', 'path', 'unavailableOwner', 'observedSha256']) { + requireString(gate.deployment[key], `${gate.id}.deployment.${key}`, failures); + } } + rejectUnknownKeys( + gate.inertMutation, + new Set(['file', 'find', 'replace', 'caseId', 'expected', 'sandboxFiles']), + `${gate.id}.inertMutation`, + failures, + ); + for (const key of ['file', 'find', 'replace']) { + if (typeof gate.inertMutation?.[key] !== 'string') { + failures.push(`${gate.id}.inertMutation.${key}: expected a string`); + } + } + if (gate.inertMutation?.caseId !== undefined) { + requireString(gate.inertMutation.caseId, `${gate.id}.inertMutation.caseId`, failures); + } + if (gate.inertMutation?.sandboxFiles !== undefined) { + validateStringArray( + gate.inertMutation.sandboxFiles, + `${gate.id}.inertMutation.sandboxFiles`, + failures, + { allowEmpty: true }, + ); + } + validateOutcome(gate.inertMutation?.expected, `${gate.id}.inertMutation.expected`, failures); for (const gateCase of gate.cases ?? []) { rejectUnknownKeys( gateCase, @@ -414,25 +619,40 @@ function validateClosedSchema(manifest, failures) { `${gate.id}/${gateCase.id}`, failures, ); - if ( - typeof gateCase.required?.exitCode !== 'number' || - typeof gateCase.actual?.exitCode !== 'number' - ) { - failures.push( - `${gate.id}/${gateCase.id}: required and actual exact exit codes are mandatory`, - ); + requireString(gateCase.id, `${gate.id}/${gateCase.id}.id`, failures); + validateStringArray( + gateCase.criterionIds, + `${gate.id}/${gateCase.id}.criterionIds`, + failures, + ); + if (typeof gateCase.mustFail !== 'boolean') { + failures.push(`${gate.id}/${gateCase.id}.mustFail: expected a boolean`); } - if ( - gateCase.invocation && - (!Array.isArray(gateCase.invocation) || gateCase.invocation.length === 0) - ) { - failures.push(`${gate.id}/${gateCase.id}: case invocation must be non-empty`); + validateOutcome(gateCase.required, `${gate.id}/${gateCase.id}.required`, failures); + validateOutcome(gateCase.actual, `${gate.id}/${gateCase.id}.actual`, failures); + if (gateCase.invocation !== undefined) { + validateStringArray(gateCase.invocation, `${gate.id}/${gateCase.id}.invocation`, failures); + } + if (typeof gateCase.reasonPattern !== 'string') { + failures.push(`${gate.id}/${gateCase.id}.reasonPattern: expected a string`); } if (gateCase.mustFail === true && !gateCase.reasonPattern?.trim()) { failures.push( `${gate.id}/${gateCase.id}: must-fail case requires a non-empty reasonPattern`, ); } + validateEnvironment(gateCase.environment, `${gate.id}/${gateCase.id}.environment`, failures); + validateFixture(gateCase.fixture, `${gate.id}/${gateCase.id}.fixture`, failures); + if (gateCase.defect !== undefined) { + rejectUnknownKeys( + gateCase.defect, + new Set(['owner', 'reason']), + `${gate.id}/${gateCase.id}.defect`, + failures, + ); + requireString(gateCase.defect.owner, `${gate.id}/${gateCase.id}.defect.owner`, failures); + requireString(gateCase.defect.reason, `${gate.id}/${gateCase.id}.defect.reason`, failures); + } } } } diff --git a/scripts/gate-verify.test.mjs b/scripts/gate-verify.test.mjs index 6e1a1126..584be776 100644 --- a/scripts/gate-verify.test.mjs +++ b/scripts/gate-verify.test.mjs @@ -18,7 +18,6 @@ async function fixture(name = 'case') { function baseManifest() { return { schemaVersion: 1, - activationCommit: null, gateRoots: ['gates'], governingClaimFiles: [], coverageBoundary: { included: ['meta fixture'], excluded: [], trackedBy: 'RM-54' }, @@ -157,6 +156,117 @@ test('duplicate stable ids and unsupported schema versions are rejected', async assert.match(output(result), /duplicate criterion id META-CRIT-1/i); }); +test('misspelled nested assertion fields are rejected instead of becoming optional', async () => { + const root = await fixture('nested-schema-typo'); + await writeGate(root); + const manifest = baseManifest(); + manifest.gates[0].cases[0].required.outputPatern = 'META_REJECT'; + manifest.gates[0].cases[0].actual.outputPatern = 'META_REJECT'; + await writeManifest(root, manifest); + + const result = verify(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /required.*unknown field outputPatern/i); + assert.match(output(result), /actual.*unknown field outputPatern/i); +}); + +test('present outcome patterns cannot be empty assertion bypasses', async () => { + const root = await fixture('nested-schema-empty-patterns'); + await writeGate(root); + const manifest = baseManifest(); + manifest.gates[0].cases[0].required.outputPattern = ''; + manifest.gates[0].cases[0].actual.notOutputPattern = ' '; + await writeManifest(root, manifest); + + const result = verify(root, ['--structure-only']); + assert.notEqual(result.status, 0); + assert.match(output(result), /required.outputPattern: expected a non-empty pattern/i); + assert.match(output(result), /actual.notOutputPattern: expected a non-empty pattern/i); +}); + +test('nested discriminator and comparison fields reject wrong types', async () => { + const root = await fixture('nested-schema-types'); + await writeGate(root); + const manifest = baseManifest(); + manifest.gates[0].cases[0].mustFail = 'true'; + manifest.gates[0].cases[0].required.exitCode = '7'; + manifest.gates[0].cases[0].actual.outputPattern = 7; + await writeManifest(root, manifest); + + const result = verify(root, ['--structure-only']); + assert.notEqual(result.status, 0); + assert.match(output(result), /mustFail: expected a boolean/i); + assert.match(output(result), /required.exitCode: expected an integer/i); + assert.match(output(result), /actual.outputPattern: expected a string/i); +}); + +test('recursive closed-schema guards reject unknown fields in every nested assertion object', async () => { + const source = JSON.parse( + await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'), + ); + const checkout = source.gates.find((gate) => gate.id === 'checkout-preflight'); + const stale = checkout.cases.find((gateCase) => gateCase.id === 'stale-build-lock'); + const queue = source.gates.find((gate) => gate.id === 'ci-queue-wait'); + const queueCase = queue.cases.find((gateCase) => gateCase.id === 'terminal-success'); + const targets = [ + ['coverageBoundary', (manifest) => manifest.coverageBoundary], + ['mergeAssertions', (manifest) => manifest.mergeAssertions], + [ + 'meaningChanges', + (manifest) => + manifest.criteria.find((criterion) => criterion.meaningChanges.length).meaningChanges[0], + ], + ['proseClaims', (manifest) => manifest.proseClaims[0]], + ['compatibility expected', (manifest) => manifest.compatibilityScenarios[0].expected], + [ + 'deployment', + (manifest) => manifest.gates.find((gate) => gate.id === 'ci-queue-wait').deployment, + ], + ['inertMutation', (manifest) => manifest.gates[0].inertMutation], + ['inert expected', (manifest) => manifest.gates[0].inertMutation.expected], + ['required', (manifest) => manifest.gates[0].cases[0].required], + ['actual', (manifest) => manifest.gates[0].cases[0].actual], + [ + 'fixture', + (manifest) => + manifest.gates + .find((gate) => gate.id === 'checkout-preflight') + .cases.find((gateCase) => gateCase.id === 'stale-build-lock').fixture, + ], + [ + 'write entry', + (manifest) => + manifest.gates + .find((gate) => gate.id === 'checkout-preflight') + .cases.find((gateCase) => gateCase.id === 'stale-build-lock').fixture.writeFiles[0], + ], + [ + 'replace entry', + (manifest) => + manifest.gates + .find((gate) => gate.id === 'checkout-preflight') + .cases.find((gateCase) => gateCase.id === 'criterion-misbinding').fixture.replaceFiles[0], + ], + [ + 'defect', + (manifest) => + manifest.gates + .find((gate) => gate.id === 'ci-queue-wait') + .cases.find((gateCase) => gateCase.id === 'terminal-success').defect, + ], + ]; + assert.ok(stale.fixture && queueCase.defect); + for (const [name, select] of targets) { + const root = await fixture(`recursive-${name.replaceAll(' ', '-')}`); + const manifest = structuredClone(source); + select(manifest).unexpectedNestedField = true; + await writeManifest(root, manifest); + const result = verify(root, ['--structure-only']); + assert.notEqual(result.status, 0, `${name}: ${output(result)}`); + assert.match(output(result), /unknown field unexpectedNestedField/i, name); + } +}); + test('manifest-controlled fixture paths cannot escape the sandbox', async () => { const root = await fixture('path-traversal'); await writeGate(root); -- 2.54.0 From fbb61912981abb250d289ec7aafd4316db6fdb11 Mon Sep 17 00:00:00 2001 From: f10-coder Date: Sat, 1 Aug 2026 12:39:49 -0500 Subject: [PATCH 10/13] fix(quality): anchor quantified registry populations --- docs/ADMIN-GUIDE/quality-gate-registry.md | 4 +- docs/DEVELOPER-GUIDE/quality-gate-registry.md | 8 +- docs/PRD.md | 10 +- docs/scratchpads/1029-rm-02-gate-registry.md | 4 +- gates/gates.manifest.json | 152 +++++++++++++++--- scripts/gate-delayed-introduction-control.mjs | 56 +++++++ scripts/gate-empty-population-control.mjs | 45 ++++++ scripts/gate-history-boundary-control.mjs | 2 +- scripts/gate-history.mjs | 30 +++- scripts/gate-history.test.mjs | 46 ++++++ scripts/gate-population-control.mjs | 72 +++++++++ scripts/gate-verify.mjs | 69 +++++++- scripts/gate-verify.test.mjs | 105 ++++++++++++ .../gate-verify-fixture-runner.mjs | 28 ++++ 14 files changed, 594 insertions(+), 37 deletions(-) create mode 100644 scripts/gate-delayed-introduction-control.mjs create mode 100644 scripts/gate-empty-population-control.mjs create mode 100644 scripts/gate-population-control.mjs create mode 100644 scripts/test-support/gate-verify-fixture-runner.mjs diff --git a/docs/ADMIN-GUIDE/quality-gate-registry.md b/docs/ADMIN-GUIDE/quality-gate-registry.md index 8f534e7f..e1d7dc56 100644 --- a/docs/ADMIN-GUIDE/quality-gate-registry.md +++ b/docs/ADMIN-GUIDE/quality-gate-registry.md @@ -30,7 +30,9 @@ Woodpecker runs `gate-verify` on every pull request and protected-main push with Provider evidence input is an optional JSON array of normalized pipeline records containing `commit`, globally unique integer pipeline `number`, pipeline `status`, and a `gate-verify` step status. Collection-wide identity/type validation occurs before commit filtering; a duplicate number across two commits fails subject binding. The highest numbered valid rerun for one commit is authoritative. Its retention window is provider-controlled and is not overstated by this repository. -Do not add or update an activation SHA in the manifest. The verifier derives the immutable history boundary from Git as the parent of the first first-parent registry-introduction commit. HEAD, HEAD's parent, and the introduction commit are registered rejected seam candidates. +Do not add or update an activation SHA in the manifest. The verifier derives the audited feature range from Git's merge-base with provider target `refs/remotes/origin/main`, so commits before a delayed registry introduction remain covered. HEAD, HEAD's parent, the introduction commit, and delayed introduction after a gate change are registered rejected constructions. + +**DOES:** The merge-base is outside branch-author control when main cannot be rewritten. **DOES NOT:** This bootstrap cannot protect a compromised or rewritten main; Builds 1-2 own that residual. Production verification also requires non-empty criteria/gates/prose/scenario populations and anchors the seven required gate IDs to canonical sources before any “all registered” claim. PR CI executes current-tree verification only, unprivileged and fail-closed. It does not execute isolated own-tree replay: RM-60 must provide a protected launcher or runner-level rootless sandbox before any PR-controlled executable/configuration is evaluated. Repo-only code cannot safely grant itself the capability intended to contain itself. diff --git a/docs/DEVELOPER-GUIDE/quality-gate-registry.md b/docs/DEVELOPER-GUIDE/quality-gate-registry.md index a2ef7e07..02fd1b0c 100644 --- a/docs/DEVELOPER-GUIDE/quality-gate-registry.md +++ b/docs/DEVELOPER-GUIDE/quality-gate-registry.md @@ -10,7 +10,7 @@ pnpm gate:verify The registry covers root typecheck, lint, and format checks; RM-01 checkout preflight; the Mosaic CI queue guard; and root Husky pre-commit/pre-push hooks. It does not imply repository-wide coverage. Framework scripts, package-local build/test scripts, templates, and deployment/release scripts remain assigned to RM-54. -Every gate declares exact invocations, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. Every nested manifest object used by outcomes, mutations, fixtures, deployments, defects, compatibility, provenance, coverage, and merge assertions has closed keys and strict field types; a misspelling cannot silently turn a required comparison into an absent optional field. Independent validation phases collect labeled failures instead of letting one thrown fixture, claim, discovery, deployment, mutation, or compatibility error mask already-known stable-ID diagnostics. This proves detection of the **declared** inerting mutation, not every possible semantic weakening. +Every gate declares exact invocations, an evidence subject equal to its stable gate ID, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. Every nested manifest object used by outcomes, mutations, fixtures, deployments, defects, compatibility, provenance, coverage, and merge assertions has closed keys and strict field types; a misspelling cannot silently turn a required comparison into an absent optional field. Independent validation phases collect labeled failures instead of letting one thrown fixture, claim, discovery, deployment, mutation, or compatibility error mask already-known stable-ID diagnostics. This proves detection of the **declared** inerting mutation, not every possible semantic weakening. ## Required versus actual @@ -42,6 +42,10 @@ The replay implementation and abuse-case tests remain fail-closed: when invoked Retained provider evidence can assert terminal-success **current-tree** records for prior commits when supplied through `GATE_PROVIDER_EVIDENCE_FILE`. Each normalized record contains `commit`, globally unique integer pipeline `number`, pipeline `status`, and exactly one `gate-verify` step; the highest-numbered rerun for a commit is authoritative. The full collection is validated before commit filtering, so one pipeline identity cannot certify two commit subjects. Ambiguous duplicates fail. Absent, expired, or currently-running evidence is reported explicitly and never inferred as success. -The history seam is not a manifest field. `gate-history.mjs` derives it from Git as the parent of the first first-parent commit adding `gates/gates.manifest.json`, which necessarily includes the introduction commit and all later first-parent registry commits. Registered controls reject HEAD, HEAD's parent, and the introduction commit as candidate seams. +The history seam is not a manifest field and is not derived from an author-positioned registry path. `gate-history.mjs` derives the feature range from Git's merge-base with the provider target `refs/remotes/origin/main`; a gate change committed before delayed registry introduction therefore remains in range and fails because its own tree has no registry. Registered controls reject HEAD, HEAD's parent, the introduction commit, and delayed introduction after a gate change. + +**DOES:** This bootstrap is sound against a branch author who cannot rewrite main: reordering or splitting commits cannot move the target merge-base. **DOES NOT:** It does not establish integrity if main itself is compromised or rewritten. Builds 1-2 own that residual main-integrity dependency. + +Universal checks first prove populations non-empty. The production profile anchors the required seven gate IDs to their canonical sources, while `gateRefs` on the D-38/D-40 criteria must exactly span every registered gate. Population controls mutate evidence-subject and type-strict comparison inputs one gate at a time and require rejection across the complete inventory. Once RM-60 supplies the external pre-execution anchor, protected post-merge/main replay is detection, not pre-merge prevention. A failed replay requires quarantine of the affected result and revert of the offending merge. It must never be represented as proof that CI blocked that merge. RM-25 tracks provider enforcement. diff --git a/docs/PRD.md b/docs/PRD.md index 56980783..7b078228 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -38,9 +38,10 @@ Existing deterministic gates can return success without enforcing their stated p 8. `RM02-REQ-08`: Every gate with a deployed counterpart SHALL register source/deployed byte identity and a must-fail drift control. Gates without a deployed counterpart SHALL say so explicitly. 9. `RM02-REQ-09`: CI SHALL run `pnpm gate:verify` on every pull request without path filtering and on protected-main pushes. 10. `RM02-REQ-10` (restated): PR CI SHALL perform unprivileged, fail-closed current-tree verification only. Isolated per-commit replay SHALL remain deferred to RM-60's protected post-merge/main authority, cross-referenced with RM-59. That future replay is detection with a quarantine/revert response, not pre-merge prevention; inability to establish its sandbox is terminal nonzero, never skip/pass. Retained provider evidence SHALL remain distinct and SHALL never be inferred when absent. -11. `RM02-REQ-11`: The history activation boundary SHALL be derived, not manifest-authored, as the parent of the first first-parent commit introducing `gates/gates.manifest.json`. The introduction commit SHALL be included. Author-controlled candidates equal to HEAD, HEAD's parent, or the introduction commit SHALL be rejected by registered must-fail controls. -12. `RM02-REQ-12` (`D-38`): Evidence SHALL be bound to exactly one subject. Provider pipeline identity uniqueness SHALL be validated over the full evidence collection before commit filtering; a duplicate pipeline number across different commits SHALL fail. -13. `RM02-REQ-13` (`D-40`): Every registry object used for discrimination, comparison, mutation, fixture construction, provenance, deployment, or outcome assessment SHALL have a recursively closed, type-strict schema. Unknown or misspelled nested fields SHALL fail rather than becoming absent optional assertions. +11. `RM02-REQ-11`: The audited branch range SHALL begin at the Git merge-base with the provider target `origin/main`, not at a manifest-authored value or author-selected introduction path. A gate-affecting commit before delayed registry introduction SHALL remain in range and fail on its missing own-tree registry. This bootstrap is sound against a branch author who cannot rewrite main; it is not sound against compromise or rewrite of main, whose integrity is the Builds 1-2 dependency. +12. `RM02-REQ-12` (`D-38`): For every gate in the mechanically anchored required inventory, evidence SHALL be bound to exactly that gate subject under review. Provider pipeline shape, gate-step cardinality, and identity uniqueness SHALL be validated over the full evidence collection before commit filtering; a duplicate pipeline number across different commits SHALL fail. +13. `RM02-REQ-13` (`D-40`): For every gate in the mechanically anchored required inventory, each discriminator and comparison input SHALL have a recursively closed, type-strict schema. Unknown, misspelled, wrong-type, or present-but-empty nested assertion fields SHALL fail rather than disabling an assertion. +14. `RM02-REQ-14` (`D-46`): No universally quantified registry check SHALL run until its population is proven non-empty and anchored. The required seven-gate inventory, criteria, prose claims, and compatibility scenarios SHALL reject empty populations before reporting that all registered cases ran. #### RM02-REQ-10 meaning-change provenance @@ -57,7 +58,8 @@ Existing deterministic gates can return success without enforcing their stated p 5. `RM02-AC-05`: Unbound or semantically misbound criteria, prose claims bound to unrelated cases, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. A simultaneous stale fixture or independent phase error SHALL NOT mask responsible stable-ID diagnostics. Registered meta-negative controls move a criterion binding, remove meaning provenance, and redirect a prose claim to an unrelated case; each is observed red for its stated reason. 6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request. 7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: current-tree gates and inerting mutations execute unprivileged and fail-closed; isolated own-tree replay does not execute in repository-controlled PR CI. RM-60/RM-59 are named, retained provider evidence is never inferred, and future protected post-merge detection specifies quarantine/revert rather than claiming pre-merge prevention. -8. `RM02-AC-08`: Registered must-fail controls reject history seam candidates at HEAD, HEAD's parent, and the registry introduction; a cross-commit duplicate provider pipeline identity; a misspelled nested outcome field; a wrong outcome field type; and a present-but-empty outcome pattern. The `RM02-EVIDENCE-SUBJECT-BINDING` and `RM02-TYPE-STRICT-SCHEMA` criteria are bidirectionally bound to their controls. +8. `RM02-AC-08`: Registered must-fail controls reject history seam candidates at HEAD, HEAD's parent, and the registry introduction; delayed registry introduction after an earlier gate change; an emptied registry; a cross-commit duplicate provider pipeline identity; a misspelled nested outcome field; a wrong outcome field type; and a present-but-empty outcome pattern. +9. `RM02-AC-09`: Population controls iterate every gate in the anchored inventory and prove evidence-subject mismatch and wrong-type comparison input are rejected for each gate. `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-BOUNDARY`, and `RM02-NONEMPTY-ANCHORED-QUANTIFICATION` are bidirectionally bound to their must-fail controls. ### Risks, dependencies, and verification boundary diff --git a/docs/scratchpads/1029-rm-02-gate-registry.md b/docs/scratchpads/1029-rm-02-gate-registry.md index be0f50f3..a42ff695 100644 --- a/docs/scratchpads/1029-rm-02-gate-registry.md +++ b/docs/scratchpads/1029-rm-02-gate-registry.md @@ -39,7 +39,7 @@ Deliver the seven-gate registry and RED-first anti-inert verifier on `feat/rm-02 - CI wiring RED: package script and unconditional Woodpecker step tests both failed before wiring. - History RED: history test failed with missing module before own-tree manifest selection/provider classification was implemented. - `pnpm gate:verify`: exit 0; seven gates each reported `META-NEGATIVE-CONTROL ... observed red`; queue source/deployed drift control observed red; six queue behavior deltas printed as `DEFECT (owner: RM-03)`. -- Focused Node tests: 48/48 pass after review hardening (31 verifier/wiring plus 17 history/provider tests). +- Focused Node tests: 54/54 pass after third-round hardening (36 verifier/wiring plus 18 history/provider tests). - `pnpm typecheck`: pass (45/45 Turbo tasks). - `pnpm lint`: pass (25/25 Turbo tasks). - `pnpm format:check`: pass. @@ -68,6 +68,8 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro - Exact-head review at `83d2ecb2` found four silent-defeat paths. Genuine RED-first tests on the pre-fix code proved: author-controlled HEAD/parent/introduction seams produced no boundary failure; cross-commit duplicate pipeline number 7 returned terminal-success; and misspelling `outputPattern` as `outputPatern` in required/actual left canonical verification green. Fixes derive the seam from Git, validate provider identity globally before subject filtering, and recursively close/type-check nested schemas. New registry criteria `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, and `RM02-HISTORY-BOUNDARY` are bidirectionally bound to eight registered must-fail controls. The broad nested-object typo table and exact derived-boundary test are regression guards added after implementation, not claimed as RED-first. Pre-commit Codex review then found that global evidence failure was only observed—not failed—when HEAD was the sole prospective commit, and that non-object collection entries threw before normalization. Both were reproduced RED-first, then fixed by one collection validator used before iteration and by per-commit assessment. Follow-up review found collection validation still omitted exactly-one-gate-step cardinality for unrelated subjects; a focused test reproduced terminal-success RED-first, and collection validation now rejects that ambiguity globally. Security review then found present-but-empty outcome patterns were truthy-optional assertion bypasses; a reason-specific test reproduced that they lacked the required schema diagnostic, and present pattern fields now require non-whitespace content. +- Exact-head review at `32b490a7` found three population-level blockers. Genuine RED-first tests proved a gate change before author-delayed registry introduction fell outside the range, and empty criteria/gates/prose/scenario populations returned zero. History now anchors at the provider target merge-base; delayed introduction is a registered must-fail control. Production verification requires non-empty populations and a hardcoded seven-gate ID/source inventory before quantified checks. D-38/D-40 criteria now quantify over `gateRefs` exactly spanning every registered gate; each gate declares its evidence subject, and population controls mutate evidence subject and comparison type for every gate. The history record states both bootstrap directions: sound against a branch author unable to rewrite main, not sound against compromised/rewritten main, with residual owned by Builds 1-2. Pre-commit review rejected an initial production CLI `--fixture-profile` test relaxation as a vacuity bypass. That flag was removed; synthetic fixtures now use a non-executable test-support runner, while regression tests prove the shipped CLI rejects the flag and production population checks remain mandatory. Follow-up review then proved deleting `gateRefs` skipped population validation; a RED-first loop reproduced all three deletions, and the three general criterion IDs now require the field before exact-span validation. + ## Documentation checklist - PRD, developer guide, admin guide, governing claim index, sitemap, plan, and scratchpad updated. diff --git a/gates/gates.manifest.json b/gates/gates.manifest.json index e51ca91c..8373caaa 100644 --- a/gates/gates.manifest.json +++ b/gates/gates.manifest.json @@ -40,6 +40,10 @@ "checkout-preflight/misspelled-outcome-field", "checkout-preflight/wrong-outcome-field-type", "checkout-preflight/empty-outcome-pattern", + "checkout-preflight/delayed-registry-introduction", + "checkout-preflight/empty-registry-populations", + "checkout-preflight/all-gates-evidence-subject-bound", + "checkout-preflight/all-gates-type-strict", "ci-queue-wait/no-status-required", "ci-queue-wait/unknown-option", "hook-pre-commit/lint-staged-failure", @@ -234,37 +238,78 @@ }, { "id": "RM02-HISTORY-BOUNDARY", - "originalText": "The history activation boundary is derived as the parent of the first first-parent commit that introduces the registry.", - "currentText": "The history activation boundary is derived as the parent of the first first-parent commit that introduces the registry.", + "originalText": "The audited branch range begins at the provider target merge-base, sound against a branch author who cannot rewrite main but not against main compromise; Builds 1-2 own the residual.", + "currentText": "The audited branch range begins at the provider target merge-base, sound against a branch author who cannot rewrite main but not against main compromise; Builds 1-2 own the residual.", "claimType": "integrity", "source": "docs/remediation/TASKS.md#d-17", "meaningChanges": [], "caseRefs": [ "checkout-preflight/history-seam-head", "checkout-preflight/history-seam-parent", - "checkout-preflight/history-seam-introduction" + "checkout-preflight/history-seam-introduction", + "checkout-preflight/delayed-registry-introduction" ] }, { "id": "RM02-EVIDENCE-SUBJECT-BINDING", - "originalText": "A provider evidence identity cannot establish success for more than one commit subject.", - "currentText": "A provider evidence identity cannot establish success for more than one commit subject.", + "originalText": "For every registered gate, evidence is bound to that gate subject under review; no evidence identity can certify a different or second subject.", + "currentText": "For every registered gate, evidence is bound to that gate subject under review; no evidence identity can certify a different or second subject.", "claimType": "integrity", "source": "docs/remediation/TASKS.md#d-38", "meaningChanges": [], - "caseRefs": ["checkout-preflight/provider-cross-commit-duplicate"] + "caseRefs": [ + "checkout-preflight/provider-cross-commit-duplicate", + "checkout-preflight/all-gates-evidence-subject-bound" + ], + "gateRefs": [ + "quality-typecheck", + "quality-lint", + "quality-format", + "checkout-preflight", + "ci-queue-wait", + "hook-pre-commit", + "hook-pre-push" + ] }, { "id": "RM02-TYPE-STRICT-SCHEMA", - "originalText": "Every registry discriminator and comparison field is recursively closed and type-strict so a misspelling cannot disable an assertion.", - "currentText": "Every registry discriminator and comparison field is recursively closed and type-strict so a misspelling cannot disable an assertion.", + "originalText": "For every registered gate, each discriminator and comparison input is recursively closed and type-strict so malformed values cannot disable its assertion.", + "currentText": "For every registered gate, each discriminator and comparison input is recursively closed and type-strict so malformed values cannot disable its assertion.", "claimType": "integrity", "source": "docs/remediation/TASKS.md#d-40", "meaningChanges": [], "caseRefs": [ "checkout-preflight/misspelled-outcome-field", "checkout-preflight/wrong-outcome-field-type", - "checkout-preflight/empty-outcome-pattern" + "checkout-preflight/empty-outcome-pattern", + "checkout-preflight/all-gates-type-strict" + ], + "gateRefs": [ + "quality-typecheck", + "quality-lint", + "quality-format", + "checkout-preflight", + "ci-queue-wait", + "hook-pre-commit", + "hook-pre-push" + ] + }, + { + "id": "RM02-NONEMPTY-ANCHORED-QUANTIFICATION", + "originalText": "No universally quantified registry check runs until its population is proven non-empty and anchored.", + "currentText": "No universally quantified registry check runs until its population is proven non-empty and anchored.", + "claimType": "integrity", + "source": "docs/remediation/TASKS.md#d-46", + "meaningChanges": [], + "caseRefs": ["checkout-preflight/empty-registry-populations"], + "gateRefs": [ + "quality-typecheck", + "quality-lint", + "quality-format", + "checkout-preflight", + "ci-queue-wait", + "hook-pre-commit", + "hook-pre-push" ] } ], @@ -348,7 +393,7 @@ "mergeAssertions": { "mode": "unprivileged-current-tree-pr-verification", "deferredReplayOwner": "RM-60", - "trustDependencies": ["RM-25", "RM-59", "RM-60"], + "trustDependencies": ["RM-25", "RM-59", "RM-60", "Builds 1-2 main-integrity bootstrap"], "providerEvidence": "assert retained current-tree terminal-success records for prior commits; report absent, expired, or current-running evidence without inference", "postMergeResponse": "protected isolated replay is detection, not prevention; quarantine and revert on failure" }, @@ -409,7 +454,8 @@ ] } } - ] + ], + "evidenceSubject": "quality-typecheck" }, { "id": "quality-lint", @@ -467,7 +513,8 @@ ] } } - ] + ], + "evidenceSubject": "quality-lint" }, { "id": "quality-format", @@ -522,7 +569,8 @@ ] } } - ] + ], + "evidenceSubject": "quality-format" }, { "id": "checkout-preflight", @@ -733,7 +781,7 @@ "exitCode": 1, "outputPattern": "history boundary candidate head rejected" }, - "reasonPattern": "derived activation is parent of registry introduction" + "reasonPattern": "derived activation is provider target merge-base" }, { "id": "history-seam-parent", @@ -748,7 +796,7 @@ "exitCode": 1, "outputPattern": "history boundary candidate parent rejected" }, - "reasonPattern": "derived activation is parent of registry introduction" + "reasonPattern": "derived activation is provider target merge-base" }, { "id": "history-seam-introduction", @@ -763,7 +811,7 @@ "exitCode": 1, "outputPattern": "history boundary candidate introduction rejected" }, - "reasonPattern": "derived activation is parent of registry introduction" + "reasonPattern": "derived activation is provider target merge-base" }, { "id": "provider-cross-commit-duplicate", @@ -890,8 +938,69 @@ } ] } + }, + { + "id": "delayed-registry-introduction", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-BOUNDARY"], + "mustFail": true, + "invocation": ["node", "scripts/gate-delayed-introduction-control.mjs"], + "required": { + "exitCode": 1, + "outputPattern": "delayed registry introduction rejected" + }, + "actual": { + "exitCode": 1, + "outputPattern": "delayed registry introduction rejected" + }, + "reasonPattern": "own-tree registry cannot be read" + }, + { + "id": "empty-registry-populations", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-NONEMPTY-ANCHORED-QUANTIFICATION"], + "mustFail": true, + "invocation": ["node", "scripts/gate-empty-population-control.mjs"], + "required": { + "exitCode": 1, + "outputPattern": "empty universally quantified registry populations rejected" + }, + "actual": { + "exitCode": 1, + "outputPattern": "empty universally quantified registry populations rejected" + }, + "reasonPattern": "empty universally quantified registry populations rejected" + }, + { + "id": "all-gates-evidence-subject-bound", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-EVIDENCE-SUBJECT-BINDING"], + "mustFail": true, + "invocation": ["node", "scripts/gate-population-control.mjs", "evidence-subject"], + "required": { + "exitCode": 1, + "outputPattern": "evidence-subject population control rejected every registered gate" + }, + "actual": { + "exitCode": 1, + "outputPattern": "evidence-subject population control rejected every registered gate" + }, + "reasonPattern": "evidence-subject population control rejected every registered gate" + }, + { + "id": "all-gates-type-strict", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-TYPE-STRICT-SCHEMA"], + "mustFail": true, + "invocation": ["node", "scripts/gate-population-control.mjs", "type-strict"], + "required": { + "exitCode": 1, + "outputPattern": "type-strict population control rejected every registered gate" + }, + "actual": { + "exitCode": 1, + "outputPattern": "type-strict population control rejected every registered gate" + }, + "reasonPattern": "type-strict population control rejected every registered gate" } - ] + ], + "evidenceSubject": "checkout-preflight" }, { "id": "ci-queue-wait", @@ -1267,7 +1376,8 @@ "writeFiles": [] } } - ] + ], + "evidenceSubject": "ci-queue-wait" }, { "id": "hook-pre-commit", @@ -1343,7 +1453,8 @@ ] } } - ] + ], + "evidenceSubject": "hook-pre-commit" }, { "id": "hook-pre-push", @@ -1419,7 +1530,8 @@ ] } } - ] + ], + "evidenceSubject": "hook-pre-push" } ] } diff --git a/scripts/gate-delayed-introduction-control.mjs b/scripts/gate-delayed-introduction-control.mjs new file mode 100644 index 00000000..ae864e99 --- /dev/null +++ b/scripts/gate-delayed-introduction-control.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { verifyHistory } from './gate-history.mjs'; + +const root = await mkdtemp(path.join(os.tmpdir(), 'gate-delayed-introduction-')); +function git(...args) { + const result = spawnSync( + 'git', + ['-c', 'user.name=gate-control', '-c', 'user.email=gate-control@example.invalid', ...args], + { cwd: root, encoding: 'utf8' }, + ); + if (result.status !== 0) throw new Error(result.stderr || result.stdout); + return result.stdout.trim(); +} + +try { + git('init', '-q'); + await writeFile(path.join(root, 'baseline.txt'), 'baseline\n'); + git('add', '.'); + git('commit', '-m', 'provider target baseline'); + const baseline = git('rev-parse', 'HEAD'); + git('update-ref', 'refs/remotes/origin/main', baseline); + await mkdir(path.join(root, 'scripts'), { recursive: true }); + await writeFile(path.join(root, 'scripts', 'preflight.mjs'), 'process.exit(0);\n'); + git('add', '.'); + git('commit', '-m', 'gate change before registry'); + const unregisteredCommit = git('rev-parse', 'HEAD'); + await mkdir(path.join(root, 'gates'), { recursive: true }); + await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); + git('add', '.'); + git('commit', '-m', 'delayed registry introduction'); + + const previousBranch = process.env.CI_COMMIT_BRANCH; + process.env.CI_COMMIT_BRANCH = 'feature/delayed-introduction-control'; + let result; + try { + result = await verifyHistory({ root, manifest: { schemaVersion: 1 } }); + } finally { + if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH; + else process.env.CI_COMMIT_BRANCH = previousBranch; + } + const detail = result.failures.join('\n'); + if (detail.includes(unregisteredCommit) && /own-tree registry cannot be read/i.test(detail)) { + process.stderr.write(`delayed registry introduction rejected: ${detail}\n`); + process.exitCode = 1; + } else { + process.stdout.write('delayed registry introduction was not rejected\n'); + } +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/scripts/gate-empty-population-control.mjs b/scripts/gate-empty-population-control.mjs new file mode 100644 index 00000000..27a5dc82 --- /dev/null +++ b/scripts/gate-empty-population-control.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { verifyRegistry } from './gate-verify.mjs'; + +const root = process.cwd(); +const manifest = JSON.parse(await readFile(path.join(root, 'gates/gates.manifest.json'), 'utf8')); +manifest.gateRoots = ['.mosaic-empty-gate-root']; +manifest.criteria = []; +manifest.gates = []; +manifest.proseClaims = []; +manifest.compatibilityScenarios = []; +const directory = await mkdtemp(path.join(os.tmpdir(), 'gate-empty-population-')); +try { + const manifestPath = path.join(directory, 'manifest.json'); + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + const result = await verifyRegistry({ + root, + manifest: manifestPath, + skipHistory: true, + structureOnly: true, + fixtureProfile: false, + }); + const required = ['criteria', 'gates', 'proseClaims', 'compatibilityScenarios']; + const missing = required.filter( + (population) => + !result.failures.some((failure) => + failure.includes(`${population} population must be non-empty and anchored`), + ), + ); + if (missing.length > 0) { + process.stdout.write(`empty populations were not rejected: ${missing.join(', ')}\n`); + process.exitCode = 0; + } else { + process.stderr.write( + 'empty universally quantified registry populations rejected before evaluation\n', + ); + process.exitCode = 1; + } +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/scripts/gate-history-boundary-control.mjs b/scripts/gate-history-boundary-control.mjs index b21b60e3..687bd0e5 100644 --- a/scripts/gate-history-boundary-control.mjs +++ b/scripts/gate-history-boundary-control.mjs @@ -35,6 +35,6 @@ if (candidate === boundary.activationCommit) { process.exit(0); } process.stderr.write( - `history boundary candidate ${candidateKind} rejected: derived activation is parent of registry introduction\n`, + `history boundary candidate ${candidateKind} rejected: derived activation is provider target merge-base\n`, ); process.exit(1); diff --git a/scripts/gate-history.mjs b/scripts/gate-history.mjs index 92064698..ee770c4e 100644 --- a/scripts/gate-history.mjs +++ b/scripts/gate-history.mjs @@ -23,6 +23,11 @@ export async function listProspectiveCommits(root, activationCommit, head = 'HEA } export function deriveHistoryBoundary(root, head = 'HEAD') { + const targetRef = 'refs/remotes/origin/main'; + const target = git(root, ['rev-parse', '--verify', targetRef], { allowFailure: true }); + if (target.status !== 0 || !target.stdout.trim()) { + throw new Error(`history boundary cannot be derived: provider target ${targetRef} is absent`); + } const introductions = git(root, [ 'log', '--first-parent', @@ -40,13 +45,23 @@ export function deriveHistoryBoundary(root, head = 'HEAD') { throw new Error('history boundary cannot be derived: registry introduction is absent'); } const introductionCommit = introductions[0]; - const parent = git(root, ['rev-parse', `${introductionCommit}^`], { allowFailure: true }); - if (parent.status !== 0 || !parent.stdout.trim()) { + const headOnTarget = git(root, ['merge-base', '--is-ancestor', head, targetRef], { + allowFailure: true, + }); + const activation = + headOnTarget.status === 0 + ? git(root, ['rev-parse', `${head}^`], { allowFailure: true }) + : git(root, ['merge-base', head, targetRef], { allowFailure: true }); + if (activation.status !== 0 || !activation.stdout.trim()) { throw new Error( - `history boundary cannot be derived: registry introduction ${introductionCommit} has no parent`, + `history boundary cannot be derived: provider target merge-base for ${head} is unavailable`, ); } - return { activationCommit: parent.stdout.trim(), introductionCommit }; + return { + activationCommit: activation.stdout.trim(), + introductionCommit, + targetRef, + }; } export async function readManifestAtCommit(root, commit) { @@ -342,6 +357,13 @@ export async function verifyHistory({ root, manifest }) { return { failures, observations }; } const onMain = isMainCommit(root, head); + // RM-02 history bootstrap boundary (Builds 1-2), kept adjacent in both directions: + // DOES: anchor feature history to the provider target merge-base, sound against an author who + // cannot rewrite main. + // DOES NOT: establish integrity when main itself is compromised; Builds 1-2 own that residual. + observations.push( + `RM-02 HISTORY BOOTSTRAP BOUNDARY ${head}: DOES: anchor the audited range to provider target ${boundary.targetRef} at merge-base ${boundary.activationCommit}, sound against a branch author who cannot rewrite main; DOES NOT: protect against compromise or rewrite of main; residual owner Builds 1-2`, + ); // RM-02 execution boundary (RM-60, cross-reference RM-59), kept adjacent in both directions: // DOES: run every registered current-tree gate and declared inerting mutation on PR CI, // unprivileged and fail-closed. diff --git a/scripts/gate-history.test.mjs b/scripts/gate-history.test.mjs index cdd006c2..746efffc 100644 --- a/scripts/gate-history.test.mjs +++ b/scripts/gate-history.test.mjs @@ -289,6 +289,13 @@ test('PR verification states the RM-60 boundary without executing an intermediat manifest: { schemaVersion: 1 }, }); assert.deepEqual(result.failures, []); + assert.ok( + result.observations.some((observation) => + /HISTORY BOOTSTRAP BOUNDARY.*DOES:.*provider target.*sound.*cannot rewrite main.*DOES NOT:.*compromise.*main.*Builds 1-2/i.test( + observation, + ), + ), + ); assert.ok( result.observations.some((observation) => /DOES:.*current tree.*DOES NOT:.*isolated.*RM-60.*RM-59/i.test(observation), @@ -306,6 +313,42 @@ test('PR verification states the RM-60 boundary without executing an intermediat } }); +test('target merge-base includes gate changes committed before registry introduction', async () => { + const root = `${fixtureRoot}-delayed-introduction`; + await rm(root, { recursive: true, force: true }); + await mkdir(root, { recursive: true }); + git(root, 'init', '-q'); + git(root, 'config', 'user.name', 'gate-test'); + git(root, 'config', 'user.email', 'gate-test@example.invalid'); + await writeFile(path.join(root, 'baseline.txt'), 'baseline\n'); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'target baseline'); + const baseline = git(root, 'rev-parse', 'HEAD'); + git(root, 'update-ref', 'refs/remotes/origin/main', baseline); + await mkdir(path.join(root, 'scripts'), { recursive: true }); + await writeFile(path.join(root, 'scripts', 'preflight.mjs'), 'process.exit(0);\n'); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'gate change before registry'); + const preRegistryGateChange = git(root, 'rev-parse', 'HEAD'); + await mkdir(path.join(root, 'gates'), { recursive: true }); + await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); + git(root, 'add', '.'); + git(root, 'commit', '-m', 'delayed registry introduction'); + + const previousBranch = process.env.CI_COMMIT_BRANCH; + process.env.CI_COMMIT_BRANCH = 'feature/delayed-introduction'; + try { + const result = await verifyHistory({ root, manifest: { schemaVersion: 1 } }); + assert.match( + result.failures.join('\n'), + new RegExp(`${preRegistryGateChange}.*own-tree registry cannot be read`, 'i'), + ); + } finally { + if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH; + else process.env.CI_COMMIT_BRANCH = previousBranch; + } +}); + test('derived history boundary includes the registry-introduction commit', async () => { const root = `${fixtureRoot}-derived-boundary`; await rm(root, { recursive: true, force: true }); @@ -317,6 +360,7 @@ test('derived history boundary includes the registry-introduction commit', async git(root, 'add', '.'); git(root, 'commit', '-m', 'baseline'); const baseline = git(root, 'rev-parse', 'HEAD'); + git(root, 'update-ref', 'refs/remotes/origin/main', baseline); await mkdir(path.join(root, 'gates'), { recursive: true }); await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); git(root, 'add', '.'); @@ -330,6 +374,7 @@ test('derived history boundary includes the registry-introduction commit', async assert.deepEqual(deriveHistoryBoundary(root, head), { activationCommit: baseline, introductionCommit: introduction, + targetRef: 'refs/remotes/origin/main', }); assert.deepEqual(await listProspectiveCommits(root, baseline, head), [introduction, head]); }); @@ -380,6 +425,7 @@ test('globally invalid provider evidence fails when HEAD is the only prospective await writeFile(path.join(root, 'baseline.txt'), 'baseline\n'); git(root, 'add', '.'); git(root, 'commit', '-m', 'baseline'); + git(root, 'update-ref', 'refs/remotes/origin/main', git(root, 'rev-parse', 'HEAD')); await mkdir(path.join(root, 'gates'), { recursive: true }); await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); git(root, 'add', '.'); diff --git a/scripts/gate-population-control.mjs b/scripts/gate-population-control.mjs new file mode 100644 index 00000000..472a3e6e --- /dev/null +++ b/scripts/gate-population-control.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { verifyRegistry } from './gate-verify.mjs'; + +const mode = process.argv[2]; +const root = process.cwd(); +const source = JSON.parse(await readFile(path.join(root, 'gates/gates.manifest.json'), 'utf8')); +const expectedGateIds = source.gates.map((gate) => gate.id); +if (expectedGateIds.length === 0) { + process.stderr.write('gate population control requires a non-empty anchored inventory\n'); + process.exit(2); +} + +async function rejectedForEveryGate(mutate, diagnostic) { + for (const gateId of expectedGateIds) { + const manifest = structuredClone(source); + const gate = manifest.gates.find((candidate) => candidate.id === gateId); + mutate(gate); + const directory = await mkdtemp(path.join(os.tmpdir(), 'gate-population-control-')); + const manifestPath = path.join(directory, 'manifest.json'); + try { + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + const result = await verifyRegistry({ + root, + manifest: manifestPath, + skipHistory: true, + structureOnly: true, + fixtureProfile: false, + }); + if (!result.failures.some((failure) => diagnostic(failure, gateId))) return false; + } finally { + await rm(directory, { recursive: true, force: true }); + } + } + return true; +} + +let rejected; +if (mode === 'evidence-subject') { + rejected = await rejectedForEveryGate( + (gate) => { + gate.evidenceSubject = 'different-gate-subject'; + }, + (failure, gateId) => + failure.includes(`gate ${gateId}: evidence subject`) && + failure.includes('does not match gate id'), + ); +} else if (mode === 'type-strict') { + rejected = await rejectedForEveryGate( + (gate) => { + gate.cases[0].actual.exitCode = '0'; + }, + (failure, gateId) => + failure.includes( + `${gateId}/${source.gates.find((gate) => gate.id === gateId).cases[0].id}.actual.exitCode`, + ) && failure.includes('expected an integer'), + ); +} else { + process.stderr.write(`unknown gate population control ${String(mode)}\n`); + process.exit(2); +} + +if (!rejected) { + process.stdout.write(`${mode} population control did not reject every registered gate\n`); + process.exit(0); +} +process.stderr.write(`${mode} population control rejected every registered gate\n`); +process.exit(1); diff --git a/scripts/gate-verify.mjs b/scripts/gate-verify.mjs index 5c05b9b3..f57ed524 100644 --- a/scripts/gate-verify.mjs +++ b/scripts/gate-verify.mjs @@ -23,6 +23,20 @@ import { spawnSync } from 'node:child_process'; import { verifyHistory } from './gate-history.mjs'; const COPY_SKIP = new Set(['.git', '.mosaic-test-work', '.next', '.turbo', 'coverage', 'dist']); +const POPULATION_CRITERION_IDS = new Set([ + 'RM02-EVIDENCE-SUBJECT-BINDING', + 'RM02-TYPE-STRICT-SCHEMA', + 'RM02-NONEMPTY-ANCHORED-QUANTIFICATION', +]); +const REQUIRED_GATE_INVENTORY = new Map([ + ['quality-typecheck', 'package.json'], + ['quality-lint', 'package.json'], + ['quality-format', 'package.json'], + ['checkout-preflight', 'scripts/preflight.mjs'], + ['ci-queue-wait', 'packages/mosaic/framework/tools/git/ci-queue-wait.sh'], + ['hook-pre-commit', '.husky/pre-commit'], + ['hook-pre-push', '.husky/pre-push'], +]); function parseArgs(argv) { const options = { @@ -382,7 +396,14 @@ function validateEnvironment(environment, label, failures) { } } -function validateClosedSchema(manifest, failures) { +function validateClosedSchema(manifest, failures, { fixtureProfile = false } = {}) { + if (!fixtureProfile) { + for (const population of ['criteria', 'gates', 'proseClaims', 'compatibilityScenarios']) { + if (!Array.isArray(manifest[population]) || manifest[population].length === 0) { + failures.push(`${population} population must be non-empty and anchored before evaluation`); + } + } + } if (manifest.schemaVersion !== 1) failures.push(`unsupported schemaVersion ${String(manifest.schemaVersion)}`); rejectUnknownKeys( @@ -450,6 +471,7 @@ function validateClosedSchema(manifest, failures) { 'source', 'meaningChanges', 'caseRefs', + 'gateRefs', ]), `criterion ${criterion.id}`, failures, @@ -475,6 +497,12 @@ function validateClosedSchema(manifest, failures) { ); } } + if (POPULATION_CRITERION_IDS.has(criterion.id) && criterion.gateRefs === undefined) { + failures.push(`${criterion.id}: gateRefs population binding is required`); + } + if (criterion.gateRefs !== undefined) { + validateStringArray(criterion.gateRefs, `criterion ${criterion.id}.gateRefs`, failures); + } if (!Array.isArray(criterion.caseRefs) || criterion.caseRefs.length === 0) { failures.push(`${criterion.id}: no declared exercising cases`); } else { @@ -535,6 +563,16 @@ function validateClosedSchema(manifest, failures) { validateFixture(scenario.fixture, `${scenario.id}.fixture`, failures); } rejectDuplicateIds(manifest.gates, 'gate', failures); + if (!fixtureProfile) { + for (const [requiredId, requiredSource] of REQUIRED_GATE_INVENTORY) { + const registered = (manifest.gates ?? []).find((gate) => gate?.id === requiredId); + if (!registered || registered.source !== requiredSource) { + failures.push( + `gates population is not anchored: required ${requiredId} at ${requiredSource}`, + ); + } + } + } for (const gate of manifest.gates ?? []) { rejectUnknownKeys( gate, @@ -546,12 +584,19 @@ function validateClosedSchema(manifest, failures) { 'inertMutation', 'cases', 'discoveryAliases', + 'evidenceSubject', ]), `gate ${gate.id}`, failures, ); requireString(gate.id, `gate ${gate.id}.id`, failures); requireString(gate.source, `gate ${gate.id}.source`, failures); + requireString(gate.evidenceSubject, `gate ${gate.id}.evidenceSubject`, failures); + if (gate.evidenceSubject !== gate.id) { + failures.push( + `gate ${gate.id}: evidence subject ${String(gate.evidenceSubject)} does not match gate id`, + ); + } rejectDuplicateIds(gate.cases, `case in gate ${gate.id}`, failures); if (!Array.isArray(gate.invocation) || gate.invocation.length === 0) { failures.push(`${gate.id}: exact invocation is missing`); @@ -657,8 +702,8 @@ function validateClosedSchema(manifest, failures) { } } -function validateStructure(manifest, failures) { - validateClosedSchema(manifest, failures); +function validateStructure(manifest, failures, options = {}) { + validateClosedSchema(manifest, failures, options); const criteria = new Map((manifest.criteria ?? []).map((criterion) => [criterion.id, criterion])); const boundCriteria = new Set(); const negativeBoundCriteria = new Set(); @@ -686,6 +731,22 @@ function validateStructure(manifest, failures) { } } + const registeredGateIds = new Set((manifest.gates ?? []).map((gate) => gate.id)); + for (const criterion of criteria.values()) { + if (criterion.gateRefs === undefined) continue; + const referenced = new Set(criterion.gateRefs); + for (const gateId of registeredGateIds) { + if (!referenced.has(gateId)) { + failures.push(`${criterion.id}: gate population binding is missing ${gateId}`); + } + } + for (const gateId of referenced) { + if (!registeredGateIds.has(gateId)) { + failures.push(`${criterion.id}: gate population binding references unknown gate ${gateId}`); + } + } + } + for (const claim of manifest.proseClaims ?? []) { if (!criteria.has(claim.criterionId)) { failures.push(`GATE-CLAIM:${claim.id} references unknown criterion ${claim.criterionId}`); @@ -996,7 +1057,7 @@ export async function verifyRegistry(options) { const manifestPath = path.resolve(options.root, options.manifest); const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); - validateStructure(manifest, failures); + validateStructure(manifest, failures, options); if (options.structureOnly) return { failures, manifest, observations }; async function collectPhaseFailure(label, action) { diff --git a/scripts/gate-verify.test.mjs b/scripts/gate-verify.test.mjs index 584be776..f00cad28 100644 --- a/scripts/gate-verify.test.mjs +++ b/scripts/gate-verify.test.mjs @@ -6,6 +6,12 @@ import { spawnSync } from 'node:child_process'; import test from 'node:test'; const verifier = path.join(process.cwd(), 'scripts', 'gate-verify.mjs'); +const fixtureRunner = path.join( + process.cwd(), + 'scripts', + 'test-support', + 'gate-verify-fixture-runner.mjs', +); const fixtureBase = path.join(process.cwd(), '.mosaic-test-work', `gate-verify-${process.pid}`); async function fixture(name = 'case') { @@ -38,6 +44,7 @@ function baseManifest() { { id: 'meta-fixture', source: 'gates/meta-fixture.sh', + evidenceSubject: 'meta-fixture', invocation: ['gates/meta-fixture.sh'], deployment: { kind: 'none', reason: 'test fixture only' }, inertMutation: { @@ -73,6 +80,14 @@ async function writeManifest(root, manifest) { } function verify(root, extraArgs = []) { + return spawnSync( + process.execPath, + [fixtureRunner, '--root', root, '--manifest', 'gates/gates.manifest.json', ...extraArgs], + { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } }, + ); +} + +function verifyProductionStructure(root, extraArgs = []) { return spawnSync( process.execPath, [ @@ -82,6 +97,7 @@ function verify(root, extraArgs = []) { '--manifest', 'gates/gates.manifest.json', '--skip-history', + '--structure-only', ...extraArgs, ], { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } }, @@ -96,6 +112,95 @@ test.after(async () => { await rm(fixtureBase, { recursive: true, force: true }); }); +test('universally quantified registry checks reject empty populations before evaluation', async () => { + const root = await fixture('empty-registry-populations'); + await mkdir(path.join(root, 'empty-gate-root'), { recursive: true }); + const manifest = baseManifest(); + manifest.gateRoots = ['empty-gate-root']; + manifest.criteria = []; + manifest.proseClaims = []; + manifest.compatibilityScenarios = []; + manifest.gates = []; + await writeManifest(root, manifest); + + const result = verifyProductionStructure(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /criteria population.*non-empty.*anchored/i); + assert.match(output(result), /gates population.*non-empty.*anchored/i); + assert.match(output(result), /proseClaims population.*non-empty.*anchored/i); + assert.match(output(result), /compatibilityScenarios population.*non-empty.*anchored/i); +}); + +test('production verifier exposes no fixture-profile population bypass', async () => { + const root = await fixture('no-production-fixture-profile'); + const result = verifyProductionStructure(root, ['--fixture-profile']); + assert.notEqual(result.status, 0); + assert.match(output(result), /unknown option: --fixture-profile/i); +}); + +test('anchored gate inventory and population criteria cannot shrink together', async () => { + const source = JSON.parse( + await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'), + ); + const root = await fixture('shrunken-gate-population'); + source.gates = source.gates.filter((gate) => gate.id !== 'hook-pre-push'); + for (const criterion of source.criteria) { + if (criterion.gateRefs) { + criterion.gateRefs = criterion.gateRefs.filter((gateId) => gateId !== 'hook-pre-push'); + } + criterion.caseRefs = criterion.caseRefs.filter( + (caseRef) => !caseRef.startsWith('hook-pre-push/'), + ); + } + await writeManifest(root, source); + + const result = verifyProductionStructure(root); + assert.notEqual(result.status, 0); + assert.match(output(result), /gates population is not anchored.*hook-pre-push/i); +}); + +test('general population criteria cannot delete their gateRefs binding', async () => { + const requiredCriteria = [ + 'RM02-EVIDENCE-SUBJECT-BINDING', + 'RM02-TYPE-STRICT-SCHEMA', + 'RM02-NONEMPTY-ANCHORED-QUANTIFICATION', + ]; + for (const criterionId of requiredCriteria) { + const source = JSON.parse( + await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'), + ); + const root = await fixture(`missing-gate-refs-${criterionId}`); + delete source.criteria.find((criterion) => criterion.id === criterionId).gateRefs; + await writeManifest(root, source); + const result = verifyProductionStructure(root); + assert.notEqual(result.status, 0); + assert.match( + output(result), + new RegExp(`${criterionId}.*gateRefs.*required`, 'i'), + criterionId, + ); + } +}); + +test('general population criteria must span every registered gate', async () => { + const source = JSON.parse( + await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'), + ); + const root = await fixture('incomplete-gate-refs'); + source.criteria.find((criterion) => criterion.id === 'RM02-EVIDENCE-SUBJECT-BINDING').gateRefs = + source.criteria + .find((criterion) => criterion.id === 'RM02-EVIDENCE-SUBJECT-BINDING') + .gateRefs.filter((gateId) => gateId !== 'quality-lint'); + await writeManifest(root, source); + + const result = verifyProductionStructure(root); + assert.notEqual(result.status, 0); + assert.match( + output(result), + /RM02-EVIDENCE-SUBJECT-BINDING.*gate population binding is missing quality-lint/i, + ); +}); + test('an externally inerted failure branch makes verification nonzero and names the gate', async () => { const root = await fixture('external-inert'); await writeGate(root, '#!/bin/sh\nexit 0\n'); diff --git a/scripts/test-support/gate-verify-fixture-runner.mjs b/scripts/test-support/gate-verify-fixture-runner.mjs new file mode 100644 index 00000000..97c20bbd --- /dev/null +++ b/scripts/test-support/gate-verify-fixture-runner.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node + +import path from 'node:path'; + +import { verifyRegistry } from '../gate-verify.mjs'; + +let root; +let manifest = 'gates/gates.manifest.json'; +let structureOnly = false; +for (let index = 0; index < process.argv.slice(2).length; index += 1) { + const args = process.argv.slice(2); + const value = args[index]; + if (value === '--root') root = path.resolve(args[++index]); + else if (value === '--manifest') manifest = args[++index]; + else if (value === '--structure-only') structureOnly = true; + else throw new Error(`unknown fixture-runner option: ${value}`); +} +if (!root) throw new Error('fixture runner requires --root'); +const { failures, observations } = await verifyRegistry({ + root, + manifest, + skipHistory: true, + structureOnly, + fixtureProfile: true, +}); +for (const observation of observations) process.stdout.write(`${observation}\n`); +for (const failure of failures) process.stderr.write(`GATE VERIFY FAILED: ${failure}\n`); +if (failures.length > 0) process.exitCode = 1; -- 2.54.0 From 9b7005d59b334a0e578839af97f2aaddad8a5387 Mon Sep 17 00:00:00 2001 From: coder-mos2 Date: Sat, 1 Aug 2026 14:39:36 -0500 Subject: [PATCH 11/13] wip(rm-02): round-4 remediation held at RM-60 boundary --- .woodpecker/ci.yml | 4 - docs/ADMIN-GUIDE/quality-gate-registry.md | 14 +- docs/DEVELOPER-GUIDE/quality-gate-registry.md | 20 +- docs/PRD.md | 33 +- docs/SITEMAP.md | 2 +- docs/plans/2026-08-01-rm-02-gate-registry.md | 11 +- docs/scratchpads/1029-rm-02-gate-registry.md | 13 + gates/gates.manifest.json | 347 +++++----- gates/required-gates.baseline.json | 16 + scripts/gate-delayed-introduction-control.mjs | 56 -- scripts/gate-empty-population-control.mjs | 1 - scripts/gate-history-boundary-control.mjs | 40 -- scripts/gate-history-exclusion-control.mjs | 37 ++ scripts/gate-history.mjs | 432 ------------ scripts/gate-history.test.mjs | 615 ------------------ scripts/gate-inventory-shrink-control.mjs | 119 ++++ scripts/gate-population-control.mjs | 86 ++- scripts/gate-provider-binding-control.mjs | 30 - scripts/gate-remediation.test.mjs | 233 +++++++ scripts/gate-verify.mjs | 145 ++++- scripts/gate-verify.test.mjs | 18 +- scripts/gate-wiring.test.mjs | 20 +- .../gate-verify-fixture-runner.mjs | 1 - 23 files changed, 851 insertions(+), 1442 deletions(-) create mode 100644 gates/required-gates.baseline.json delete mode 100644 scripts/gate-delayed-introduction-control.mjs delete mode 100644 scripts/gate-history-boundary-control.mjs create mode 100644 scripts/gate-history-exclusion-control.mjs delete mode 100644 scripts/gate-history.mjs delete mode 100644 scripts/gate-history.test.mjs create mode 100644 scripts/gate-inventory-shrink-control.mjs delete mode 100644 scripts/gate-provider-binding-control.mjs create mode 100644 scripts/gate-remediation.test.mjs diff --git a/.woodpecker/ci.yml b/.woodpecker/ci.yml index e372361a..9df46305 100644 --- a/.woodpecker/ci.yml +++ b/.woodpecker/ci.yml @@ -72,12 +72,8 @@ steps: # step-level `when`, because a gate can be disabled by changes outside its own path. gate-verify: image: *node_image - # Woodpecker's shallow marker makes merge-base reject even present parents; - # full history is required for activation ancestry and manifest provenance. commands: - *enable_pnpm - - apk add --no-cache bubblewrap - - if [ -f .git/shallow ]; then git fetch --unshallow --no-tags origin; fi - pnpm gate:verify depends_on: - install diff --git a/docs/ADMIN-GUIDE/quality-gate-registry.md b/docs/ADMIN-GUIDE/quality-gate-registry.md index e1d7dc56..9f419687 100644 --- a/docs/ADMIN-GUIDE/quality-gate-registry.md +++ b/docs/ADMIN-GUIDE/quality-gate-registry.md @@ -10,7 +10,7 @@ Investigate any of these immediately: - `unregistered gate` — an executable appeared under a declared gate root without a registry entry. - `no negative control` — a gate has no must-fail case. - `DEPLOYED IDENTITY UNAVAILABLE` — the runner cannot reach the installed enforcing copy. The pinned observation is checked, but live equality is not asserted. -- `PROVIDER EVIDENCE ... ABSENT` — retained external history was unavailable; do not infer merge-time success. +- `HISTORY_PROVENANCE_FORBIDDEN` — a history assertion path was reintroduced at the repository layer; remove it and keep RM-60 as the tracked external-boundary owner. ## Updating a gate @@ -26,14 +26,10 @@ Do not add an ownerless exception or describe an open delta as pass/green/OK. ## CI behavior -Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. The step unshallows the checkout so activation ancestry and historical manifest provenance can be checked; a shallow boundary must never be interpreted as non-ancestry. +Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. The step needs no local history preparation because RM-02 asserts no history-provenance property. -Provider evidence input is an optional JSON array of normalized pipeline records containing `commit`, globally unique integer pipeline `number`, pipeline `status`, and a `gate-verify` step status. Collection-wide identity/type validation occurs before commit filtering; a duplicate number across two commits fails subject binding. The highest numbered valid rerun for one commit is authoritative. Its retention window is provider-controlled and is not overstated by this repository. +**DOES:** PR CI executes current-tree verification unprivileged and fail-closed. It compares the manifest and verifier inventory separately with `gates/required-gates.baseline.json`, and consumed case evidence carries a subject checked against its gate definition. The registered shrink-both and per-gate evidence-subject controls must remain red for their stated reasons. -Do not add or update an activation SHA in the manifest. The verifier derives the audited feature range from Git's merge-base with provider target `refs/remotes/origin/main`, so commits before a delayed registry introduction remain covered. HEAD, HEAD's parent, the introduction commit, and delayed introduction after a gate change are registered rejected constructions. +**DOES NOT:** No local git state in the PR checkout is trustworthy as a history anchor because PR-controlled lifecycle code executes before the gate. The verifier has no history-verification path, and its closed current-tree observation renderer has no history/ancestry/lineage success class. Do not add a local ref, config, remote URL, source constant, or author-positioned path as a replacement anchor. -**DOES:** The merge-base is outside branch-author control when main cannot be rewritten. **DOES NOT:** This bootstrap cannot protect a compromised or rewritten main; Builds 1-2 own that residual. Production verification also requires non-empty criteria/gates/prose/scenario populations and anchors the seven required gate IDs to canonical sources before any “all registered” claim. - -PR CI executes current-tree verification only, unprivileged and fail-closed. It does not execute isolated own-tree replay: RM-60 must provide a protected launcher or runner-level rootless sandbox before any PR-controlled executable/configuration is evaluated. Repo-only code cannot safely grant itself the capability intended to contain itself. - -The deferred replay implementation remains hard-fail when its sandbox cannot be established; it is not silently skipped as a successful replay. Tests recognize unavailability only from parent-generated Bubblewrap-launch provenance combined with proof that the sandbox entry command did not run. A denial-looking string from child-controlled output is not evidence. When RM-60 activates replay under protected authority, it uses frozen own-tree dependencies, namespace/environment isolation, and archived-file identity checks. A post-merge failure triggers quarantine and revert. This is detection, not pre-merge prevention. +`scripts/gate-history-exclusion-control.mjs` enforces the output incapacity by testing alternate success wording and production renderer wiring; its registered fixture proves adding a prohibited success class goes red. RM-60 is the tracked owner of the provider-controlled/protected pre-execution boundary. If a bootstrap override is ever proposed before RM-60, it requires a separate explicit, loud, audited, retiring, negative-controlled design; RM-02 contains no override. diff --git a/docs/DEVELOPER-GUIDE/quality-gate-registry.md b/docs/DEVELOPER-GUIDE/quality-gate-registry.md index 02fd1b0c..b470f9af 100644 --- a/docs/DEVELOPER-GUIDE/quality-gate-registry.md +++ b/docs/DEVELOPER-GUIDE/quality-gate-registry.md @@ -10,7 +10,7 @@ pnpm gate:verify The registry covers root typecheck, lint, and format checks; RM-01 checkout preflight; the Mosaic CI queue guard; and root Husky pre-commit/pre-push hooks. It does not imply repository-wide coverage. Framework scripts, package-local build/test scripts, templates, and deployment/release scripts remain assigned to RM-54. -Every gate declares exact invocations, an evidence subject equal to its stable gate ID, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. Every nested manifest object used by outcomes, mutations, fixtures, deployments, defects, compatibility, provenance, coverage, and merge assertions has closed keys and strict field types; a misspelling cannot silently turn a required comparison into an absent optional field. Independent validation phases collect labeled failures instead of letting one thrown fixture, claim, discovery, deployment, mutation, or compatibility error mask already-known stable-ID diagnostics. This proves detection of the **declared** inerting mutation, not every possible semantic weakening. +Every gate declares exact invocations, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every case result carries an evidence-side subject that is compared with the gate definition when the result is consumed; the population control mutates that evidence-side subject independently for every required gate. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. Every nested manifest object used by outcomes, mutations, fixtures, deployments, defects, compatibility, provenance, coverage, and merge assertions has closed keys and strict field types; a misspelling cannot silently turn a required comparison into an absent optional field. Independent validation phases collect labeled failures instead of letting one thrown fixture, claim, discovery, deployment, mutation, or compatibility error mask already-known stable-ID diagnostics. This proves detection of the **declared** inerting mutation, not every possible semantic weakening. ## Required versus actual @@ -32,20 +32,14 @@ Restatements preserve original text, current text, reason, finding/task, and dat A gate with an external installed counterpart declares it explicitly. When the installed queue guard is reachable, its bytes must equal repository source and an internal drift control is observed red. In CI the operator-home installation may be outside the container; the verifier checks the pinned observed source digest, reports `DEPLOYED IDENTITY UNAVAILABLE (owner: RM-04)`, and does not infer live equality. -## Commit and provider boundary +## Current-tree and history-provenance boundary -**DOES:** Every PR evaluates the current checkout's registered gates and declared inerting mutations directly, unprivileged and fail-closed. +**DOES:** Every PR evaluates the current checkout's registered gates and declared inerting mutations directly, unprivileged and fail-closed. The seven-gate population is compared independently against `gates/required-gates.baseline.json`; a registered control shrinks the verifier inventory and manifest together and proves that the unchanged baseline rejects the attack. Evidence-side subjects are consumed and compared with gate definitions for every gate. -**DOES NOT:** Repository-controlled PR CI does not execute a commit's own verifier in an isolated replay. Doing so safely would require granting namespace capability before PR-controlled configuration or code runs; that same PR could consume the capability directly. This is an absent trust boundary, not unfinished hardening. RM-60 owns a runner-level rootless sandbox or protected immutable launcher; RM-59 owns the parallel artifact-integrity anchor. +**DOES NOT:** This repository layer establishes history provenance at all. `pnpm install` executes PR-controlled lifecycle code before `gate:verify`, so no local ref, git config, remote URL, constant, or author-positioned path in the checkout can anchor a history claim. An observation saying “unverifiable” while returning zero would be a green wearing a disclaimer, so the claim and history verifier path are removed. -The replay implementation and abuse-case tests remain fail-closed: when invoked by a future protected authority, inability to establish Bubblewrap is terminal nonzero; controls are never omitted or treated as replay success. On an unprivileged CI runner, sandbox integration tests pass only when the result carries parent-generated Bubblewrap-launch provenance and proves the sandbox entry command never ran. Child-controlled text that merely reproduces a Bubblewrap denial is not accepted. Capable local/protected environments exercise the full abuse cases. Historical installs use frozen lockfiles, isolated network/PID/IPC/UTS and environment/home boundaries, and authoritative-file snapshots that detect lifecycle rewrites. +The production output path uses a closed current-tree observation renderer; history, ancestry, and provider-lineage success are not representable observation classes. `scripts/gate-history-exclusion-control.mjs` exercises alternate success wording and production renderer wiring. Its registered must-fail case turns red if a prohibited success class is added or renderer consumption is bypassed. RM-60 owns the provider-controlled/protected pre-execution boundary needed before history provenance can be asserted. No bootstrap override exists in RM-02. -Retained provider evidence can assert terminal-success **current-tree** records for prior commits when supplied through `GATE_PROVIDER_EVIDENCE_FILE`. Each normalized record contains `commit`, globally unique integer pipeline `number`, pipeline `status`, and exactly one `gate-verify` step; the highest-numbered rerun for a commit is authoritative. The full collection is validated before commit filtering, so one pipeline identity cannot certify two commit subjects. Ambiguous duplicates fail. Absent, expired, or currently-running evidence is reported explicitly and never inferred as success. +Universal checks first prove populations non-empty. The production profile reads the independent baseline before comparing the verifier inventory and manifest separately, while `gateRefs` on the D-38/D-40 criteria must exactly span every registered gate. Population controls mutate evidence-side subjects and type-strict comparison inputs one gate at a time and require rejection across the complete inventory. -The history seam is not a manifest field and is not derived from an author-positioned registry path. `gate-history.mjs` derives the feature range from Git's merge-base with the provider target `refs/remotes/origin/main`; a gate change committed before delayed registry introduction therefore remains in range and fails because its own tree has no registry. Registered controls reject HEAD, HEAD's parent, the introduction commit, and delayed introduction after a gate change. - -**DOES:** This bootstrap is sound against a branch author who cannot rewrite main: reordering or splitting commits cannot move the target merge-base. **DOES NOT:** It does not establish integrity if main itself is compromised or rewritten. Builds 1-2 own that residual main-integrity dependency. - -Universal checks first prove populations non-empty. The production profile anchors the required seven gate IDs to their canonical sources, while `gateRefs` on the D-38/D-40 criteria must exactly span every registered gate. Population controls mutate evidence-subject and type-strict comparison inputs one gate at a time and require rejection across the complete inventory. - -Once RM-60 supplies the external pre-execution anchor, protected post-merge/main replay is detection, not pre-merge prevention. A failed replay requires quarantine of the affected result and revert of the offending merge. It must never be represented as proof that CI blocked that merge. RM-25 tracks provider enforcement. +The independent baseline blocks the registered source-plus-manifest shrink attack. It does not claim same-authority authenticity against an actor who consistently rewrites every repository artifact; RM-60/RM-59 own external execution/artifact integrity. diff --git a/docs/PRD.md b/docs/PRD.md index 7b078228..7056dd26 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -22,9 +22,9 @@ Existing deterministic gates can return success without enforcing their stated p ### Scope -**In scope:** root typecheck, lint, and format gates; RM-01 checkout preflight; the Mosaic CI queue guard; root Husky pre-commit and pre-push hooks; criterion bindings; modeled compatibility; meaning-change provenance; security/integrity prose claim markers; source-versus-deployed identity; unprivileged current-tree PR verification; retained provider CI evidence where available; and explicit deferral of isolated per-commit replay to RM-60's protected external authority. +**In scope:** root typecheck, lint, and format gates; RM-01 checkout preflight; the Mosaic CI queue guard; root Husky pre-commit and pre-push hooks; criterion bindings; modeled compatibility; meaning-change provenance; security/integrity prose claim markers; source-versus-deployed identity; unprivileged current-tree PR verification; independent required-inventory comparison; evidence-side subject consumption; and enforced structural exclusion of history provenance until RM-60 provides protected external authority. -**Out of scope:** fixing the queue guard (RM-03); exhaustive registration of every repository executable (RM-54); semantic proof that arbitrary English criteria are mutually satisfiable (RM-54/RM-55); a same-authority trust anchor for repository-authored evidence (RM-25/RM-59). +**Out of scope:** fixing the queue guard (RM-03); exhaustive registration of every repository executable (RM-54); semantic proof that arbitrary English criteria are mutually satisfiable (RM-54/RM-55); history provenance before RM-60's provider-controlled/protected execution boundary; and a same-authority trust anchor for repository-authored evidence (RM-25/RM-59). ### Normative requirements @@ -37,17 +37,17 @@ Existing deterministic gates can return success without enforcing their stated p 7. `RM02-REQ-07`: Executables under declared gate roots SHALL fail with `unregistered gate` when absent from the registry. The initial coverage boundary SHALL explicitly list exclusions and bind the broader inventory to RM-54. 8. `RM02-REQ-08`: Every gate with a deployed counterpart SHALL register source/deployed byte identity and a must-fail drift control. Gates without a deployed counterpart SHALL say so explicitly. 9. `RM02-REQ-09`: CI SHALL run `pnpm gate:verify` on every pull request without path filtering and on protected-main pushes. -10. `RM02-REQ-10` (restated): PR CI SHALL perform unprivileged, fail-closed current-tree verification only. Isolated per-commit replay SHALL remain deferred to RM-60's protected post-merge/main authority, cross-referenced with RM-59. That future replay is detection with a quarantine/revert response, not pre-merge prevention; inability to establish its sandbox is terminal nonzero, never skip/pass. Retained provider evidence SHALL remain distinct and SHALL never be inferred when absent. -11. `RM02-REQ-11`: The audited branch range SHALL begin at the Git merge-base with the provider target `origin/main`, not at a manifest-authored value or author-selected introduction path. A gate-affecting commit before delayed registry introduction SHALL remain in range and fail on its missing own-tree registry. This bootstrap is sound against a branch author who cannot rewrite main; it is not sound against compromise or rewrite of main, whose integrity is the Builds 1-2 dependency. -12. `RM02-REQ-12` (`D-38`): For every gate in the mechanically anchored required inventory, evidence SHALL be bound to exactly that gate subject under review. Provider pipeline shape, gate-step cardinality, and identity uniqueness SHALL be validated over the full evidence collection before commit filtering; a duplicate pipeline number across different commits SHALL fail. -13. `RM02-REQ-13` (`D-40`): For every gate in the mechanically anchored required inventory, each discriminator and comparison input SHALL have a recursively closed, type-strict schema. Unknown, misspelled, wrong-type, or present-but-empty nested assertion fields SHALL fail rather than disabling an assertion. -14. `RM02-REQ-14` (`D-46`): No universally quantified registry check SHALL run until its population is proven non-empty and anchored. The required seven-gate inventory, criteria, prose claims, and compatibility scenarios SHALL reject empty populations before reporting that all registered cases ran. +10. `RM02-REQ-10` (restated): PR CI SHALL perform unprivileged, fail-closed current-tree verification only. The production observation renderer SHALL be a closed current-tree-only output type with no representable history-provenance success state. A registered must-fail control SHALL turn red if history/ancestry/lineage success is added to that renderer or if production output bypasses the renderer. RM-60 owns the provider-controlled/protected pre-execution boundary required to establish history provenance. +11. `RM02-REQ-11` (`D-46`): The required seven-gate inventory SHALL be read from an independent baseline artifact and compared separately with both the verifier inventory and manifest. Shrinking the verifier inventory and manifest together while leaving the baseline intact SHALL fail for the removed gate. +12. `RM02-REQ-12` (`D-38`): Every consumed case result SHALL carry an evidence-side subject independently declared from the gate definition. The verifier SHALL compare that evidence subject with the gate definition when consuming the result. A population control SHALL mutate the evidence-side subject independently for every required gate and observe rejection for every gate. +13. `RM02-REQ-13` (`D-40`): For every gate in the independently baselined required inventory, each discriminator and comparison input SHALL have a recursively closed, type-strict schema. Unknown, misspelled, wrong-type, or present-but-empty nested assertion fields SHALL fail rather than disabling an assertion. +14. `RM02-REQ-14` (`D-46`): No universally quantified registry check SHALL run until its population is proven non-empty and independently baselined. The required seven-gate inventory, criteria, prose claims, and compatibility scenarios SHALL reject empty populations before reporting that all registered cases ran. #### RM02-REQ-10 meaning-change provenance - **Original:** “assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.” -- **Restatement:** PR CI performs unprivileged, fail-closed current-tree verification only. Isolated per-commit replay is deferred to a protected post-merge/main authority, where it is detection with a defined quarantine/revert response — explicitly not a pre-merge gate. Inability to establish the sandbox is hard nonzero, never a skip. -- **Reason:** Isolated replay on PR CI would require granting namespace capability to PR-controlled configuration, which the same PR could use directly before containment. The trust boundary is impossible at the repository layer, not merely expensive. RM-60 owns the external pre-execution anchor; RM-59 tracks the corresponding artifact-integrity anchor. +- **Restatement:** The repository verifier performs current-tree verification and is structurally incapable of asserting history provenance. RM-60 supplies the provider-controlled/protected pre-execution boundary before any repository-controlled lifecycle code executes. +- **Reason:** `pnpm install` executes PR-controlled lifecycle code before `gate:verify`; therefore no local ref, git config, remote URL, constant, or author-positioned path in the checkout is a trustworthy history anchor. A local disclaimer would be a green wearing a note, not a property. The history-provenance claim is removed rather than weakened. ### Acceptance criteria @@ -57,17 +57,16 @@ Existing deterministic gates can return success without enforcing their stated p 4. `RM02-AC-04`: A gate with zero must-fail cases returns nonzero and includes `no negative control`. 5. `RM02-AC-05`: Unbound or semantically misbound criteria, prose claims bound to unrelated cases, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. A simultaneous stale fixture or independent phase error SHALL NOT mask responsible stable-ID diagnostics. Registered meta-negative controls move a criterion binding, remove meaning provenance, and redirect a prose claim to an unrelated case; each is observed red for its stated reason. 6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request. -7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: current-tree gates and inerting mutations execute unprivileged and fail-closed; isolated own-tree replay does not execute in repository-controlled PR CI. RM-60/RM-59 are named, retained provider evidence is never inferred, and future protected post-merge detection specifies quarantine/revert rather than claiming pre-merge prevention. -8. `RM02-AC-08`: Registered must-fail controls reject history seam candidates at HEAD, HEAD's parent, and the registry introduction; delayed registry introduction after an earlier gate change; an emptied registry; a cross-commit duplicate provider pipeline identity; a misspelled nested outcome field; a wrong outcome field type; and a present-but-empty outcome pattern. -9. `RM02-AC-09`: Population controls iterate every gate in the anchored inventory and prove evidence-subject mismatch and wrong-type comparison input are rejected for each gate. `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-BOUNDARY`, and `RM02-NONEMPTY-ANCHORED-QUANTIFICATION` are bidirectionally bound to their must-fail controls. +7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: the repository layer verifies current-tree registered cases, independent inventory shape, and evidence-side subject consumption; it does not establish history provenance at all. RM-60 is the tracked owner of the provider-controlled/protected pre-execution boundary. +8. `RM02-AC-08`: Registered must-fail controls reject an emptied registry; shrinking the verifier inventory and manifest together; adding history/ancestry/lineage success to the closed observation renderer or bypassing renderer consumption; a misspelled nested outcome field; a wrong outcome field type; and a present-but-empty outcome pattern. +9. `RM02-AC-09`: Population controls iterate every gate in the independently baselined inventory and prove consumed evidence-subject mismatch and wrong-type comparison input are rejected for each gate. `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-PROVENANCE-EXCLUDED`, and `RM02-NONEMPTY-ANCHORED-QUANTIFICATION` are bidirectionally bound to their must-fail controls. ### Risks, dependencies, and verification boundary -- The repository verifier proves declared controls, modeled scenarios, bidirectional declared criterion/case relationships, source/deployed equality at execution time, and unprivileged current-tree behavior. It does **not** infer arbitrary-English semantics, execute isolated per-commit replay, or defend against an actor able to rewrite the gate, registry, verifier, and sandbox entry consistently. -- Sandbox refusal tests require parent-generated Bubblewrap-launch provenance and proof that the sandbox entry command never ran; child-controlled denial-looking text alone cannot establish unavailability. -- Repo-only code cannot both grant namespace capability to PR configuration and prevent that same PR from using the capability directly. RM-60 owns a runner/provider-controlled pre-execution boundary; RM-59 owns the parallel artifact-integrity anchor. -- Protected post-merge replay, once RM-60 exists, is detection only. Failure requires immediate quarantine of the affected result and revert of the offending merge; it is not equivalent to a pre-merge gate. -- External branch protection and provider CI history supply merge-time current-tree evidence where retained. RM-25 tracks provider-side enforcement. +- **DOES:** The repository verifier proves declared current-tree controls, modeled scenarios, bidirectional criterion/case relationships, source/deployed equality at execution time, an independent seven-gate baseline comparison, and evidence-side subject consumption. +- **DOES NOT:** This layer establishes no history provenance. PR-controlled lifecycle code executes before the gate, so no local git state in the checkout is trustworthy as a history anchor. RM-60 owns the provider-controlled/protected pre-execution boundary. The production verifier has no history verifier path, and its closed current-tree observation renderer cannot represent a history-provenance success state. +- The independent inventory baseline prevents the registered shrink-both attack proved by RM-02's control; it does not claim same-authority authenticity against an actor who consistently rewrites every repository artifact. RM-60/RM-59 own external execution/artifact integrity. +- The verifier does **not** infer arbitrary-English semantics or defend against an actor able to rewrite the gate, registry, verifier, controls, and baseline consistently. - `ASSUMPTION:` RM-54 is the owner for expanding registration and prose-marker coverage beyond this approved seven-gate slice; rationale: the remediation task graph already assigns the fleet-wide inert-gate audit there. --- diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index a8f6b24c..35b35898 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -2,7 +2,7 @@ ## Gate verification -- [Developer gate registry guide](DEVELOPER-GUIDE/quality-gate-registry.md) — manifest schema, negative controls, defect deltas, modeled boundaries, and commit/provider evidence. +- [Developer gate registry guide](DEVELOPER-GUIDE/quality-gate-registry.md) — manifest schema, negative controls, evidence-side subjects, independent inventory baseline, and the enforced RM-60 history-provenance exclusion. - [Gate registry operations](ADMIN-GUIDE/quality-gate-registry.md) — routine verification, failure interpretation, registry updates, and unconditional CI behavior. - [RM-02 governing claim index](remediation/GATE-CLAIMS.md) — marker bindings for orchestrator-owned remediation claims without modifying task tracking. diff --git a/docs/plans/2026-08-01-rm-02-gate-registry.md b/docs/plans/2026-08-01-rm-02-gate-registry.md index a750ea00..659ce777 100644 --- a/docs/plans/2026-08-01-rm-02-gate-registry.md +++ b/docs/plans/2026-08-01-rm-02-gate-registry.md @@ -4,7 +4,7 @@ **Goal:** Build a machine-readable seven-gate registry and an unconditional CI verifier that detects inert gates, binds criteria to observed negative controls, records defects honestly, and verifies the current PR tree unprivileged and fail-closed. -**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json`, validates its closed schema and references, then runs typed cases in isolated main-disk fixtures. Gate-specific fixture setup remains declarative; exact invocations and exact observed/required exits stay in JSON. A separate history module checks activation/manifest provenance and retained external current-tree CI evidence without inferring missing evidence. Isolated own-tree execution remains fail-closed code for RM-60's future protected authority; repository-controlled PR CI does not invoke it. +**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json` and the independent `gates/required-gates.baseline.json`, validates closed schemas and references, then runs typed cases in isolated main-disk fixtures. Gate-specific fixture setup remains declarative; exact invocations, evidence-side subjects, and exact observed/required exits stay in JSON. The production verifier is structurally incapable of asserting history provenance; RM-60 owns the provider-controlled/protected pre-execution boundary. **Tech Stack:** Node.js ESM, `node:test`, JSON, shell gates, pnpm, Woodpecker CI. @@ -66,16 +66,17 @@ Write and observe a failing test with a byte-mutated deployed counterpart. Imple Enumerate current security/integrity claims, bind each marker/id to a negative case, and reject unbound markers. Execute finite compatibility scenarios and clearly document that arbitrary English consistency is outside the model. -### Task 6: Current-tree boundary, deferred replay, and provider evidence +### Task 6: Current-tree boundary and enforced history-provenance exclusion **Files:** -- Create: `scripts/gate-history.mjs` -- Create: `scripts/gate-history.test.mjs` +- Create: `gates/required-gates.baseline.json` +- Create: `scripts/gate-history-exclusion-control.mjs` +- Create: `scripts/gate-inventory-shrink-control.mjs` - Modify: `scripts/gate-verify.mjs` - Modify: `gates/gates.manifest.json` -Test with a synthetic git repository containing two commits whose manifests differ. PR verification must state adjacent `DOES`/`DOES NOT` boundaries and must not execute the intermediate commit's verifier. Preserve isolated replay as a direct fail-closed primitive for RM-60's future protected pre-execution authority; sandbox failure remains nonzero. Add bounded Gitea/Woodpecker current-tree status lookup for prior commits when credentials/history are available. Missing, expired, and currently-running evidence must be explicit states, never inferred success. Protected post-merge replay is detection with quarantine/revert, never pre-merge prevention. +Verify current-tree behavior only. Remove the anchor-dependent history verifier and provider-history consumption because PR-controlled lifecycle code executes before the gate and makes every local git anchor untrustworthy. Use a closed current-tree observation renderer and register a must-fail control that turns red if history/ancestry/lineage success becomes representable or production output bypasses renderer consumption. Compare the verifier inventory and manifest separately against the independent baseline, and register a must-fail attack that shrinks source inventory plus manifest together. State both directions: current-tree controls, inventory shape, and evidence subjects are enforced here; history provenance is not established until RM-60 provides a provider-controlled/protected pre-execution boundary. ### Task 7: CI and documentation diff --git a/docs/scratchpads/1029-rm-02-gate-registry.md b/docs/scratchpads/1029-rm-02-gate-registry.md index a42ff695..20adb2b7 100644 --- a/docs/scratchpads/1029-rm-02-gate-registry.md +++ b/docs/scratchpads/1029-rm-02-gate-registry.md @@ -88,3 +88,16 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro - Provider CI cannot report the currently executing pipeline as terminal success; current-commit evidence must be labeled pending and becomes historical current-tree evidence only after provider completion. - Isolated per-commit execution requires RM-60/#1031. Until that external authority exists, no replay success is claimed. A future protected post-merge failure requires quarantine/revert. - CI containers may not expose the operator-home deployed queue guard. In that layer the verifier checks the pinned observed digest and reports live identity unavailable under RM-04; it does not infer live equality. + +## coder-mos2 remediation — fourth round + +- Coordinator correction loaded: Blocker 2 requires a genuine shrink-both control against an independent inventory baseline; Blocker 3 requires evidence-side subjects consumed against gate definitions for every gate; anchor-dependent history provenance must be removed rather than relabelled because PR lifecycle code makes all local git state untrustworthy before verification. +- Boundary to preserve in both directions: this repository layer DOES verify current-tree registered cases, independently anchored inventory shape, evidence subject consumption, and an enforced absence of history-provenance claims. It DOES NOT establish history provenance at all. RM-60 owns the provider-controlled/protected pre-execution boundary required for that property; no local ref, config, URL, constant, or author-positioned path is treated as an anchor. +- TDD plan: add three genuine red-first controls before implementation: shrink verifier inventory plus manifest together; mutate evidence-side subject for every gate; and reject the currently enabled history verifier/import/report path. Existing controls retained as regression guards and labelled honestly. +- Identity observation before first commit: `git var GIT_AUTHOR_IDENT` returned `coder-mos2 ` using process-scoped author/committer variables; shared repository config was not modified. +- Genuine RED-first evidence: `node --test scripts/gate-remediation.test.mjs` produced 0/3 passing on frozen head `fbb61912`: source+manifest shrink exited zero, no evidence-side case subjects existed, and the production verifier still imported/invoked history verification. These were failures for the three stated blocker reasons, not regression guards. +- Sixth mutation found before review: after the first baseline implementation, shrinking the new baseline and manifest together while leaving the verifier inventory unchanged still exited zero. A new test observed that attack RED first. Inventory equality diagnostics are now bidirectional, and the registered shrink control attacks both source+manifest and baseline+manifest pairs. +- Regression guards retained and honestly labelled: manifest-only shrink, exact `gateRefs` span, production fixture-profile rejection, and broad nested schema checks already blocked before this round. +- Current focused evidence before independent review: remediation controls 4/4; verifier/wiring/remediation suite 39/39; canonical `pnpm gate:verify` exits zero while reporting six RM-03-owned `DEFECT` deltas and all seven gate meta-negative controls observed red. +- Independent Codex code review requested changes on two valid blockers. First, the evidence population control called the subject helper directly rather than traversing production result consumption. It now creates one lightweight executed fixture per required gate, invokes the real `verifyRegistry` path, and fails to observe rejection if the production consumer is removed; a regression mutation proves that coupling. Second, a lexical history blacklist overclaimed structural incapacity. Production output now passes through a closed current-tree observation renderer with no history/ancestry/lineage success class; the exclusion control tests three alternate success wordings plus exact production renderer wiring, and the registered must-fail fixture adds a prohibited class. +- Codex review test attempts were unrunnable in its read-only sandbox (`EROFS`/`EPERM`); the reviewer disclosed this rather than substituting a passing variant. Local writable-worktree tests remain the runnable evidence. diff --git a/gates/gates.manifest.json b/gates/gates.manifest.json index 8373caaa..16ec4a5e 100644 --- a/gates/gates.manifest.json +++ b/gates/gates.manifest.json @@ -33,21 +33,18 @@ "checkout-preflight/criterion-misbinding", "checkout-preflight/missing-meaning-provenance", "checkout-preflight/prose-claim-misbinding", - "checkout-preflight/history-seam-head", - "checkout-preflight/history-seam-parent", - "checkout-preflight/history-seam-introduction", - "checkout-preflight/provider-cross-commit-duplicate", "checkout-preflight/misspelled-outcome-field", "checkout-preflight/wrong-outcome-field-type", "checkout-preflight/empty-outcome-pattern", - "checkout-preflight/delayed-registry-introduction", "checkout-preflight/empty-registry-populations", "checkout-preflight/all-gates-evidence-subject-bound", "checkout-preflight/all-gates-type-strict", "ci-queue-wait/no-status-required", "ci-queue-wait/unknown-option", "hook-pre-commit/lint-staged-failure", - "hook-pre-push/typecheck-failure" + "hook-pre-push/typecheck-failure", + "checkout-preflight/history-provenance-exclusion", + "checkout-preflight/inventory-source-and-manifest-shrink" ] }, { @@ -113,18 +110,26 @@ }, { "id": "RM02-CURRENT-TREE-BOUNDARY", - "originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE \u2014 not against current main.", - "currentText": "PR CI performs unprivileged, fail-closed current-tree verification only; isolated per-commit replay is deferred to RM-60's protected post-merge/main authority as detection with quarantine/revert, not pre-merge prevention.", + "originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.", + "currentText": "PR CI performs unprivileged, fail-closed current-tree verification only and makes no history-provenance assertion; RM-60 owns that external protected property.", "claimType": "security", "source": "docs/PRD.md#rm02-req-10-meaning-change-provenance", "meaningChanges": [ { - "originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE \u2014 not against current main.", + "originalText": "assert that every merged commit passed every required gate, evaluated AGAINST THAT COMMIT'S OWN TREE — not against current main.", "restatement": "PR CI performs unprivileged, fail-closed current-tree verification only; isolated per-commit replay is deferred to a protected post-merge/main authority as detection with quarantine/revert, not pre-merge prevention.", "reason": "PR-controlled code would otherwise receive and could directly use the namespace capability intended to contain it; the pre-execution trust boundary is absent at the repository layer.", "finding": "D-25", "task": "RM-60/RM-59", "date": "2026-08-01" + }, + { + "originalText": "PR CI performs unprivileged, fail-closed current-tree verification only; isolated per-commit replay is deferred to RM-60's protected post-merge/main authority as detection with quarantine/revert, not pre-merge prevention.", + "restatement": "PR CI performs unprivileged, fail-closed current-tree verification only and makes no history-provenance assertion; RM-60 owns that external protected property.", + "reason": "D-48 proved PR lifecycle code can rewrite every local git anchor before gate execution, so the repository history claim is removed rather than annotated as unverifiable.", + "finding": "D-48", + "task": "RM-60", + "date": "2026-08-01" } ], "caseRefs": ["checkout-preflight/privileged-pr-gate"] @@ -237,30 +242,31 @@ "caseRefs": ["ci-queue-wait/terminal-success", "ci-queue-wait/unknown-option"] }, { - "id": "RM02-HISTORY-BOUNDARY", + "id": "RM02-HISTORY-PROVENANCE-EXCLUDED", "originalText": "The audited branch range begins at the provider target merge-base, sound against a branch author who cannot rewrite main but not against main compromise; Builds 1-2 own the residual.", - "currentText": "The audited branch range begins at the provider target merge-base, sound against a branch author who cannot rewrite main but not against main compromise; Builds 1-2 own the residual.", + "currentText": "This repository verifier is structurally incapable of asserting history provenance; RM-60 owns the provider-controlled protected pre-execution boundary required to establish it.", "claimType": "integrity", - "source": "docs/remediation/TASKS.md#d-17", - "meaningChanges": [], - "caseRefs": [ - "checkout-preflight/history-seam-head", - "checkout-preflight/history-seam-parent", - "checkout-preflight/history-seam-introduction", - "checkout-preflight/delayed-registry-introduction" - ] + "source": "docs/remediation/MISSION.md#first-class-principle-the-anchor-must-live-outside-the-audited-partys-authority", + "meaningChanges": [ + { + "originalText": "The audited branch range begins at the provider target merge-base, sound against a branch author who cannot rewrite main but not against main compromise; Builds 1-2 own the residual.", + "restatement": "This repository verifier is structurally incapable of asserting history provenance; RM-60 owns the provider-controlled protected pre-execution boundary required to establish it.", + "reason": "PR-controlled lifecycle code executes before gate verification, so no local ref, config, remote URL, constant, or author-positioned path is trustworthy enough to anchor history provenance.", + "finding": "D-48", + "task": "RM-60", + "date": "2026-08-01" + } + ], + "caseRefs": ["checkout-preflight/history-provenance-exclusion"] }, { "id": "RM02-EVIDENCE-SUBJECT-BINDING", - "originalText": "For every registered gate, evidence is bound to that gate subject under review; no evidence identity can certify a different or second subject.", - "currentText": "For every registered gate, evidence is bound to that gate subject under review; no evidence identity can certify a different or second subject.", + "originalText": "For every registered gate, consumed evidence carries an independently declared evidence-side subject that must match the gate definition.", + "currentText": "For every registered gate, consumed evidence carries an independently declared evidence-side subject that must match the gate definition.", "claimType": "integrity", "source": "docs/remediation/TASKS.md#d-38", "meaningChanges": [], - "caseRefs": [ - "checkout-preflight/provider-cross-commit-duplicate", - "checkout-preflight/all-gates-evidence-subject-bound" - ], + "caseRefs": ["checkout-preflight/all-gates-evidence-subject-bound"], "gateRefs": [ "quality-typecheck", "quality-lint", @@ -301,7 +307,10 @@ "claimType": "integrity", "source": "docs/remediation/TASKS.md#d-46", "meaningChanges": [], - "caseRefs": ["checkout-preflight/empty-registry-populations"], + "caseRefs": [ + "checkout-preflight/empty-registry-populations", + "checkout-preflight/inventory-source-and-manifest-shrink" + ], "gateRefs": [ "quality-typecheck", "quality-lint", @@ -391,11 +400,10 @@ } ], "mergeAssertions": { - "mode": "unprivileged-current-tree-pr-verification", + "mode": "unprivileged-current-tree-verification-with-history-provenance-excluded", "deferredReplayOwner": "RM-60", - "trustDependencies": ["RM-25", "RM-59", "RM-60", "Builds 1-2 main-integrity bootstrap"], - "providerEvidence": "assert retained current-tree terminal-success records for prior commits; report absent, expired, or current-running evidence without inference", - "postMergeResponse": "protected isolated replay is detection, not prevention; quarantine and revert on failure" + "trustDependencies": ["RM-60"], + "postMergeResponse": "RM-60 defines provider-owned protected execution, quarantine, and revert behavior" }, "gates": [ { @@ -427,7 +435,10 @@ "actual": { "exitCode": 0 }, - "reasonPattern": "" + "reasonPattern": "", + "evidence": { + "subject": "quality-typecheck" + } }, { "id": "type-error", @@ -452,10 +463,12 @@ "content": "export const gateTypeError: string = 42;\n" } ] + }, + "evidence": { + "subject": "quality-typecheck" } } - ], - "evidenceSubject": "quality-typecheck" + ] }, { "id": "quality-lint", @@ -486,7 +499,10 @@ "actual": { "exitCode": 0 }, - "reasonPattern": "" + "reasonPattern": "", + "evidence": { + "subject": "quality-lint" + } }, { "id": "invalid-syntax", @@ -511,10 +527,12 @@ "content": "export const = ;\n" } ] + }, + "evidence": { + "subject": "quality-lint" } } - ], - "evidenceSubject": "quality-lint" + ] }, { "id": "quality-format", @@ -545,7 +563,10 @@ "actual": { "exitCode": 0 }, - "reasonPattern": "" + "reasonPattern": "", + "evidence": { + "subject": "quality-format" + } }, { "id": "unformatted-json", @@ -567,10 +588,12 @@ "content": "{\"bad\":true,\"spacing\":[1,2,3]}\n" } ] + }, + "evidence": { + "subject": "quality-format" } } - ], - "evidenceSubject": "quality-format" + ] }, { "id": "checkout-preflight", @@ -603,7 +626,10 @@ "exitCode": 0, "outputPattern": "checkout preflight passed" }, - "reasonPattern": "" + "reasonPattern": "", + "evidence": { + "subject": "checkout-preflight" + } }, { "id": "stale-build-lock", @@ -625,6 +651,9 @@ "content": "negative control\n" } ] + }, + "evidence": { + "subject": "checkout-preflight" } }, { @@ -650,6 +679,9 @@ "replace": " gate-verify:\n image: *node_image\n privileged: true\n" } ] + }, + "evidence": { + "subject": "checkout-preflight" } }, { @@ -675,11 +707,7 @@ }, "reasonPattern": "RM02-SET-COVERS: declared exercising case checkout-preflight/criterion-misbinding is not bound", "fixture": { - "copyPaths": [ - "gates/gates.manifest.json", - "scripts/gate-verify.mjs", - "scripts/gate-history.mjs" - ], + "copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"], "replaceFiles": [ { "path": "gates/gates.manifest.json", @@ -692,6 +720,9 @@ "replace": "\"criterionIds\": [\"RM02-CHECK-RIGHT\"]," } ] + }, + "evidence": { + "subject": "checkout-preflight" } }, { @@ -717,11 +748,7 @@ }, "reasonPattern": "RM02-MEANING-PROVENANCE: missing meaning-change provenance", "fixture": { - "copyPaths": [ - "gates/gates.manifest.json", - "scripts/gate-verify.mjs", - "scripts/gate-history.mjs" - ], + "copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"], "replaceFiles": [ { "path": "gates/gates.manifest.json", @@ -729,6 +756,9 @@ "replace": "\"currentText\": \"A restated criterion changed without provenance\"," } ] + }, + "evidence": { + "subject": "checkout-preflight" } }, { @@ -754,11 +784,7 @@ }, "reasonPattern": "GATE-CLAIM:PROSE-IS-A-CLAIM exercising case quality-typecheck/type-error does not exercise criterion RM02-PROSE-CONTROL", "fixture": { - "copyPaths": [ - "gates/gates.manifest.json", - "scripts/gate-verify.mjs", - "scripts/gate-history.mjs" - ], + "copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"], "replaceFiles": [ { "path": "gates/gates.manifest.json", @@ -766,68 +792,11 @@ "replace": "\"caseRef\": \"quality-typecheck/type-error\"" } ] + }, + "evidence": { + "subject": "checkout-preflight" } }, - { - "id": "history-seam-head", - "criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-BOUNDARY"], - "mustFail": true, - "invocation": ["node", "scripts/gate-history-boundary-control.mjs", "head"], - "required": { - "exitCode": 1, - "outputPattern": "history boundary candidate head rejected" - }, - "actual": { - "exitCode": 1, - "outputPattern": "history boundary candidate head rejected" - }, - "reasonPattern": "derived activation is provider target merge-base" - }, - { - "id": "history-seam-parent", - "criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-BOUNDARY"], - "mustFail": true, - "invocation": ["node", "scripts/gate-history-boundary-control.mjs", "parent"], - "required": { - "exitCode": 1, - "outputPattern": "history boundary candidate parent rejected" - }, - "actual": { - "exitCode": 1, - "outputPattern": "history boundary candidate parent rejected" - }, - "reasonPattern": "derived activation is provider target merge-base" - }, - { - "id": "history-seam-introduction", - "criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-BOUNDARY"], - "mustFail": true, - "invocation": ["node", "scripts/gate-history-boundary-control.mjs", "introduction"], - "required": { - "exitCode": 1, - "outputPattern": "history boundary candidate introduction rejected" - }, - "actual": { - "exitCode": 1, - "outputPattern": "history boundary candidate introduction rejected" - }, - "reasonPattern": "derived activation is provider target merge-base" - }, - { - "id": "provider-cross-commit-duplicate", - "criterionIds": ["RM02-CHECK-RIGHT", "RM02-EVIDENCE-SUBJECT-BINDING"], - "mustFail": true, - "invocation": ["node", "scripts/gate-provider-binding-control.mjs"], - "required": { - "exitCode": 1, - "outputPattern": "duplicate pipeline identity across commits" - }, - "actual": { - "exitCode": 1, - "outputPattern": "duplicate pipeline identity across commits" - }, - "reasonPattern": "duplicate pipeline identity across commits" - }, { "id": "misspelled-outcome-field", "criterionIds": ["RM02-CHECK-RIGHT", "RM02-TYPE-STRICT-SCHEMA"], @@ -851,11 +820,7 @@ }, "reasonPattern": "required: unknown field outputPatern", "fixture": { - "copyPaths": [ - "gates/gates.manifest.json", - "scripts/gate-verify.mjs", - "scripts/gate-history.mjs" - ], + "copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"], "replaceFiles": [ { "path": "gates/gates.manifest.json", @@ -863,6 +828,9 @@ "replace": "\"required\": {\n \"exitCode\": 0,\n \"outputPatern\": \"checkout preflight passed\"\n },\n \"actual\":" } ] + }, + "evidence": { + "subject": "checkout-preflight" } }, { @@ -888,11 +856,7 @@ }, "reasonPattern": "required.exitCode: expected an integer", "fixture": { - "copyPaths": [ - "gates/gates.manifest.json", - "scripts/gate-verify.mjs", - "scripts/gate-history.mjs" - ], + "copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"], "replaceFiles": [ { "path": "gates/gates.manifest.json", @@ -900,6 +864,9 @@ "replace": "\"required\": {\n \"exitCode\": \"0\",\n \"outputPattern\": \"checkout preflight passed\"\n },\n \"actual\":" } ] + }, + "evidence": { + "subject": "checkout-preflight" } }, { @@ -925,11 +892,7 @@ }, "reasonPattern": "required.outputPattern: expected a non-empty pattern", "fixture": { - "copyPaths": [ - "gates/gates.manifest.json", - "scripts/gate-verify.mjs", - "scripts/gate-history.mjs" - ], + "copyPaths": ["gates/gates.manifest.json", "scripts/gate-verify.mjs"], "replaceFiles": [ { "path": "gates/gates.manifest.json", @@ -937,23 +900,11 @@ "replace": "\"required\": {\n \"exitCode\": 0,\n \"outputPattern\": \" \"\n },\n \"actual\":" } ] + }, + "evidence": { + "subject": "checkout-preflight" } }, - { - "id": "delayed-registry-introduction", - "criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-BOUNDARY"], - "mustFail": true, - "invocation": ["node", "scripts/gate-delayed-introduction-control.mjs"], - "required": { - "exitCode": 1, - "outputPattern": "delayed registry introduction rejected" - }, - "actual": { - "exitCode": 1, - "outputPattern": "delayed registry introduction rejected" - }, - "reasonPattern": "own-tree registry cannot be read" - }, { "id": "empty-registry-populations", "criterionIds": ["RM02-CHECK-RIGHT", "RM02-NONEMPTY-ANCHORED-QUANTIFICATION"], @@ -967,7 +918,10 @@ "exitCode": 1, "outputPattern": "empty universally quantified registry populations rejected" }, - "reasonPattern": "empty universally quantified registry populations rejected" + "reasonPattern": "empty universally quantified registry populations rejected", + "evidence": { + "subject": "checkout-preflight" + } }, { "id": "all-gates-evidence-subject-bound", @@ -982,7 +936,10 @@ "exitCode": 1, "outputPattern": "evidence-subject population control rejected every registered gate" }, - "reasonPattern": "evidence-subject population control rejected every registered gate" + "reasonPattern": "evidence-subject population control rejected every registered gate", + "evidence": { + "subject": "checkout-preflight" + } }, { "id": "all-gates-type-strict", @@ -997,10 +954,58 @@ "exitCode": 1, "outputPattern": "type-strict population control rejected every registered gate" }, - "reasonPattern": "type-strict population control rejected every registered gate" + "reasonPattern": "type-strict population control rejected every registered gate", + "evidence": { + "subject": "checkout-preflight" + } + }, + { + "id": "history-provenance-exclusion", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-HISTORY-PROVENANCE-EXCLUDED"], + "mustFail": true, + "invocation": ["node", "scripts/gate-history-exclusion-control.mjs"], + "required": { + "exitCode": 79, + "outputPattern": "HISTORY_PROVENANCE_FORBIDDEN" + }, + "actual": { + "exitCode": 79, + "outputPattern": "HISTORY_PROVENANCE_FORBIDDEN" + }, + "reasonPattern": "HISTORY_PROVENANCE_FORBIDDEN", + "fixture": { + "copyPaths": ["scripts/gate-history-exclusion-control.mjs", "scripts/gate-verify.mjs"], + "replaceFiles": [ + { + "path": "scripts/gate-verify.mjs", + "find": "const CURRENT_TREE_OBSERVATION_PATTERNS = [\n /^META-NEGATIVE-CONTROL /,", + "replace": "const CURRENT_TREE_OBSERVATION_PATTERNS = [\n /^COMMIT ANCESTRY VERIFIED /,\n /^META-NEGATIVE-CONTROL /," + } + ] + }, + "evidence": { + "subject": "checkout-preflight" + } + }, + { + "id": "inventory-source-and-manifest-shrink", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-NONEMPTY-ANCHORED-QUANTIFICATION"], + "mustFail": true, + "invocation": ["node", "scripts/gate-inventory-shrink-control.mjs"], + "required": { + "exitCode": 83, + "outputPattern": "INVENTORY_SHRINK_REJECTED" + }, + "actual": { + "exitCode": 83, + "outputPattern": "INVENTORY_SHRINK_REJECTED" + }, + "reasonPattern": "INVENTORY_SHRINK_REJECTED", + "evidence": { + "subject": "checkout-preflight" + } } - ], - "evidenceSubject": "checkout-preflight" + ] }, { "id": "ci-queue-wait", @@ -1083,6 +1088,9 @@ "defect": { "owner": "RM-03", "reason": "The status classifier consumes its Python program from stdin, so piped provider JSON is not read and even terminal success becomes unknown." + }, + "evidence": { + "subject": "ci-queue-wait" } }, { @@ -1145,6 +1153,9 @@ "defect": { "owner": "RM-03", "reason": "The status classifier does not read the provider payload, so --require-status never reaches no-status." + }, + "evidence": { + "subject": "ci-queue-wait" } }, { @@ -1194,6 +1205,9 @@ "content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n" } ] + }, + "evidence": { + "subject": "ci-queue-wait" } }, { @@ -1243,6 +1257,9 @@ "content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n" } ] + }, + "evidence": { + "subject": "ci-queue-wait" } }, { @@ -1292,6 +1309,9 @@ "content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n" } ] + }, + "evidence": { + "subject": "ci-queue-wait" } }, { @@ -1351,6 +1371,9 @@ "content": "#!/bin/sh\ncase \"$*\" in\n *'/branches/'*) printf '{\"commit\":{\"id\":\"gate-sha\"}}\\n200' ;;\n *'/commits/'*) printf '%s' \"$GATE_STATUS_JSON\" ;;\nesac\n" } ] + }, + "evidence": { + "subject": "ci-queue-wait" } }, { @@ -1374,10 +1397,12 @@ "fixture": { "copyPaths": ["packages/mosaic/framework/tools/git"], "writeFiles": [] + }, + "evidence": { + "subject": "ci-queue-wait" } } - ], - "evidenceSubject": "ci-queue-wait" + ] }, { "id": "hook-pre-commit", @@ -1423,6 +1448,9 @@ "content": "#!/bin/sh\necho FAKE_NPX_EXIT=$FAKE_EXIT >&2\nexit \"$FAKE_EXIT\"\n" } ] + }, + "evidence": { + "subject": "hook-pre-commit" } }, { @@ -1451,10 +1479,12 @@ "content": "#!/bin/sh\necho FAKE_NPX_EXIT=$FAKE_EXIT >&2\nexit \"$FAKE_EXIT\"\n" } ] + }, + "evidence": { + "subject": "hook-pre-commit" } } - ], - "evidenceSubject": "hook-pre-commit" + ] }, { "id": "hook-pre-push", @@ -1500,6 +1530,9 @@ "content": "#!/bin/sh\nif [ \"$1\" = \"$FAIL_PNPM_COMMAND\" ]; then echo FAKE_PNPM_FAILURE=$1 >&2; exit 19; fi\nexit 0\n" } ] + }, + "evidence": { + "subject": "hook-pre-push" } }, { @@ -1528,10 +1561,12 @@ "content": "#!/bin/sh\nif [ \"$1\" = \"$FAIL_PNPM_COMMAND\" ]; then echo FAKE_PNPM_FAILURE=$1 >&2; exit 19; fi\nexit 0\n" } ] + }, + "evidence": { + "subject": "hook-pre-push" } } - ], - "evidenceSubject": "hook-pre-push" + ] } ] } diff --git a/gates/required-gates.baseline.json b/gates/required-gates.baseline.json new file mode 100644 index 00000000..992c4a88 --- /dev/null +++ b/gates/required-gates.baseline.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "purpose": "Independent required-gate population baseline; manifest and verifier inventory must both match.", + "gates": [ + { "id": "quality-typecheck", "source": "package.json" }, + { "id": "quality-lint", "source": "package.json" }, + { "id": "quality-format", "source": "package.json" }, + { "id": "checkout-preflight", "source": "scripts/preflight.mjs" }, + { + "id": "ci-queue-wait", + "source": "packages/mosaic/framework/tools/git/ci-queue-wait.sh" + }, + { "id": "hook-pre-commit", "source": ".husky/pre-commit" }, + { "id": "hook-pre-push", "source": ".husky/pre-push" } + ] +} diff --git a/scripts/gate-delayed-introduction-control.mjs b/scripts/gate-delayed-introduction-control.mjs deleted file mode 100644 index ae864e99..00000000 --- a/scripts/gate-delayed-introduction-control.mjs +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env node - -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; - -import { verifyHistory } from './gate-history.mjs'; - -const root = await mkdtemp(path.join(os.tmpdir(), 'gate-delayed-introduction-')); -function git(...args) { - const result = spawnSync( - 'git', - ['-c', 'user.name=gate-control', '-c', 'user.email=gate-control@example.invalid', ...args], - { cwd: root, encoding: 'utf8' }, - ); - if (result.status !== 0) throw new Error(result.stderr || result.stdout); - return result.stdout.trim(); -} - -try { - git('init', '-q'); - await writeFile(path.join(root, 'baseline.txt'), 'baseline\n'); - git('add', '.'); - git('commit', '-m', 'provider target baseline'); - const baseline = git('rev-parse', 'HEAD'); - git('update-ref', 'refs/remotes/origin/main', baseline); - await mkdir(path.join(root, 'scripts'), { recursive: true }); - await writeFile(path.join(root, 'scripts', 'preflight.mjs'), 'process.exit(0);\n'); - git('add', '.'); - git('commit', '-m', 'gate change before registry'); - const unregisteredCommit = git('rev-parse', 'HEAD'); - await mkdir(path.join(root, 'gates'), { recursive: true }); - await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); - git('add', '.'); - git('commit', '-m', 'delayed registry introduction'); - - const previousBranch = process.env.CI_COMMIT_BRANCH; - process.env.CI_COMMIT_BRANCH = 'feature/delayed-introduction-control'; - let result; - try { - result = await verifyHistory({ root, manifest: { schemaVersion: 1 } }); - } finally { - if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH; - else process.env.CI_COMMIT_BRANCH = previousBranch; - } - const detail = result.failures.join('\n'); - if (detail.includes(unregisteredCommit) && /own-tree registry cannot be read/i.test(detail)) { - process.stderr.write(`delayed registry introduction rejected: ${detail}\n`); - process.exitCode = 1; - } else { - process.stdout.write('delayed registry introduction was not rejected\n'); - } -} finally { - await rm(root, { recursive: true, force: true }); -} diff --git a/scripts/gate-empty-population-control.mjs b/scripts/gate-empty-population-control.mjs index 27a5dc82..b73404c9 100644 --- a/scripts/gate-empty-population-control.mjs +++ b/scripts/gate-empty-population-control.mjs @@ -20,7 +20,6 @@ try { const result = await verifyRegistry({ root, manifest: manifestPath, - skipHistory: true, structureOnly: true, fixtureProfile: false, }); diff --git a/scripts/gate-history-boundary-control.mjs b/scripts/gate-history-boundary-control.mjs deleted file mode 100644 index 687bd0e5..00000000 --- a/scripts/gate-history-boundary-control.mjs +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env node - -import { spawnSync } from 'node:child_process'; - -import { deriveHistoryBoundary } from './gate-history.mjs'; - -const candidateKind = process.argv[2]; -const root = process.cwd(); -const boundary = deriveHistoryBoundary(root); - -function revParse(revision) { - const result = spawnSync('git', ['rev-parse', revision], { - cwd: root, - encoding: 'utf8', - }); - if (result.status !== 0) { - process.stderr.write(`history boundary control could not resolve ${revision}\n`); - process.exit(2); - } - return result.stdout.trim(); -} - -const candidates = { - head: revParse('HEAD'), - parent: revParse('HEAD^'), - introduction: boundary.introductionCommit, -}; -if (!Object.hasOwn(candidates, candidateKind)) { - process.stderr.write(`unknown history boundary candidate ${String(candidateKind)}\n`); - process.exit(2); -} -const candidate = candidates[candidateKind]; -if (candidate === boundary.activationCommit) { - process.stdout.write(`history boundary candidate ${candidateKind} matched derived activation\n`); - process.exit(0); -} -process.stderr.write( - `history boundary candidate ${candidateKind} rejected: derived activation is provider target merge-base\n`, -); -process.exit(1); diff --git a/scripts/gate-history-exclusion-control.mjs b/scripts/gate-history-exclusion-control.mjs new file mode 100644 index 00000000..dfe9eaaf --- /dev/null +++ b/scripts/gate-history-exclusion-control.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { assertCurrentTreeObservation } from './gate-verify.mjs'; + +const prohibitedReports = [ + 'HISTORY PROVENANCE VERIFIED local-ref', + 'COMMIT ANCESTRY VERIFIED origin/main', + 'PROVIDER LINEAGE SUCCESS pipeline-7', +]; +const accepted = prohibitedReports.filter((report) => { + try { + assertCurrentTreeObservation(report); + return true; + } catch { + return false; + } +}); + +const verifier = await readFile(path.join(process.cwd(), 'scripts', 'gate-verify.mjs'), 'utf8'); +const outputWrites = [...verifier.matchAll(/process\.stdout\.write\s*\(/g)].length; +const productionRendererWired = + outputWrites === 2 && + /for \(const observation of observations\) \{\s*assertCurrentTreeObservation\(observation\);\s*process\.stdout\.write/s.test( + verifier, + ) && + !/\bconsole\.(?:log|info|debug)\s*\(/.test(verifier); + +if (accepted.length > 0 || !productionRendererWired) { + process.stderr.write( + `HISTORY_PROVENANCE_FORBIDDEN: closed current-tree observation renderer rejected=${prohibitedReports.length - accepted.length}/${prohibitedReports.length} production-wired=${productionRendererWired}\n`, + ); + process.exit(79); +} +process.stdout.write('history provenance reporting capability is absent; owner RM-60\n'); diff --git a/scripts/gate-history.mjs b/scripts/gate-history.mjs deleted file mode 100644 index ee770c4e..00000000 --- a/scripts/gate-history.mjs +++ /dev/null @@ -1,432 +0,0 @@ -import { existsSync } from 'node:fs'; -import { createHash, randomUUID } from 'node:crypto'; -import { access, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm } from 'node:fs/promises'; -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; - -function git(root, args, { allowFailure = false } = {}) { - const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' }); - if (result.status !== 0 && !allowFailure) { - throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || result.stdout).trim()}`); - } - return result; -} - -export async function listProspectiveCommits(root, activationCommit, head = 'HEAD') { - const result = git(root, [ - 'rev-list', - '--first-parent', - '--reverse', - `${activationCommit}..${head}`, - ]); - return result.stdout.trim() ? result.stdout.trim().split('\n') : []; -} - -export function deriveHistoryBoundary(root, head = 'HEAD') { - const targetRef = 'refs/remotes/origin/main'; - const target = git(root, ['rev-parse', '--verify', targetRef], { allowFailure: true }); - if (target.status !== 0 || !target.stdout.trim()) { - throw new Error(`history boundary cannot be derived: provider target ${targetRef} is absent`); - } - const introductions = git(root, [ - 'log', - '--first-parent', - '--diff-filter=A', - '--format=%H', - '--reverse', - head, - '--', - 'gates/gates.manifest.json', - ]) - .stdout.trim() - .split('\n') - .filter(Boolean); - if (introductions.length === 0) { - throw new Error('history boundary cannot be derived: registry introduction is absent'); - } - const introductionCommit = introductions[0]; - const headOnTarget = git(root, ['merge-base', '--is-ancestor', head, targetRef], { - allowFailure: true, - }); - const activation = - headOnTarget.status === 0 - ? git(root, ['rev-parse', `${head}^`], { allowFailure: true }) - : git(root, ['merge-base', head, targetRef], { allowFailure: true }); - if (activation.status !== 0 || !activation.stdout.trim()) { - throw new Error( - `history boundary cannot be derived: provider target merge-base for ${head} is unavailable`, - ); - } - return { - activationCommit: activation.stdout.trim(), - introductionCommit, - targetRef, - }; -} - -export async function readManifestAtCommit(root, commit) { - const result = git(root, ['show', `${commit}:gates/gates.manifest.json`]); - return JSON.parse(result.stdout); -} - -async function snapshotAuthoritativeTree(root) { - const snapshot = new Map(); - async function walk(current) { - for (const child of await readdir(current, { withFileTypes: true })) { - if (['.git', '.home', 'node_modules'].includes(child.name)) continue; - const absolute = path.join(current, child.name); - const relative = path.relative(root, absolute).split(path.sep).join('/'); - const stats = await lstat(absolute); - if (stats.isDirectory()) { - await walk(absolute); - } else if (stats.isSymbolicLink()) { - snapshot.set(relative, `symlink:${stats.mode}:${await readlink(absolute)}`); - } else if (stats.isFile()) { - const digest = createHash('sha256') - .update(await readFile(absolute)) - .digest('hex'); - snapshot.set(relative, `file:${stats.mode}:${digest}`); - } - } - } - await walk(root); - return snapshot; -} - -async function authoritativeTreeChanges(root, snapshot) { - const changes = []; - for (const [relative, expected] of snapshot) { - const absolute = path.join(root, relative); - let actual; - try { - const stats = await lstat(absolute); - if (stats.isSymbolicLink()) { - actual = `symlink:${stats.mode}:${await readlink(absolute)}`; - } else if (stats.isFile()) { - const digest = createHash('sha256') - .update(await readFile(absolute)) - .digest('hex'); - actual = `file:${stats.mode}:${digest}`; - } else { - actual = `other:${stats.mode}`; - } - } catch (error) { - if (error.code !== 'ENOENT') throw error; - actual = 'missing'; - } - if (actual !== expected) changes.push(relative); - } - return changes; -} - -function bubblewrap(root, command, args, { storePath, timeout = 300_000 } = {}) { - const sandboxArgs = [ - '--unshare-net', - '--unshare-pid', - '--unshare-ipc', - '--unshare-uts', - '--die-with-parent', - '--new-session', - '--clearenv', - ]; - for (const systemPath of ['/usr', '/bin', '/lib', '/lib64', '/etc']) { - if (existsSync(systemPath)) sandboxArgs.push('--ro-bind', systemPath, systemPath); - } - sandboxArgs.push('--dev', '/dev', '--proc', '/proc', '--tmpfs', '/tmp', '--bind', root, '/work'); - if (storePath) sandboxArgs.push('--ro-bind', storePath, '/pnpm-store'); - const corepackHome = path.join(process.env.HOME ?? '', '.cache', 'node', 'corepack'); - if (existsSync(corepackHome)) sandboxArgs.push('--ro-bind', corepackHome, '/corepack'); - sandboxArgs.push( - '--chdir', - '/work', - '--setenv', - 'HOME', - '/work/.home', - '--setenv', - 'PATH', - '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', - '--setenv', - 'LANG', - 'C.UTF-8', - '--setenv', - 'CI', - 'true', - ); - if (storePath) sandboxArgs.push('--setenv', 'NPM_CONFIG_STORE_DIR', '/pnpm-store'); - if (existsSync(corepackHome)) sandboxArgs.push('--setenv', 'COREPACK_HOME', '/corepack'); - const enteredMarker = `__MOSAIC_BWRAP_ENTERED_${randomUUID()}__`; - sandboxArgs.push( - '/bin/sh', - '-c', - 'printf "%s\\n" "$1"; shift; exec "$@"', - 'mosaic-bwrap-entry', - enteredMarker, - command, - ...args, - ); - const result = spawnSync('bwrap', sandboxArgs, { encoding: 'utf8', timeout }); - const sandboxEntered = result.stdout?.includes(enteredMarker) === true; - return { - ...result, - stdout: (result.stdout ?? '').replace(`${enteredMarker}\n`, ''), - sandboxLauncher: 'bwrap', - sandboxEntered, - }; -} - -export async function replayCommit(root, commit) { - const replayRoot = await mkdtemp( - path.join(path.dirname(root), `.gate-history-${commit.slice(0, 12)}-`), - ); - const archive = `${replayRoot}.tar`; - try { - git(root, ['archive', '--format=tar', `--output=${archive}`, commit]); - const extract = spawnSync('tar', ['-xf', archive, '-C', replayRoot], { encoding: 'utf8' }); - if (extract.status !== 0) { - return { status: extract.status, stdout: extract.stdout, stderr: extract.stderr }; - } - const authoritativeSnapshot = await snapshotAuthoritativeTree(replayRoot); - await mkdir(path.join(replayRoot, '.home'), { recursive: true }); - let storePath; - try { - await access(path.join(replayRoot, 'package.json')); - const init = spawnSync('git', ['init', '--quiet', replayRoot], { encoding: 'utf8' }); - if (init.status !== 0) return init; - const store = spawnSync('pnpm', ['store', 'path'], { encoding: 'utf8' }); - if (store.status !== 0) return store; - storePath = store.stdout.trim(); - const install = bubblewrap( - replayRoot, - 'pnpm', - ['install', '--frozen-lockfile', '--offline'], - { storePath, timeout: 600_000 }, - ); - if (install.status !== 0 || install.error || install.signal) { - return { - ...install, - stderr: `historical frozen dependency install failed: ${install.error?.message || install.stderr || install.stdout || ''}`, - }; - } - const authoritativeChanges = await authoritativeTreeChanges( - replayRoot, - authoritativeSnapshot, - ); - if (authoritativeChanges.length > 0) { - return { - status: 1, - stdout: '', - stderr: `authoritative archived file changed during historical install: ${authoritativeChanges.join(', ')}`, - }; - } - } catch (error) { - if (error.code !== 'ENOENT') throw error; - } - return bubblewrap( - replayRoot, - process.execPath, - [ - '/work/scripts/gate-verify.mjs', - '--root', - '/work', - '--manifest', - 'gates/gates.manifest.json', - '--skip-history', - ], - { storePath }, - ); - } finally { - await rm(archive, { force: true }); - await rm(replayRoot, { recursive: true, force: true }); - } -} - -export function validateProviderEvidenceCollection(pipelines) { - if (!Array.isArray(pipelines)) return 'provider evidence collection is malformed'; - const pipelineStates = new Set(['success', 'failure', 'error', 'pending', 'running', 'queued']); - const stepStates = new Set([ - 'success', - 'failure', - 'error', - 'pending', - 'running', - 'queued', - 'skipped', - ]); - const malformed = pipelines.some( - (candidate) => - !candidate || - typeof candidate !== 'object' || - Array.isArray(candidate) || - typeof candidate.commit !== 'string' || - candidate.commit.length === 0 || - !Number.isInteger(candidate.number) || - !pipelineStates.has(candidate.status) || - !Array.isArray(candidate.steps) || - candidate.steps.some( - (step) => - !step || - typeof step !== 'object' || - Array.isArray(step) || - typeof step.name !== 'string' || - typeof step.status !== 'string' || - !stepStates.has(step.status), - ), - ); - if (malformed) return 'provider records are malformed'; - const ambiguousGateRecord = pipelines.find( - (candidate) => candidate.steps.filter((step) => step.name === 'gate-verify').length !== 1, - ); - if (ambiguousGateRecord) { - const count = ambiguousGateRecord.steps.filter((step) => step.name === 'gate-verify').length; - return `provider record ${ambiguousGateRecord.number} has ambiguous gate-verify step count ${count}`; - } - const numbers = pipelines.map((candidate) => candidate.number); - if (new Set(numbers).size !== numbers.length) { - return 'duplicate pipeline identity across commits in provider evidence collection'; - } - return undefined; -} - -export function assessProviderEvidence(commit, pipelines) { - const collectionFailure = validateProviderEvidenceCollection(pipelines); - if (collectionFailure) { - return { - state: 'terminal-failure', - detail: collectionFailure, - }; - } - const matches = pipelines.filter((candidate) => candidate.commit === commit); - if (matches.length === 0) { - return { - state: 'absent', - detail: - 'no retained provider record was supplied; retention expiry and never-ran are not inferred', - }; - } - const pipeline = [...matches].sort((left, right) => right.number - left.number)[0]; - const gateSteps = (pipeline.steps ?? []).filter((step) => step.name === 'gate-verify'); - if (gateSteps.length !== 1) { - return { - state: 'terminal-failure', - detail: `provider record has ambiguous gate-verify step count ${gateSteps.length}`, - }; - } - const [gateStep] = gateSteps; - if (pipeline.status === 'success' && gateStep.status === 'success') { - return { state: 'terminal-success', detail: 'pipeline and gate-verify step succeeded' }; - } - if (['pending', 'running', 'queued'].includes(pipeline.status)) { - return { state: 'current-running', detail: `pipeline is ${pipeline.status}` }; - } - return { - state: 'terminal-failure', - detail: `pipeline=${pipeline.status ?? 'unknown'}, gate-verify=${gateStep?.status ?? 'absent'}`, - }; -} - -async function loadProviderEvidence() { - const evidenceFile = process.env.GATE_PROVIDER_EVIDENCE_FILE; - if (!evidenceFile) return []; - const parsed = JSON.parse(await readFile(evidenceFile, 'utf8')); - if (!Array.isArray(parsed)) throw new Error('provider evidence file must contain a JSON array'); - return parsed; -} - -function isMainCommit(root, head) { - if (process.env.CI_COMMIT_BRANCH === 'main') return true; - const result = git(root, ['merge-base', '--is-ancestor', head, 'refs/remotes/origin/main'], { - allowFailure: true, - }); - return result.status === 0; -} - -export async function verifyHistory({ root, manifest }) { - const failures = []; - const observations = []; - const head = git(root, ['rev-parse', 'HEAD']).stdout.trim(); - if (Object.hasOwn(manifest, 'activationCommit')) { - failures.push( - 'author-controlled activationCommit is forbidden; history boundary is derived from the registry introduction', - ); - } - let boundary; - try { - boundary = deriveHistoryBoundary(root, head); - } catch (error) { - failures.push(error.message); - return { failures, observations }; - } - const onMain = isMainCommit(root, head); - // RM-02 history bootstrap boundary (Builds 1-2), kept adjacent in both directions: - // DOES: anchor feature history to the provider target merge-base, sound against an author who - // cannot rewrite main. - // DOES NOT: establish integrity when main itself is compromised; Builds 1-2 own that residual. - observations.push( - `RM-02 HISTORY BOOTSTRAP BOUNDARY ${head}: DOES: anchor the audited range to provider target ${boundary.targetRef} at merge-base ${boundary.activationCommit}, sound against a branch author who cannot rewrite main; DOES NOT: protect against compromise or rewrite of main; residual owner Builds 1-2`, - ); - // RM-02 execution boundary (RM-60, cross-reference RM-59), kept adjacent in both directions: - // DOES: run every registered current-tree gate and declared inerting mutation on PR CI, - // unprivileged and fail-closed. - // DOES NOT: execute a commit's own verifier in an isolated PR replay. PR-controlled code would - // otherwise need the namespace capability intended to contain that same code. That external - // trust boundary must be runner/provider-owned before any PR executable or config is evaluated. - observations.push( - `RM-02 EXECUTION BOUNDARY ${head}: DOES: verify the current tree and declared inerting mutations on every PR, unprivileged and fail-closed; DOES NOT: execute isolated per-commit verifier replay in repository-controlled CI; owner RM-60, cross-reference RM-59`, - ); - if (!onMain) { - observations.push( - `PROVIDER ASSERTION DEFERRED ${head}: commit is not yet on main; retained provider evidence starts after merge and no replay success is inferred`, - ); - } - - const pipelines = onMain ? await loadProviderEvidence() : []; - const collectionFailure = validateProviderEvidenceCollection(pipelines); - if (collectionFailure) { - failures.push(`provider evidence collection invalid: ${collectionFailure}`); - } - const commits = await listProspectiveCommits(root, boundary.activationCommit, head); - for (const commit of commits) { - let commitManifest; - try { - commitManifest = await readManifestAtCommit(root, commit); - } catch (error) { - failures.push(`${commit}: own-tree registry cannot be read: ${error.message}`); - continue; - } - if (commitManifest.schemaVersion !== manifest.schemaVersion) { - failures.push(`${commit}: own-tree registry schema is not supported`); - continue; - } - const evidence = assessProviderEvidence(commit, pipelines); - if (commit === head) { - observations.push( - `CURRENT TREE EVALUATED ${commit}: all registered cases ran from this checkout; provider evidence=${evidence.state} (${evidence.detail})`, - ); - continue; - } - observations.push( - `INTERMEDIATE REPLAY DEFERRED ${commit}: isolated own-tree execution is not performed by repository-controlled CI; owner RM-60, cross-reference RM-59; no success is inferred`, - ); - if (!onMain) { - observations.push( - `PROVIDER EVIDENCE ${commit}: DEFERRED until the commit is on main; no success is inferred`, - ); - continue; - } - observations.push( - `POST-MERGE DETECTION BOUNDARY ${commit}: protected isolated replay awaits RM-60; when available, a failure requires quarantine/revert and is detection, not pre-merge prevention`, - ); - if (evidence.state === 'terminal-failure') { - failures.push( - `${commit}: retained provider evidence is not terminal-success (${evidence.detail})`, - ); - } else if (evidence.state === 'terminal-success') { - observations.push(`PROVIDER EVIDENCE ${commit}: terminal-success (${evidence.detail})`); - } else { - observations.push( - `PROVIDER EVIDENCE ${commit}: ${evidence.state.toUpperCase()} (${evidence.detail}); no merge-time success is inferred`, - ); - } - } - return { failures, observations }; -} diff --git a/scripts/gate-history.test.mjs b/scripts/gate-history.test.mjs deleted file mode 100644 index 746efffc..00000000 --- a/scripts/gate-history.test.mjs +++ /dev/null @@ -1,615 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdir, rm, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { spawn, spawnSync } from 'node:child_process'; -import test from 'node:test'; - -import { - assessProviderEvidence, - deriveHistoryBoundary, - listProspectiveCommits, - readManifestAtCommit, - replayCommit, - verifyHistory, -} from './gate-history.mjs'; - -const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `gate-history-${process.pid}`); - -function sandboxUnavailable(result) { - if (result.sandboxLauncher !== 'bwrap' || result.sandboxEntered === true) return false; - const detail = `${result.stdout ?? ''}${result.stderr ?? ''}${result.error?.message ?? ''}`; - const bubblewrapSpawnDenied = - ['EPERM', 'EACCES', 'ENOENT'].includes(result.error?.code) && - /spawnSync bwrap/i.test(result.error?.message ?? ''); - if ( - !bubblewrapSpawnDenied && - !/bwrap:.*(?:Operation not permitted|Creating new namespace failed)/i.test(detail) - ) { - return false; - } - assert.notEqual(result.status, 0, 'sandbox unavailability must remain terminal nonzero'); - return true; -} - -function git(root, ...args) { - const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' }); - assert.equal(result.status, 0, result.stderr); - return result.stdout.trim(); -} - -async function commitManifest(root, marker) { - await mkdir(path.join(root, 'gates'), { recursive: true }); - await writeFile( - path.join(root, 'gates', 'gates.manifest.json'), - `${JSON.stringify({ schemaVersion: 1, marker })}\n`, - ); - git(root, 'add', '.'); - git(root, 'commit', '-m', marker); - return git(root, 'rev-parse', 'HEAD'); -} - -test.after(async () => { - await rm(fixtureRoot, { recursive: true, force: true }); -}); - -test('sandbox refusal classification requires Bubblewrap provenance', () => { - for (const code of ['EPERM', 'EACCES', 'ENOENT']) { - assert.equal( - sandboxUnavailable({ - status: null, - error: { code, message: `spawnSync bwrap ${code}` }, - sandboxLauncher: 'bwrap', - sandboxEntered: false, - }), - true, - ); - } - assert.equal( - sandboxUnavailable({ - status: null, - error: { code: 'EPERM', message: 'spawnSync git EPERM' }, - }), - false, - ); - assert.equal( - sandboxUnavailable({ status: 1, stderr: 'historical verifier said bwrap ENOENT' }), - false, - ); - assert.equal( - sandboxUnavailable({ - status: 1, - stderr: 'bwrap: Creating new namespace failed: Operation not permitted', - sandboxLauncher: 'bwrap', - sandboxEntered: false, - }), - true, - ); - assert.equal( - sandboxUnavailable({ - status: 1, - stderr: 'bwrap: Creating new namespace failed: Operation not permitted', - }), - false, - ); - assert.equal( - sandboxUnavailable({ - status: 1, - stderr: 'bwrap: Creating new namespace failed: Operation not permitted', - sandboxLauncher: 'bwrap', - sandboxEntered: true, - }), - false, - ); -}); - -test('prospective history reads each commit own manifest rather than the current tree', async () => { - await rm(fixtureRoot, { recursive: true, force: true }); - await mkdir(fixtureRoot, { recursive: true }); - git(fixtureRoot, 'init', '-q'); - git(fixtureRoot, 'config', 'user.name', 'gate-test'); - git(fixtureRoot, 'config', 'user.email', 'gate-test@example.invalid'); - await writeFile(path.join(fixtureRoot, 'activation.txt'), 'activation\n'); - git(fixtureRoot, 'add', '.'); - git(fixtureRoot, 'commit', '-m', 'activation'); - const activation = git(fixtureRoot, 'rev-parse', 'HEAD'); - const first = await commitManifest(fixtureRoot, 'FIRST'); - const second = await commitManifest(fixtureRoot, 'SECOND'); - - assert.deepEqual(await listProspectiveCommits(fixtureRoot, activation, second), [first, second]); - assert.equal((await readManifestAtCommit(fixtureRoot, first)).marker, 'FIRST'); - assert.equal((await readManifestAtCommit(fixtureRoot, second)).marker, 'SECOND'); -}); - -test('historical replay executes each selected commit verifier from that commit tree', async () => { - const root = `${fixtureRoot}-replay`; - await rm(root, { recursive: true, force: true }); - await mkdir(path.join(root, 'scripts'), { recursive: true }); - await mkdir(path.join(root, 'gates'), { recursive: true }); - git(root, 'init', '-q'); - git(root, 'config', 'user.name', 'gate-test'); - git(root, 'config', 'user.email', 'gate-test@example.invalid'); - await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); - await writeFile( - path.join(root, 'scripts', 'gate-verify.mjs'), - "process.stderr.write('OLD TREE INERT\\n'); process.exitCode = 1;\n", - ); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'inert historical verifier'); - const inert = git(root, 'rev-parse', 'HEAD'); - await writeFile( - path.join(root, 'scripts', 'gate-verify.mjs'), - "process.stdout.write('NEW TREE VERIFIED\\n');\n", - ); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'fixed historical verifier'); - const fixed = git(root, 'rev-parse', 'HEAD'); - - const inertResult = await replayCommit(root, inert); - const fixedResult = await replayCommit(root, fixed); - if (sandboxUnavailable(inertResult) || sandboxUnavailable(fixedResult)) return; - assert.notEqual(inertResult.status, 0); - assert.match(inertResult.stderr, /OLD TREE INERT/); - assert.equal(fixedResult.status, 0); - assert.match(fixedResult.stdout, /NEW TREE VERIFIED/); -}); - -test('historical install lifecycle cannot replace an authoritative verifier', async () => { - const root = `${fixtureRoot}-install-tamper`; - await rm(root, { recursive: true, force: true }); - await mkdir(path.join(root, 'scripts'), { recursive: true }); - await mkdir(path.join(root, 'gates'), { recursive: true }); - git(root, 'init', '-q'); - git(root, 'config', 'user.name', 'gate-test'); - git(root, 'config', 'user.email', 'gate-test@example.invalid'); - await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); - await writeFile( - path.join(root, 'scripts', 'gate-verify.mjs'), - "process.stderr.write('ORIGINAL VERIFIER RAN\\n'); process.exitCode = 7;\n", - ); - await writeFile(path.join(root, 'forged.mjs'), "process.stdout.write('FORGED SUCCESS\\n');\n"); - await writeFile( - path.join(root, 'package.json'), - `${JSON.stringify({ - name: 'historical-install-tamper', - version: '1.0.0', - scripts: { postinstall: 'cp forged.mjs scripts/gate-verify.mjs' }, - })}\n`, - ); - await writeFile( - path.join(root, 'pnpm-lock.yaml'), - "lockfileVersion: '9.0'\nsettings:\n autoInstallPeers: true\n excludeLinksFromLockfile: false\nimporters:\n .: {}\n", - ); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'tampering lifecycle fixture'); - const commit = git(root, 'rev-parse', 'HEAD'); - - const result = await replayCommit(root, commit); - assert.notEqual(result.status, 0); - if (sandboxUnavailable(result)) return; - assert.match(result.stderr, /authoritative archived file changed.*scripts\/gate-verify\.mjs/i); - assert.doesNotMatch(result.stdout, /FORGED SUCCESS/); -}); - -test('historical verifier receives no current-process secret environment', async () => { - const root = `${fixtureRoot}-secretless`; - await rm(root, { recursive: true, force: true }); - await mkdir(path.join(root, 'scripts'), { recursive: true }); - await mkdir(path.join(root, 'gates'), { recursive: true }); - git(root, 'init', '-q'); - git(root, 'config', 'user.name', 'gate-test'); - git(root, 'config', 'user.email', 'gate-test@example.invalid'); - await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); - await writeFile( - path.join(root, 'scripts', 'gate-verify.mjs'), - "if (process.env.REPLAY_SENTINEL) { process.stderr.write('SECRET LEAKED\\n'); process.exitCode = 9; } else { process.stdout.write('SECRETLESS\\n'); }\n", - ); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'secretless replay fixture'); - const commit = git(root, 'rev-parse', 'HEAD'); - - process.env.REPLAY_SENTINEL = 'must-not-cross-boundary'; - try { - const result = await replayCommit(root, commit); - if (sandboxUnavailable(result)) return; - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /SECRETLESS/); - assert.doesNotMatch( - `${result.stdout}${result.stderr}`, - /SECRET LEAKED|must-not-cross-boundary/, - ); - } finally { - delete process.env.REPLAY_SENTINEL; - } -}); - -test('historical replay cannot observe a sibling process in the runner PID namespace', async () => { - const root = `${fixtureRoot}-pidless`; - await rm(root, { recursive: true, force: true }); - await mkdir(path.join(root, 'scripts'), { recursive: true }); - await mkdir(path.join(root, 'gates'), { recursive: true }); - git(root, 'init', '-q'); - git(root, 'config', 'user.name', 'gate-test'); - git(root, 'config', 'user.email', 'gate-test@example.invalid'); - await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); - - const sleeper = spawn('sleep', ['30'], { - env: { ...process.env, REPLAY_PID_SENTINEL: 'must-not-be-visible' }, - }); - try { - await writeFile( - path.join(root, 'scripts', 'gate-verify.mjs'), - `import { existsSync } from 'node:fs';\nif (existsSync('/proc/${sleeper.pid}/environ')) { process.stderr.write('HOST PID VISIBLE\\n'); process.exitCode = 9; } else { process.stdout.write('PIDLESS\\n'); }\n`, - ); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'pid-isolated replay fixture'); - const commit = git(root, 'rev-parse', 'HEAD'); - const result = await replayCommit(root, commit); - if (sandboxUnavailable(result)) return; - assert.equal(result.status, 0, result.stderr); - assert.match(result.stdout, /PIDLESS/); - assert.doesNotMatch(`${result.stdout}${result.stderr}`, /HOST PID VISIBLE/); - } finally { - sleeper.kill('SIGTERM'); - } -}); - -test('PR verification states the RM-60 boundary without executing an intermediate verifier', async () => { - const root = `${fixtureRoot}-feature`; - await rm(root, { recursive: true, force: true }); - await mkdir(root, { recursive: true }); - git(root, 'init', '-q'); - git(root, 'config', 'user.name', 'gate-test'); - git(root, 'config', 'user.email', 'gate-test@example.invalid'); - await writeFile(path.join(root, 'activation.txt'), 'activation\n'); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'activation'); - const activation = git(root, 'rev-parse', 'HEAD'); - git(root, 'update-ref', 'refs/remotes/origin/main', activation); - await mkdir(path.join(root, 'scripts'), { recursive: true }); - await mkdir(path.join(root, 'gates'), { recursive: true }); - await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); - await writeFile( - path.join(root, 'scripts', 'gate-verify.mjs'), - "process.stderr.write('INTERMEDIATE INERT\\n'); process.exitCode = 1;\n", - ); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'inert intermediate'); - await writeFile( - path.join(root, 'scripts', 'gate-verify.mjs'), - "process.stdout.write('HEAD HEALTHY\\n');\n", - ); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'healthy head'); - - const previousBranch = process.env.CI_COMMIT_BRANCH; - process.env.CI_COMMIT_BRANCH = 'feature/rm-02'; - try { - const result = await verifyHistory({ - root, - manifest: { schemaVersion: 1 }, - }); - assert.deepEqual(result.failures, []); - assert.ok( - result.observations.some((observation) => - /HISTORY BOOTSTRAP BOUNDARY.*DOES:.*provider target.*sound.*cannot rewrite main.*DOES NOT:.*compromise.*main.*Builds 1-2/i.test( - observation, - ), - ), - ); - assert.ok( - result.observations.some((observation) => - /DOES:.*current tree.*DOES NOT:.*isolated.*RM-60.*RM-59/i.test(observation), - ), - ); - assert.ok( - result.observations.some((observation) => - /INTERMEDIATE REPLAY DEFERRED.*RM-60.*no success is inferred/i.test(observation), - ), - ); - assert.ok(result.observations.every((observation) => !/INTERMEDIATE INERT/.test(observation))); - } finally { - if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH; - else process.env.CI_COMMIT_BRANCH = previousBranch; - } -}); - -test('target merge-base includes gate changes committed before registry introduction', async () => { - const root = `${fixtureRoot}-delayed-introduction`; - await rm(root, { recursive: true, force: true }); - await mkdir(root, { recursive: true }); - git(root, 'init', '-q'); - git(root, 'config', 'user.name', 'gate-test'); - git(root, 'config', 'user.email', 'gate-test@example.invalid'); - await writeFile(path.join(root, 'baseline.txt'), 'baseline\n'); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'target baseline'); - const baseline = git(root, 'rev-parse', 'HEAD'); - git(root, 'update-ref', 'refs/remotes/origin/main', baseline); - await mkdir(path.join(root, 'scripts'), { recursive: true }); - await writeFile(path.join(root, 'scripts', 'preflight.mjs'), 'process.exit(0);\n'); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'gate change before registry'); - const preRegistryGateChange = git(root, 'rev-parse', 'HEAD'); - await mkdir(path.join(root, 'gates'), { recursive: true }); - await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'delayed registry introduction'); - - const previousBranch = process.env.CI_COMMIT_BRANCH; - process.env.CI_COMMIT_BRANCH = 'feature/delayed-introduction'; - try { - const result = await verifyHistory({ root, manifest: { schemaVersion: 1 } }); - assert.match( - result.failures.join('\n'), - new RegExp(`${preRegistryGateChange}.*own-tree registry cannot be read`, 'i'), - ); - } finally { - if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH; - else process.env.CI_COMMIT_BRANCH = previousBranch; - } -}); - -test('derived history boundary includes the registry-introduction commit', async () => { - const root = `${fixtureRoot}-derived-boundary`; - await rm(root, { recursive: true, force: true }); - await mkdir(root, { recursive: true }); - git(root, 'init', '-q'); - git(root, 'config', 'user.name', 'gate-test'); - git(root, 'config', 'user.email', 'gate-test@example.invalid'); - await writeFile(path.join(root, 'baseline.txt'), 'baseline\n'); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'baseline'); - const baseline = git(root, 'rev-parse', 'HEAD'); - git(root, 'update-ref', 'refs/remotes/origin/main', baseline); - await mkdir(path.join(root, 'gates'), { recursive: true }); - await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'registry introduction'); - const introduction = git(root, 'rev-parse', 'HEAD'); - await writeFile(path.join(root, 'later.txt'), 'later\n'); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'later'); - const head = git(root, 'rev-parse', 'HEAD'); - - assert.deepEqual(deriveHistoryBoundary(root, head), { - activationCommit: baseline, - introductionCommit: introduction, - targetRef: 'refs/remotes/origin/main', - }); - assert.deepEqual(await listProspectiveCommits(root, baseline, head), [introduction, head]); -}); - -test('author-controlled activation seams cannot omit registry-era history', async () => { - const root = `${fixtureRoot}-activation-seam`; - await rm(root, { recursive: true, force: true }); - await mkdir(path.join(root, 'gates'), { recursive: true }); - git(root, 'init', '-q'); - git(root, 'config', 'user.name', 'gate-test'); - git(root, 'config', 'user.email', 'gate-test@example.invalid'); - await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'registry introduction'); - const introduction = git(root, 'rev-parse', 'HEAD'); - await writeFile(path.join(root, 'one.txt'), 'one\n'); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'one'); - await writeFile(path.join(root, 'two.txt'), 'two\n'); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'two'); - const head = git(root, 'rev-parse', 'HEAD'); - const parent = git(root, 'rev-parse', 'HEAD^'); - - const previousBranch = process.env.CI_COMMIT_BRANCH; - process.env.CI_COMMIT_BRANCH = 'feature/activation-seam'; - try { - for (const candidate of [head, parent, introduction]) { - const result = await verifyHistory({ - root, - manifest: { schemaVersion: 1, activationCommit: candidate }, - }); - assert.match(result.failures.join('\n'), /author-controlled activationCommit.*forbidden/i); - } - } finally { - if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH; - else process.env.CI_COMMIT_BRANCH = previousBranch; - } -}); - -test('globally invalid provider evidence fails when HEAD is the only prospective commit', async () => { - const root = `${fixtureRoot}-head-only-evidence`; - await rm(root, { recursive: true, force: true }); - await mkdir(root, { recursive: true }); - git(root, 'init', '-q'); - git(root, 'config', 'user.name', 'gate-test'); - git(root, 'config', 'user.email', 'gate-test@example.invalid'); - await writeFile(path.join(root, 'baseline.txt'), 'baseline\n'); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'baseline'); - git(root, 'update-ref', 'refs/remotes/origin/main', git(root, 'rev-parse', 'HEAD')); - await mkdir(path.join(root, 'gates'), { recursive: true }); - await writeFile(path.join(root, 'gates', 'gates.manifest.json'), '{"schemaVersion":1}\n'); - git(root, 'add', '.'); - git(root, 'commit', '-m', 'registry introduction'); - const head = git(root, 'rev-parse', 'HEAD'); - const evidenceFile = path.join(root, 'provider-evidence.json'); - await writeFile( - evidenceFile, - JSON.stringify([ - { - commit: head, - number: 7, - status: 'success', - steps: [{ name: 'gate-verify', status: 'success' }], - }, - { - commit: 'other-subject', - number: 7, - status: 'success', - steps: [{ name: 'gate-verify', status: 'success' }], - }, - ]), - ); - const previousBranch = process.env.CI_COMMIT_BRANCH; - const previousEvidence = process.env.GATE_PROVIDER_EVIDENCE_FILE; - process.env.CI_COMMIT_BRANCH = 'main'; - process.env.GATE_PROVIDER_EVIDENCE_FILE = evidenceFile; - try { - const result = await verifyHistory({ root, manifest: { schemaVersion: 1 } }); - assert.match(result.failures.join('\n'), /duplicate pipeline identity across commits/i); - } finally { - if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH; - else process.env.CI_COMMIT_BRANCH = previousBranch; - if (previousEvidence === undefined) delete process.env.GATE_PROVIDER_EVIDENCE_FILE; - else process.env.GATE_PROVIDER_EVIDENCE_FILE = previousEvidence; - } -}); - -test('collection-wide validation rejects ambiguous gate steps on unrelated commits', () => { - const records = [ - { - commit: 'target', - number: 7, - status: 'success', - steps: [{ name: 'gate-verify', status: 'success' }], - }, - { - commit: 'unrelated', - number: 8, - status: 'success', - steps: [ - { name: 'gate-verify', status: 'success' }, - { name: 'gate-verify', status: 'failure' }, - ], - }, - ]; - const result = assessProviderEvidence('target', records); - assert.equal(result.state, 'terminal-failure'); - assert.match(result.detail, /ambiguous gate-verify step count/i); -}); - -test('provider evidence rejects non-object collection entries without crashing', () => { - for (const record of [null, [], 'text', 42]) { - const result = assessProviderEvidence('aaa', [record]); - assert.equal(result.state, 'terminal-failure'); - assert.match(result.detail, /malformed/i); - } -}); - -test('provider evidence rejects duplicate pipeline identity across commits', () => { - const records = [ - { - commit: 'aaa', - number: 7, - status: 'success', - steps: [{ name: 'gate-verify', status: 'success' }], - }, - { - commit: 'bbb', - number: 7, - status: 'success', - steps: [{ name: 'gate-verify', status: 'success' }], - }, - ]; - const result = assessProviderEvidence('aaa', records); - assert.equal(result.state, 'terminal-failure'); - assert.match(result.detail, /duplicate pipeline.*across.*commit|global.*pipeline.*identity/i); -}); - -test('provider evidence distinguishes retained success, failure, and absent history', () => { - const pipelines = [ - { - commit: 'aaa', - number: 1, - status: 'success', - steps: [{ name: 'gate-verify', status: 'success' }], - }, - { - commit: 'bbb', - number: 2, - status: 'failure', - steps: [{ name: 'gate-verify', status: 'failure' }], - }, - ]; - assert.deepEqual(assessProviderEvidence('aaa', pipelines), { - state: 'terminal-success', - detail: 'pipeline and gate-verify step succeeded', - }); - assert.equal(assessProviderEvidence('bbb', pipelines).state, 'terminal-failure'); - assert.equal(assessProviderEvidence('ccc', pipelines).state, 'absent'); -}); - -test('duplicate gate-verify steps cannot establish provider success', () => { - const result = assessProviderEvidence('aaa', [ - { - commit: 'aaa', - number: 7, - status: 'success', - steps: [ - { name: 'gate-verify', status: 'success' }, - { name: 'gate-verify', status: 'failure' }, - ], - }, - ]); - assert.equal(result.state, 'terminal-failure'); - assert.match(result.detail, /ambiguous.*gate-verify/i); -}); - -test('a malformed single provider record cannot establish success', () => { - assert.equal( - assessProviderEvidence('aaa', [ - { commit: 'aaa', status: 'success', steps: [{ name: 'gate-verify', status: 'success' }] }, - ]).state, - 'terminal-failure', - ); - assert.equal( - assessProviderEvidence('bbb', [ - { - commit: 'bbb', - number: 1, - status: 'surprising', - steps: [{ name: 'gate-verify', status: 'success' }], - }, - ]).state, - 'terminal-failure', - ); -}); - -test('provider evidence selects the highest numbered rerun deterministically', () => { - const failedThenSucceeded = [ - { - commit: 'aaa', - number: 10, - status: 'failure', - steps: [{ name: 'gate-verify', status: 'failure' }], - }, - { - commit: 'aaa', - number: 11, - status: 'success', - steps: [{ name: 'gate-verify', status: 'success' }], - }, - ]; - const succeededThenFailed = [ - { - commit: 'bbb', - number: 21, - status: 'success', - steps: [{ name: 'gate-verify', status: 'success' }], - }, - { - commit: 'bbb', - number: 22, - status: 'failure', - steps: [{ name: 'gate-verify', status: 'failure' }], - }, - ]; - assert.equal(assessProviderEvidence('aaa', failedThenSucceeded).state, 'terminal-success'); - assert.equal(assessProviderEvidence('bbb', succeededThenFailed).state, 'terminal-failure'); - assert.equal( - assessProviderEvidence('ccc', [ - { commit: 'ccc', status: 'success', steps: [{ name: 'gate-verify', status: 'success' }] }, - { commit: 'ccc', status: 'failure', steps: [{ name: 'gate-verify', status: 'failure' }] }, - ]).state, - 'terminal-failure', - ); -}); diff --git a/scripts/gate-inventory-shrink-control.mjs b/scripts/gate-inventory-shrink-control.mjs new file mode 100644 index 00000000..bac40c30 --- /dev/null +++ b/scripts/gate-inventory-shrink-control.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const root = process.cwd(); +const removedGateId = 'hook-pre-push'; +const inventoryEntry = " ['hook-pre-push', '.husky/pre-push'],\n"; + +function shrinkManifest(manifest) { + const removedGate = manifest.gates.find((gate) => gate.id === removedGateId); + assert.ok(removedGate); + const removedCaseRefs = new Set( + removedGate.cases.map((gateCase) => `${removedGateId}/${gateCase.id}`), + ); + const removedCriterionIds = new Set( + manifest.criteria + .filter( + (criterion) => + criterion.caseRefs.length > 0 && + criterion.caseRefs.every((caseRef) => removedCaseRefs.has(caseRef)), + ) + .map((criterion) => criterion.id), + ); + manifest.gates = manifest.gates.filter((gate) => gate.id !== removedGateId); + manifest.criteria = manifest.criteria + .filter((criterion) => !removedCriterionIds.has(criterion.id)) + .map((criterion) => ({ + ...criterion, + caseRefs: criterion.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)), + ...(criterion.gateRefs + ? { gateRefs: criterion.gateRefs.filter((gateId) => gateId !== removedGateId) } + : {}), + })); + manifest.proseClaims = manifest.proseClaims.filter( + (claim) => !removedCriterionIds.has(claim.criterionId) && !removedCaseRefs.has(claim.caseRef), + ); + manifest.compatibilityScenarios = manifest.compatibilityScenarios + .map((scenario) => ({ + ...scenario, + caseRefs: scenario.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)), + })) + .filter((scenario) => scenario.caseRefs.length > 0); + for (const gate of manifest.gates) { + for (const gateCase of gate.cases) { + gateCase.criterionIds = gateCase.criterionIds.filter( + (criterionId) => !removedCriterionIds.has(criterionId), + ); + } + } +} + +async function attack(mode) { + const fixture = await mkdtemp(path.join(os.tmpdir(), `gate-inventory-${mode}-`)); + try { + await mkdir(path.join(fixture, 'scripts'), { recursive: true }); + await mkdir(path.join(fixture, 'gates'), { recursive: true }); + const source = await readFile(path.join(root, 'scripts', 'gate-verify.mjs'), 'utf8'); + if (source.split(inventoryEntry).length - 1 !== 1) { + throw new Error('source inventory fixture drifted'); + } + await writeFile( + path.join(fixture, 'scripts', 'gate-verify.mjs'), + mode === 'source-manifest' ? source.replace(inventoryEntry, '') : source, + ); + const baseline = JSON.parse( + await readFile(path.join(root, 'gates', 'required-gates.baseline.json'), 'utf8'), + ); + if (mode === 'baseline-manifest') { + baseline.gates = baseline.gates.filter((gate) => gate.id !== removedGateId); + } + await writeFile( + path.join(fixture, 'gates', 'required-gates.baseline.json'), + `${JSON.stringify(baseline)}\n`, + ); + const manifest = JSON.parse( + await readFile(path.join(root, 'gates', 'gates.manifest.json'), 'utf8'), + ); + shrinkManifest(manifest); + await writeFile( + path.join(fixture, 'gates', 'gates.manifest.json'), + `${JSON.stringify(manifest)}\n`, + ); + const result = spawnSync( + process.execPath, + [ + path.join(fixture, 'scripts', 'gate-verify.mjs'), + '--root', + fixture, + '--manifest', + 'gates/gates.manifest.json', + '--structure-only', + ], + { cwd: fixture, encoding: 'utf8' }, + ); + const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + return ( + result.status !== 0 && + new RegExp(`(?:baseline|verifier inventory).*${removedGateId}`, 'i').test(combined) + ); + } finally { + await rm(fixture, { recursive: true, force: true }); + } +} + +const sourceManifestRejected = await attack('source-manifest'); +const baselineManifestRejected = await attack('baseline-manifest'); +if (sourceManifestRejected && baselineManifestRejected) { + process.stderr.write( + 'INVENTORY_SHRINK_REJECTED: source+manifest and baseline+manifest shrink attacks detected\n', + ); + process.exit(83); +} +process.stdout.write( + `inventory shrink attack escaped: source-manifest=${sourceManifestRejected} baseline-manifest=${baselineManifestRejected}\n`, +); diff --git a/scripts/gate-population-control.mjs b/scripts/gate-population-control.mjs index 472a3e6e..b26df0b7 100644 --- a/scripts/gate-population-control.mjs +++ b/scripts/gate-population-control.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -27,7 +27,6 @@ async function rejectedForEveryGate(mutate, diagnostic) { const result = await verifyRegistry({ root, manifest: manifestPath, - skipHistory: true, structureOnly: true, fixtureProfile: false, }); @@ -41,14 +40,81 @@ async function rejectedForEveryGate(mutate, diagnostic) { let rejected; if (mode === 'evidence-subject') { - rejected = await rejectedForEveryGate( - (gate) => { - gate.evidenceSubject = 'different-gate-subject'; - }, - (failure, gateId) => - failure.includes(`gate ${gateId}: evidence subject`) && - failure.includes('does not match gate id'), - ); + rejected = true; + for (const gateId of expectedGateIds) { + const directory = await mkdtemp(path.join(os.tmpdir(), 'gate-evidence-consumption-')); + try { + await mkdir(path.join(directory, 'gates'), { recursive: true }); + const probe = path.join(directory, 'gates', 'probe.sh'); + await writeFile(probe, '#!/bin/sh\necho EVIDENCE_PROBE >&2\nexit 7\n'); + await chmod(probe, 0o755); + const manifest = { + schemaVersion: 1, + gateRoots: ['gates'], + governingClaimFiles: [], + coverageBoundary: { included: ['evidence fixture'], excluded: [], trackedBy: 'RM-02' }, + criteria: [ + { + id: 'EVIDENCE-CONSUMPTION', + originalText: 'Consumed evidence stays bound to its gate.', + currentText: 'Consumed evidence stays bound to its gate.', + claimType: 'integrity', + source: 'gate-population-control', + meaningChanges: [], + caseRefs: [`${gateId}/probe`], + }, + ], + proseClaims: [], + compatibilityScenarios: [], + gates: [ + { + id: gateId, + source: 'gates/probe.sh', + invocation: ['gates/probe.sh'], + deployment: { kind: 'none', reason: 'population fixture' }, + inertMutation: { + file: 'gates/probe.sh', + find: 'exit 7', + replace: 'exit 0', + caseId: 'probe', + expected: { exitCode: 0 }, + }, + cases: [ + { + id: 'probe', + criterionIds: ['EVIDENCE-CONSUMPTION'], + mustFail: true, + required: { exitCode: 7 }, + actual: { exitCode: 7 }, + evidence: { subject: 'different-gate-subject' }, + reasonPattern: 'EVIDENCE_PROBE', + }, + ], + }, + ], + }; + const manifestPath = path.join(directory, 'gates', 'gates.manifest.json'); + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + const result = await verifyRegistry({ + root: directory, + manifest: manifestPath, + structureOnly: false, + fixtureProfile: true, + }); + if ( + !result.failures.some( + (failure) => + failure.includes(`gate ${gateId}: consumed evidence subject`) && + failure.includes('does not match gate definition'), + ) + ) { + rejected = false; + break; + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + } } else if (mode === 'type-strict') { rejected = await rejectedForEveryGate( (gate) => { diff --git a/scripts/gate-provider-binding-control.mjs b/scripts/gate-provider-binding-control.mjs deleted file mode 100644 index 167c9e31..00000000 --- a/scripts/gate-provider-binding-control.mjs +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node - -import { assessProviderEvidence } from './gate-history.mjs'; - -const records = [ - { - commit: 'subject-a', - number: 7, - status: 'success', - steps: [{ name: 'gate-verify', status: 'success' }], - }, - { - commit: 'subject-b', - number: 7, - status: 'success', - steps: [{ name: 'gate-verify', status: 'success' }], - }, -]; -const result = assessProviderEvidence('subject-a', records); -if ( - result.state === 'terminal-failure' && - /duplicate pipeline identity across commits/i.test(result.detail) -) { - process.stderr.write(`${result.detail}\n`); - process.exit(1); -} -process.stdout.write( - `cross-commit duplicate was not rejected: ${result.state} (${result.detail})\n`, -); -process.exit(0); diff --git a/scripts/gate-remediation.test.mjs b/scripts/gate-remediation.test.mjs new file mode 100644 index 00000000..0eea9def --- /dev/null +++ b/scripts/gate-remediation.test.mjs @@ -0,0 +1,233 @@ +import assert from 'node:assert/strict'; +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; + +const root = process.cwd(); +const verifierPath = path.join(root, 'scripts', 'gate-verify.mjs'); +const manifestPath = path.join(root, 'gates', 'gates.manifest.json'); +const requiredGateId = 'hook-pre-push'; + +function output(result) { + return `${result.stdout ?? ''}\n${result.stderr ?? ''}`; +} + +function shrinkManifest(manifest, removedGateId) { + const removedGate = manifest.gates.find((gate) => gate.id === removedGateId); + assert.ok(removedGate, `fixture gate ${removedGateId} must exist`); + const removedCaseRefs = new Set( + removedGate.cases.map((gateCase) => `${removedGateId}/${gateCase.id}`), + ); + const removedCriterionIds = new Set( + manifest.criteria + .filter( + (criterion) => + criterion.caseRefs.length > 0 && + criterion.caseRefs.every((caseRef) => removedCaseRefs.has(caseRef)), + ) + .map((criterion) => criterion.id), + ); + + manifest.gates = manifest.gates.filter((gate) => gate.id !== removedGateId); + manifest.criteria = manifest.criteria + .filter((criterion) => !removedCriterionIds.has(criterion.id)) + .map((criterion) => ({ + ...criterion, + caseRefs: criterion.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)), + ...(criterion.gateRefs + ? { gateRefs: criterion.gateRefs.filter((gateId) => gateId !== removedGateId) } + : {}), + })); + manifest.proseClaims = manifest.proseClaims.filter( + (claim) => !removedCriterionIds.has(claim.criterionId) && !removedCaseRefs.has(claim.caseRef), + ); + manifest.compatibilityScenarios = manifest.compatibilityScenarios + .map((scenario) => ({ + ...scenario, + caseRefs: scenario.caseRefs.filter((caseRef) => !removedCaseRefs.has(caseRef)), + })) + .filter((scenario) => scenario.caseRefs.length > 0); + for (const gate of manifest.gates) { + for (const gateCase of gate.cases) { + gateCase.criterionIds = gateCase.criterionIds.filter( + (criterionId) => !removedCriterionIds.has(criterionId), + ); + } + } +} + +test('shrinking the verifier inventory and manifest together is rejected by an independent baseline', async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-shrink-both-')); + try { + await mkdir(path.join(fixture, 'scripts'), { recursive: true }); + await mkdir(path.join(fixture, 'gates'), { recursive: true }); + const verifier = await readFile(verifierPath, 'utf8'); + const inventoryEntry = " ['hook-pre-push', '.husky/pre-push'],\n"; + assert.equal(verifier.split(inventoryEntry).length - 1, 1, 'source inventory fixture drifted'); + await writeFile( + path.join(fixture, 'scripts', 'gate-verify.mjs'), + verifier.replace(inventoryEntry, ''), + ); + await copyFile( + path.join(root, 'gates', 'required-gates.baseline.json'), + path.join(fixture, 'gates', 'required-gates.baseline.json'), + ); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + shrinkManifest(manifest, requiredGateId); + await writeFile( + path.join(fixture, 'gates', 'gates.manifest.json'), + `${JSON.stringify(manifest)}\n`, + ); + + const result = spawnSync( + process.execPath, + [ + path.join(fixture, 'scripts', 'gate-verify.mjs'), + '--root', + fixture, + '--manifest', + 'gates/gates.manifest.json', + '--structure-only', + ], + { cwd: fixture, encoding: 'utf8' }, + ); + assert.notEqual(result.status, 0, 'shrinking source anchor and manifest together must go red'); + assert.match(output(result), /independent required-gate baseline.*hook-pre-push/i); + } finally { + await rm(fixture, { recursive: true, force: true }); + } +}); + +test('shrinking the independent baseline and manifest together is rejected by verifier inventory', async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-shrink-baseline-manifest-')); + try { + await mkdir(path.join(fixture, 'scripts'), { recursive: true }); + await mkdir(path.join(fixture, 'gates'), { recursive: true }); + await copyFile(verifierPath, path.join(fixture, 'scripts', 'gate-verify.mjs')); + const baseline = JSON.parse( + await readFile(path.join(root, 'gates', 'required-gates.baseline.json'), 'utf8'), + ); + baseline.gates = baseline.gates.filter((gate) => gate.id !== requiredGateId); + await writeFile( + path.join(fixture, 'gates', 'required-gates.baseline.json'), + `${JSON.stringify(baseline)}\n`, + ); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + shrinkManifest(manifest, requiredGateId); + await writeFile( + path.join(fixture, 'gates', 'gates.manifest.json'), + `${JSON.stringify(manifest)}\n`, + ); + + const result = spawnSync( + process.execPath, + [ + path.join(fixture, 'scripts', 'gate-verify.mjs'), + '--root', + fixture, + '--manifest', + 'gates/gates.manifest.json', + '--structure-only', + ], + { cwd: fixture, encoding: 'utf8' }, + ); + assert.notEqual(result.status, 0, 'shrinking baseline and manifest together must go red'); + assert.match(output(result), /verifier inventory.*hook-pre-push.*baseline/i); + } finally { + await rm(fixture, { recursive: true, force: true }); + } +}); + +test('every gate carries an evidence-side subject distinct from its definition', async () => { + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + for (const gate of manifest.gates) { + assert.ok(gate.cases.length > 0, `${gate.id} must have consumable evidence`); + for (const gateCase of gate.cases) { + assert.equal( + gateCase.evidence?.subject, + gate.id, + `${gate.id}/${gateCase.id} must source its subject from the evidence record`, + ); + } + } +}); + +test('evidence population control depends on production result consumption wiring', async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-evidence-consumer-inert-')); + try { + await mkdir(path.join(fixture, 'scripts'), { recursive: true }); + await mkdir(path.join(fixture, 'gates'), { recursive: true }); + const verifier = await readFile(verifierPath, 'utf8'); + const consumer = ` const subjectFailure = consumeEvidenceSubject(gate, result.evidence);\n if (subjectFailure) failures.push(subjectFailure);\n`; + assert.equal(verifier.split(consumer).length - 1, 1, 'consumer fixture drifted'); + await writeFile( + path.join(fixture, 'scripts', 'gate-verify.mjs'), + verifier.replace(consumer, ''), + ); + await copyFile( + path.join(root, 'scripts', 'gate-population-control.mjs'), + path.join(fixture, 'scripts', 'gate-population-control.mjs'), + ); + await copyFile(manifestPath, path.join(fixture, 'gates', 'gates.manifest.json')); + const result = spawnSync( + process.execPath, + [path.join(fixture, 'scripts', 'gate-population-control.mjs'), 'evidence-subject'], + { cwd: fixture, encoding: 'utf8' }, + ); + assert.equal(result.status, 0, output(result)); + assert.match(output(result), /did not reject every registered gate/i); + } finally { + await rm(fixture, { recursive: true, force: true }); + } +}); + +test('production verification has a closed current-tree observation renderer', async () => { + const verifier = await readFile(verifierPath, 'utf8'); + assert.doesNotMatch(verifier, /from ['"]\.\/gate-history\.mjs['"]/); + assert.doesNotMatch(verifier, /\bverifyHistory\s*\(/); + assert.doesNotMatch(verifier, /history[-_ ]?provenance/i); + const attacks = [ + [ + 'history wording', + ' /^META-NEGATIVE-CONTROL /,', + ' /^HISTORY PROVENANCE VERIFIED /,\n /^META-NEGATIVE-CONTROL /,', + ], + [ + 'renamed ancestry wording', + ' /^META-NEGATIVE-CONTROL /,', + ' /^COMMIT ANCESTRY VERIFIED /,\n /^META-NEGATIVE-CONTROL /,', + ], + [ + 'provider lineage wording', + ' /^META-NEGATIVE-CONTROL /,', + ' /^PROVIDER LINEAGE SUCCESS /,\n /^META-NEGATIVE-CONTROL /,', + ], + ['renderer bypass', ' assertCurrentTreeObservation(observation);\n', ''], + ]; + for (const [name, find, replace] of attacks) { + const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-history-renderer-')); + try { + await mkdir(path.join(fixture, 'scripts'), { recursive: true }); + assert.equal(verifier.split(find).length - 1, 1, `${name}: fixture drifted`); + await writeFile( + path.join(fixture, 'scripts', 'gate-verify.mjs'), + verifier.replace(find, replace), + ); + await copyFile( + path.join(root, 'scripts', 'gate-history-exclusion-control.mjs'), + path.join(fixture, 'scripts', 'gate-history-exclusion-control.mjs'), + ); + const result = spawnSync( + process.execPath, + [path.join(fixture, 'scripts', 'gate-history-exclusion-control.mjs')], + { cwd: fixture, encoding: 'utf8' }, + ); + assert.equal(result.status, 79, `${name}: ${output(result)}`); + assert.match(output(result), /HISTORY_PROVENANCE_FORBIDDEN/); + } finally { + await rm(fixture, { recursive: true, force: true }); + } + } +}); diff --git a/scripts/gate-verify.mjs b/scripts/gate-verify.mjs index f57ed524..99d01bba 100644 --- a/scripts/gate-verify.mjs +++ b/scripts/gate-verify.mjs @@ -20,8 +20,6 @@ import { import path from 'node:path'; import { spawnSync } from 'node:child_process'; -import { verifyHistory } from './gate-history.mjs'; - const COPY_SKIP = new Set(['.git', '.mosaic-test-work', '.next', '.turbo', 'coverage', 'dist']); const POPULATION_CRITERION_IDS = new Set([ 'RM02-EVIDENCE-SUBJECT-BINDING', @@ -42,14 +40,12 @@ function parseArgs(argv) { const options = { root: process.cwd(), manifest: 'gates/gates.manifest.json', - skipHistory: false, structureOnly: false, }; for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; if (value === '--root') options.root = path.resolve(argv[++index]); else if (value === '--manifest') options.manifest = argv[++index]; - else if (value === '--skip-history') options.skipHistory = true; else if (value === '--structure-only') options.structureOnly = true; else throw new Error(`unknown option: ${value}`); } @@ -222,7 +218,8 @@ async function runCase(root, gate, gateCase) { expand(value, caseRoot), ]), ); - return runInvocation(caseRoot, gateCase.invocation ?? gate.invocation, environment); + const result = runInvocation(caseRoot, gateCase.invocation ?? gate.invocation, environment); + return { ...result, evidence: structuredClone(gateCase.evidence) }; } finally { if (caseRoot !== root) await rm(caseRoot, { recursive: true, force: true }); } @@ -396,7 +393,36 @@ function validateEnvironment(environment, label, failures) { } } -function validateClosedSchema(manifest, failures, { fixtureProfile = false } = {}) { +function inventoriesEqual(left, right) { + return structuredValuesEqual([...left.entries()], [...right.entries()]); +} + +export function consumeEvidenceSubject(gate, evidence) { + if (evidence?.subject !== gate.id) { + return `gate ${gate.id}: consumed evidence subject ${String(evidence?.subject)} does not match gate definition`; + } + return undefined; +} + +const CURRENT_TREE_OBSERVATION_PATTERNS = [ + /^META-NEGATIVE-CONTROL /, + /^DEPLOYMENT-NEGATIVE-CONTROL /, + /^DEPLOYED IDENTITY UNAVAILABLE /, + /^DEFECT /, + /^COMPATIBILITY /, +]; + +export function assertCurrentTreeObservation(observation) { + if (!CURRENT_TREE_OBSERVATION_PATTERNS.some((pattern) => pattern.test(observation))) { + throw new Error(`unsupported observation class: ${observation}`); + } +} + +function validateClosedSchema( + manifest, + failures, + { fixtureProfile = false, requiredGateInventory } = {}, +) { if (!fixtureProfile) { for (const population of ['criteria', 'gates', 'proseClaims', 'compatibilityScenarios']) { if (!Array.isArray(manifest[population]) || manifest[population].length === 0) { @@ -440,17 +466,11 @@ function validateClosedSchema(manifest, failures, { fixtureProfile = false } = { if (manifest.mergeAssertions !== undefined) { rejectUnknownKeys( manifest.mergeAssertions, - new Set([ - 'mode', - 'providerEvidence', - 'deferredReplayOwner', - 'trustDependencies', - 'postMergeResponse', - ]), + new Set(['mode', 'deferredReplayOwner', 'trustDependencies', 'postMergeResponse']), 'mergeAssertions', failures, ); - for (const key of ['mode', 'providerEvidence', 'deferredReplayOwner', 'postMergeResponse']) { + for (const key of ['mode', 'deferredReplayOwner', 'postMergeResponse']) { requireString(manifest.mergeAssertions?.[key], `mergeAssertions.${key}`, failures); } validateStringArray( @@ -564,12 +584,32 @@ function validateClosedSchema(manifest, failures, { fixtureProfile = false } = { } rejectDuplicateIds(manifest.gates, 'gate', failures); if (!fixtureProfile) { - for (const [requiredId, requiredSource] of REQUIRED_GATE_INVENTORY) { - const registered = (manifest.gates ?? []).find((gate) => gate?.id === requiredId); - if (!registered || registered.source !== requiredSource) { - failures.push( - `gates population is not anchored: required ${requiredId} at ${requiredSource}`, - ); + if (!(requiredGateInventory instanceof Map) || requiredGateInventory.size === 0) { + failures.push('independent required-gate baseline is absent or empty'); + } else { + if (!inventoriesEqual(REQUIRED_GATE_INVENTORY, requiredGateInventory)) { + for (const [requiredId, requiredSource] of requiredGateInventory) { + if (REQUIRED_GATE_INVENTORY.get(requiredId) !== requiredSource) { + failures.push( + `independent required-gate baseline rejects verifier inventory drift at ${requiredId}`, + ); + } + } + for (const [requiredId, requiredSource] of REQUIRED_GATE_INVENTORY) { + if (requiredGateInventory.get(requiredId) !== requiredSource) { + failures.push( + `verifier inventory ${requiredId} is absent or changed in independent required-gate baseline`, + ); + } + } + } + for (const [requiredId, requiredSource] of requiredGateInventory) { + const registered = (manifest.gates ?? []).find((gate) => gate?.id === requiredId); + if (!registered || registered.source !== requiredSource) { + failures.push( + `independent required-gate baseline rejects manifest drift at ${requiredId}: required source ${requiredSource}`, + ); + } } } } @@ -584,19 +624,12 @@ function validateClosedSchema(manifest, failures, { fixtureProfile = false } = { 'inertMutation', 'cases', 'discoveryAliases', - 'evidenceSubject', ]), `gate ${gate.id}`, failures, ); requireString(gate.id, `gate ${gate.id}.id`, failures); requireString(gate.source, `gate ${gate.id}.source`, failures); - requireString(gate.evidenceSubject, `gate ${gate.id}.evidenceSubject`, failures); - if (gate.evidenceSubject !== gate.id) { - failures.push( - `gate ${gate.id}: evidence subject ${String(gate.evidenceSubject)} does not match gate id`, - ); - } rejectDuplicateIds(gate.cases, `case in gate ${gate.id}`, failures); if (!Array.isArray(gate.invocation) || gate.invocation.length === 0) { failures.push(`${gate.id}: exact invocation is missing`); @@ -656,6 +689,7 @@ function validateClosedSchema(manifest, failures, { fixtureProfile = false } = { 'invocation', 'required', 'actual', + 'evidence', 'reasonPattern', 'environment', 'fixture', @@ -675,6 +709,17 @@ function validateClosedSchema(manifest, failures, { fixtureProfile = false } = { } validateOutcome(gateCase.required, `${gate.id}/${gateCase.id}.required`, failures); validateOutcome(gateCase.actual, `${gate.id}/${gateCase.id}.actual`, failures); + rejectUnknownKeys( + gateCase.evidence, + new Set(['subject']), + `${gate.id}/${gateCase.id}.evidence`, + failures, + ); + requireString( + gateCase.evidence?.subject, + `${gate.id}/${gateCase.id}.evidence.subject`, + failures, + ); if (gateCase.invocation !== undefined) { validateStringArray(gateCase.invocation, `${gate.id}/${gateCase.id}.invocation`, failures); } @@ -1056,8 +1101,40 @@ export async function verifyRegistry(options) { const observations = []; const manifestPath = path.resolve(options.root, options.manifest); const manifest = JSON.parse(await readFile(manifestPath, 'utf8')); + let requiredGateInventory; + if (!options.fixtureProfile) { + const baselinePath = path.resolve(options.root, 'gates/required-gates.baseline.json'); + try { + const baseline = JSON.parse(await readFile(baselinePath, 'utf8')); + if ( + baseline.schemaVersion !== 1 || + !Array.isArray(baseline.gates) || + Object.keys(baseline).some((key) => !['schemaVersion', 'purpose', 'gates'].includes(key)) || + baseline.gates.some( + (gate) => + !gate || + typeof gate !== 'object' || + Array.isArray(gate) || + Object.keys(gate).some((key) => !['id', 'source'].includes(key)) || + typeof gate.id !== 'string' || + gate.id.length === 0 || + typeof gate.source !== 'string' || + gate.source.length === 0, + ) + ) { + failures.push('independent required-gate baseline has unsupported structure'); + } else { + requiredGateInventory = new Map(baseline.gates.map((gate) => [gate.id, gate.source])); + if (requiredGateInventory.size !== baseline.gates.length) { + failures.push('independent required-gate baseline has duplicate gate ids'); + } + } + } catch (error) { + failures.push(`independent required-gate baseline cannot be read: ${error.message}`); + } + } - validateStructure(manifest, failures, options); + validateStructure(manifest, failures, { ...options, requiredGateInventory }); if (options.structureOnly) return { failures, manifest, observations }; async function collectPhaseFailure(label, action) { @@ -1088,6 +1165,8 @@ export async function verifyRegistry(options) { if (!result) continue; const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; try { + const subjectFailure = consumeEvidenceSubject(gate, result.evidence); + if (subjectFailure) failures.push(subjectFailure); if (!outcomeMatches(gateCase.actual, result)) { failures.push( `${gate.id}/${gateCase.id}: observed exit ${String(result.status)}${result.signal ? ` signal ${result.signal}` : ''}${result.error ? ` error ${result.error.message}` : ''} or output disagrees with registry actual ${JSON.stringify(gateCase.actual)}`, @@ -1122,13 +1201,11 @@ export async function verifyRegistry(options) { async function main() { try { const options = parseArgs(process.argv.slice(2)); - const { failures, observations, manifest } = await verifyRegistry(options); - if (!options.skipHistory && failures.length === 0) { - const history = await verifyHistory({ root: options.root, manifest }); - failures.push(...history.failures); - observations.push(...history.observations); + const { failures, observations } = await verifyRegistry(options); + for (const observation of observations) { + assertCurrentTreeObservation(observation); + process.stdout.write(`${observation}\n`); } - for (const observation of observations) process.stdout.write(`${observation}\n`); if (failures.length > 0) { for (const failure of failures) process.stderr.write(`GATE VERIFY FAILED: ${failure}\n`); process.exitCode = 1; diff --git a/scripts/gate-verify.test.mjs b/scripts/gate-verify.test.mjs index f00cad28..c6fe46ca 100644 --- a/scripts/gate-verify.test.mjs +++ b/scripts/gate-verify.test.mjs @@ -44,7 +44,6 @@ function baseManifest() { { id: 'meta-fixture', source: 'gates/meta-fixture.sh', - evidenceSubject: 'meta-fixture', invocation: ['gates/meta-fixture.sh'], deployment: { kind: 'none', reason: 'test fixture only' }, inertMutation: { @@ -61,6 +60,7 @@ function baseManifest() { invocation: ['gates/meta-fixture.sh'], required: { exitCode: 7 }, actual: { exitCode: 7 }, + evidence: { subject: 'meta-fixture' }, reasonPattern: 'META_REJECT', }, ], @@ -96,7 +96,6 @@ function verifyProductionStructure(root, extraArgs = []) { root, '--manifest', 'gates/gates.manifest.json', - '--skip-history', '--structure-only', ...extraArgs, ], @@ -143,6 +142,10 @@ test('anchored gate inventory and population criteria cannot shrink together', a await readFile(path.join(process.cwd(), 'gates', 'gates.manifest.json'), 'utf8'), ); const root = await fixture('shrunken-gate-population'); + await copyFile( + path.join(process.cwd(), 'gates', 'required-gates.baseline.json'), + path.join(root, 'gates', 'required-gates.baseline.json'), + ); source.gates = source.gates.filter((gate) => gate.id !== 'hook-pre-push'); for (const criterion of source.criteria) { if (criterion.gateRefs) { @@ -156,7 +159,10 @@ test('anchored gate inventory and population criteria cannot shrink together', a const result = verifyProductionStructure(root); assert.notEqual(result.status, 0); - assert.match(output(result), /gates population is not anchored.*hook-pre-push/i); + assert.match( + output(result), + /independent required-gate baseline rejects manifest drift at hook-pre-push/i, + ); }); test('general population criteria cannot delete their gateRefs binding', async () => { @@ -648,6 +654,7 @@ test('compatibility scenarios execute referenced conditions as one construction' fixture: { writeFiles: [{ path: 'conditions/second', content: 'present\n' }] }, required: { exitCode: 7 }, actual: { exitCode: 7 }, + evidence: { subject: 'meta-fixture' }, reasonPattern: 'SECOND_REASON', environment: { SECOND_REASON: 'SECOND_REASON' }, }); @@ -761,10 +768,6 @@ test('deployment drift meta-control fails if the shared comparator is made inert ); assert.notEqual(inertSource, verifierSource, 'shared deployment comparator mutation went stale'); await writeFile(path.join(alteredScripts, 'gate-verify.mjs'), inertSource); - await copyFile( - path.join(process.cwd(), 'scripts', 'gate-history.mjs'), - path.join(alteredScripts, 'gate-history.mjs'), - ); const result = spawnSync( process.execPath, @@ -774,7 +777,6 @@ test('deployment drift meta-control fails if the shared comparator is made inert root, '--manifest', 'gates/gates.manifest.json', - '--skip-history', ], { cwd: root, encoding: 'utf8', env: { ...process.env, HOME: os.homedir() } }, ); diff --git a/scripts/gate-wiring.test.mjs b/scripts/gate-wiring.test.mjs index 1f0692c3..2cc90a1d 100644 --- a/scripts/gate-wiring.test.mjs +++ b/scripts/gate-wiring.test.mjs @@ -13,12 +13,8 @@ const expectedTriggers = `when: - event: push branch: main`; const expectedGateStep = ` image: *node_image - # Woodpecker's shallow marker makes merge-base reject even present parents; - # full history is required for activation ancestry and manifest provenance. commands: - *enable_pnpm - - apk add --no-cache bubblewrap - - if [ -f .git/shallow ]; then git fetch --unshallow --no-tags origin; fi - pnpm gate:verify depends_on: - install @@ -52,7 +48,11 @@ export function assertUnprivilegedGateStep(pipeline) { // Closed textual construction by design: accepting arbitrary YAML syntax here // would require a duplicate-key-preserving parser. Exact equality rejects all // extra keys, quoted/escaped key spellings, aliases, and mapping merges. - assert.equal(matches[0][1].trimEnd(), expectedGateStep, 'gate-verify step must match closed unprivileged construction'); + assert.equal( + matches[0][1].trimEnd(), + expectedGateStep, + 'gate-verify step must match closed unprivileged construction', + ); } test('package.json exposes the canonical gate:verify command', async () => { @@ -77,7 +77,10 @@ test('gate wiring rejects privilege syntax, merges, duplicate keys, and trigger ' "<<": *privileged-step\n', ]; for (const addition of additions) { - const changed = pipeline.replace(' gate-verify:\n image:', ` gate-verify:\n${addition} image:`); + const changed = pipeline.replace( + ' gate-verify:\n image:', + ` gate-verify:\n${addition} image:`, + ); assert.throws(() => assertUnprivilegedGateStep(changed)); } const privilegedInstall = pipeline.replace( @@ -85,10 +88,7 @@ test('gate wiring rejects privilege syntax, merges, duplicate keys, and trigger ' install:\n privileged: true\n image:', ); const duplicate = `${pipeline}\n gate-verify:\n image: *node_image\n`; - const noPullRequest = pipeline.replace( - ' - event: [pull_request, manual]', - ' - event: manual', - ); + const noPullRequest = pipeline.replace(' - event: [pull_request, manual]', ' - event: manual'); const filteredPullRequest = pipeline.replace( ' - event: [pull_request, manual]', ' - event: [pull_request, manual]\n path: [scripts/**]', diff --git a/scripts/test-support/gate-verify-fixture-runner.mjs b/scripts/test-support/gate-verify-fixture-runner.mjs index 97c20bbd..82889994 100644 --- a/scripts/test-support/gate-verify-fixture-runner.mjs +++ b/scripts/test-support/gate-verify-fixture-runner.mjs @@ -19,7 +19,6 @@ if (!root) throw new Error('fixture runner requires --root'); const { failures, observations } = await verifyRegistry({ root, manifest, - skipHistory: true, structureOnly, fixtureProfile: true, }); -- 2.54.0 From c5b0d510d7561b74a9d776e42029e2d241fed08b Mon Sep 17 00:00:00 2001 From: coder-mos2 Date: Sat, 1 Aug 2026 14:45:11 -0500 Subject: [PATCH 12/13] wip(rm-02): close all success output paths --- docs/scratchpads/1029-rm-02-gate-registry.md | 1 + scripts/gate-history-exclusion-control.mjs | 19 +++++++++++++++---- scripts/gate-remediation.test.mjs | 12 +++++++++++- scripts/gate-verify.mjs | 13 +++++++++---- 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/docs/scratchpads/1029-rm-02-gate-registry.md b/docs/scratchpads/1029-rm-02-gate-registry.md index 20adb2b7..d0fbd11f 100644 --- a/docs/scratchpads/1029-rm-02-gate-registry.md +++ b/docs/scratchpads/1029-rm-02-gate-registry.md @@ -101,3 +101,4 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro - Current focused evidence before independent review: remediation controls 4/4; verifier/wiring/remediation suite 39/39; canonical `pnpm gate:verify` exits zero while reporting six RM-03-owned `DEFECT` deltas and all seven gate meta-negative controls observed red. - Independent Codex code review requested changes on two valid blockers. First, the evidence population control called the subject helper directly rather than traversing production result consumption. It now creates one lightweight executed fixture per required gate, invokes the real `verifyRegistry` path, and fails to observe rejection if the production consumer is removed; a regression mutation proves that coupling. Second, a lexical history blacklist overclaimed structural incapacity. Production output now passes through a closed current-tree observation renderer with no history/ancestry/lineage success class; the exclusion control tests three alternate success wordings plus exact production renderer wiring, and the registered must-fail fixture adds a prohibited class. - Codex review test attempts were unrunnable in its read-only sandbox (`EROFS`/`EPERM`); the reviewer disclosed this rather than substituting a passing variant. Local writable-worktree tests remain the runnable evidence. +- Renderer remediation: every non-error production observation and final summary now routes through the closed current-tree output renderer. The exclusion control additionally requires one stdout sink, exactly two `GATE VERIFY FAILED` stderr sinks, no console sinks, and renderer use for both per-observation and final-summary paths. Regression attacks cover allowlisted history/ancestry/lineage wording, writer assertion removal, direct final-success stdout, and direct success stderr; focused suite 41/41, `pnpm gate:verify` green with six declared RM-03 deltas, and format check green. diff --git a/scripts/gate-history-exclusion-control.mjs b/scripts/gate-history-exclusion-control.mjs index dfe9eaaf..c6d8cf2e 100644 --- a/scripts/gate-history-exclusion-control.mjs +++ b/scripts/gate-history-exclusion-control.mjs @@ -20,13 +20,24 @@ const accepted = prohibitedReports.filter((report) => { }); const verifier = await readFile(path.join(process.cwd(), 'scripts', 'gate-verify.mjs'), 'utf8'); -const outputWrites = [...verifier.matchAll(/process\.stdout\.write\s*\(/g)].length; +const stdoutWrites = [...verifier.matchAll(/process\.stdout\.write\s*\(/g)].length; +const stderrWrites = [...verifier.matchAll(/process\.stderr\.write\s*\(/g)].length; +const gatedErrorWrites = [...verifier.matchAll(/process\.stderr\.write\(`GATE VERIFY FAILED:/g)] + .length; +const currentTreeWriterCalls = [...verifier.matchAll(/writeCurrentTreeOutput\s*\(/g)].length; const productionRendererWired = - outputWrites === 2 && - /for \(const observation of observations\) \{\s*assertCurrentTreeObservation\(observation\);\s*process\.stdout\.write/s.test( + stdoutWrites === 1 && + stderrWrites === 2 && + gatedErrorWrites === 2 && + currentTreeWriterCalls === 3 && + /function writeCurrentTreeOutput\(output\) \{\s*assertCurrentTreeObservation\(output\);\s*process\.stdout\.write/s.test( verifier, ) && - !/\bconsole\.(?:log|info|debug)\s*\(/.test(verifier); + /for \(const observation of observations\) \{\s*writeCurrentTreeOutput\(observation\);\s*\}/s.test( + verifier, + ) && + /writeCurrentTreeOutput\(\s*`REGISTRY SUMMARY open behavior deltas:/s.test(verifier) && + !/\bconsole\.(?:log|info|debug|error|warn)\s*\(/.test(verifier); if (accepted.length > 0 || !productionRendererWired) { process.stderr.write( diff --git a/scripts/gate-remediation.test.mjs b/scripts/gate-remediation.test.mjs index 0eea9def..6432e568 100644 --- a/scripts/gate-remediation.test.mjs +++ b/scripts/gate-remediation.test.mjs @@ -204,7 +204,17 @@ test('production verification has a closed current-tree observation renderer', a ' /^META-NEGATIVE-CONTROL /,', ' /^PROVIDER LINEAGE SUCCESS /,\n /^META-NEGATIVE-CONTROL /,', ], - ['renderer bypass', ' assertCurrentTreeObservation(observation);\n', ''], + ['writer assertion bypass', ' assertCurrentTreeObservation(output);\n', ''], + [ + 'final success stdout bypass', + ' writeCurrentTreeOutput(\n `REGISTRY SUMMARY open behavior deltas: ${defects}; required-behavior conformance is not asserted while deltas remain`,\n );', + " process.stdout.write('COMMIT ANCESTRY VERIFIED\\n');", + ], + [ + 'direct success stderr bypass', + " const defects = observations.filter((line) => line.startsWith('DEFECT ')).length;", + " process.stderr.write('PROVIDER LINEAGE SUCCESS\\n');\n const defects = observations.filter((line) => line.startsWith('DEFECT ')).length;", + ], ]; for (const [name, find, replace] of attacks) { const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-history-renderer-')); diff --git a/scripts/gate-verify.mjs b/scripts/gate-verify.mjs index 99d01bba..137c2a5a 100644 --- a/scripts/gate-verify.mjs +++ b/scripts/gate-verify.mjs @@ -410,6 +410,7 @@ const CURRENT_TREE_OBSERVATION_PATTERNS = [ /^DEPLOYED IDENTITY UNAVAILABLE /, /^DEFECT /, /^COMPATIBILITY /, + /^REGISTRY SUMMARY open behavior deltas: [0-9]+; required-behavior conformance is not asserted while deltas remain$/, ]; export function assertCurrentTreeObservation(observation) { @@ -418,6 +419,11 @@ export function assertCurrentTreeObservation(observation) { } } +function writeCurrentTreeOutput(output) { + assertCurrentTreeObservation(output); + process.stdout.write(`${output}\n`); +} + function validateClosedSchema( manifest, failures, @@ -1203,8 +1209,7 @@ async function main() { const options = parseArgs(process.argv.slice(2)); const { failures, observations } = await verifyRegistry(options); for (const observation of observations) { - assertCurrentTreeObservation(observation); - process.stdout.write(`${observation}\n`); + writeCurrentTreeOutput(observation); } if (failures.length > 0) { for (const failure of failures) process.stderr.write(`GATE VERIFY FAILED: ${failure}\n`); @@ -1212,8 +1217,8 @@ async function main() { return; } const defects = observations.filter((line) => line.startsWith('DEFECT ')).length; - process.stdout.write( - `registry observations matched; open behavior deltas: ${defects}; required-behavior conformance is not asserted while deltas remain\n`, + writeCurrentTreeOutput( + `REGISTRY SUMMARY open behavior deltas: ${defects}; required-behavior conformance is not asserted while deltas remain`, ); } catch (error) { process.stderr.write(`GATE VERIFY FAILED: ${error.message}\n`); -- 2.54.0 From e910a45ab3578a9c77ad3eeaa52b31b72611f6c6 Mon Sep 17 00:00:00 2001 From: coder-mos2 Date: Sat, 1 Aug 2026 15:09:57 -0500 Subject: [PATCH 13/13] fix(rm-02): narrow inventory drift guarantee --- docs/ADMIN-GUIDE/quality-gate-registry.md | 2 +- docs/DEVELOPER-GUIDE/quality-gate-registry.md | 6 +- docs/PRD.md | 16 ++--- docs/SITEMAP.md | 2 +- docs/plans/2026-08-01-rm-02-gate-registry.md | 4 +- docs/remediation/GATE-CLAIMS.md | 8 +++ docs/scratchpads/1029-rm-02-gate-registry.md | 3 + gates/gates.manifest.json | 62 +++++++++++++++++-- gates/required-gates.baseline.json | 32 +++++++--- scripts/gate-inventory-claim-control.mjs | 44 +++++++++++++ scripts/gate-remediation.test.mjs | 46 +++++++++++++- scripts/gate-verify.mjs | 14 ++--- scripts/gate-verify.test.mjs | 2 +- 13 files changed, 203 insertions(+), 38 deletions(-) create mode 100644 scripts/gate-inventory-claim-control.mjs diff --git a/docs/ADMIN-GUIDE/quality-gate-registry.md b/docs/ADMIN-GUIDE/quality-gate-registry.md index 9f419687..98d509a2 100644 --- a/docs/ADMIN-GUIDE/quality-gate-registry.md +++ b/docs/ADMIN-GUIDE/quality-gate-registry.md @@ -28,7 +28,7 @@ Do not add an ownerless exception or describe an open delta as pass/green/OK. Woodpecker runs `gate-verify` on every pull request and protected-main push without path filtering. This is deliberate: changes outside gate files can make a gate inert. The step needs no local history preparation because RM-02 asserts no history-provenance property. -**DOES:** PR CI executes current-tree verification unprivileged and fail-closed. It compares the manifest and verifier inventory separately with `gates/required-gates.baseline.json`, and consumed case evidence carries a subject checked against its gate definition. The registered shrink-both and per-gate evidence-subject controls must remain red for their stated reasons. +**DOES:** PR CI executes current-tree verification unprivileged and fail-closed. It compares the manifest and verifier inventory separately with `gates/required-gates.baseline.json`, and consumed case evidence carries a subject checked against its gate definition. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.** The registered shrink-both, overclaim, and per-gate evidence-subject controls must remain red for their stated reasons. **DOES NOT:** No local git state in the PR checkout is trustworthy as a history anchor because PR-controlled lifecycle code executes before the gate. The verifier has no history-verification path, and its closed current-tree observation renderer has no history/ancestry/lineage success class. Do not add a local ref, config, remote URL, source constant, or author-positioned path as a replacement anchor. diff --git a/docs/DEVELOPER-GUIDE/quality-gate-registry.md b/docs/DEVELOPER-GUIDE/quality-gate-registry.md index b470f9af..1fde50ef 100644 --- a/docs/DEVELOPER-GUIDE/quality-gate-registry.md +++ b/docs/DEVELOPER-GUIDE/quality-gate-registry.md @@ -34,12 +34,10 @@ A gate with an external installed counterpart declares it explicitly. When the i ## Current-tree and history-provenance boundary -**DOES:** Every PR evaluates the current checkout's registered gates and declared inerting mutations directly, unprivileged and fail-closed. The seven-gate population is compared independently against `gates/required-gates.baseline.json`; a registered control shrinks the verifier inventory and manifest together and proves that the unchanged baseline rejects the attack. Evidence-side subjects are consumed and compared with gate definitions for every gate. +**DOES:** Every PR evaluates the current checkout's registered gates and declared inerting mutations directly, unprivileged and fail-closed. The seven-gate population, verifier inventory, and `gates/required-gates.baseline.json` are compared inside the checkout. Evidence-side subjects are consumed and compared with gate definitions for every gate. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.** **DOES NOT:** This repository layer establishes history provenance at all. `pnpm install` executes PR-controlled lifecycle code before `gate:verify`, so no local ref, git config, remote URL, constant, or author-positioned path in the checkout can anchor a history claim. An observation saying “unverifiable” while returning zero would be a green wearing a disclaimer, so the claim and history verifier path are removed. The production output path uses a closed current-tree observation renderer; history, ancestry, and provider-lineage success are not representable observation classes. `scripts/gate-history-exclusion-control.mjs` exercises alternate success wording and production renderer wiring. Its registered must-fail case turns red if a prohibited success class is added or renderer consumption is bypassed. RM-60 owns the provider-controlled/protected pre-execution boundary needed before history provenance can be asserted. No bootstrap override exists in RM-02. -Universal checks first prove populations non-empty. The production profile reads the independent baseline before comparing the verifier inventory and manifest separately, while `gateRefs` on the D-38/D-40 criteria must exactly span every registered gate. Population controls mutate evidence-side subjects and type-strict comparison inputs one gate at a time and require rejection across the complete inventory. - -The independent baseline blocks the registered source-plus-manifest shrink attack. It does not claim same-authority authenticity against an actor who consistently rewrites every repository artifact; RM-60/RM-59 own external execution/artifact integrity. +Universal checks first prove populations non-empty. The production profile reads the same-checkout baseline before comparing the verifier inventory and manifest separately, while `gateRefs` on the D-38/D-40 criteria must exactly span every registered gate. Population controls mutate evidence-side subjects and type-strict comparison inputs one gate at a time and require rejection across the complete inventory. A registered prose-claim control fails when the same-checkout mechanism is described as an adversarial protection. diff --git a/docs/PRD.md b/docs/PRD.md index 7056dd26..520e00c2 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -38,10 +38,10 @@ Existing deterministic gates can return success without enforcing their stated p 8. `RM02-REQ-08`: Every gate with a deployed counterpart SHALL register source/deployed byte identity and a must-fail drift control. Gates without a deployed counterpart SHALL say so explicitly. 9. `RM02-REQ-09`: CI SHALL run `pnpm gate:verify` on every pull request without path filtering and on protected-main pushes. 10. `RM02-REQ-10` (restated): PR CI SHALL perform unprivileged, fail-closed current-tree verification only. The production observation renderer SHALL be a closed current-tree-only output type with no representable history-provenance success state. A registered must-fail control SHALL turn red if history/ancestry/lineage success is added to that renderer or if production output bypasses the renderer. RM-60 owns the provider-controlled/protected pre-execution boundary required to establish history provenance. -11. `RM02-REQ-11` (`D-46`): The required seven-gate inventory SHALL be read from an independent baseline artifact and compared separately with both the verifier inventory and manifest. Shrinking the verifier inventory and manifest together while leaving the baseline intact SHALL fail for the removed gate. +11. `RM02-REQ-11` (`D-46`): The required seven-gate inventory SHALL be read from a same-checkout baseline and compared separately with both the verifier inventory and manifest. Shrinking the verifier inventory and manifest together while leaving the baseline intact SHALL fail for the removed gate. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.** 12. `RM02-REQ-12` (`D-38`): Every consumed case result SHALL carry an evidence-side subject independently declared from the gate definition. The verifier SHALL compare that evidence subject with the gate definition when consuming the result. A population control SHALL mutate the evidence-side subject independently for every required gate and observe rejection for every gate. -13. `RM02-REQ-13` (`D-40`): For every gate in the independently baselined required inventory, each discriminator and comparison input SHALL have a recursively closed, type-strict schema. Unknown, misspelled, wrong-type, or present-but-empty nested assertion fields SHALL fail rather than disabling an assertion. -14. `RM02-REQ-14` (`D-46`): No universally quantified registry check SHALL run until its population is proven non-empty and independently baselined. The required seven-gate inventory, criteria, prose claims, and compatibility scenarios SHALL reject empty populations before reporting that all registered cases ran. +13. `RM02-REQ-13` (`D-40`): For every gate listed in the required inventory baseline, each discriminator and comparison input SHALL have a recursively closed, type-strict schema. Unknown, misspelled, wrong-type, or present-but-empty nested assertion fields SHALL fail rather than disabling an assertion. +14. `RM02-REQ-14` (`D-46`): No universally quantified registry check SHALL run until its population is proven non-empty and compared with the same-checkout baseline. The required seven-gate inventory, criteria, prose claims, and compatibility scenarios SHALL reject empty populations before reporting that all registered cases ran. #### RM02-REQ-10 meaning-change provenance @@ -57,15 +57,15 @@ Existing deterministic gates can return success without enforcing their stated p 4. `RM02-AC-04`: A gate with zero must-fail cases returns nonzero and includes `no negative control`. 5. `RM02-AC-05`: Unbound or semantically misbound criteria, prose claims bound to unrelated cases, unbound governing prose markers, ownerless behavior deltas, stale mutations, source/deployed drift, and modeled compatibility conflicts each return nonzero with the responsible stable ID. A simultaneous stale fixture or independent phase error SHALL NOT mask responsible stable-ID diagnostics. Registered meta-negative controls move a criterion binding, remove meaning provenance, and redirect a prose claim to an unrelated case; each is observed red for its stated reason. 6. `RM02-AC-06`: CI configuration invokes the verifier unconditionally on every pull request. -7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: the repository layer verifies current-tree registered cases, independent inventory shape, and evidence-side subject consumption; it does not establish history provenance at all. RM-60 is the tracked owner of the provider-controlled/protected pre-execution boundary. -8. `RM02-AC-08`: Registered must-fail controls reject an emptied registry; shrinking the verifier inventory and manifest together; adding history/ancestry/lineage success to the closed observation renderer or bypassing renderer consumption; a misspelled nested outcome field; a wrong outcome field type; and a present-but-empty outcome pattern. -9. `RM02-AC-09`: Population controls iterate every gate in the independently baselined inventory and prove consumed evidence-subject mismatch and wrong-type comparison input are rejected for each gate. `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-PROVENANCE-EXCLUDED`, and `RM02-NONEMPTY-ANCHORED-QUANTIFICATION` are bidirectionally bound to their must-fail controls. +7. `RM02-AC-07`: PR output states adjacent `DOES`/`DOES NOT` boundaries: the repository layer verifies current-tree registered cases, same-checkout inventory drift, and evidence-side subject consumption; it does not establish history provenance at all. **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.** +8. `RM02-AC-08`: Registered must-fail controls reject an emptied registry; shrinking the verifier inventory and manifest together; overstating the same-checkout inventory boundary; adding history/ancestry/lineage success to the closed observation renderer or bypassing renderer consumption; a misspelled nested outcome field; a wrong outcome field type; and a present-but-empty outcome pattern. +9. `RM02-AC-09`: Population controls iterate every gate listed in the baseline and prove consumed evidence-subject mismatch and wrong-type comparison input are rejected for each gate. `RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-PROVENANCE-EXCLUDED`, and `RM02-NONEMPTY-ANCHORED-QUANTIFICATION` are bidirectionally bound to their must-fail controls. ### Risks, dependencies, and verification boundary -- **DOES:** The repository verifier proves declared current-tree controls, modeled scenarios, bidirectional criterion/case relationships, source/deployed equality at execution time, an independent seven-gate baseline comparison, and evidence-side subject consumption. +- **DOES:** The repository verifier proves declared current-tree controls, modeled scenarios, bidirectional criterion/case relationships, source/deployed equality at execution time, a same-checkout seven-gate baseline comparison, and evidence-side subject consumption. - **DOES NOT:** This layer establishes no history provenance. PR-controlled lifecycle code executes before the gate, so no local git state in the checkout is trustworthy as a history anchor. RM-60 owns the provider-controlled/protected pre-execution boundary. The production verifier has no history verifier path, and its closed current-tree observation renderer cannot represent a history-provenance success state. -- The independent inventory baseline prevents the registered shrink-both attack proved by RM-02's control; it does not claim same-authority authenticity against an actor who consistently rewrites every repository artifact. RM-60/RM-59 own external execution/artifact integrity. +- **Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.** A registered claim control fails if the checked artifacts overstate this boundary. - The verifier does **not** infer arbitrary-English semantics or defend against an actor able to rewrite the gate, registry, verifier, controls, and baseline consistently. - `ASSUMPTION:` RM-54 is the owner for expanding registration and prose-marker coverage beyond this approved seven-gate slice; rationale: the remediation task graph already assigns the fleet-wide inert-gate audit there. diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index 35b35898..8d14d6a3 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -2,7 +2,7 @@ ## Gate verification -- [Developer gate registry guide](DEVELOPER-GUIDE/quality-gate-registry.md) — manifest schema, negative controls, evidence-side subjects, independent inventory baseline, and the enforced RM-60 history-provenance exclusion. +- [Developer gate registry guide](DEVELOPER-GUIDE/quality-gate-registry.md) — manifest schema, negative controls, evidence-side subjects, and the enforced RM-60 history-provenance exclusion. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary. - [Gate registry operations](ADMIN-GUIDE/quality-gate-registry.md) — routine verification, failure interpretation, registry updates, and unconditional CI behavior. - [RM-02 governing claim index](remediation/GATE-CLAIMS.md) — marker bindings for orchestrator-owned remediation claims without modifying task tracking. diff --git a/docs/plans/2026-08-01-rm-02-gate-registry.md b/docs/plans/2026-08-01-rm-02-gate-registry.md index 659ce777..d5d1b8f9 100644 --- a/docs/plans/2026-08-01-rm-02-gate-registry.md +++ b/docs/plans/2026-08-01-rm-02-gate-registry.md @@ -4,7 +4,7 @@ **Goal:** Build a machine-readable seven-gate registry and an unconditional CI verifier that detects inert gates, binds criteria to observed negative controls, records defects honestly, and verifies the current PR tree unprivileged and fail-closed. -**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json` and the independent `gates/required-gates.baseline.json`, validates closed schemas and references, then runs typed cases in isolated main-disk fixtures. Gate-specific fixture setup remains declarative; exact invocations, evidence-side subjects, and exact observed/required exits stay in JSON. The production verifier is structurally incapable of asserting history provenance; RM-60 owns the provider-controlled/protected pre-execution boundary. +**Architecture:** A dependency-free Node CLI reads `gates/gates.manifest.json` and the same-checkout `gates/required-gates.baseline.json`, validates closed schemas and references, then runs typed cases in isolated main-disk fixtures. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary. Gate-specific fixture setup remains declarative; exact invocations, evidence-side subjects, and exact observed/required exits stay in JSON. The production verifier is structurally incapable of asserting history provenance; RM-60 owns the provider-controlled/protected pre-execution boundary. **Tech Stack:** Node.js ESM, `node:test`, JSON, shell gates, pnpm, Woodpecker CI. @@ -76,7 +76,7 @@ Enumerate current security/integrity claims, bind each marker/id to a negative c - Modify: `scripts/gate-verify.mjs` - Modify: `gates/gates.manifest.json` -Verify current-tree behavior only. Remove the anchor-dependent history verifier and provider-history consumption because PR-controlled lifecycle code executes before the gate and makes every local git anchor untrustworthy. Use a closed current-tree observation renderer and register a must-fail control that turns red if history/ancestry/lineage success becomes representable or production output bypasses renderer consumption. Compare the verifier inventory and manifest separately against the independent baseline, and register a must-fail attack that shrinks source inventory plus manifest together. State both directions: current-tree controls, inventory shape, and evidence subjects are enforced here; history provenance is not established until RM-60 provides a provider-controlled/protected pre-execution boundary. +Verify current-tree behavior only. Remove the anchor-dependent history verifier and provider-history consumption because PR-controlled lifecycle code executes before the gate and makes every local git anchor untrustworthy. Use a closed current-tree observation renderer and register a must-fail control that turns red if history/ancestry/lineage success becomes representable or production output bypasses renderer consumption. Compare the verifier inventory and manifest separately against the same-checkout baseline, register must-fail attacks that shrink either paired representation, and register an overclaim control. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary. State both directions: current-tree controls, inventory shape, and evidence subjects are enforced here; history provenance is not established until RM-60 provides a provider-controlled/protected pre-execution boundary. ### Task 7: CI and documentation diff --git a/docs/remediation/GATE-CLAIMS.md b/docs/remediation/GATE-CLAIMS.md index f99b47b7..025b54ea 100644 --- a/docs/remediation/GATE-CLAIMS.md +++ b/docs/remediation/GATE-CLAIMS.md @@ -24,6 +24,14 @@ This index binds remediation claims that live in orchestrator-owned `TASKS.md` w - Anchored text: “SELF-VERIFICATION BY THE AUDITED PARTY IS NOT VERIFICATION.” - Dependency: RM-60/#1031, cross-referenced with RM-59. +## Same-checkout inventory drift boundary + + + +- Source: RM-02 ruling after D-48/CWE-353 reproduced against the round-4 baseline. +- Boundary: Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary. +- Negative control: `checkout-preflight/inventory-claim-overstatement` rewrites the boundary as protection and must go red. + ## Criterion restatement provenance diff --git a/docs/scratchpads/1029-rm-02-gate-registry.md b/docs/scratchpads/1029-rm-02-gate-registry.md index d0fbd11f..91b2c468 100644 --- a/docs/scratchpads/1029-rm-02-gate-registry.md +++ b/docs/scratchpads/1029-rm-02-gate-registry.md @@ -102,3 +102,6 @@ The queue guard's `get_state_from_status_json` runs `python3 - <<'PY'` while pro - Independent Codex code review requested changes on two valid blockers. First, the evidence population control called the subject helper directly rather than traversing production result consumption. It now creates one lightweight executed fixture per required gate, invokes the real `verifyRegistry` path, and fails to observe rejection if the production consumer is removed; a regression mutation proves that coupling. Second, a lexical history blacklist overclaimed structural incapacity. Production output now passes through a closed current-tree observation renderer with no history/ancestry/lineage success class; the exclusion control tests three alternate success wordings plus exact production renderer wiring, and the registered must-fail fixture adds a prohibited class. - Codex review test attempts were unrunnable in its read-only sandbox (`EROFS`/`EPERM`); the reviewer disclosed this rather than substituting a passing variant. Local writable-worktree tests remain the runnable evidence. - Renderer remediation: every non-error production observation and final summary now routes through the closed current-tree output renderer. The exclusion control additionally requires one stdout sink, exactly two `GATE VERIFY FAILED` stderr sinks, no console sinks, and renderer use for both per-observation and final-summary paths. Regression attacks cover allowlisted history/ancestry/lineage wording, writer assertion removal, direct final-success stdout, and direct success stderr; focused suite 41/41, `pnpm gate:verify` green with six declared RM-03 deltas, and format check green. +- Coordinator ruling after CWE-353: retain the same-checkout inventory comparison but narrow its claim. Canonical current boundary: “Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.” This supersedes the earlier “independent/anchored” wording in the historical entries above; those entries remain as append-only evidence, not current claims. +- Genuine RED-first overclaim control: before claim remediation, `node scripts/gate-inventory-claim-control.mjs` exited 84 and named every artifact missing the narrowed boundary plus each current overclaim. The registered `inventory-claim-overstatement` case now rewrites the baseline purpose to a protection claim and must exit 84 with `INVENTORY_CLAIM_OVERSTATED`. Existing shrink-pair controls remain regression guards for the same-checkout drift property, not adversarial-integrity claims. +- Follow-up code review found a genuine propagation/control gap: `docs/PRD.md` still said “independent seven-gate baseline,” and the first forbidden regex did not match the intervening qualifier. The reviewer observed the control green against that overclaim. The PRD now says same-checkout, the detector rejects `independent … baseline` across bounded same-line qualifiers, the registered mutation uses the exact qualified wording, and a focused behavioral test requires exit 84 for it. Follow-up security review passed with risk `none` and explicitly accepted the narrowed RM-60 boundary. diff --git a/gates/gates.manifest.json b/gates/gates.manifest.json index 16ec4a5e..2ba1b3ad 100644 --- a/gates/gates.manifest.json +++ b/gates/gates.manifest.json @@ -44,7 +44,8 @@ "hook-pre-commit/lint-staged-failure", "hook-pre-push/typecheck-failure", "checkout-preflight/history-provenance-exclusion", - "checkout-preflight/inventory-source-and-manifest-shrink" + "checkout-preflight/inventory-source-and-manifest-shrink", + "checkout-preflight/inventory-claim-overstatement" ] }, { @@ -303,13 +304,23 @@ { "id": "RM02-NONEMPTY-ANCHORED-QUANTIFICATION", "originalText": "No universally quantified registry check runs until its population is proven non-empty and anchored.", - "currentText": "No universally quantified registry check runs until its population is proven non-empty and anchored.", + "currentText": "No universally quantified registry check runs over an empty population. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.", "claimType": "integrity", "source": "docs/remediation/TASKS.md#d-46", - "meaningChanges": [], + "meaningChanges": [ + { + "originalText": "No universally quantified registry check runs until its population is proven non-empty and anchored.", + "restatement": "No universally quantified registry check runs over an empty population. Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.", + "reason": "D-48/CWE-353 established that a same-checkout baseline detects drift but is not an adversarial trust anchor.", + "finding": "D-48", + "task": "RM-60", + "date": "2026-08-01" + } + ], "caseRefs": [ "checkout-preflight/empty-registry-populations", - "checkout-preflight/inventory-source-and-manifest-shrink" + "checkout-preflight/inventory-source-and-manifest-shrink", + "checkout-preflight/inventory-claim-overstatement" ], "gateRefs": [ "quality-typecheck", @@ -362,6 +373,11 @@ "id": "CRITERION-RESTATEMENT", "criterionId": "RM02-MEANING-PROVENANCE", "caseRef": "checkout-preflight/missing-meaning-provenance" + }, + { + "id": "INVENTORY-DRIFT-BOUNDARY", + "criterionId": "RM02-NONEMPTY-ANCHORED-QUANTIFICATION", + "caseRef": "checkout-preflight/inventory-claim-overstatement" } ], "compatibilityScenarios": [ @@ -1004,6 +1020,44 @@ "evidence": { "subject": "checkout-preflight" } + }, + { + "id": "inventory-claim-overstatement", + "criterionIds": ["RM02-CHECK-RIGHT", "RM02-NONEMPTY-ANCHORED-QUANTIFICATION"], + "mustFail": true, + "invocation": ["node", "scripts/gate-inventory-claim-control.mjs"], + "required": { + "exitCode": 84, + "outputPattern": "INVENTORY_CLAIM_OVERSTATED" + }, + "actual": { + "exitCode": 84, + "outputPattern": "INVENTORY_CLAIM_OVERSTATED" + }, + "reasonPattern": "INVENTORY_CLAIM_OVERSTATED", + "fixture": { + "copyPaths": [ + "scripts/gate-inventory-claim-control.mjs", + "gates/required-gates.baseline.json", + "gates/gates.manifest.json", + "docs/PRD.md", + "docs/ADMIN-GUIDE/quality-gate-registry.md", + "docs/DEVELOPER-GUIDE/quality-gate-registry.md", + "docs/remediation/GATE-CLAIMS.md", + "docs/plans/2026-08-01-rm-02-gate-registry.md", + "docs/SITEMAP.md" + ], + "replaceFiles": [ + { + "path": "gates/required-gates.baseline.json", + "find": "Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.", + "replace": "Provides an independent seven-gate baseline comparison." + } + ] + }, + "evidence": { + "subject": "checkout-preflight" + } } ] }, diff --git a/gates/required-gates.baseline.json b/gates/required-gates.baseline.json index 992c4a88..63cdf3e2 100644 --- a/gates/required-gates.baseline.json +++ b/gates/required-gates.baseline.json @@ -1,16 +1,34 @@ { "schemaVersion": 1, - "purpose": "Independent required-gate population baseline; manifest and verifier inventory must both match.", + "purpose": "Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary.", "gates": [ - { "id": "quality-typecheck", "source": "package.json" }, - { "id": "quality-lint", "source": "package.json" }, - { "id": "quality-format", "source": "package.json" }, - { "id": "checkout-preflight", "source": "scripts/preflight.mjs" }, + { + "id": "quality-typecheck", + "source": "package.json" + }, + { + "id": "quality-lint", + "source": "package.json" + }, + { + "id": "quality-format", + "source": "package.json" + }, + { + "id": "checkout-preflight", + "source": "scripts/preflight.mjs" + }, { "id": "ci-queue-wait", "source": "packages/mosaic/framework/tools/git/ci-queue-wait.sh" }, - { "id": "hook-pre-commit", "source": ".husky/pre-commit" }, - { "id": "hook-pre-push", "source": ".husky/pre-push" } + { + "id": "hook-pre-commit", + "source": ".husky/pre-commit" + }, + { + "id": "hook-pre-push", + "source": ".husky/pre-push" + } ] } diff --git a/scripts/gate-inventory-claim-control.mjs b/scripts/gate-inventory-claim-control.mjs new file mode 100644 index 00000000..fb2e5c58 --- /dev/null +++ b/scripts/gate-inventory-claim-control.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; + +const boundary = + "Detects accidental and incompetent inventory drift within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier consistently — that guarantee requires RM-60's external boundary."; +const artifacts = [ + 'gates/required-gates.baseline.json', + 'gates/gates.manifest.json', + 'docs/PRD.md', + 'docs/ADMIN-GUIDE/quality-gate-registry.md', + 'docs/DEVELOPER-GUIDE/quality-gate-registry.md', + 'docs/remediation/GATE-CLAIMS.md', + 'docs/plans/2026-08-01-rm-02-gate-registry.md', + 'docs/SITEMAP.md', +]; +const forbidden = [ + /\bindependent\b[^\n.]{0,80}\bbaseline\b/i, + /independently baselined/i, + /independently anchored inventory/i, + /protected (?:inventory )?(?:anchor|baseline)/i, + /inventory (?:anchor|anchored)/i, +]; +const failures = []; +for (const relativePath of artifacts) { + const contents = await readFile(path.join(process.cwd(), relativePath), 'utf8'); + const claims = + relativePath === 'gates/gates.manifest.json' + ? JSON.parse(contents) + .criteria.map((criterion) => criterion.currentText) + .join('\n') + : contents; + if (!claims.includes(boundary)) failures.push(`${relativePath}: narrowed boundary is missing`); + const overclaim = forbidden.find((pattern) => pattern.test(claims)); + if (overclaim) failures.push(`${relativePath}: inventory protection is overstated`); +} +if (failures.length > 0) { + for (const failure of failures) { + process.stderr.write(`INVENTORY_CLAIM_OVERSTATED: ${failure}\n`); + } + process.exit(84); +} +process.stdout.write('inventory drift boundary is stated in both directions; owner RM-60\n'); diff --git a/scripts/gate-remediation.test.mjs b/scripts/gate-remediation.test.mjs index 6432e568..baeb2bac 100644 --- a/scripts/gate-remediation.test.mjs +++ b/scripts/gate-remediation.test.mjs @@ -58,7 +58,7 @@ function shrinkManifest(manifest, removedGateId) { } } -test('shrinking the verifier inventory and manifest together is rejected by an independent baseline', async () => { +test('shrinking the verifier inventory and manifest together is rejected by a same-checkout baseline', async () => { const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-shrink-both-')); try { await mkdir(path.join(fixture, 'scripts'), { recursive: true }); @@ -94,13 +94,13 @@ test('shrinking the verifier inventory and manifest together is rejected by an i { cwd: fixture, encoding: 'utf8' }, ); assert.notEqual(result.status, 0, 'shrinking source anchor and manifest together must go red'); - assert.match(output(result), /independent required-gate baseline.*hook-pre-push/i); + assert.match(output(result), /same-checkout required-gate baseline.*hook-pre-push/i); } finally { await rm(fixture, { recursive: true, force: true }); } }); -test('shrinking the independent baseline and manifest together is rejected by verifier inventory', async () => { +test('shrinking the same-checkout baseline and manifest together is rejected by verifier inventory', async () => { const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-shrink-baseline-manifest-')); try { await mkdir(path.join(fixture, 'scripts'), { recursive: true }); @@ -154,6 +154,46 @@ test('every gate carries an evidence-side subject distinct from its definition', } }); +test('inventory claim control rejects qualified independence wording', async () => { + const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-inventory-overclaim-')); + const artifacts = [ + 'gates/required-gates.baseline.json', + 'gates/gates.manifest.json', + 'docs/PRD.md', + 'docs/ADMIN-GUIDE/quality-gate-registry.md', + 'docs/DEVELOPER-GUIDE/quality-gate-registry.md', + 'docs/remediation/GATE-CLAIMS.md', + 'docs/plans/2026-08-01-rm-02-gate-registry.md', + 'docs/SITEMAP.md', + ]; + try { + await mkdir(path.join(fixture, 'scripts'), { recursive: true }); + await copyFile( + path.join(root, 'scripts', 'gate-inventory-claim-control.mjs'), + path.join(fixture, 'scripts', 'gate-inventory-claim-control.mjs'), + ); + for (const relativePath of artifacts) { + const target = path.join(fixture, relativePath); + await mkdir(path.dirname(target), { recursive: true }); + await copyFile(path.join(root, relativePath), target); + } + const prd = path.join(fixture, 'docs', 'PRD.md'); + await writeFile( + prd, + `${await readFile(prd, 'utf8')}\nThis provides an independent seven-gate baseline comparison.\n`, + ); + const result = spawnSync( + process.execPath, + [path.join(fixture, 'scripts', 'gate-inventory-claim-control.mjs')], + { cwd: fixture, encoding: 'utf8' }, + ); + assert.equal(result.status, 84, output(result)); + assert.match(output(result), /INVENTORY_CLAIM_OVERSTATED.*docs\/PRD\.md/i); + } finally { + await rm(fixture, { recursive: true, force: true }); + } +}); + test('evidence population control depends on production result consumption wiring', async () => { const fixture = await mkdtemp(path.join(os.tmpdir(), 'rm02-evidence-consumer-inert-')); try { diff --git a/scripts/gate-verify.mjs b/scripts/gate-verify.mjs index 137c2a5a..ffd2f87a 100644 --- a/scripts/gate-verify.mjs +++ b/scripts/gate-verify.mjs @@ -591,20 +591,20 @@ function validateClosedSchema( rejectDuplicateIds(manifest.gates, 'gate', failures); if (!fixtureProfile) { if (!(requiredGateInventory instanceof Map) || requiredGateInventory.size === 0) { - failures.push('independent required-gate baseline is absent or empty'); + failures.push('same-checkout required-gate baseline is absent or empty'); } else { if (!inventoriesEqual(REQUIRED_GATE_INVENTORY, requiredGateInventory)) { for (const [requiredId, requiredSource] of requiredGateInventory) { if (REQUIRED_GATE_INVENTORY.get(requiredId) !== requiredSource) { failures.push( - `independent required-gate baseline rejects verifier inventory drift at ${requiredId}`, + `same-checkout required-gate baseline rejects verifier inventory drift at ${requiredId}`, ); } } for (const [requiredId, requiredSource] of REQUIRED_GATE_INVENTORY) { if (requiredGateInventory.get(requiredId) !== requiredSource) { failures.push( - `verifier inventory ${requiredId} is absent or changed in independent required-gate baseline`, + `verifier inventory ${requiredId} is absent or changed in same-checkout required-gate baseline`, ); } } @@ -613,7 +613,7 @@ function validateClosedSchema( const registered = (manifest.gates ?? []).find((gate) => gate?.id === requiredId); if (!registered || registered.source !== requiredSource) { failures.push( - `independent required-gate baseline rejects manifest drift at ${requiredId}: required source ${requiredSource}`, + `same-checkout required-gate baseline rejects manifest drift at ${requiredId}: required source ${requiredSource}`, ); } } @@ -1128,15 +1128,15 @@ export async function verifyRegistry(options) { gate.source.length === 0, ) ) { - failures.push('independent required-gate baseline has unsupported structure'); + failures.push('same-checkout required-gate baseline has unsupported structure'); } else { requiredGateInventory = new Map(baseline.gates.map((gate) => [gate.id, gate.source])); if (requiredGateInventory.size !== baseline.gates.length) { - failures.push('independent required-gate baseline has duplicate gate ids'); + failures.push('same-checkout required-gate baseline has duplicate gate ids'); } } } catch (error) { - failures.push(`independent required-gate baseline cannot be read: ${error.message}`); + failures.push(`same-checkout required-gate baseline cannot be read: ${error.message}`); } } diff --git a/scripts/gate-verify.test.mjs b/scripts/gate-verify.test.mjs index c6fe46ca..82b9c4e5 100644 --- a/scripts/gate-verify.test.mjs +++ b/scripts/gate-verify.test.mjs @@ -161,7 +161,7 @@ test('anchored gate inventory and population criteria cannot shrink together', a assert.notEqual(result.status, 0); assert.match( output(result), - /independent required-gate baseline rejects manifest drift at hook-pre-push/i, + /same-checkout required-gate baseline rejects manifest drift at hook-pre-push/i, ); }); -- 2.54.0