Compare commits

..
Author SHA1 Message Date
coder-mos1 868b9b871a docs(rm-03): record review bypass remediation
ci/woodpecker/pr/ci Pipeline was successful
2026-08-01 13:31:44 -05:00
coder-mos1 afa4927b7e fix(rm-03): remove merge queue-guard bypass 2026-08-01 13:31:44 -05:00
coder-mos1 98872cd8f5 test(rm-03): prove merge queue bypass reaches provider 2026-08-01 13:31:44 -05:00
coder-mos1 cd65ccbfd7 docs(rm-03): record scope and checkpoint verification 2026-08-01 13:31:44 -05:00
coder-mos1 b238e0a8b1 wip(rm-03): rescue checkpoint of in-flight queue-guard work
CHECKPOINT ONLY — not a deliverable, not reviewed, not complete.

Committed by mos-remediation as TRANSPORT after the authoring seat (coder-mos1) became
unable to act: its context window was exceeded at 102.6%/372k AND its overflow-recovery
path failed ("Turn prefix summarization failed: servers overloaded"). The seat could not
process instructions or commit its own work, leaving 28 modified files durable only on
local disk.

Authorship preserved as coder-mos1 — this is transport, not authorship. The work is the
seat's; committing it is the only way to make it survive the rotation that must follow.

Scope note, unresolved: this diff spans the queue-guard tools (9 files under
framework/tools/git) AND 5 framework guides plus 11 agent templates. RM-03 was scoped to
ci-queue-wait.sh and its tests. Whether the doc/template edits are consequential to the
tri-state change or drive-by was queried and never answered — the seat was bricked before
it could reply. That question stays open and must be resolved before any of this is
reviewed; the wider edits may need splitting out.

Direct instance of D-31: commit-then-rotate works, rotate-without-commit loses everything.
2026-08-01 13:31:44 -05:00
52 changed files with 1077 additions and 5510 deletions
+3 -17
View File
@@ -68,29 +68,15 @@ 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
# 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
typecheck:
image: *node_image
commands:
- *enable_pnpm
- pnpm typecheck
depends_on:
- gate-verify
- install
- sanitization
- upgrade-guard
# lint, format, and test are independent — run in parallel after typecheck
lint:
-3
View File
@@ -1,3 +0,0 @@
# Administrator Guide
- [Gate registry operations](quality-gate-registry.md)
-39
View File
@@ -1,39 +0,0 @@
# 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. 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.
- `PROVIDER EVIDENCE ... ABSENT` — retained external history was unavailable; do not infer merge-time success.
## Updating a gate
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.
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. 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`, 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 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.
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.
-3
View File
@@ -1,3 +0,0 @@
# Developer Guide
- [Gate registry and negative controls](quality-gate-registry.md)
@@ -1,51 +0,0 @@
# 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, 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
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 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:<id>` 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.
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
**DOES:** Every PR evaluates the current checkout's registered gates and declared inerting mutations directly, unprivileged and fail-closed.
**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 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`, 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 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.
-58
View File
@@ -14,64 +14,6 @@
---
## 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; 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).
### 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 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:<id>` 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.
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.
#### 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
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 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.
### 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.
- `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.
-6
View File
@@ -1,11 +1,5 @@
# 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.
@@ -1,94 +0,0 @@
# 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 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.
**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: Current-tree boundary, deferred 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. 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
**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.
-32
View File
@@ -1,32 +0,0 @@
# 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
<!-- GATE-CLAIM:PROSE-IS-A-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
<!-- GATE-CLAIM:GENERATED-STATE-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.”
## Execution trust boundary
<!-- GATE-CLAIM: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
<!-- GATE-CLAIM:CRITERION-RESTATEMENT -->
- 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.”
-8
View File
@@ -12,8 +12,6 @@ gate/program; the LLM handles only genuine judgment.
### First-class principle — observe the property, not the exit code
<!-- GATE-CLAIM:OBSERVE-PROPERTY -->
> **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
@@ -32,8 +30,6 @@ gate/program; the LLM handles only genuine judgment.
### First-class principle — pre-registration prevents retrofitting, and nothing else
<!-- GATE-CLAIM:PREREGISTRATION-BOUNDARY -->
> **A pre-registered check set can fail in three distinct ways:**
>
> | mode | the set is… | found as |
@@ -60,8 +56,6 @@ gate/program; the LLM handles only genuine judgment.
### Corollary — never ship an integrity claim dressed as a property
<!-- GATE-CLAIM:ARTIFACT-INTEGRITY-BOUNDARY -->
> 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,
@@ -74,8 +68,6 @@ gate/program; the LLM handles only genuine judgment.
### First-class principle — when a property cannot exist at the layer it was specified
<!-- GATE-CLAIM:IMPOSSIBLE-LAYER-BOUNDARY -->
> 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:
@@ -1,90 +0,0 @@
# 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 <[email protected]>`; 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: 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.
- `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.
- 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. 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.
- 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.
- User/API documentation not applicable: no user workflow or API changed.
- 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. 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
- 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 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.
+120
View File
@@ -0,0 +1,120 @@
# RM-03 — CI Queue Guard Repair
- **Task:** RM-03
- **Issue:** #1019
- **Branch:** `fix/rm-03-queue-guard`
- **Owner:** coder-mos1
- **Reviewer:** rev-974 (independent; author != reviewer)
- **Started:** 2026-08-01
## Objective
Repair the mandatory CI queue guard so it reads provider payloads, blocks asserted non-green CI, distinguishes provider unavailability from a real non-green result, and inspects the branch actually being pushed or merged.
## Constraints
- Worktree only: `/home/hermes/agent-work/rm-03`; never mutate `/src/mosaic-stack`.
- JSON payload travels through stdin; never argv. Large payload must remain below no ARG_MAX dependency.
- TDD is mandatory. Every behavior case must be observed red before implementation.
- No bypass flags or hook suppression.
- Do not cite the existing guard's green as evidence; D-23 establishes it is zero-information.
- Gate-ready is a frozen exact head. Any push after a merge-gate verdict voids that verdict.
- No merge: coordinator holds the merge hand pending Jason.
## Design
1. Feed JSON to `python3 -c` on stdin, including pending-context rendering.
2. Classify valid green as `READY`; pending/failure/no-status/malformed/mixed as `ASSERTED_NOT_READY`; provider/credential/transport inability as `CANNOT_ASSERT`.
3. `ASSERTED_NOT_READY` exits nonzero. `CANNOT_ASSERT` emits a loud diagnostic and appends a local JSONL audit record. Push degrades to exit 0; merge holds with distinct retryable exit 75 until provider recovery, then self-clears without manual reset. Inability to write the audit exits nonzero.
4. Derive the current branch when `-B` is omitted. The merge wrapper passes the exact PR head branch, repository, and full commit SHA—not its `main` base—so fork PRs cannot resolve against an adjacent base-repository branch.
## Test matrix
| Case | Required outcome |
| --- | --- |
| success | exit 0; terminal-success |
| pending | nonzero after bounded timeout |
| failure | nonzero |
| no-status | nonzero |
| malformed | nonzero |
| >=150 KiB payload | unchanged classification; never rc126 |
| provider unreachable on push | loud audited CANNOT_ASSERT; degraded exit 0 |
| provider unreachable on merge | loud audited CANNOT_ASSERT; retryable exit 75/HOLD |
| audit unavailable | nonzero |
| implicit push branch | provider URL uses checked-out feature branch |
| merge wrapper | queue guard receives exact PR head branch/repository/full SHA |
## RED-first evidence
Observed against the unmodified `origin/main` implementation before source edits:
- `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` → rc 1 with 15 failed assertions.
- Success payload was reported `state=unknown`.
- Pending, failure, no-status, and malformed payloads each exited 0 and omitted `ASSERTED_NOT_READY`.
- The 160 KiB payload produced rc 141 because Python never consumed the pipe; it did not classify success.
- Provider-unreachable exited 7 with no `CANNOT_ASSERT` audit record.
- Implicit push queried `/branches/main`, not `/branches/fix/rm-03-fixture`.
- Audit-unavailable emitted no audit diagnostic.
- A credential-resolution hard-block mutant was then run before trusting that added case: `credential-unresolvable` returned rc 1 and omitted `CANNOT_ASSERT`; the matrix returned rc 1 with two named assertion failures.
- Review-blocker controls were observed red: structurally invalid `statuses` string and null-entry payloads each exited 0 as `terminal-success`; unsupported-platform discovery exited 1 without diagnostic or audit (seven named assertion failures total).
- After the push/merge asymmetry ruling, merge-side provider unavailability was observed red at rc 0; its registered case required distinct retryable rc 75.
- Aggregate `state=success` with zero contexts was observed red: it exited 0 as `terminal-success`; the registered case requires `no-status`/nonzero.
- Fork/exact-head controls were observed red: `pr-merge.sh` omitted the fork repository and full SHA, and an ignored-arguments mutant re-resolved through `/branches/` instead of the exact fork commit (two named failures).
- GitHub check-run-only success/pending/failure were each misclassified as `no-status`; the RED run had five named failures and proved the Checks API was never queried.
- The first merge-pin control was unrunnable because one `local` declaration referenced a variable before assignment under `set -u`; this was disclosed and corrected rather than counted. The runnable RED then showed Gitea payload `{"Do":"squash"}` lacked `head_commit_id`; a separate GitHub run showed `gh pr merge 123 --squash` lacked `--match-head-commit`.
- A stale-verdict mutant removed the `--expect-head` comparison and was observed red because a moved head reached the provider merge call.
- `bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh` initially returned rc 1; captured call was `--purpose merge -B main -t 900 -i 15`.
Logs remain untracked under the worktree as `.mosaic-test-work-red-*.log` and will not be committed.
## Progress
- [x] Mission, remediation charter, task evidence, board, issue #1019, and superseded PR #1023 read.
- [x] Isolated worktree created and identity configured coherently.
- [x] Mutant tests authored and observed red.
- [x] Implementation green.
- [x] Baseline and focused situational gates green; full package suite has an unrelated framework-shell environment abort recorded below.
- [ ] Independent review clean (rev-974 requested changes at `44ffa99a`; bypass remediation committed and awaiting re-review).
- [ ] PR CI terminal-green at exact head by full step scan.
- [ ] Merge-gate verdict issued against frozen head.
## Scope disposition
- The five framework guides are consequential documentation: they define the purpose-aware tri-state contract, including audited push degradation and merge HOLD.
- The agent templates are consequential because they ship the same queue-guard instructions into newly seeded agent contracts; leaving them binary/stale would contradict the repaired tool.
- `pr-merge.sh` is consequential: it must inspect the PR's exact head branch/repository/SHA and enforce the exact-head merge pin.
- `pr-metadata.sh` is consequential only as the normalized source of that head branch/repository/SHA. Its diff is limited to exposing those fields on GitHub and Gitea.
- `test-pr-merge-gitea-empty-uid.sh` changes because exact-head Gitea merges now always use the API path (the only path that can send `head_commit_id`), superseding the prior tea-empty-identity fallback behavior.
## Review remediation
- rev-974 independently proved that the documented `--skip-queue-guard` merge option bypassed an exit-99 guard stub, reached the provider merge payload, printed success, and exited 0 at head `44ffa99a`.
- RED-first reproduction was added to `test-pr-merge-head-pin.sh` before the production fix: `FAIL merge-bypass: --skip-queue-guard reached the provider merge path`, suite rc 1. The test-only commit is `241113e6`.
- Production remediation `37aae650` removes the option from parsing, usage, help, and examples. Every merge-capable path now invokes the queue guard; `--dry-run` alone omits it and has a regression proving that it exits before provider dispatch and creates no merge payload.
- Existing Gitea merge tests now exercise a successful guard response rather than bypassing the guard.
## Risks / boundaries
- The local JSONL audit is durable operational evidence but not tamper-resistant against the same UID. RM-03 does not claim otherwise.
- Push-side audited exit 0 is an explicit owner ruling (Option B), accepted to avoid bricking recovery work; merge-side CANNOT_ASSERT remains retryable exit 75/HOLD. The automated security reviewer continues to flag the deliberate push availability tradeoff.
- Source/deployed-copy equality is owned by RM-02/D-22; this branch changes repository source and its tests only.
## Test evidence
Fresh after rescue checkpoint `b7175012`:
- Focused situational matrix: tri-state, GitHub checks pagination, branch-absent, merge head branch/repository/SHA, exact-head pin, and Gitea exact-head API regressions all passed.
- `bash -n` on the three production shell scripts passed.
- `shellcheck -x -P packages/mosaic/framework/tools/git ...` on all changed shell scripts passed.
- `pnpm typecheck` passed (45/45 Turbo tasks).
- `pnpm lint` passed (25/25 Turbo tasks).
- `pnpm format:check` passed.
- `pnpm --filter @mosaicstack/mosaic test`: Vitest passed 1508/1508 on the confirmation run; framework-shell then aborted at the pre-existing wake coordinate assertion with exit 97: `BASH_LINENO ... probe reported [3 5], expected [3 4] ... (#973)`. This is outside the RM-03 diff and is disclosed rather than substituted or called green.
- The prior package-suite attempt had one transient, out-of-diff `install-ordering-guard.spec.ts` failure (1/1508); its isolated rerun passed 19/19 and the confirmation full Vitest run passed 1508/1508.
- After bypass remediation: all six focused RM-03 queue/merge regressions passed, including bypass refusal and dry-run non-dispatch; shell syntax and source-aware ShellCheck passed; `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` passed.
- Fresh `test:framework-shell` reached and passed every RM-03 test, then again aborted at the unrelated wake coordinate assertion with exit 97; it remains explicitly non-green rather than substituted.
- An ad hoc raw Prettier invocation over `.template` and `.sh` files was unrunnable because no parser is registered for those extensions; it was not used as a substitute for canonical `pnpm format:check`.
## Final evidence
Pending.
File diff suppressed because it is too large Load Diff
-1
View File
@@ -6,7 +6,6 @@
"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",
@@ -925,14 +925,16 @@ Woodpecker note:
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
```bash
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>
```
Behavior:
- If pipeline state is running/queued/pending, wait until queue clears.
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero.
- Credential, transport, or provider unavailability is `CANNOT_ASSERT`: the guard emits a loud diagnostic and durable JSONL audit record. For push it exits 0 so recovery work is not bricked. For merge it returns distinct retryable exit 75 and holds until provider recovery; rerunning then self-clears without manual reset. This result is never evidence that CI was clear. If the audit cannot be written, the guard exits nonzero.
- `pr-merge.sh` resolves and guards the exact PR head repository and full SHA automatically, including fork PRs.
## Gitea as Unified Platform
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
- PR target for delivery is `main`.
- Direct pushes to `main` are prohibited.
- Merge to `main` MUST be squash-only.
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent).
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
## Review Checklist
@@ -79,7 +79,7 @@ For implementation work, you MUST run this cycle in order:
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. `push` - push immediately after queue guard passes.
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`.
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes).
@@ -93,8 +93,8 @@ For implementation work, you MUST run this cycle in order:
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
> without asking.
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists)
5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop.
@@ -425,11 +425,11 @@ git push
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
- Before merging, run queue guard:
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
- Ensure PR exists for the task branch (create/update via wrappers if needed):
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
- Merge via wrapper:
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
- Wait for terminal CI status:
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
- Close linked issue after merge + green CI:
@@ -630,7 +630,7 @@ Construct this from the task row and pass to worker via Task tool:
**MANDATORY:** This ALWAYS includes linting. If the project has a linter configured
(ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched.
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
## Git Scripts
@@ -638,8 +638,9 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
@@ -23,10 +23,12 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a
# Milestones
~/.config/mosaic/tools/git/milestone-create.sh
# CI queue guard (required before push/merge)
# CI queue guard (required before push/merge; defaults to the checked-out branch)
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
```
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
### Code Review (Codex)
```bash
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -88,7 +88,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable)
@@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -97,7 +97,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable)
@@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -101,7 +101,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable)
@@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -87,7 +87,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable)
@@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -84,7 +84,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable)
@@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -85,7 +85,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable)
@@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -7,7 +7,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
BRANCH="main"
BRANCH=""
TARGET_REPO=""
HEAD_SHA=""
TIMEOUT_SEC=900
INTERVAL_SEC=15
PURPOSE="merge"
@@ -15,10 +17,12 @@ REQUIRE_STATUS=0
usage() {
cat <<EOF
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
Options:
-B, --branch BRANCH Branch head to inspect (default: main)
-B, --branch BRANCH Branch head to inspect (default: current branch)
-R, --repo OWNER/REPO Repository containing the branch (default: origin repo)
--sha FULL_SHA Inspect this exact 40-character commit instead of resolving the branch
-t, --timeout SECONDS Max wait time in seconds (default: 900)
-i, --interval SECONDS Poll interval in seconds (default: 15)
--purpose VALUE Log context: push|merge (default: merge)
@@ -27,63 +31,65 @@ Options:
Examples:
$(basename "$0")
$(basename "$0") --purpose push -B main -t 600 -i 10
$(basename "$0") --purpose push -t 600 -i 10
EOF
}
# get_remote_host and get_gitea_token are provided by detect-platform.sh
get_state_from_status_json() {
python3 - <<'PY'
# Python source comes from -c so the provider payload remains on stdin.
# Never move the payload to argv: commit-status responses can exceed ARG_MAX.
python3 -c '
import json
import sys
try:
payload = json.load(sys.stdin)
if not isinstance(payload, dict):
raise ValueError("status payload is not an object")
except Exception:
print("unknown")
print("malformed")
raise SystemExit(0)
statuses = payload.get("statuses") or []
state = (payload.get("state") or "").lower()
raw_statuses = payload.get("statuses", [])
raw_state = payload.get("state", "")
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
print("malformed")
raise SystemExit(0)
statuses = raw_statuses
state = raw_state.lower()
pending_values = {"pending", "queued", "running", "waiting"}
failure_values = {"failure", "error", "failed"}
success_values = {"success"}
if state in pending_values:
print("pending")
raise SystemExit(0)
if state in failure_values:
print("terminal-failure")
raise SystemExit(0)
if state in success_values:
print("terminal-success")
raise SystemExit(0)
values = []
for item in statuses:
if not isinstance(item, dict):
continue
value = (item.get("status") or item.get("state") or "").lower()
if value:
values.append(value)
print("malformed")
raise SystemExit(0)
raw_value = item.get("status") or item.get("state")
if not isinstance(raw_value, str) or not raw_value:
print("malformed")
raise SystemExit(0)
values.append(raw_value.lower())
if not values and not state:
print("no-status")
elif any(v in pending_values for v in values):
if any(value in pending_values for value in values) or state in pending_values:
print("pending")
elif any(v in failure_values for v in values):
elif any(value in failure_values for value in values) or state in failure_values:
print("terminal-failure")
elif values and all(v in success_values for v in values):
elif values and all(value in success_values for value in values) and state in {"", "success"}:
print("terminal-success")
elif not values:
print("no-status")
else:
print("unknown")
PY
'
}
print_pending_contexts() {
python3 - <<'PY'
python3 -c '
import json
import sys
@@ -104,17 +110,61 @@ for item in statuses:
if not isinstance(item, dict):
continue
name = item.get("context") or item.get("name") or "unknown-context"
value = (item.get("status") or item.get("state") or "unknown").lower()
value = str(item.get("status") or item.get("state") or "unknown").lower()
target = item.get("target_url") or item.get("url") or ""
if value in pending_values:
found = True
if target:
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
else:
print(f"[ci-queue-wait] pending: {name}={value}")
suffix = f" ({target})" if target else ""
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
if not found:
print("[ci-queue-wait] no pending contexts")
'
}
record_cannot_assert() {
local reason="$1"
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
echo "Error: CANNOT_ASSERT and audit directory is unavailable; refusing degraded pass." >&2
return 70
fi
if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
import datetime
import json
import os
import sys
path, reason, platform, purpose, branch, repo = sys.argv[1:]
record = {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"outcome": "CANNOT_ASSERT",
"reason": reason,
"platform": platform,
"purpose": purpose,
"disposition": "hold" if purpose == "merge" else "degraded-pass",
"branch": branch,
"repo": repo,
}
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
try:
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
finally:
os.close(fd)
PY
then
echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2
return 70
fi
if [[ "$PURPOSE" == "merge" ]]; then
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2
return 75
fi
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2
return 0
}
github_get_branch_head_sha() {
@@ -128,7 +178,87 @@ github_get_commit_status_json() {
local owner="$1"
local repo="$2"
local sha="$3"
gh api "repos/${owner}/${repo}/commits/${sha}/status"
local work_root status_file checks_file
work_root="${AGENT_WORK_ROOT:-${HOME:-}/.cache/mosaic/ci-queue-wait}"
mkdir -p "$work_root" || return 1
status_file=$(mktemp "$work_root/github-status.XXXXXX") || return 1
checks_file=$(mktemp "$work_root/github-checks.XXXXXX") || {
rm -f "$status_file"
return 1
}
if ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/statuses?per_page=100" > "$status_file" ||
! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100&filter=latest" > "$checks_file"; then
rm -f "$status_file" "$checks_file"
return 1
fi
python3 - "$status_file" "$checks_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
status_pages = json.load(handle)
with open(sys.argv[2], encoding="utf-8") as handle:
check_pages = json.load(handle)
if not isinstance(status_pages, list) or not isinstance(check_pages, list):
raise SystemExit(1)
# The statuses endpoint is newest-first and can contain retries for one context.
# Keep only the newest entry per context after flattening every page.
combined = []
seen_contexts = set()
for page in status_pages:
if not isinstance(page, list):
raise SystemExit(1)
for status in page:
if not isinstance(status, dict):
raise SystemExit(1)
context = status.get("context")
if not isinstance(context, str) or not context or context in seen_contexts:
continue
seen_contexts.add(context)
combined.append(status)
check_runs = []
reported_total = 0
for page in check_pages:
if not isinstance(page, dict):
raise SystemExit(1)
page_runs = page.get("check_runs") or []
total_count = page.get("total_count")
if not isinstance(page_runs, list) or not isinstance(total_count, int):
raise SystemExit(1)
reported_total = max(reported_total, total_count)
check_runs.extend(page_runs)
if len(check_runs) < reported_total:
raise SystemExit(1)
for run in check_runs:
if not isinstance(run, dict):
raise SystemExit(1)
status = run.get("status")
conclusion = run.get("conclusion")
if status != "completed":
value = "pending"
elif conclusion == "success":
value = "success"
elif conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"}:
value = "failure"
else:
value = "unknown"
combined.append({
"context": run.get("name") or "github-check",
"status": value,
"target_url": run.get("html_url") or run.get("details_url") or "",
})
json.dump({"state": "", "statuses": combined}, sys.stdout)
PY
local status=$?
rm -f "$status_file" "$checks_file"
return "$status"
}
gitea_get_branch_head_sha() {
@@ -174,6 +304,14 @@ while [[ $# -gt 0 ]]; do
BRANCH="$2"
shift 2
;;
-R|--repo)
TARGET_REPO="$2"
shift 2
;;
--sha)
HEAD_SHA="$2"
shift 2
;;
-t|--timeout)
TIMEOUT_SEC="$2"
shift 2
@@ -206,45 +344,89 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
echo "Error: timeout and interval must be integer seconds." >&2
exit 1
fi
if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2
exit 1
fi
if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then
echo "Error: --repo must be OWNER/REPO." >&2
exit 1
fi
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
detect_platform > /dev/null
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
echo "Error: --purpose must be push or merge." >&2
exit 1
fi
OWNER="unknown"
REPO="unknown"
PLATFORM="unknown"
if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then
record_cannot_assert "repository-owner-unresolvable"
exit $?
fi
if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then
record_cannot_assert "repository-name-unresolvable"
exit $?
fi
if ! detect_platform > /dev/null; then
PLATFORM="${PLATFORM:-unknown}"
record_cannot_assert "unsupported-platform"
exit $?
fi
PLATFORM="${PLATFORM:-unknown}"
if [[ -n "$TARGET_REPO" ]]; then
OWNER="${TARGET_REPO%%/*}"
REPO="${TARGET_REPO##*/}"
fi
if [[ -z "$BRANCH" ]]; then
if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then
record_cannot_assert "current-branch-unresolvable"
exit $?
fi
fi
if [[ "$PLATFORM" == "github" ]]; then
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
exit 1
record_cannot_assert "github-cli-unavailable"
exit $?
fi
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
if [[ -z "$HEAD_SHA" ]]; then
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
exit 1
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
record_cannot_assert "branch-head-unavailable"
exit $?
fi
fi
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
elif [[ "$PLATFORM" == "gitea" ]]; then
HOST=$(get_remote_host) || {
echo "Error: Could not determine remote host." >&2
exit 1
}
TOKEN=$(get_gitea_token "$HOST") || {
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
exit 1
}
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
exit 0
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
record_cannot_assert "remote-host-unresolvable"
exit $?
fi
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
record_cannot_assert "credential-unresolvable"
exit $?
fi
if [[ -z "$HEAD_SHA" ]]; then
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
exit 1
if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
record_cannot_assert "branch-head-unavailable"
exit $?
fi
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
exit 0
fi
if [[ -z "$HEAD_SHA" ]]; then
record_cannot_assert "branch-head-unavailable"
exit $?
fi
fi
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
else
echo "Error: Unsupported platform '${PLATFORM}'." >&2
exit 1
record_cannot_assert "unsupported-platform"
exit $?
fi
START_TS=$(date +%s)
@@ -253,14 +435,20 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
while true; do
NOW_TS=$(date +%s)
if (( NOW_TS > DEADLINE_TS )); then
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
exit 124
fi
if [[ "$PLATFORM" == "github" ]]; then
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
record_cannot_assert "status-provider-unreachable"
exit $?
fi
else
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
record_cannot_assert "status-provider-unreachable"
exit $?
fi
fi
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
@@ -271,21 +459,24 @@ while true; do
printf '%s' "$STATUS_JSON" | print_pending_contexts
sleep "$INTERVAL_SEC"
;;
terminal-success)
exit 0
;;
no-status)
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
exit 1
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
else
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
fi
echo "[ci-queue-wait] no status contexts present; proceeding."
exit 0
exit 3
;;
terminal-success|terminal-failure|unknown)
# Queue guard only blocks on pending/running/queued states.
exit 0
terminal-failure|malformed|unknown)
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3
;;
*)
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
exit 0
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3
;;
esac
done
+42 -58
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
set -euo pipefail
@@ -12,8 +12,8 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
MERGE_METHOD="squash"
DELETE_BRANCH=false
SKIP_QUEUE_GUARD=false
DRY_RUN=false
EXPECT_HEAD=""
usage() {
cat <<EOF
@@ -25,15 +25,14 @@ Options:
-n, --number NUMBER PR number to merge (required)
-m, --method METHOD Merge method: squash only (default: squash)
-d, --delete-branch Delete the head branch after merge
--skip-queue-guard Skip CI queue guard wait before merge
--dry-run Run metadata/login preflight without merging
--expect-head SHA Refuse unless the PR head matches this full commit SHA
-h, --help Show this help message
Examples:
$(basename "$0") -n 42 # Merge PR #42
$(basename "$0") -n 42 -m squash # Squash merge
$(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
EOF
exit "${1:-1}"
}
@@ -53,15 +52,14 @@ while [[ $# -gt 0 ]]; do
DELETE_BRANCH=true
shift
;;
--skip-queue-guard)
SKIP_QUEUE_GUARD=true
shift
;;
--dry-run)
DRY_RUN=true
SKIP_QUEUE_GUARD=true
shift
;;
--expect-head)
EXPECT_HEAD="$2"
shift 2
;;
-h|--help)
usage 0
;;
@@ -86,18 +84,36 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
exit 1
fi
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
exit 1
fi
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
exit 1
fi
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
exit 1
fi
if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
echo "Error: PR head moved: expected $EXPECT_HEAD, found $HEAD_SHA." >&2
exit 1
fi
if [[ "$DRY_RUN" != true ]]; then
"$SCRIPT_DIR/ci-queue-wait.sh" \
--purpose merge \
-B "$BASE_BRANCH" \
-B "$HEAD_BRANCH" \
-R "$HEAD_REPO" \
--sha "$HEAD_SHA" \
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
fi
@@ -106,31 +122,22 @@ PLATFORM=$(detect_platform)
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
is_known_tea_empty_identity_failure() {
local error_file="$1"
python3 - "$error_file" <<'PY'
import re
import sys
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
error = handle.read()
known_empty_identity = re.search(
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
error,
flags=re.IGNORECASE | re.DOTALL,
)
raise SystemExit(0 if known_empty_identity else 1)
PY
}
merge_gitea_with_api() {
local host="$1" api_url token basic_auth body_file raw_code payload
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
payload='{"Do":"squash"}'
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
import json
import sys
head_sha, delete_branch = sys.argv[1:]
payload = {"Do": "squash", "head_commit_id": head_sha}
if delete_branch == "true":
payload["delete_branch_after_merge"] = True
print(json.dumps(payload, separators=(",", ":")))
PY
)
token=$(get_gitea_token "$host" || true)
if [[ -n "$token" ]]; then
@@ -202,7 +209,7 @@ fi
case "$PLATFORM" in
github)
cmd=(gh pr merge "$PR_NUMBER" --squash)
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
"${cmd[@]}"
;;
@@ -211,32 +218,9 @@ case "$PLATFORM" in
echo "Error: Cannot determine host from origin remote URL" >&2
exit 1
}
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
if [[ -n "$TEA_LOGIN" ]]; then
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
rm -f "$TEA_ERROR_FILE"
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
cat "$TEA_ERROR_FILE" >&2
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
rm -f "$TEA_ERROR_FILE"
merge_gitea_with_api "$HOST"
else
cat "$TEA_ERROR_FILE" >&2
rm -f "$TEA_ERROR_FILE"
exit 1
fi
else
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
merge_gitea_with_api "$HOST"
fi
# Delete branch after merge if requested
if [[ "$DELETE_BRANCH" == true ]]; then
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
fi
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
# tea cannot express it, so exact-head merges use the authenticated API path.
merge_gitea_with_api "$HOST"
;;
*)
echo "Error: Could not detect git platform" >&2
@@ -109,7 +109,7 @@ PY
detect_platform > /dev/null
if [[ "$PLATFORM" == "github" ]]; then
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
write_metadata "$METADATA"
elif [[ "$PLATFORM" == "gitea" ]]; then
OWNER=$(get_repo_owner)
@@ -182,6 +182,25 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
data.get('head_ref'),
head_ref,
)
head_sha = first_non_empty(
nested(data, 'head', 'sha'),
nested(data, 'head', 'id'),
data.get('head_sha'),
)
head_repo = first_non_empty(
nested(data, 'head', 'repo', 'full_name'),
nested(data, 'head', 'repo', 'name_with_owner'),
)
if not head_repo:
head_repo_owner = first_non_empty(
nested(data, 'head', 'repo', 'owner', 'login'),
nested(data, 'head', 'repo', 'owner', 'username'),
nested(data, 'head', 'repo', 'owner_name'),
)
head_repo_name = first_non_empty(nested(data, 'head', 'repo', 'name'))
if head_repo_owner and head_repo_name:
head_repo = f'{head_repo_owner}/{head_repo_name}'
base_ref = first_non_empty(
nested(data, 'base', 'ref'),
nested(data, 'base', 'name'),
@@ -207,6 +226,8 @@ normalized = {
'state': data.get('state'),
'author': nested(data, 'user', 'login') or '',
'headRefName': head_ref,
'headRefOid': head_sha,
'headRepository': head_repo,
'baseRefName': base_ref,
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
@@ -13,7 +13,7 @@
# Covers:
# (a) 404 branch-absent -> exit 0, "queue clear" message.
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
set -euo pipefail
@@ -62,7 +62,7 @@ case "$mode" in
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
500) code=500; body='{"message":"internal server error"}' ;;
no-status) code=200; body='{}' ;;
terminal-success) code=200; body='{"state":"success"}' ;;
terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
*)
echo "curl stub: unknown mode=$mode" >&2
exit 2
@@ -91,6 +91,7 @@ run_ci_queue_wait() {
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export GITEA_TOKEN="stub-token"
export GITEA_URL="https://git.example.test"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
)
}
@@ -131,19 +132,26 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
fail=1
fi
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
set +e
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
status_c=$?
set -e
if [[ "$status_c" -eq 0 ]]; then
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
if [[ "$status_c" -ne 0 ]]; then
echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
echo "$out_c" >&2
fail=1
elif [[ "$out_c" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL(c): expected a loud CANNOT_ASSERT diagnostic" >&2
echo "$out_c" >&2
fail=1
elif [[ "$out_c" == *"queue clear"* ]]; then
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
echo "$out_c" >&2
fail=1
elif [[ ! -s "$WORK_DIR/audit/ci-queue-wait.jsonl" ]]; then
echo "FAIL(c): expected a durable CANNOT_ASSERT audit record" >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# GitHub Actions uses Checks API check-runs, not only legacy commit statuses.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-github-checks}"
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" checkout -q -b fix/github-checks
git -C "$REPO_DIR" remote add origin https://github.com/acme/widgets.git
cat > "$STUB_DIR/gh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
endpoint=""
for arg in "$@"; do
[[ "$arg" == repos/* ]] && endpoint="$arg"
done
printf '%s\n' "$*" >> "${MOSAIC_GH_CALL_LOG:?}"
case "$endpoint" in
repos/acme/widgets/branches/fix/github-checks)
printf '%s\n' '0123456789abcdef0123456789abcdef01234567'
;;
repos/acme/widgets/commits/*/statuses?per_page=100)
printf '%s\n' '[[]]'
;;
repos/acme/widgets/commits/*/check-runs?per_page=100\&filter=latest)
case "${MOSAIC_GH_CHECK_MODE:?}" in
success) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"success"}]}]' ;;
pending) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"in_progress","conclusion":null}]}]' ;;
failure) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"failure"}]}]' ;;
late-failure) printf '%s\n' '[{"total_count":2,"check_runs":[{"name":"first-page","status":"completed","conclusion":"success"}]},{"total_count":2,"check_runs":[{"name":"later-page","status":"completed","conclusion":"failure"}]}]' ;;
*) exit 2 ;;
esac
;;
*) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;
esac
SH
chmod +x "$STUB_DIR/gh"
run_guard() {
local mode="$1"
(
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_GH_CHECK_MODE="$mode"
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
)
}
failures=0
assert_case() {
local mode="$1" expected_rc="$2" expected_state="$3" output rc
set +e
output=$(run_guard "$mode" 2>&1)
rc=$?
set -e
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
echo "FAIL github-$mode: expected rc=0, got $rc" >&2
failures=$((failures + 1))
elif [[ "$expected_rc" == nonzero && "$rc" -eq 0 ]]; then
echo "FAIL github-$mode: expected rc!=0, got 0" >&2
failures=$((failures + 1))
fi
if [[ "$output" != *"state=$expected_state"* ]]; then
echo "FAIL github-$mode: expected state=$expected_state, got:" >&2
printf '%s\n' "$output" >&2
failures=$((failures + 1))
fi
}
set -e
: > "$WORK_DIR/gh-calls.log"
assert_case success zero terminal-success
assert_case pending nonzero pending
assert_case failure nonzero terminal-failure
assert_case late-failure nonzero terminal-failure
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
echo "FAIL: expected every case to query all Checks API pages" >&2
failures=$((failures + 1))
fi
if [[ "$failures" -ne 0 ]]; then
echo "GitHub check-runs regression failed ($failures assertions)" >&2
exit 1
fi
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
@@ -0,0 +1,232 @@
#!/usr/bin/env bash
# Exit-asserting RM-03 regression harness for ci-queue-wait.sh.
# Every case is a process-level assertion: a classifier-only green cannot satisfy it.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}"
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
FEATURE_BRANCH="fix/rm-03-fixture"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
cat > "$STUB_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
url=""
has_write_out=0
for arg in "$@"; do
case "$arg" in
-w) has_write_out=1 ;;
http://*|https://*) url="$arg" ;;
esac
done
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
case "$url" in
*/branches/*)
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
exit 7
fi
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
if [[ "$has_write_out" -eq 1 ]]; then
printf '%s\n200' "$body"
else
printf '%s' "$body"
fi
;;
*/status)
case "${MOSAIC_STUB_STATUS_MODE:?}" in
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
failure) printf '%s' '{"state":"failure","statuses":[{"status":"failure"}]}' ;;
no-status) printf '%s' '{"state":"","statuses":[]}' ;;
aggregate-success-no-status) printf '%s' '{"state":"success","statuses":[]}' ;;
malformed) printf '%s' 'not-json' ;;
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
large-success)
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
;;
unreachable) exit 7 ;;
*) echo "unknown status mode" >&2; exit 2 ;;
esac
;;
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
esac
SH
chmod +x "$STUB_DIR/curl"
run_guard() {
local status_mode="$1"
local audit_log="${2:-$AUDIT_LOG}"
shift 2 || true
(
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
if [[ "$status_mode" == "credential-unresolvable" ]]; then
export HOME="$WORK_DIR/empty-home"
mkdir -p "$HOME"
unset GITEA_TOKEN GITEA_URL MOSAIC_GIT_IDENTITY
export MOSAIC_STUB_STATUS_MODE=success
else
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_STUB_STATUS_MODE="$status_mode"
fi
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
)
}
failures=0
run_assertion() {
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
local output rc
shift 4
set +e
output=$(run_guard "$status_mode" "$AUDIT_LOG" "$@" 2>&1)
rc=$?
set -e
case "$expected_rc" in
zero)
if [[ "$rc" -ne 0 ]]; then
echo "FAIL $name: expected rc=0, got rc=$rc" >&2
failures=$((failures + 1))
fi
;;
nonzero)
if [[ "$rc" -eq 0 ]]; then
echo "FAIL $name: expected rc!=0, got rc=0" >&2
failures=$((failures + 1))
fi
;;
not126)
if [[ "$rc" -eq 126 ]]; then
echo "FAIL $name: payload transport hit ARG_MAX (rc=126)" >&2
failures=$((failures + 1))
fi
;;
esac
if [[ "$output" != *"$required_text"* ]]; then
echo "FAIL $name: output missing '$required_text' (rc=$rc)" >&2
printf '%s\n' "$output" >&2
failures=$((failures + 1))
fi
}
set -e
: > "$WORK_DIR/urls.log"
run_assertion success zero success 'state=terminal-success'
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
run_assertion large-payload not126 large-success 'state=terminal-success'
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
failures=$((failures + 1))
fi
# Merge cannot proceed without exact-head evidence. CANNOT_ASSERT is retryable exit 75,
# distinct from ASSERTED_NOT_READY (3/124), and still writes its audit record.
merge_audit_lines_before=$(wc -l < "$AUDIT_LOG")
set +e
merge_unreachable_output=$(MOSAIC_TEST_PURPOSE=merge run_guard unreachable "$AUDIT_LOG" 2>&1)
merge_unreachable_rc=$?
set -e
if [[ "$merge_unreachable_rc" -ne 75 ]]; then
echo "FAIL merge-provider-unreachable: expected rc=75, got rc=$merge_unreachable_rc" >&2
failures=$((failures + 1))
fi
if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
failures=$((failures + 1))
fi
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
failures=$((failures + 1))
fi
# A feature-branch push with no -B must inspect the checked-out feature branch.
if ! grep -q "/branches/$FEATURE_BRANCH" "$WORK_DIR/urls.log"; then
echo "FAIL implicit-branch: provider was not queried for $FEATURE_BRANCH" >&2
failures=$((failures + 1))
fi
# Merge callers can pin both a fork repository and the exact reviewed head SHA.
exact_sha=0123456789abcdef0123456789abcdef01234567
: > "$WORK_DIR/urls.log"
run_assertion exact-fork-head zero success 'state=terminal-success' \
-B fix/rm-03-fixture -R contributor/widgets-fork --sha "$exact_sha"
if ! grep -q "/repos/contributor/widgets-fork/commits/$exact_sha/status" "$WORK_DIR/urls.log"; then
echo "FAIL exact-fork-head: status URL did not bind fork repository and exact SHA" >&2
failures=$((failures + 1))
fi
if grep -q '/branches/' "$WORK_DIR/urls.log"; then
echo "FAIL exact-fork-head: explicit SHA must not be re-resolved through a branch" >&2
failures=$((failures + 1))
fi
# Platform/repository discovery failures use the same audited CANNOT_ASSERT path.
audit_lines_before=$(wc -l < "$AUDIT_LOG")
git -C "$REPO_DIR" remote set-url origin https://gitlab.com/acme/widgets.git
set +e
unsupported_output=$(run_guard success "$AUDIT_LOG" 2>&1)
unsupported_rc=$?
set -e
git -C "$REPO_DIR" remote set-url origin https://git.example.test/acme/widgets.git
if [[ "$unsupported_rc" -ne 0 ]]; then
echo "FAIL unsupported-platform: expected degraded rc=0, got rc=$unsupported_rc" >&2
failures=$((failures + 1))
fi
if [[ "$unsupported_output" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL unsupported-platform: expected loud CANNOT_ASSERT diagnostic" >&2
failures=$((failures + 1))
fi
audit_lines_after=$(wc -l < "$AUDIT_LOG")
if [[ "$audit_lines_after" -le "$audit_lines_before" ]]; then
echo "FAIL unsupported-platform: expected an additional audit record" >&2
failures=$((failures + 1))
fi
# A degraded pass is forbidden if the audit receipt cannot be written.
mkdir -p "$WORK_DIR/not-a-directory"
printf 'file' > "$WORK_DIR/not-a-directory/parent"
set +e
audit_failure_output=$(run_guard unreachable "$WORK_DIR/not-a-directory/parent/audit.jsonl" 2>&1)
audit_failure_rc=$?
set -e
if [[ "$audit_failure_rc" -eq 0 ]]; then
echo "FAIL audit-unavailable: expected rc!=0, got rc=0" >&2
failures=$((failures + 1))
fi
if [[ "$audit_failure_output" != *"audit"* ]]; then
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
failures=$((failures + 1))
fi
if [[ "$failures" -ne 0 ]]; then
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
exit 1
fi
echo "ci-queue-wait tri-state regression passed (all outcome classes)"
@@ -1,5 +1,5 @@
#!/bin/bash
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
set -euo pipefail
@@ -79,15 +79,24 @@ emit_response() {
printf '200'
fi
}
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then
emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}'
exit 0
fi
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
exit 0
fi
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
echo "unexpected merge payload: $post_data" >&2
exit 96
fi
POST_DATA="$post_data" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["POST_DATA"])
assert payload == {
"Do": "squash",
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
}, payload
PY
emit_response '{"merged":true,"message":"mock merge complete"}'
exit 0
fi
@@ -107,8 +116,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
export GITEA_TOKEN="redacted-test-token"
OUTPUT="$SANDBOX/output.log"
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
echo "--- output ---" >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
echo "--- mock log ---" >&2
@@ -127,38 +136,6 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
exit 1
fi
cat > "$MOCK_BIN/tea" <<'EOF'
#!/bin/bash
set -euo pipefail
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
printf '\n' >> "$PR_MERGE_TEST_LOG"
if [[ "$*" == *"login list"* ]]; then
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
exit 0
fi
if [[ "$*" == *"pr merge"* ]]; then
echo 'tea network timeout' >&2
exit 2
fi
exit 0
EOF
chmod +x "$MOCK_BIN/tea"
: > "$LOG_FILE"
if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected arbitrary tea failure to remain blocking." >&2
exit 1
fi
if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
exit 1
fi
if ! grep -q 'tea network timeout' "$OUTPUT"; then
echo "Expected arbitrary tea error to be preserved in output." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1
fi
cat > "$MOCK_BIN/tea" <<'EOF'
#!/bin/bash
set -euo pipefail
@@ -177,8 +154,8 @@ EOF
chmod +x "$MOCK_BIN/tea"
unset GITEA_LOGIN
: > "$LOG_FILE"
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
echo "Expected the exact-head API path not to depend on a tea login." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
exit 1
@@ -215,7 +192,7 @@ cd "$REPO_DIR"
git remote set-url origin https://github.com/mosaicstack/stack.git
: > "$LOG_FILE"
rm -f "$SENTINEL"
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
echo "Expected GitHub metacharacter PR number to be rejected." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1
@@ -240,7 +217,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
export GITEA_LOGIN="git.mosaicstack.dev"
: > "$LOG_FILE"
rm -f "$SENTINEL"
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
echo "Expected Gitea metacharacter PR number to be rejected." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1
@@ -260,4 +237,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
exit 1
fi
echo "pr-merge.sh Gitea fallback regression passed"
echo "pr-merge.sh Gitea exact-head API regression passed"
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# shellcheck disable=SC2030,SC2031 # Provider arms isolate PATH/credentials in subshells.
# The commit whose CI was guarded must be the commit the provider atomically merges.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}"
SHA=0123456789abcdef0123456789abcdef01234567
make_fixture() {
local name="$1" remote="$2"
local root="$WORK_DIR/$name"
local tools="$root/tools/git"
mkdir -p "$tools" "$root/repo"
cp "$SCRIPT_DIR/pr-merge.sh" "$tools/pr-merge.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
git -C "$root/repo" init -q
git -C "$root/repo" remote add origin "$remote"
cat > "$tools/pr-metadata.sh" <<SH
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
SH
cat > "$tools/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
exit 0
SH
chmod +x "$tools"/*.sh
}
rm -rf "$WORK_DIR"
make_fixture gitea https://git.example.test/acme/widgets.git
make_fixture github https://github.com/acme/widgets.git
cat > "$WORK_DIR/gitea/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
payload=""
for ((i=1; i<=$#; i++)); do
if [[ "${!i}" == "-d" ]]; then
j=$((i + 1))
payload="${!j}"
fi
done
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
printf '200'
SH
chmod +x "$WORK_DIR/gitea/curl"
set +e
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123
) >"$WORK_DIR/gitea.out" 2>&1
gitea_rc=$?
set -e
if [[ "$gitea_rc" -ne 0 ]]; then
echo "FAIL gitea-pin: merge fixture returned $gitea_rc" >&2
cat "$WORK_DIR/gitea.out" >&2
exit 1
fi
python3 - "$WORK_DIR/gitea-payload.json" "$SHA" <<'PY'
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert set(payload) <= {"Do", "head_commit_id", "delete_branch_after_merge"}, payload
assert payload.get("Do") == "squash", payload
assert payload.get("head_commit_id") == sys.argv[2], payload
PY
# A merge-gate verdict is commit-bound. A stale expected head must fail before merge.
wrong_sha=ffffffffffffffffffffffffffffffffffffffff
rm -f "$WORK_DIR/gitea-payload-stale.json"
set +e
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-stale.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha"
) >"$WORK_DIR/gitea-stale.out" 2>&1
stale_rc=$?
set -e
if [[ "$stale_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-stale.json" ]]; then
echo "FAIL stale-verdict: moved head was not refused before provider merge" >&2
exit 1
fi
# A merge-capable path cannot bypass the mandatory queue guard. The legacy
# --skip-queue-guard option must be rejected before any provider merge call.
cat > "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
exit 99
SH
chmod +x "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh"
rm -f "$WORK_DIR/gitea-payload-bypass.json"
set +e
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-bypass.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard
) >"$WORK_DIR/gitea-bypass.out" 2>&1
bypass_rc=$?
set -e
if [[ "$bypass_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-bypass.json" ]]; then
echo "FAIL merge-bypass: --skip-queue-guard reached the provider merge path" >&2
exit 1
fi
# Dry-run is the only path that may omit the guard because it exits before the
# provider merge dispatch. Prove the exit and absence of a merge payload.
rm -f "$WORK_DIR/gitea-payload-dry-run.json"
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-dry-run.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run
) >"$WORK_DIR/gitea-dry-run.out" 2>&1
if [[ -e "$WORK_DIR/gitea-payload-dry-run.json" ]]; then
echo "FAIL dry-run: non-merging preflight reached the provider merge path" >&2
exit 1
fi
cat > "$WORK_DIR/github/gh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" > "${MOSAIC_GH_MERGE_LOG:?}"
SH
chmod +x "$WORK_DIR/github/gh"
(
cd "$WORK_DIR/github/repo"
export PATH="$WORK_DIR/github:$PATH"
export MOSAIC_GH_MERGE_LOG="$WORK_DIR/github-call.log"
"$WORK_DIR/github/tools/git/pr-merge.sh" -n 123
) >"$WORK_DIR/github.out" 2>&1
if ! grep -q -- "--match-head-commit $SHA" "$WORK_DIR/github-call.log"; then
echo "FAIL github-pin: merge command omitted --match-head-commit $SHA" >&2
cat "$WORK_DIR/github-call.log" >&2
exit 1
fi
echo "PR merge exact-head pin regression passed (Gitea + GitHub)"
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# RM-03: pr-merge must guard the PR head branch, not its main base branch.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-queue-branch}"
FIXTURE_DIR="$WORK_DIR/tools/git"
CALL_LOG="$WORK_DIR/queue-call.log"
rm -rf "$WORK_DIR"
mkdir -p "$FIXTURE_DIR"
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
exit 42
SH
chmod +x "$FIXTURE_DIR"/*.sh
set +e
(
cd "$WORK_DIR"
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
"$FIXTURE_DIR/pr-merge.sh" -n 123
) >/dev/null 2>&1
rc=$?
set -e
if [[ "$rc" -ne 42 ]]; then
echo "FAIL: expected queue stub rc=42 to propagate, got $rc" >&2
exit 1
fi
if [[ ! -s "$CALL_LOG" ]]; then
echo "FAIL: merge wrapper did not invoke the queue guard" >&2
exit 1
fi
if ! grep -q -- '-B fix/rm-03-fixture' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive PR head branch" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if grep -q -- '-B main' "$CALL_LOG"; then
echo "FAIL: merge queue guard still received the main base branch" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive the fork head repository" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '--sha 0123456789abcdef0123456789abcdef01234567' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive the exact PR head SHA" >&2
cat "$CALL_LOG" >&2
exit 1
fi
echo "pr-merge queue branch/repository/SHA regression passed"
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -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', '[email protected]', ...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 });
}
-45
View File
@@ -1,45 +0,0 @@
#!/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 });
}
-40
View File
@@ -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);
-432
View File
@@ -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 };
}
-615
View File
@@ -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', '[email protected]');
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', '[email protected]');
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', '[email protected]');
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', '[email protected]');
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', '[email protected]');
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', '[email protected]');
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', '[email protected]');
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', '[email protected]');
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', '[email protected]');
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', '[email protected]');
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',
);
});
-72
View File
@@ -1,72 +0,0 @@
#!/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);
-30
View File
@@ -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);
File diff suppressed because it is too large Load Diff
-783
View File
@@ -1,783 +0,0 @@
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 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') {
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,
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: [],
caseRefs: ['meta-fixture/rejects-bad-input'],
},
],
compatibilityScenarios: [],
proseClaims: [],
gates: [
{
id: 'meta-fixture',
source: 'gates/meta-fixture.sh',
evidenceSubject: 'meta-fixture',
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, 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,
[
verifier,
'--root',
root,
'--manifest',
'gates/gates.manifest.json',
'--skip-history',
'--structure-only',
...extraArgs,
],
{ 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('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');
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('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);
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('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('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(
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);
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.criteria[0].caseRefs.push('meta-fixture/second-condition');
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);
});
-104
View File
@@ -1,104 +0,0 @@
import assert from 'node:assert/strict';
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 the closed unprivileged gate construction on every pipeline', async () => {
const pipeline = await readFile(`${root}/.woodpecker/ci.yml`, 'utf8');
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/,
);
});
@@ -1,28 +0,0 @@
#!/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;