Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9746b23c8 | ||
|
|
38f1b249cc | ||
|
|
9b4d4beb0e | ||
|
|
e4c30a33e8 | ||
|
|
ada0cbe60e | ||
|
|
bd603da4b0 | ||
|
|
444662e79a | ||
|
|
f65e9ea656 | ||
|
|
f58b3699a6 |
@@ -8,6 +8,7 @@ coverage
|
||||
.env.local
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
__pycache__/
|
||||
docs/reports/
|
||||
|
||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
pnpm typecheck && pnpm lint && pnpm format:check
|
||||
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
|
||||
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
|
||||
# instead of repopulating a fresh one.
|
||||
store-dir=/root/.local/share/pnpm/store
|
||||
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
|
||||
# Non-root checkouts use their own HOME. Override without editing this file via
|
||||
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
|
||||
store-dir=${HOME}/.local/share/pnpm/store
|
||||
|
||||
+17
-3
@@ -68,15 +68,29 @@ 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:
|
||||
- install
|
||||
- sanitization
|
||||
- upgrade-guard
|
||||
- gate-verify
|
||||
|
||||
# lint, format, and test are independent — run in parallel after typecheck
|
||||
lint:
|
||||
|
||||
@@ -201,8 +201,21 @@ git clone [email protected]:mosaicstack/stack.git
|
||||
cd stack
|
||||
|
||||
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
|
||||
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
|
||||
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
|
||||
pnpm install
|
||||
|
||||
# Verify dependencies and generated state before running source-quality gates.
|
||||
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
|
||||
# The web build certifies its exact standalone symlink manifest; added, removed,
|
||||
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
|
||||
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
|
||||
# a five-month-stale .next that produced 19 phantom TS2307 errors.
|
||||
# It does NOT defend against a same-UID actor that can rewrite both manifest and
|
||||
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
|
||||
# trust anchor outside worktree authority.
|
||||
pnpm preflight
|
||||
|
||||
# Optional local queue service only. This does not start PostgreSQL.
|
||||
docker compose up -d valkey
|
||||
|
||||
@@ -230,6 +243,7 @@ Gateway start command until KBN-101-02 makes that state fail closed.
|
||||
### Quality Gates
|
||||
|
||||
```bash
|
||||
pnpm preflight # Checkout/dependency/generated-state validation
|
||||
pnpm typecheck # TypeScript type checking (all packages)
|
||||
pnpm lint # ESLint (all packages)
|
||||
pnpm test # Vitest (all packages)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"build": "node ../../scripts/build-web.mjs",
|
||||
"dev": "next dev",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Administrator Guide
|
||||
|
||||
- [Gate registry operations](quality-gate-registry.md)
|
||||
@@ -0,0 +1,35 @@
|
||||
# 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`.
|
||||
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`, unique integer pipeline `number`, pipeline `status`, and a `gate-verify` step status. The highest numbered rerun is authoritative; ambiguous duplicates fail. Its retention window is provider-controlled and is not overstated by this repository.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Developer Guide
|
||||
|
||||
- [Gate registry and negative controls](quality-gate-registry.md)
|
||||
@@ -0,0 +1,45 @@
|
||||
# Gate Registry and Negative Controls
|
||||
|
||||
`gates/gates.manifest.json` is the machine-readable registry for the initial RM-02 gate slice. Run:
|
||||
|
||||
```bash
|
||||
pnpm gate:verify
|
||||
```
|
||||
|
||||
## Registered slice
|
||||
|
||||
The registry covers root typecheck, lint, and format checks; RM-01 checkout preflight; the Mosaic CI queue guard; and root Husky pre-commit/pre-push hooks. It does not imply repository-wide coverage. Framework scripts, package-local build/test scripts, templates, and deployment/release scripts remain assigned to RM-54.
|
||||
|
||||
Every gate declares exact invocations, observed and required outcomes, criterion bindings, and a single exact inerting mutation. Every must-fail case requires a non-empty reason diagnostic. The verifier rejects a stale, ambiguous, crashing, or ineffective mutation. Fixture and mutation writes reject path traversal and final-component symlinks. 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`, unique integer pipeline `number`, pipeline `status`, and exactly one `gate-verify` step; the highest-numbered rerun is authoritative. Ambiguous duplicates fail. Absent, expired, or currently-running evidence is reported explicitly and never inferred as success.
|
||||
|
||||
Once RM-60 supplies the external pre-execution anchor, protected post-merge/main replay is detection, not pre-merge prevention. A failed replay requires quarantine of the affected result and revert of the offending merge. It must never be represented as proof that CI blocked that merge. RM-25 tracks provider enforcement.
|
||||
+52
@@ -14,6 +14,58 @@
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
#### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Documentation Sitemap
|
||||
|
||||
## Gate verification
|
||||
|
||||
- [Developer gate registry guide](DEVELOPER-GUIDE/quality-gate-registry.md) — manifest schema, negative controls, defect deltas, modeled boundaries, and commit/provider evidence.
|
||||
- [Gate registry operations](ADMIN-GUIDE/quality-gate-registry.md) — routine verification, failure interpretation, registry updates, and unconditional CI behavior.
|
||||
- [RM-02 governing claim index](remediation/GATE-CLAIMS.md) — marker bindings for orchestrator-owned remediation claims without modifying task tracking.
|
||||
|
||||
## Compaction refresh lease broker
|
||||
|
||||
- [Internal broker protocol](architecture/lease-broker-protocol.md) — kernel identity, ancestry and generation invariants, framed requests, responses, and persisted cycle bindings.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# RM-02 Gate Registry Implementation Plan
|
||||
|
||||
> **For Pi:** Use test-driven development and execute each task RED → GREEN → refactor.
|
||||
|
||||
**Goal:** Build a machine-readable seven-gate registry and an unconditional CI verifier that detects inert gates, binds criteria to observed negative controls, records defects honestly, and 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.
|
||||
@@ -1,58 +0,0 @@
|
||||
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: the merge-base anchor round
|
||||
|
||||
**Subject head (exact):** `fbb61912981abb250d289ec7aafd4316db6fdb11`
|
||||
**Supersedes:** the second NO-GO at `32b490a7` (D-45/D-46/D-47). That verdict is VOID.
|
||||
**Registered by:** `mos-remediation` **before any reviewer read the diff.** **Reviewer:** `rev-974`.
|
||||
|
||||
## What the orchestrator already reproduced (do not re-spend the review here)
|
||||
|
||||
- Derived boundary `f4fd5967` **equals** an independently computed `git merge-base HEAD origin/main`.
|
||||
- Emptying `criteria`/`gates`/`proseClaims`/`compatibilityScenarios` now fails with
|
||||
_"population must be non-empty and anchored before evaluation"_.
|
||||
- The both-directions bootstrap statement is present in `gate-history.mjs` with the Builds 1–2 residual.
|
||||
|
||||
## A — attack the ANCHOR (highest value)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **A1** | `targetRef` is the literal `refs/remotes/origin/main`. **Who controls that ref in the environment where the gate runs?** Can a PR author influence it — a `remote.origin.url` rewrite, a crafted `refs/remotes/origin/main` in their own clone, a push to a fork's `main`? If the gate trusts a locally-writable ref, the anchor moved from the manifest into git config. |
|
||||
| **A2** | **Absent/stale target:** if `refs/remotes/origin/main` is missing, stale, or the fetch is shallow, does it fail CLOSED (line 57 suggests it does) or silently derive a narrower range? |
|
||||
| **A3** | **Delayed introduction — the round-2 attack, re-run.** Commit a gate change BEFORE adding the manifest. Does it now stay in range and fail the own-tree read? |
|
||||
| **A4** | **Branch from an old main:** branch from a point far behind `origin/main`, so merge-base is old. Does the range widen correctly (safe) or include unrelated main commits that cannot carry manifests (a new false-red)? Over-inclusion is the natural failure mode of this fix. |
|
||||
| **A5** | Is the **bootstrap residual** recorded as a **tracked dependency on Builds 1–2**, not prose? (D-19's third mandatory move.) |
|
||||
|
||||
## P — the non-empty/anchored precondition
|
||||
|
||||
| id | check |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **P1** | Does the precondition run **before** every quantified check, on **every** path — or only the ones the author enumerated? Find a quantified check that still runs before its precondition. |
|
||||
| **P2** | **Seven required gate IDs anchored to canonical sources** — can that anchor list itself be emptied, shrunk, or pointed elsewhere? An anchor list the author edits is D-45 again, one level in. |
|
||||
| **P3** | The author reports finding and REMOVING a **production CLI fixture-profile bypass** in their own pre-commit review. **Verify it is gone from the shipped CLI path**, that the remaining relaxation is test-support only and non-executable in production, and that the CLI rejection is tested. This was self-disclosed — confirm it independently. |
|
||||
|
||||
## Q — clauses quantified over the population (D-47)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Q1** | `gateRefs` **exactly spans** every registered gate. Can a gate be added WITHOUT a corresponding ref — i.e. does adding a gate to the population escape the clause? |
|
||||
| **Q2** | Deleting `gateRefs`, or shrinking gates and refs **together**, must fail. Shrink-together is the D-46 shape; verify it is a genuine control and not a regression guard mislabelled. |
|
||||
| **Q3** | Do the population controls **iterate all seven gates** and mutate evidence-subject and comparison-type **per gate**, or only a representative one? |
|
||||
| **Q4** | Honest labelling: author claims delayed-introduction, empty-populations, fixture-profile bypass and removable gateRefs as **genuine red-first**, and shrink-together/exact-span as **regression guards**. Verify each claim against pre-fix code (D-8). |
|
||||
|
||||
## C — head, CI, integrity
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **C1** | Head `fbb61912` on git and provider; `Closes #1029` present. |
|
||||
| **C2** | Exact-head CI terminal-green, full `-f json`, counts stated incl. `clone`; machine-checked with `verify-terminal-green.py --expect-commit fbb61912981abb250d289ec7aafd4316db6fdb11`. |
|
||||
| **C3** | Nothing weakened: `32b490a7` → `fbb61912` removes/relaxes no existing case, test, or assertion. |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `fbb61912981abb250d289ec7aafd4316db6fdb11`, evidence enumerated, posted durably under
|
||||
your own identity. If a check is unrunnable, **say so**.
|
||||
|
||||
**A1 and P2 are the ones I most want answered** — both ask whether the fix moved the author's control
|
||||
point again rather than removing it, which is now the mission's named first-class principle and has
|
||||
recurred three times. **Attack outside this set.**
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
||||
@@ -1,67 +0,0 @@
|
||||
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: the four-blocker hardening pass
|
||||
|
||||
**Subject head (exact):** `32b490a712a5e8e77f13c40c60099b51feb38994`
|
||||
**Supersedes:** the NO-GO at `83d2ecb2` (D-44, four silent-defeat paths). That verdict is VOID.
|
||||
**Registered by:** `mos-remediation` **before any reviewer read the diff.** **Reviewer:** `rev-974`.
|
||||
|
||||
## Framing — attack the FIX, not the original bug
|
||||
|
||||
Every round on this mission, **the remediation introduced the next hole**: the commit-binding fix
|
||||
shipped a type confusion; the type-strict fix was clean but the registry around it had four defeats.
|
||||
So the highest-value checks here are **not** "was each blocker fixed" — the orchestrator already
|
||||
reproduced three of the four attacks as dead — but **"is the NEW mechanism itself defeatable?"**
|
||||
|
||||
Blocker 1's fix replaced an author-settable field with a **derivation**. A derivation has inputs. **The
|
||||
question is who controls them.**
|
||||
|
||||
## H — the new derivation (highest value)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **H1** | `deriveHistoryBoundary` = parent of the first first-parent commit introducing `gates/gates.manifest.json`. **Are its inputs author-controlled?** Can a PR author influence the derived value by adding, moving, renaming, deleting-and-recreating, or symlinking that path — or by adding a SECOND manifest earlier in history? If the derivation is gameable, blocker 1 is not fixed, only relocated. |
|
||||
| **H2** | **First-parent traversal:** what happens on a merge commit, an octopus merge, a squash, a rebase that reorders, or a branch where the introducing commit is not on the first-parent chain? Does the boundary silently move, or fail closed? |
|
||||
| **H3** | **Shallow/partial clone:** the branch previously needed an unshallow fix (`e8959975` "unshallow gate replay history"). If history is shallow, does the derivation fail CLOSED or silently derive a wrong/empty boundary? |
|
||||
| **H4** | **Path deletion:** if `gates/gates.manifest.json` is absent at HEAD, or was deleted and re-added, what is derived? Fail closed, or a boundary that excludes the deletion window? |
|
||||
| **H5** | Are the **three registered seam controls (HEAD, HEAD^, introduction) genuinely RED-FIRST** — do they fail against the PRE-fix code for their own stated reason? A control that was always green is a regression guard, not a must-fail control (D-8). |
|
||||
|
||||
## S — the recursive schema closure
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **S1** | **Is closure COMPLETE or enumerated?** The author lists the objects they closed. Find one they did **not** — any nested object anywhere in the manifest — and inject an unknown key there. If it is accepted, the fix covers instances, not the class. |
|
||||
| **S2** | **Wrong-type, not just unknown-key:** `outputPattern` as a number/array/object/null rather than misspelled. Does the closed schema type-check values, or only key names? |
|
||||
| **S3** | **Empty/degenerate values:** `outputPattern: ""` — does an empty regex match everything and silently neuter the assertion the same way a typo did? The author claims an empty-pattern control; verify it is bound. |
|
||||
| **S4** | Confirm the registered typo/wrong-type/empty-pattern controls are **genuine red-first** against pre-fix code, and that anything labelled a regression guard is honestly labelled as one. |
|
||||
|
||||
## E — provider evidence (blocker 4)
|
||||
|
||||
| id | check |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **E1** | Global validation runs **before** per-commit filtering **on every path**, including the HEAD-only path the author calls out. Is there any code path that reaches per-commit assessment without it? |
|
||||
| **E2** | **Duplicate identity by another key:** the fix makes pipeline NUMBER globally unique. Can two records still collide on a different identity dimension — same commit + different number, same URL, same step-id — and certify the wrong subject? |
|
||||
| **E3** | "Exactly one gate-verify step per record" — what if a record has zero, or two with different outcomes? Fail closed? |
|
||||
|
||||
## C — clauses, integrity, CI
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **C1** | The three new criteria (`RM02-EVIDENCE-SUBJECT-BINDING`, `RM02-TYPE-STRICT-SCHEMA`, `RM02-HISTORY-BOUNDARY`) are **checked criteria with bidirectionally bound cases**, not prose. Orchestrator observed caseRefs 1 / 3 / 3 — verify the bindings actually run and can fail. |
|
||||
| **C2** | **B1/B2 satisfied in substance:** does `RM02-EVIDENCE-SUBJECT-BINDING` express D-38 generally ("does this gate bind its evidence to its subject?") or only the one pipeline-number instance? Same for D-40 vs the one typo instance. **A clause that only covers its originating instance is not a clause.** |
|
||||
| **C3** | **Nothing weakened:** diff `83d2ecb2` → `32b490a7` removes/relaxes no existing case, test, or assertion. |
|
||||
| **C4** | Exact-head CI terminal-green, full `-f json`, counts stated **including `clone`**; machine-checked with `verify-terminal-green.py --expect-commit 32b490a712a5e8e77f13c40c60099b51feb38994`. |
|
||||
| **C5** | `Closes #1029` still present (D-38c). |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `32b490a712a5e8e77f13c40c60099b51feb38994`, evidence enumerated, posted durably under
|
||||
your own identity. If a check is unrunnable, **say so** — never substitute a passing variant.
|
||||
|
||||
**H1 and S1 are the two I most want answered**, because they ask whether each fix closed a **class** or
|
||||
an **instance**. Attack outside this set: four of the last five blockers on this PR came from mutating
|
||||
something nobody had registered.
|
||||
|
||||
**Environment note:** `pnpm gate:verify` fails on `checkout-preflight` with outcome 42 (bubblewrap
|
||||
class) in at least one worktree. If you hit it, report it as an environment limitation — do not treat it
|
||||
as a finding, and do not let it mask one.
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
||||
@@ -1,59 +0,0 @@
|
||||
# RM-02 / PR #1030 — PRE-REGISTERED RE-REVIEW: honest narrowing + (d)-strict
|
||||
|
||||
**Subject head (exact):** `e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`
|
||||
**Supersedes:** the third NO-GO at `fbb61912` (D-45/D-46/D-47) and the D-52 stop. Those are VOID.
|
||||
**Registered by:** `mos-remediation` before any reviewer read the diff. **Reviewer:** `rev-974`.
|
||||
|
||||
## Already reproduced by the orchestrator (do not re-spend here)
|
||||
|
||||
- The required narrowing sentence is **verbatim** in `gates/required-gates.baseline.json` and in
|
||||
`scripts/gate-inventory-claim-control.mjs`: _"Detects accidental and incompetent inventory drift
|
||||
within a checkout; does NOT survive an adversary who rewrites baseline, manifest, and verifier
|
||||
consistently — that guarantee requires RM-60's external boundary."_
|
||||
- **The overclaim control FIRES**: clean ⇒ exit 0; injecting "protected, independently anchored" into
|
||||
the baseline purpose ⇒ **exit 84**, `INVENTORY_CLAIM_OVERSTATED`.
|
||||
- CI #2201 at the exact head, 10/10 incl. `clone`; machine check bound, `exempted_steps 0`, no anomalies.
|
||||
|
||||
## N — the narrowing (D-52's ruling is the wording)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **N1** | ★ Is the narrowed claim **honest EVERYWHERE**, or only where the control looks? Find any place — docs, comments, observations, manifest prose, CLI output — that still implies the baseline is protected or independently anchored. **The control only checks the files it checks.** |
|
||||
| **N2** | ★ Can the **overclaim control itself** be defeated? It is PR-controlled like everything else. Weaken/skip/reword it so an overstatement passes. If it can be trivially neutered, D-52 is re-armed one level down — the exact recursion this PR keeps producing. |
|
||||
| **N3** | Does the control detect **semantic** overclaim or only the literal strings it greps? An artifact could assert protection in different words and pass. |
|
||||
| **N4** | Is the narrowed claim **TRUE**? Verify the baseline genuinely catches accidental/inconsistent drift — not just that it says so. A narrowed claim that is still overstated is D-48 again. |
|
||||
|
||||
## H — (d)-strict history removal
|
||||
|
||||
| id | check |
|
||||
| ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **H1** | Is the verifier **structurally incapable** of reporting history-provenance-verified, or merely not currently doing so? The manifest says "structurally incapable" — test the claim. |
|
||||
| **H2** | ★ **TRAP 1 re-check:** nothing emits the unverifiable state as an OBSERVATION (which exits 0). Confirm no path yields exit 0 while implying provenance was considered. |
|
||||
| **H3** | ★ **TRAP 2 re-check:** the exclusion is **enforced**, not merely declared in `coverageBoundary.excluded`. Re-enable history provenance without a trustworthy anchor — does a registered case go RED? |
|
||||
| **H4** | The residual is stated **correctly** — no local git state is trustworthy because PR code executes before the gate — **NOT** the D-48 wording ("compromised main"). |
|
||||
|
||||
## B3 / R — evidence-subject and renderer
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **B1** | Subject is on the **EVIDENCE side** and compared **at consumption**, not `gate.evidenceSubject === gate.id` metadata-vs-itself. Mutate the evidence-side subject **per gate**. |
|
||||
| **B2** | Can evidence still be moved or misbound while every gate satisfies its check? |
|
||||
| **R1** | Renderer now covers the **final success stdout/stderr** paths. |
|
||||
|
||||
## C — integrity + CI
|
||||
|
||||
| id | check |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **C1** | Head `e910a45a` on git + provider; `Closes #1029` present. |
|
||||
| **C2** | Exact-head CI terminal-green, `-f json`, counts incl. `clone`; machine-checked with `--expect-commit e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`. |
|
||||
| **C3** | Nothing weakened `fbb61912` → `e910a45a`. Red-first labels honest (D-8). |
|
||||
| **C4** | **NO fourth local anchor was invented.** Blocker 1 and blocker 2's adversarial guarantee both track RM-60. |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `e910a45ab3578a9c77ad3eeaa52b31b72611f6c6`, evidence enumerated, posted durably.
|
||||
Unrunnable ⇒ **say so**. **N1 and N2 are the sharp ones** — this PR's history is that every fix
|
||||
relocates the defect one level down, and N2 asks whether the control that enforces honesty is itself
|
||||
honest. **Attack outside the set: six for six so far.**
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
||||
@@ -1,71 +0,0 @@
|
||||
# RM-02 / PR #1030 — PRE-REGISTERED ACCEPTANCE CHECKS (post-rebase, keystone)
|
||||
|
||||
**Subject head (exact):** `83d2ecb2243f1b0987ef2bc14de47f444285a684`
|
||||
**Rebased onto:** `main` @ `f4fd5967fc5d4cbc72d680b199d88224aa855131` (post-RM-61)
|
||||
**Registered by:** `mos-remediation` **BEFORE the reviewer read the diff.** **Reviewer:** `rev-974`
|
||||
(author ≠ reviewer; branch commits are `f10-coder`'s and `coder-mos1`'s).
|
||||
**Prior reviews 63 / 65 are SUPERSEDED; this head carries NO live review of any kind.**
|
||||
|
||||
RM-02 is the **keystone**: it is the anti-inert-gate registry every downstream gate is measured against.
|
||||
A defect that survives here propagates into every gate this mission ships.
|
||||
|
||||
## ★ CRUX — the one non-mechanical change, made by the author, that turned a red green
|
||||
|
||||
The rebase was clean. One further commit was **not** purely mechanical and the author disclosed it
|
||||
unprompted (`83d2ecb2` — advance registry activation seam):
|
||||
|
||||
gates/gates.manifest.json
|
||||
- "activationCommit": "f65e9ea656ec466e12640bf6ab5d46fe07ff160c"
|
||||
+ "activationCommit": "f4fd5967fc5d4cbc72d680b199d88224aa855131"
|
||||
|
||||
Stated rationale: after the rebase, `pnpm gate:verify` correctly failed because the newly-merged
|
||||
pre-registry RM-61 commit was treated as _prospective_ and required a manifest that could not exist.
|
||||
|
||||
`activationCommit` sets the lower bound of `activationCommit..HEAD` — **the set of commits required to
|
||||
carry own-tree registry provenance** (`gate-history.mjs:314` → `listProspectiveCommits`). Advancing it
|
||||
**shrinks that set**.
|
||||
|
||||
**Determine, do not assume — and I have deliberately formed and stated no verdict (D-39):**
|
||||
|
||||
| id | check |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **A1** | **Is the advance JUSTIFIED?** Can a pre-registry commit on `main` (e.g. `f4fd5967`) satisfy the provenance requirement at all, or is requiring it impossible-by-construction? If impossible, is advancing the seam the _correct_ remedy, or does a correct remedy exist that does not move an author-controlled field? |
|
||||
| **A2** | **Is the advance MINIMAL?** Was it advanced exactly to the new parent baseline, or beyond it? Does any commit that _should_ remain covered fall outside the new range? |
|
||||
| **A3** | **Are all 7 branch commits still PROSPECTIVE under the new seam?** Enumerate them and show each is still required to carry provenance. The author claims "own-tree provenance over every registry-era branch commit" — verify the claim, do not accept it. |
|
||||
| **A4** | ★★ **IS THE SEAM ITSELF CONSTRAINED?** The only validation found is `merge-base --is-ancestor activationCommit head` (`gate-history.mjs:288`). **A commit is its own ancestor.** Determine what `activationCommit = HEAD` does: is the prospective range empty, does the per-commit loop iterate zero times, and does the history check therefore PASS VACUOUSLY? If so, the anti-inert-gate registry contains an author-settable field that can inert its own history check. **Run it. Report the observed exit and whether any failure is raised.** |
|
||||
| **A5** | **If A4 shows vacuity is reachable, is there a registered MUST-FAIL negative control for it?** RM-02's own founding rule is that a gate with no proven failure path manufactures evidence. Does the registry hold a case that goes RED when the seam is over-advanced? If not, that is a hole in the registry's coverage of itself. |
|
||||
| **A6** | **Self-verification shape (charter / D-19 / D-39b):** the field was advanced by the change's own author to make the author's gate pass. Independent of whether the value is correct, determine whether the _mechanism_ permits an audited party to relax its own audit, and whether that needs a constraint, an owner, or an escalation. |
|
||||
|
||||
## Registry substance — the clauses this PR must now carry
|
||||
|
||||
| id | check |
|
||||
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **B1** | **D-38 clause present and enforced:** the registry asks of every gate _"does this gate BIND its evidence to the subject under review?"_ Ruled in-scope for THIS PR by Mos. Verify it exists as a checked clause, not prose. |
|
||||
| **B2** | **D-40 clause present and enforced:** _"discriminator and comparison inputs must be TYPE-STRICT."_ Also ruled in-scope for THIS PR. `== 0` matching `False`/`0.0` is the banked instance; the clause is the general form. |
|
||||
| **B3** | **D-42's clause is CORRECTLY ABSENT** — provider-side negative control tracks separately (Mos: landing it now would give the registry a clause with nothing to satisfy it, going red on its own clause). Confirm its absence is deliberate and recorded, not forgotten. |
|
||||
| **B4** | **The four founding clauses still hold** (`MISSION.md`): every check proven **right** (red for its own stated reason), the set **covers**, no two criteria **conflict**, and criterion evolution **retains original text + restatement + reason**. |
|
||||
| **B5** | **Nothing was weakened, skipped, or deleted by the rebase.** Diff `f9746b23` → `83d2ecb2` and confirm no registered case, test, or assertion was removed or relaxed to make the rebase land. |
|
||||
| **B6** | **`coverageBoundary.excluded[]` is honest in BOTH directions** (charter principle 4): what it does NOT cover is stated beside what it DOES, and the gap is tracked (`trackedBy: RM-54`) rather than implied-fixed. |
|
||||
| **B7** | **The RM-02 execution boundary** (`gate-history.mjs`, DOES / DOES NOT, owner RM-60, xref RM-59) states its limits in both directions and names a tracked owner — not a documented gap with no owner. |
|
||||
|
||||
## Head + CI (re-run these; do not carry them forward from the author's report)
|
||||
|
||||
| id | check |
|
||||
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **C1** | Head `83d2ecb2…` on **both** git and provider; `Closes #1029` present in the body (delivery-issue rule, D-38c). |
|
||||
| **C2** | Exact-head CI terminal-green by **full `-f json` scan**, counts stated **including `clone`**. Note the count changed 9 → **10** (this PR adds `gate-verify`) — confirm the new step is real, not a miscount. |
|
||||
| **C3** | **Machine-check it, do not assert it:** `verify-terminal-green.py --expect-commit 83d2ecb2243f1b0987ef2bc14de47f444285a684`. Report exit, `exempted_steps`, `commit == expected_commit`. |
|
||||
| **C4** | `exempted_steps=0` is expected here — `ci-postgres` succeeded, so the #1000 exemption was **not needed**. Confirm the exemption is present-but-unused rather than silently inapplicable. |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `83d2ecb2243f1b0987ef2bc14de47f444285a684`, evidence enumerated per check, posted
|
||||
durably under your own identity. If a check is unrunnable, **say so** — never substitute a variant that
|
||||
passes. Scan CI from `-f json`, never default text (D-33); state your counts.
|
||||
|
||||
**A4 is the check I most want an answer to and the one I have least confidence about.** Attack outside
|
||||
this set as well: every blocker found on this mission so far came from mutating something nobody had
|
||||
registered a check for.
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).** Observations may be
|
||||
relayed to you; verdicts may not. The ruling is yours.
|
||||
@@ -1,42 +0,0 @@
|
||||
# RM-03 / PR #1032 — PRE-REGISTERED RE-REVIEW (post-freshening)
|
||||
|
||||
**Subject head (exact):** `868b9b871a5118762aa5b8aafecd2f0592f7ab5c`
|
||||
**Rebased onto:** `main` @ `f4fd5967` **Registered by:** `mos-remediation` before the reviewer read the
|
||||
diff. **Reviewer:** `rev-974`.
|
||||
|
||||
**Why this exists:** review 66 APPROVED and the merge-gate GO were bound to `78ec47cd`; `main` drifted
|
||||
under a long hold and the PR went `mergeable=false` (**D-50 — a GO has a shelf life**). Both verdicts
|
||||
are VOID at the new head. Jason's merge approval is **standing** and executes once this re-gates.
|
||||
|
||||
## What the orchestrator already verified (do not re-spend here)
|
||||
|
||||
- `git range-diff 78ec47cd...868b9b87`: commits **2–5 are `=` (patch-equivalent)**; only commit 1 differs.
|
||||
- That single difference is the `test:framework-shell` chain, resolved as a **UNION** — main's
|
||||
`test-terminal-green-contract.sh` **and** RM-03's `tristate`, `github-checks`, `merge-queue-branch`,
|
||||
`merge-head-pin`, plus the pre-existing `branch-absent`. **Nothing dropped from either side.**
|
||||
- CI #2199 at the exact head: 9 children, 9 success incl. `clone`; machine check exit 0, bound, no anomalies.
|
||||
- `mergeable` is now **true**.
|
||||
|
||||
## Checks
|
||||
|
||||
| id | check |
|
||||
| ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **R1** | ★ **THE GUARD CAN FAIL.** RM-03 exists because the queue guard is ZERO-INFORMATION (D-23) — it returns pass for every possible input. Prove the rebased head's guard **actually fails** on asserted non-readiness, not merely that its tests pass. This is the whole deliverable. |
|
||||
| **R2** | ★ **NO QUEUE-GUARD SEMANTIC MOVED IN THE REBASE.** Confirm the range-diff equivalence independently. A queue-guard semantic resolved quietly re-breaks the thing this PR fixes. |
|
||||
| **R3** | **Union is complete in BOTH directions:** no RM-03 suite lost to main's version, no main suite (esp. `test-terminal-green-contract.sh`) lost to RM-03's. Enumeration count reconciles. |
|
||||
| **R4** | **Tri-state is real** (`verified` / `written-unverified` / `failed`) — P-WRAPPER-001. Verify an indeterminate result cannot present as a pass; that is the exact defect (`state=unknown exit 0`). |
|
||||
| **R5** | The guard's **exit-asserting non-null-case** tests are genuine — each fails for its own stated reason against pre-fix code, not merely present (D-8). |
|
||||
| **R6** | `test:framework-shell` is **runnable to completion in CI** (canonical env). Known: it exits 97 on some hosts via a Bash 5.2.15 `BASH_LINENO` assertion — if you hit that, report it as an environment limitation, do not treat it as a finding and do not let it mask one. |
|
||||
| **R7** | Exact-head CI terminal-green, full `-f json`, counts stated incl. `clone`; machine-checked with `--expect-commit 868b9b871a5118762aa5b8aafecd2f0592f7ab5c`. |
|
||||
| **R8** | `Closes #1019` present in the body (D-38c delivery-issue rule). |
|
||||
| **R9** | Nothing weakened by the rebase: no test, case, or assertion removed or relaxed to make it land. |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to `868b9b871a5118762aa5b8aafecd2f0592f7ab5c`, evidence enumerated, posted durably under
|
||||
your own identity. If a check is unrunnable, **say so**.
|
||||
|
||||
**R1 is the deliverable and R2 is the risk.** Attack outside this set. **Do not cite the queue guard's
|
||||
own green as evidence of anything** — it remains inert until this very PR lands (D-23).
|
||||
|
||||
**No one dispatching this review may state a conclusion on an open check (D-39).**
|
||||
@@ -1,40 +0,0 @@
|
||||
# RM-61 / PR #1033 — PRE-REGISTERED RE-REVIEW ACCEPTANCE CHECKS
|
||||
|
||||
**Subject head (exact):** `033b2ffb46674b2c0bcc5197273c109b461f62d9`
|
||||
**Supersedes:** review 67 / comment 20403 @ `e7b29219` (NO GO). That verdict is VOID — the head moved.
|
||||
**Registered by:** `mos-remediation` (orchestrator). **Reviewer:** `rev-974` (author ≠ reviewer).
|
||||
**Registered BEFORE the reviewer read the diff.** Any head move after this file is committed voids the
|
||||
re-review and requires re-registration.
|
||||
|
||||
## Scope discipline
|
||||
|
||||
The prior review passed AC1–AC8 and still found a blocker, because the registered set tested whether the
|
||||
signature DISCRIMINATES and never tested whether the evidence was BOUND TO ITS SUBJECT (Finding 3 /
|
||||
coverage-failure-mode-2, D-17 class). This set therefore carries the binding property as a first-class
|
||||
check, and **RR7 explicitly invites the reviewer to attack outside the set** — a registered set is
|
||||
protection against retrofitting only, never a ceiling on scrutiny.
|
||||
|
||||
## Checks
|
||||
|
||||
| id | check | verdict form |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| **RR1** | The full 12-case contract harness passes at the exact head: `bash test-terminal-green-contract.sh` | ⇒0, and states 12 cases |
|
||||
| **RR2** | ★CRUX — the ORIGINAL ATTACK IS DEAD. Take the real `#2188` record, mutate ONLY `commit` to an unrelated 40-hex value, verify against the true expected head | ⇒1, `exempted_steps == 0`, anomaly naming expected vs actual |
|
||||
| **RR3** | Missing `--expect-commit` cannot be defaulted, inferred, or skipped | ⇒2 (not 0, not 1) |
|
||||
| **RR4** | Malformed expected commit (short SHA, non-hex, empty) is rejected — no silent normalisation into a pass | ⇒2 |
|
||||
| **RR5** | A record with the `commit` key ABSENT (not merely different) is rejected — fail-closed on missing, not just on mismatch | ⇒1, `exempted_steps == 0` |
|
||||
| **RR6** | The genuine artifact still passes when correctly bound: real `#2188` + its true head | ⇒0, `exempted_steps == 1`, exactly one `WP-K8S-1000-CI-POSTGRES-TEARDOWN` |
|
||||
| **RR7** | ★RED-FIRST, PROVED RETROACTIVELY. The four new cases must FAIL against the OLD verifier at `e7b29219` — otherwise they do not test what they claim (D-8 class). Run the new cases against the previous implementation | new cases ⇒≠0 under `e7b29219` |
|
||||
| **RR8** | AC2 OF THE PRIOR SET DID NOT REGRESS: both REAL controls stay terminal red — `#2189` (`ci-postgres` exit 1) and `#2191` (exit 137, `test` exit 61) | both ⇒1, `exempted_steps == 0` |
|
||||
| **RR9** | Still NO fetch / trigger / retry / re-roll / sleep / network of any kind in the verifier or harness. The coin flip must remain removed, not codified (D-21) | grep ⇒ no such call sites |
|
||||
| **RR10** | The doc/baseline changes REQUIRE the current provider PR head to be passed — they must not merely mention it. Check `merge-gate.md`, `CI-CD-PIPELINES.md`, `woodpecker/README.md` state it as a requirement a gate operator cannot satisfy by omission | reviewer judgement, quote the lines |
|
||||
| **RR11** | Exemption remains bound to #1000 and retires with it; signature conjunction unchanged and not widened by this fix | diff-scoped, ⇒ no widening |
|
||||
| **RR12** | Case-sensitivity: an uppercase-hex record commit against a lowercase expected head must NOT silently pass by accident of comparison. State which way it resolves and whether it fails closed | state the observed behaviour |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Report the verdict **bound to `033b2ffb46674b2c0bcc5197273c109b461f62d9`** and state each check's
|
||||
observed result, including counts read from `pipeline-status.sh -f json` (never default text — it omits
|
||||
`clone`, D-33). If any check is unrunnable, **say so** — never substitute a passing variant. Attack
|
||||
outside this set and report anything it finds; RR7 and RR12 exist because the last blocker was found
|
||||
exactly that way.
|
||||
@@ -1,60 +0,0 @@
|
||||
# RM-61 / PR #1033 — PRE-REGISTERED ACCEPTANCE CHECKS: `exit_code` TYPE-STRICTNESS (D-40)
|
||||
|
||||
**Registered by:** `mos-remediation` (orchestrator) — **BEFORE the fix was pushed and before any
|
||||
reviewer read a diff.** At registration time the fix existed only as an unpushed local commit
|
||||
(`6e7a336d`) on coder-mos1's machine which **I have not read**. There is therefore no diff for these
|
||||
checks to have been retrofitted to.
|
||||
|
||||
**Subject head:** TBD — binds to the NEW head once coder-mos1 pushes. The prior head
|
||||
`033b2ffb46674b2c0bcc5197273c109b461f62d9` and its review 69 / pipeline #2193 go VOID on that push;
|
||||
that cost was accepted deliberately by Mos's BLOCKING ruling on D-40.
|
||||
|
||||
**Ruling being enforced (Mos, 2026-08-01):** `exit_code` must be accepted ONLY as a real integer.
|
||||
`false`, `0.0`, `"0"`, and `null` must all fail to satisfy the exemption. Wrong-ACCEPT is the
|
||||
disqualifying direction; this hole sits inside the load-bearing discriminator.
|
||||
|
||||
## ⚠ Read this before writing the tests — RED-FIRST HERE IS NOT UNIFORM
|
||||
|
||||
Two of the four near-miss values **already block** at `033b2ffb`. Demanding "all four observed RED
|
||||
first" would be demanding an impossible red for two of them, and the predictable response to an
|
||||
impossible demand is a fudge — weakening something real to manufacture the red. So state it precisely:
|
||||
|
||||
| value | behaviour at `033b2ffb` (pre-fix) | what the new case is |
|
||||
| ------- | --------------------------------- | ------------------------- |
|
||||
| `false` | **WRONGLY EXEMPTS** (exit 0) | **genuine RED-FIRST** |
|
||||
| `0.0` | **WRONGLY EXEMPTS** (exit 0) | **genuine RED-FIRST** |
|
||||
| `"0"` | correctly blocks (exit 1) | **regression guard** only |
|
||||
| `null` | correctly blocks (exit 1) | **regression guard** only |
|
||||
|
||||
Claiming red-first for `"0"` or `null` would be a false claim about your own evidence. Say which is
|
||||
which. **Do not weaken anything to make a green case go red** (D-8).
|
||||
|
||||
## Checks
|
||||
|
||||
| id | check | verdict form |
|
||||
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| **TS1** | ★RED-FIRST. `exit_code: false` on the real #2188 record is **observed wrongly exempting at `033b2ffb`** (exit 0, `exempted_steps 1`), then blocks after the fix | pre-fix ⇒0/exempt 1 · post-fix ⇒1/exempt 0 |
|
||||
| **TS2** | ★RED-FIRST. Same for `exit_code: 0.0` | pre-fix ⇒0/exempt 1 · post-fix ⇒1/exempt 0 |
|
||||
| **TS3** | REGRESSION GUARD. `exit_code: "0"` blocked before AND after — state honestly that it was already green | both ⇒1, `exempted_steps 0` |
|
||||
| **TS4** | REGRESSION GUARD. `exit_code: null` blocked before AND after | both ⇒1, `exempted_steps 0` |
|
||||
| **TS5** | ★NOT OVER-TIGHTENED. The genuine artifact — real #2188, real integer `exit_code: 0`, correctly bound head — still passes | ⇒0, `exempted_steps 1`, exactly one `WP-K8S-1000-CI-POSTGRES-TEARDOWN` |
|
||||
| **TS6** | The strictness sits on the value the EXEMPTION rests on, not on a cosmetic sibling. Show the guarded comparison is the one feeding `exemption_applies` | reviewer quotes the line |
|
||||
| **TS7** | `bool` is excluded EXPLICITLY, not incidentally. A bare `isinstance(x, int)` still admits `True`/`False` — verify the `not isinstance(x, bool)` clause exists | ⇒ clause present; `true` also blocks |
|
||||
| **TS8** | Negative control unaffected: a genuine failed step with a real integer non-zero exit still blocks | ⇒1, `exempted_steps 0` |
|
||||
| **TS9** | NO REGRESSION ON THE PRIOR ROUND'S BINDING WORK: mutated record commit ⇒1; `--expect-commit` omitted ⇒2; record `commit` absent ⇒1 | ⇒1 / ⇒2 / ⇒1 |
|
||||
| **TS10** | Signature conjunction NOT widened elsewhere by this fix — `POD_NOT_FOUND` / name / type / state untouched | diff-scoped ⇒ no widening |
|
||||
| **TS11** | Still no fetch / trigger / retry / re-roll / sleep / network | grep ⇒ no call sites |
|
||||
| **TS12** | Full harness passes at the new head; **state the case count** (12 previously, expected 16 — confirm the actual number rather than the expected one) | ⇒0, count stated |
|
||||
|
||||
## Reviewer instruction
|
||||
|
||||
Verdict bound to the NEW head, evidence enumerated per check, CI counts from `pipeline-status.sh -f json`
|
||||
(never default text — it omits `clone`, D-33). If a check is unrunnable, **say so**; never substitute a
|
||||
passing variant.
|
||||
|
||||
**Attack outside this set and report what you find.** Both blockers on this PR so far — the missing
|
||||
commit binding, and this type confusion — were found outside the registered set, by mutating a field
|
||||
nobody had registered a check for. That is now the expectation, not a bonus.
|
||||
|
||||
**Nobody dispatching this review may state a conclusion on an open check here (D-39).** If you are sent
|
||||
an observation, it is an observation; the ruling is yours.
|
||||
@@ -23,68 +23,3 @@ Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ th
|
||||
### **D-4 / P-LIFECYCLE + hygiene — a dispatched agent silently IGNORED an in-message context reset.**
|
||||
|
||||
planner-sol was at 64.3%/372k; the brief asked it to reset first; it began work on dirty context anyway. Only an out-of-band `/new` driven by the orchestrator guaranteed clean state. Confirms the postmortem thesis: **instructions are not enforcement.** Reset must be a mechanical pre-dispatch step, not a request.
|
||||
|
||||
<!-- board-roll: 2 entries rolled from BOARD.md -->
|
||||
|
||||
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
|
||||
|
||||
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
|
||||
|
||||
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
|
||||
|
||||
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
|
||||
|
||||
<!-- board-roll: 1 entry rolled from BOARD.md -->
|
||||
|
||||
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
|
||||
|
||||
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
|
||||
|
||||
<!-- board-roll: Decisions-log narrative rolled from BOARD.md 2026-08-01 (D-43: meet the
|
||||
byte budget by ROLLING OUT, never by rewording what stays) -->
|
||||
|
||||
### Decisions log — full record in [`TASKS.md`](./TASKS.md)
|
||||
|
||||
All 44 findings (D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-43 + D-38c in `TASKS.md`) and every ruling with its
|
||||
rationale live there. **Not duplicated here** — a second copy is a second thing to go stale, which this
|
||||
board had done three times in one night (gate list, capability registry, DECISION-1 status), and three
|
||||
more times by the next rotation seam (RM-61 "building", "nothing implemented yet", DECISION-1/2/3
|
||||
"must be ruled"). The rulings a fresh seat needs are items 4–7 above; they are **not** repeated here,
|
||||
because that repetition is what went stale.
|
||||
|
||||
<!-- board-roll: Sequencing section rolled verbatim from BOARD.md 2026-08-01 (D-43: meet the
|
||||
byte budget by ROLLING OUT, never by rewording what stays). Canonical: MISSION.md + TASKS.md §5 -->
|
||||
|
||||
### Sequencing — see [`MISSION.md`](./MISSION.md)
|
||||
|
||||
Builds 1-5, the cross-cutting retirements, and DECISION-1's corrected wire-in target are stated once in
|
||||
the charter and `TASKS.md` §5. **Not repeated here** — the previous copy of DECISION-1's status on this
|
||||
board is one of the six stale restatements below.
|
||||
|
||||
<!-- board-roll: capability/seat-identity bullets rolled verbatim from BOARD.md 2026-08-01
|
||||
(D-43: roll out, never reword). Authoritative home: TASKS.md D-11 / D-11a / D-11b. -->
|
||||
|
||||
- Capability is **per-path** (D-11b → **superseded in part by D-13/D-15**): a token file is **necessary,
|
||||
not sufficient**. Three layers — token file (raw-API), `tea` login (tea paths), **repository permission**
|
||||
(writes). Before dispatch, assert `permissions.push == true` **as that seat**, not token existence and
|
||||
not a 200 on a read. Mos owns provisioning; escalate missing pairs.
|
||||
- Seat identity (D-11a): token identity AND `git config user.name`/`user.email` must BOTH be set and
|
||||
agree. Exporting `MOSAIC_GIT_IDENTITY` alone does NOT fix commit authorship.
|
||||
|
||||
<!-- board-roll: D-26 gate-restatement rationale rolled verbatim from BOARD.md 2026-08-01 -->
|
||||
|
||||
### Why the board must not restate the delivery gates (D-26)
|
||||
|
||||
Restating the gate from memory is how the merge-gate step went missing from mission setup twice,
|
||||
once inside the correction for it (**D-26**).
|
||||
|
||||
<!-- board-roll: Fleet seats rolled verbatim from BOARD.md 2026-08-01 (D-43: roll out, never
|
||||
reword). NOTE: all these seats are UNMANAGED per D-41; `mosaic fleet ps` is live truth. -->
|
||||
|
||||
## Fleet seats
|
||||
|
||||
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
|
||||
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle
|
||||
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle
|
||||
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
|
||||
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
|
||||
|
||||
+73
-65
@@ -1,85 +1,93 @@
|
||||
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
||||
|
||||
**Phase:** EXECUTING — **RM-02 keystone is the front**.
|
||||
**Updated:** 2026-08-05 — successor seat live. D-56 state correction applied (lost round-5 dispatch,
|
||||
4 idle days); round-5 review then ran same day: verdict id 88 REQUEST_CHANGES @ `e910a45a`,
|
||||
remediation dispatched to `coder-mos2`. Review dispatches now always leave a durable PR comment.
|
||||
⚠ That was a **MANUAL pane respawn** (prior seat ~803k tokens): it validates the checkpoint+rehydration
|
||||
**design**, NOT a lifecycle **mechanism** — P-LIFECYCLE rotation does not exist yet (**D-41 / RM-62**).
|
||||
**Phase:** EXECUTING — P0 open. RM-01 MERGED; RM-02 (keystone gate registry) is next.
|
||||
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
|
||||
|
||||
## Head
|
||||
|
||||
- Charter + 15 decisions + 4-build plan: `MISSION.md`. Backlog + all findings: `TASKS.md`.
|
||||
- Planning DONE (58 tasks, P0–P5). **DECISION-1/2/3 all RULED by Mos 2026-07-31** (`TASKS.md` §5) —
|
||||
nothing is waiting on a decision. D-2's availability _target_ is Jason-pending and non-blocking.
|
||||
- **Executing, not planning.** RM-01 is MERGED; three lanes are live (see In-flight).
|
||||
- Orchestrator seat `mos-remediation` LIVE, owns the mission, resumed across the rotation seam
|
||||
2026-08-01 and re-attested to Mos from the files. Residency attestation: PASS.
|
||||
- Mission charter + 15 decisions + 4-build plan: PERSISTED (`docs/remediation/MISSION.md`).
|
||||
- HOLD lifted for this workstream (Jason 2026-07-31). Nothing implemented yet — planning first.
|
||||
- Orchestrator seat `mos-remediation` is LIVE and owns the mission. Residency attestation: PASS.
|
||||
- **TASK-0 DONE** — checkout repaired, all three gates green HONESTLY (no `--no-verify`), branch pushed.
|
||||
- **TASK-1 DONE** — both planners delivered independently on clean context; reconciled into `TASKS.md`
|
||||
(58 tasks across P0–P5, 7 convergences, 7 adjudicated disagreements, 3 escalated decisions).
|
||||
- **NEXT ACTION IS NOT MINE:** DECISION-1/2/3 (`TASKS.md` §5) must be ruled before P0 dispatch.
|
||||
RM-01 is dispatchable immediately regardless — it depends on nothing and blocks everything.
|
||||
|
||||
## In-flight
|
||||
|
||||
| Task | Owner | State |
|
||||
| ------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| RM-01 checkout | — | **MERGED** `f58b3699` (#1027) |
|
||||
| RM-03 queue guard | Jason (re-sync) | ✅ **MERGED** `58b971ab` (#1032), #1019 closed. ⚠ **NOT DELIVERED**: installed guard still the broken one (291 lines / 0 `ASSERTED_NOT_READY` vs main 482 / 5). Re-sync via `mosaic upgrade`, then **prove it blocks a KNOWN-RED pipeline** — **D-51** |
|
||||
| RM-02 registry ★key | coder-mos2 | **ROUND-5 VERDICT id 88 REQUEST_CHANGES @ `e910a45a` (2026-08-05T22:37Z, live).** 2 blockers left: renderer accepts manifest-controlled `COMPATIBILITY `-prefixed provenance-success payloads; overclaim control is lexical-not-semantic vs docs claims. Shrink-together + evidence-subject CONFIRMED CLOSED — do not weaken. Remediation dispatched to `coder-mos2` 22:5xZ (PR comment + verified tmux); context guard: checkpoint at 85%. ⚠ **MERGE PRECONDITIONS (D-55/D-55f, measured):** poster=f10-coder (1 of 3 authors); (1) explicit `-m squash` (repo default `merge` discards ALL authorship); (2) **merger ≠ f10-coder** (self-merge suppresses the trailer generator → all 3 lost); (3) **the EXECUTING pr-merge.sh must contain `MergeMessageField` — merging #1066 is NOT sufficient** (deployed wrapper `08a65e85` = 0 refs, physically cannot emit; D-51's merged≠effective class inside the remedy; Mos-as-merger verifies wrapper sha256 before ANY trailer-dependent merge and refuses on `08a65e85`); (4) ✅ **CLOSED ON MEASUREMENT** — N=2 supplied non-poster trailers BOTH land, order preserved, poster line appended last (fixture prmerge-trailer-fixture#2 → `39db9d13`, #1030's exact shape; N=3+/poster-dup-dedup/Co-committed-by absence remain unmeasured but #1030 needs N=2). Merge decision resolved: all-land ⇒ merge as planned, three authors preserved. Trade in body; **branch MUST NOT be deleted**. ACs @ `dde38717` |
|
||||
| RM-61 CI exemption | — | ✅ **MERGED** `f4fd5967` (#1033). #1034 closed; **#1000 stays OPEN** (retirement trigger). Exemption is on `main` |
|
||||
| RM-59 / RM-60 | Jason (infra) | tracked deps; RM-60 option **B** |
|
||||
| #1023 queue attempt | Jason | SUPERSEDED-PENDING-JASON — live REQUEST_CHANGES, do **not** merge |
|
||||
|
||||
### For the incoming orchestrator — read this before acting
|
||||
|
||||
1. **Lane state lives in the In-flight table above — this item does NOT restate it.** It went stale
|
||||
three times in one session by duplicating that table (D-26's class). Read the table. ⚠ **And
|
||||
re-derive any board claim from the provider before load-bearing use (D-43)** — the board is
|
||||
sole-written and has no independent verifier.
|
||||
2. **`docs/remediation/TASKS.md` is authoritative**, not the newest voice in a chat. It holds 58 findings
|
||||
(D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-55c + D-38c in TASKS.md), every ruling with its rationale, and the
|
||||
requirements each finding placed on RM-02/RM-34/RM-50/RM-55.
|
||||
3. **`MISSION.md` carries the first-class principles** — read them there, they are not listed here.
|
||||
Two added 2026-08-01: **the anchor must live outside the audited party's authority** (D-19/D-25/D-45,
|
||||
third arrival) and **no universally-quantified check may pass over an empty set** (D-44/D-46).
|
||||
4. **Seat identity:** `export MOSAIC_GIT_IDENTITY=<seat>` is stripped by a context reset (**D-34**) —
|
||||
every dispatch/rehydration brief must re-export it, or the seat cannot use its credentials.
|
||||
5. **Scan CI from `-f json`, never default text** — text mode omits `clone` (**D-33**). State counts.
|
||||
6. **The queue guard is zero-information until RM-03 merges** (**D-23**) — never cite its green.
|
||||
7. **The bounded CI re-roll used on RM-02 was a one-time stopgap, NOT policy.** A per-PR free re-roll is
|
||||
D-21 normalisation. Do not repeat it; RM-61 is the fix.
|
||||
|
||||
## Delivery gates — REFERENCE, do not restate
|
||||
|
||||
Canonical: `~/.config/mosaic/fleet/roles.local/merge-gate.md` (verdict authority) +
|
||||
`~/.config/mosaic/fleet/roles/validator.md`. Order and the freeze/zero-information rules: `MISSION.md`
|
||||
and `KICKSTART.md`. **Read them there** (why: **D-26**, in `BOARD-LEDGER.md`).
|
||||
| Task | Owner | State |
|
||||
| ----------------------------------- | --------------- | ------------------------------------------------------------------------- |
|
||||
| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (PR #1027) — rev-974 APPROVE + CI #2172 8/8 green |
|
||||
| RM-02 gate registry ★keystone | unassigned | **READY** — depends only on RM-01; not held by RM-03 |
|
||||
| RM-03 queue guard (3 defects) | — | HOLD — #1023 SUPERSEDED-PENDING-JASON |
|
||||
| RM-59 close D-19 residual risk | — | BLOCKED by RM-12/RM-21/RM-25 (spine + executor) — tracked edge, not prose |
|
||||
| `remediation/state` snapshot → main | mos-remediation | opening at this mission seam |
|
||||
|
||||
## Fleet seats
|
||||
|
||||
Roster rolled verbatim to [`BOARD-LEDGER.md`](./BOARD-LEDGER.md); live truth is `mosaic fleet ps`.
|
||||
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
|
||||
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle
|
||||
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle
|
||||
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
|
||||
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
|
||||
|
||||
## Gate status
|
||||
|
||||
- Delivery gates active: author≠reviewer, diff-blind pre-registered checks, CI-green, merged-PR completion.
|
||||
- Freeze: LIFTED for this workstream only.
|
||||
- Git identity: orchestrator runs `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
|
||||
- Git identity: `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
|
||||
gate-16 protects; rev-974 reviews, mos-dt-0 never self-reviews). Dedicated identity TRACKED, Mos provisions.
|
||||
- Capability + seat identity (**D-11b / D-11a**, incl. the false-NEGATIVE twin): authoritative in
|
||||
`TASKS.md`. Short form — **assert the DIFFERENTIAL as that seat** (authenticated `push:true` vs
|
||||
unauthenticated `push:false`); a single endpoint can be true for anyone or 403 for an unrelated
|
||||
scope. Full text rolled to [`BOARD-LEDGER.md`](./BOARD-LEDGER.md).
|
||||
- ⚠ **LIVE HAZARD (D-37) — one shared `.git/config` re-identifies EVERY worktree at once.** Every seat,
|
||||
including `rev-974`'s review worktree, currently authors as **`coder-mos1`**; `MOSAIC_GIT_IDENTITY`
|
||||
does **not** override it. **STANDING ORDER: commit with explicit
|
||||
`git -c user.name=<seat> -c user.email=<seat>@…`, and NOBODY rewrites the shared config mid-flight.**
|
||||
Real fix authorised, Mos owns it, sequenced at a quiet seam. **#1024 implicated.** Detail: D-37.
|
||||
- Standing worker-brief doctrine (mandatory in EVERY brief): re-export `MOSAIC_GIT_IDENTITY` (**D-34**);
|
||||
commit early/WIP (**D-31**); don't weaken a RED test to pass; if a check is unrunnable SAY SO, never
|
||||
substitute; `agent-send -f` never `-m`; artifacts off shared `/tmp`; scan CI from `-f json` (**D-33**);
|
||||
**relay observations into an open review, NEVER your own conclusion on an open check (D-39)**; the
|
||||
**author never adjudicates their own PR's blocker status** — surface evidence, prepare the fix, hold.
|
||||
- Capability check (D-11b): before dispatching seat X to provider Y, verify
|
||||
`~/.config/mosaic/secrets/gitea-tokens/gitea-<Y>-<X>.token` exists. Token-file set = authoritative
|
||||
capability registry. Mos owns provisioning; escalate missing pairs to him.
|
||||
- Seat identity (D-11a): token identity AND `git config user.name`/`user.email` must BOTH be set and
|
||||
agree. Exporting `MOSAIC_GIT_IDENTITY` alone does NOT fix commit authorship.
|
||||
- Standing worker-brief doctrine (accreted, mandatory in every brief): don't weaken a RED test to make
|
||||
it pass; if a check is unrunnable as written SAY SO, never silently substitute; `agent-send -f` never
|
||||
`-m`; heavy artifacts off shared `/tmp`.
|
||||
- Remote control: native `/remote-control` NOT wired in this runtime. Path is **Mos-relay**
|
||||
(Jason ↔ mos-claude via Discord ↔ mos-remediation via agent-send). Not a blocker.
|
||||
|
||||
## Decisions log — full record in [`TASKS.md`](./TASKS.md)
|
||||
## Sequencing (from MISSION.md)
|
||||
|
||||
All 58 findings (D-1…D-6 in `BOARD-LEDGER.md`, D-7…D-55c + D-38c in `TASKS.md`) and every ruling with
|
||||
its rationale live there. **Not duplicated here.** The history of _why_ this board must not restate —
|
||||
six stale copies across two seams — is rolled verbatim into [`BOARD-LEDGER.md`](./BOARD-LEDGER.md).
|
||||
1. Spine + choke-point service (MACP wiring @ mosaic_orchestrator.py::run_single_task) + PG/Redis
|
||||
⚠ **CONTESTED — see DECISION-1.** Both planners independently reject this wire-in point: that
|
||||
controller is `"enabled": false` and references a dispatcher that does not exist here. Charter text
|
||||
left UNCHANGED pending Mos/Jason ruling; do not treat it as settled.
|
||||
2. Rotation daemon (finish Mission Control Plane, reuse packages/coord)
|
||||
3. Comms service (envelope→service→PG/Redis→adapters)
|
||||
4. Hygiene + conformance harness
|
||||
Cross-cutting retirements: flat-file tracking, 3 MACP islands, silent MOSAIC BYPASS.
|
||||
|
||||
## Dogfood evidence — live failure classes, not hypotheticals
|
||||
|
||||
> Newest first. Oldest entries roll to `BOARD-LEDGER.md` via `board-roll.sh` when this file
|
||||
> exceeds its 8 KB cap. Keystone detail is duplicated in `TASKS.md` §1a, so rolling loses nothing.
|
||||
|
||||
<!-- BOARD-ROLL:START -->
|
||||
|
||||
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
|
||||
|
||||
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
|
||||
|
||||
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
|
||||
|
||||
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
|
||||
|
||||
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
|
||||
|
||||
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
|
||||
|
||||
<!-- BOARD-ROLL:END -->
|
||||
|
||||
## Decisions log
|
||||
|
||||
- 2026-07-31 — Mission set up by Mos post-postmortem (15/15 decided). Dogfood posture active.
|
||||
- 2026-07-31 — Mos: stale `.mosaic/orchestrator/mission.json` is RESIDUE of the disabled Python
|
||||
orchestrator rail that this plan RETIRES. Do NOT invest in it; do NOT build on that rail. The 0/0
|
||||
milestone banner is cosmetic. (Supersedes any plan to repair it.)
|
||||
- 2026-07-31 — Mos: planners must be dispatched with GUARANTEED clean context, not requested-clean.
|
||||
Prior default-socket planner sessions predate this mission; dirty context is the indicted hygiene.
|
||||
- 2026-07-31 — mos-remediation: worker briefs forbid all git ops and restrict each worker to a single
|
||||
named output file, so two planners can share one checkout without a branch race (M2-era incident doctrine).
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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.”
|
||||
@@ -25,18 +25,8 @@ mechanically until Build 3 (rotation) makes it automatic.
|
||||
## Standing invariants (never violate)
|
||||
|
||||
- **North star:** deterministic-right-answer → code/gate; LLM only for judgment.
|
||||
- **Delivery gates — REFERENCE the canonical files, never restate them:**
|
||||
`~/.config/mosaic/fleet/roles.local/merge-gate.md` (verdict authority) and
|
||||
`~/.config/mosaic/fleet/roles/validator.md` (validator role). Order:
|
||||
independent review (author ≠ reviewer, `rev-974`; pre-registered diff-blind checks committed before the
|
||||
diff is read) → remediation → **CI terminal-green at the exact head by full step scan** →
|
||||
**merge-gate verdict `GO`/`NO-GO`/`HOLD`**, commit-bound and **void the instant the head moves**, posted
|
||||
durably with enumerated evidence under its own minted identity → **coordinator head-pinned merge**.
|
||||
The queue guard runs but is **zero-information until RM-03 lands** (D-23) and must not be cited as evidence.
|
||||
**After a `GO`, freeze pushes** — even a doc tweak voids the verdict. The coordinator assigns the gate seat.
|
||||
- **Query for refutation, never for confirmation.** A subordinate asked to confirm a hypothesis will
|
||||
agree — the bias is in the question, not the answerer, and agent seats are agreeable by construction.
|
||||
State the hypothesis as yours, ask for the evidence that KILLS it, and reproduce when it matters.
|
||||
- **Delivery gates:** author≠reviewer; PRE-REGISTERED diff-blind checks committed before reading the diff;
|
||||
CI terminal-green; completion = merged PR + closed issue. rev-974 = the mosaicstack reviewer identity.
|
||||
- **Dogfooding:** every fix validated against its live seed case (MISSION.md lists them).
|
||||
- **Tracking → DB** (hard cutover); do NOT re-invest in flat-file tracking. jarvis-brain PDA is off-limits.
|
||||
- **Git identity:** export `MOSAIC_GIT_IDENTITY=<your-seat>` so wrappers author correctly and survive respawn.
|
||||
|
||||
+10
-143
@@ -12,6 +12,8 @@ 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
|
||||
@@ -30,6 +32,8 @@ 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 |
|
||||
@@ -56,6 +60,8 @@ 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,
|
||||
@@ -68,6 +74,8 @@ gate/program; the LLM handles only genuine judgment.
|
||||
|
||||
### First-class principle — when a property cannot exist at the layer it was specified
|
||||
|
||||
<!-- 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:
|
||||
@@ -91,99 +99,6 @@ gate/program; the LLM handles only genuine judgment.
|
||||
> seat in the loop. Residual risk bound to **RM-59** (`depends_on: RM-12, RM-21, RM-25`), where the
|
||||
> choke-point executor and spine verify from _outside_ the worktree's authority.
|
||||
|
||||
### First-class principle — query for refutation, never for confirmation
|
||||
|
||||
> **A subordinate asked to confirm a hypothesis will agree. Ask it to refute, with evidence.**
|
||||
>
|
||||
> The bias is induced by the **query**, not by the answerer's diligence. _"The DB flaked — please
|
||||
> confirm"_ and _"confirm or refute this, with the log line that proves it"_ are different instruments,
|
||||
> and they return different answers to the same question. The first harvests agreement; only the second
|
||||
> can return **"you are wrong, and here is why."**
|
||||
>
|
||||
> This matters most with agent subordinates, which are **agreeable by construction**: fluent, eager to
|
||||
> be useful, and structurally disinclined to tell the dispatcher their premise is false. A confirmation
|
||||
> query aimed at one is close to a guaranteed yes — so the discipline cannot rest on the answerer being
|
||||
> rigorous. **It has to be built into how the question is asked.**
|
||||
>
|
||||
> Promoted to the charter by Mos (2026-08-01). Origin: the orchestrator hypothesised that a coincident
|
||||
> `ci-postgres` failure caused a CI test failure and asked the implementing seat to **confirm or refute**
|
||||
> it. The seat **refuted it** with the log (`ci-postgres:5432 - accepting connections`, migrations
|
||||
> completed) and identified the real cause. Reproduction on an identical head then settled it. Had the
|
||||
> query been phrased for confirmation, the agreement would have been returned, **D-21 would have been
|
||||
> re-classified on a false premise**, and a banked finding would have been silently corrupted.
|
||||
>
|
||||
> **Operationally:** state your hypothesis explicitly, mark it as yours, ask for _evidence that kills
|
||||
> it_, and say what evidence would change your mind. A hypothesis you cannot describe how to falsify is
|
||||
> not yet a hypothesis. Where the answer is consequential, **reproduce** rather than accept — two
|
||||
> independent runs beat one confident report.
|
||||
|
||||
### First-class principle — the anchor must live outside the audited party's authority
|
||||
|
||||
> **You cannot fix "the author controls X" by deriving X from something the author ALSO controls.**
|
||||
> Deriving merely **moves** the control point; it does not remove it.
|
||||
>
|
||||
> Promoted to the charter by Mos (2026-08-01) on the **THIRD INDEPENDENT ARRIVAL** of the same
|
||||
> conclusion, each reached while trying to ship something else, each from a different direction:
|
||||
>
|
||||
> | arrival | the audited party controls… | found as |
|
||||
> | ----------------- | ------------------------------------------------------------------- | -------- |
|
||||
> | manifest | the tree that certifies its own generated state (same-UID, CWE-345) | **D-19** |
|
||||
> | sandbox | the code that enters the sandbox, before the boundary exists | **D-25** |
|
||||
> | **registry seam** | **WHEN the tracked path is introduced, hence the derived boundary** | **D-45** |
|
||||
>
|
||||
> Round 1 the value was an author-settable **field**, so it was **derived**. Round 2 the derivation
|
||||
> depended on **when the author introduces the path**. Same authority, new costume. **Three
|
||||
> impossibility-derivations of one conclusion is not a coincidence to note — it is the strongest
|
||||
> architectural evidence this mission has produced**, and it is precisely what **forces Builds 1–2**
|
||||
> rather than making them a preference.
|
||||
>
|
||||
> **Operationally:** anchor to a reference the audited party cannot move. A git **merge-base against
|
||||
> `main`** is such a reference for a PR author (they own their branch; they do not own `main`).
|
||||
> **State the bootstrap in BOTH directions (D-19):** that anchor is sound against an author who cannot
|
||||
> rewrite `main` — the threat in scope — and **NOT** sound against an attacker who can. **That residual
|
||||
> is what Builds 1–2 close, and it must be recorded as a tracked dependency, never implied.**
|
||||
|
||||
### First-class principle — no universally-quantified check may pass over an empty set
|
||||
|
||||
> **"All registered cases ran" is VACUOUSLY TRUE when there are no registered cases.**
|
||||
> **Non-emptiness and anchoring are PRECONDITIONS asserted before the quantified check runs — not
|
||||
> properties hoped for after it.**
|
||||
>
|
||||
> Promoted by Mos (2026-08-01) after the identical vacuity appeared **twice, at two different levels**:
|
||||
> `activationCommit = HEAD` emptied the **commit range** (D-44), and an emptied manifest — `criteria`,
|
||||
> `gates`, `proseClaims`, `compatibilityScenarios` all `[]` — emptied the **registry population**
|
||||
> (D-46), each yielding **exit 0**. The first was fixed **as an instance**; the principle was never
|
||||
> extracted, **so it returned one level up.**
|
||||
>
|
||||
> **Delete every gate and every criterion TOGETHER and no remaining reference complains — because every
|
||||
> reference went with them.** A check that quantifies over a population must actually **range** over it,
|
||||
> and that population must be **provably complete and non-empty**.
|
||||
>
|
||||
> **Corollary (same disease):** a clause written for the instance that produced it is not a clause.
|
||||
> **Do not relabel the originating instances as the general clauses** — quantify over the population.
|
||||
|
||||
### First-class principle — redundant observation on evidence-bearing steps
|
||||
|
||||
> **Two observers of the same evidence, disagreeing, catch what neither catches alone.** Apply redundancy
|
||||
> not only to judgement calls but to **evidence gathering itself** — the step everyone assumes is
|
||||
> mechanical and therefore skips.
|
||||
>
|
||||
> Promoted by Mos (2026-08-01) from **D-33**. A seat scanned a pipeline with `-f json` and reported 9
|
||||
> steps; the orchestrator scanned the same pipeline in the wrapper's default text mode and reported 8.
|
||||
> **The default output omits `clone`.** Every "full step scan" that night had been 8-of-9 and was stated
|
||||
> as complete in good faith. No verdict changed — but the _method_ was wrong, invisibly, and **only the
|
||||
> disagreement between two counts surfaced it.**
|
||||
>
|
||||
> The reason it survived: **a summary that resembles an enumeration is more dangerous than one that
|
||||
> obviously summarises.** A labelled list of named steps with states _looks_ like the artifact, so nobody
|
||||
> checks it against the record. Compare D-24 — `mergeable` was a _true answer to a different question_;
|
||||
> this was a _true answer to a smaller one_. Neither is a lie; both pass every sniff test.
|
||||
>
|
||||
> **Operationally:** where a step _produces evidence a decision rests on_, have it produced twice by
|
||||
> different means, and treat **any divergence as a finding rather than as noise to reconcile**. Prefer the
|
||||
> machine-readable record over the human-readable rendering — _read the artifact, not the summary_ — and
|
||||
> state the counts observed so a divergence is detectable at all.
|
||||
|
||||
## Decision record (authoritative, immutable)
|
||||
|
||||
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
|
||||
@@ -202,31 +117,6 @@ gate/program; the LLM handles only genuine judgment.
|
||||
| **4. Comms service** | AUTHORITY, INBOX (+ versioning roadmap) | Envelope (comms/v1) → sole-path service → PG/Redis → pluggable adapters (tmux→Matrix/Discord/Slack/Telegram). Version the protocol, not participants. |
|
||||
| **+ Hygiene & proof** | FLEET, WORKFLOW, CONFORMANCE | One roster-owned socket/host + stale GC; allowlist auto-sync; the conformance harness that fault-injects the failure classes and proves builds 1–4 hold. |
|
||||
|
||||
## Why Builds 1–2 are necessary — two independent impossibility proofs
|
||||
|
||||
**The choke-point executor and PG spine are not a design preference. They are forced.** Twice during
|
||||
the mission's own first deliveries, work stopped against a security property that **cannot exist** at
|
||||
the layer that needed it — and both times the only resolution was an authority _outside_ the audited
|
||||
party's control, which is precisely what Builds 1–2 provide.
|
||||
|
||||
| | the audited party controls… | so what fails | found as |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------- | -------- |
|
||||
| **Artifact integrity** | the manifest that certifies its own generated tree (same-UID write ⇒ regenerate manifest _and_ marker consistently, CWE-345) | tamper-evidence | **D-19** |
|
||||
| **Execution integrity** | the code that enters the sandbox (PR-controlled config executes _before_ the boundary exists) | isolation | **D-25** |
|
||||
|
||||
Both reduce to one sentence:
|
||||
|
||||
> **Self-verification by the audited party is not verification.**
|
||||
|
||||
And to one consequence: **the anchor must live outside the audited party's authority.** A local check
|
||||
cannot defend against an actor who can rewrite the check; a repo cannot grant a capability to
|
||||
PR-controlled config and simultaneously prevent that config from using it.
|
||||
|
||||
**An architecture forced by two independent impossibility proofs is stronger evidence than one argued
|
||||
for.** Neither proof was sought — both arrived while trying to ship something else, from different
|
||||
directions (a symlink manifest; a CI sandbox), at different layers. RM-59 and RM-60 are the two tracked
|
||||
dependencies this creates, and they are the same dependency in different clothes.
|
||||
|
||||
## The finding that sets the cost
|
||||
|
||||
**Built-but-unwired disease.** `@mosaicstack/macp` is stranded (nothing calls it); `packages/coord` primitives
|
||||
@@ -279,29 +169,6 @@ orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retir
|
||||
- **Project orchestrator** `mos-remediation` (this seat) owns the mission; coordinates under Mos (lead).
|
||||
- **Adversarial task decomposition:** `planner-opus` (robustness) + `planner-sol` (pragmatic) each decompose
|
||||
the plan independently; orchestrator reconciles into `TASKS.md`/DB tasks. Oppositional by design.
|
||||
- **Delivery gates — REFERENCE, do not restate.** The authoritative definitions live at
|
||||
[`~/.config/mosaic/fleet/roles.local/merge-gate.md`](file:///home/hermes/.config/mosaic/fleet/roles.local/merge-gate.md)
|
||||
(verdict authority) and
|
||||
[`~/.config/mosaic/fleet/roles/validator.md`](file:///home/hermes/.config/mosaic/fleet/roles/validator.md)
|
||||
(validator/certificate role). **Read them; do not paraphrase them.** Restating an authoritative source
|
||||
is lossy every time — see D-26, where a subset restated from memory dropped a security precondition.
|
||||
|
||||
**Gate order** (the sequence only; the definitions are in the files above):
|
||||
1. Independent review, **author ≠ reviewer** (`rev-974` on mosaicstack), with PRE-REGISTERED diff-blind
|
||||
acceptance checks committed before the diff is read
|
||||
2. Remediation of findings
|
||||
3. **CI terminal-green** at the exact full-40 head, by **full step scan**
|
||||
4. **Merge-gate verdict — `GO` / `NO-GO` / `HOLD`** (class `merge-gate`; "Ultron" is an _instance name_,
|
||||
display data, never an authority source). **Bound to a commit and VOID the instant the head moves.**
|
||||
`HOLD` persists until replaced; a `NO-GO` answered by an empty commit must be re-issued as `NO-GO`.
|
||||
Posted durably on the PR under the gate's own minted identity, **enumerating** its evidence — a bare
|
||||
"GO — gates verified" is non-conforming.
|
||||
5. **Coordinator merge**, head-pinned. The gate never merges; it holds `push=False` by design.
|
||||
|
||||
⚠ **The CI queue guard runs but is ZERO-INFORMATION until RM-03 lands** (D-23: it returns pass for every
|
||||
possible input). It must not be cited as evidence by any gate, including the coordinator's own merge path.
|
||||
|
||||
**Assignment:** the coordinator assigns the merge-gate seat; the orchestrator does not. The orchestrator
|
||||
owns getting a PR _gate-ready_.
|
||||
|
||||
- **Delivery gates (non-negotiable):** author≠reviewer, PRE-REGISTERED diff-blind acceptance checks committed
|
||||
before reading the diff, CI terminal-green, completion = merged PR + closed issue. rev-974 = mosaicstack reviewer.
|
||||
- **Compaction survival:** see `KICKSTART.md` in this dir — the resume procedure. Persist typed state, not transcript.
|
||||
|
||||
+12
-2090
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
# 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: 38/38 pass after review hardening (27 verifier/wiring plus 11 history/provider tests).
|
||||
- `pnpm typecheck`: pass (45/45 Turbo tasks).
|
||||
- `pnpm lint`: pass (25/25 Turbo tasks).
|
||||
- `pnpm format:check`: pass.
|
||||
- `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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,58 @@
|
||||
# RM-01 — Reproducible checkout
|
||||
|
||||
- Task/ref: RM-01 (`docs/remediation/TASKS.md`, internal mission tracking)
|
||||
- Objective: make checkout/install/typecheck hooks fail on code rather than environmental residue, for root CI and non-root seats.
|
||||
- Scope: pnpm store configuration, transactional Husky installation, dependency/generated-state preflight, checkout regression tests, developer documentation.
|
||||
- Constraints: isolated worktree; no skip-switch fixes; no writes under `/root` or `/tmp`; workers do not edit `docs/remediation/TASKS.md`; author does not review or merge.
|
||||
- Acceptance: AC1–AC8 from the orchestrator dispatch/addendum.
|
||||
- Plan:
|
||||
1. Add RED-first tests for missing dependencies, stale/foreign `.next`, and interrupted hook installation.
|
||||
2. Implement environment-overridable HOME-based pnpm store defaults, deterministic preflight, and transactional hook installation.
|
||||
3. Run focused tests, install/build/baseline gates, and explicit AC negative controls.
|
||||
4. Obtain independent review, push after queue guard, open PR, and send evidence to `mos-remediation`.
|
||||
- Budget: orchestrator estimate 6K/60K; no explicit hard token cap. Keep scope to RM-01 and avoid unrelated cleanup.
|
||||
- Risks: 97%-full shared `/tmp`; native dependency install size; root-owned fixtures may require Docker for realistic verification.
|
||||
|
||||
## Progress / evidence
|
||||
|
||||
- Worktree created at `/home/hermes/agent-work/rm-01` from `origin/main` `06e0d403`.
|
||||
- `/tmp` baseline: 28G used, 889M available (97%); worktree and planned store are on `/home`.
|
||||
- Root causes confirmed from source: committed `.npmrc` pins `/root`; `prepare` invokes Husky directly; web typecheck includes generated `.next` types without validating ownership/freshness.
|
||||
|
||||
## Checkpoint evidence (c45e5e19)
|
||||
|
||||
- AC1 IN PROGRESS: non-root `pnpm install --frozen-lockfile --store-dir "$HOME/.local/share/pnpm/store"` exited 0; `pnpm exec turbo run typecheck --force` exited 0 (45/45 uncached). Clean CI-container run not performed.
|
||||
- AC2 DONE: with `node_modules` absent, `pnpm preflight` exited 42 with `MOSAIC_PREFLIGHT_MISSING_DEPS` and `run pnpm install`; after install it exited 0.
|
||||
- AC3 DONE: appending `export const x: number = "s"` to `packages/types/src/index.ts` made `pnpm -w typecheck` exit 2 with TS2322; reverting made it exit 0.
|
||||
- AC4 IN PROGRESS: local `pnpm -w build` exited 0 and `git status --porcelain` showed no generated residue beyond the intended RM-01 source changes. Fresh-clone proof not performed.
|
||||
- AC5 DONE: non-root install exited 0; `pnpm store path` resolved `/home/hermes/.local/share/pnpm/store/v10`; no `/root` write was attempted.
|
||||
- AC6 IN PROGRESS: focused failure/rollback tests passed, but final review found a concurrent-install race. Two installers can both observe `.husky/_` absent; after one installs successfully, the losing install's catch path can quarantine the winner's active hooks and restore stale Git config (`scripts/install-hooks.mjs`, activation/catch transaction). A RED regression is committed after the checkpoint.
|
||||
- AC7 DONE: install/store/worktree were on `/home`; full `pnpm -w build` exited 0; `/tmp` usage changed by 4096 bytes during the build (23,805,173,760 → 23,805,177,856 bytes), not materially.
|
||||
- AC8 DONE for the implemented path: store resolves under `$HOME`; test/quarantine/build state resolves under the worktree; no implemented component requires a writable path outside `$HOME` or the worktree.
|
||||
|
||||
## Continuation evidence
|
||||
|
||||
- AC6 DONE: the committed race reproducer was observed RED (`node --test --test-name-pattern='a competing successful installer is not removed by the losing process' scripts/install-hooks.test.mjs`, exit 1/ENOENT), then passed after cleanup became ownership-safe. The losing installer never removes an active hook set or restores Git configuration it did not activate. `pnpm test:checkout` passes 21/21, exit 0, including the original race and a post-rename peer-replacement regression.
|
||||
- Generated-state remediation: replaced mtime inference with a source/build-input fingerprint, written only after a serialized successful Next build with unchanged inputs. Failed/interrupted/overlapping builds leave no trusted marker. The fingerprint uses Next's own environment loader, covers resolved `NEXT_PUBLIC_*` values, inherited TypeScript configuration, lock/workspace inputs, and rejects symlink inputs.
|
||||
- Baseline: `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` each exit 0. Local `pnpm test` still exits 97 only at the pre-existing Bash `BASH_LINENO` convention guard (#973/#1003), after checkout tests and package tests pass; this is not reported as a green full-suite result.
|
||||
- Automated review remediation: resolved findings for peer-hook ownership, stale/failed build markers, build-input changes, expanded environment inputs, inherited TypeScript config, symlink inputs, and overlapping build serialization. Independent PR review remains assigned to rev-974.
|
||||
- AC1 DONE at `0f706119`: a clean clone created inside `git.mosaicstack.dev/mosaicstack/stack/ci-base:latest` ran the exact acceptance sequence `pnpm install --frozen-lockfile && pnpm -w typecheck`; exit 0 with 45/45 uncached typecheck tasks successful. An earlier bind-mounted clone attempt exited 1 because root in the container rejected the host-owned Git directory; that failed attempt is not counted as evidence.
|
||||
- AC4 DONE at `0f706119`: in that same fresh clone and CI image, `pnpm -w build` completed 25/25 tasks and the immediately following `git status --porcelain` was empty; combined assertion exit 0.
|
||||
- Push BLOCKED after the required queue guard: `git push origin fix/rm-01-reproducible-checkout` was rejected by Gitea with `User permission denied for writing` / `pre-receive hook declined`, despite `MOSAIC_GIT_IDENTITY=f10-coder` resolving username `f10-coder` from the provisioned `gitea-mosaicstack-f10-coder.token`.
|
||||
|
||||
## Review remediation — restated AC2
|
||||
|
||||
- Independent review correctly found that an added symlink under a successfully built `.next` tree passed preflight. The exact reviewer control, `ln -s /etc/hosts apps/web/.next/reviewer-symlink && pnpm preflight`, was observed passing before remediation.
|
||||
- The original blanket symlink wording conflicts with AC4 because canonical Next `output: 'standalone'` emits legitimate pnpm dependency symlinks. The coordinator independently verified 42 such links and approved the operative restatement: `.next` itself must not be a symlink; descendant symlinks must exactly match the successful build's certified manifest.
|
||||
- RED-first controls were observed failing together against the prior implementation (exit 1): `.next` root, added, removed, retargeted, tampered-manifest, and canonical-style certified-link cases. The build now publishes the manifest atomically before the existing source certification commit marker; that marker binds the manifest SHA-256. Missing/partial/modified manifests remain untrusted.
|
||||
- GREEN evidence: the six-case symlink control passes; the exact reviewer-added link exits 43; removing it restores preflight exit 0. The added RED-first build-publication control also proves a symlinked `.next` cannot redirect certification writes outside the checkout. `pnpm test:checkout` passes 23 top-level tests / 29 including subtests. Canonical `pnpm --filter @mosaicstack/web build` and the following `pnpm preflight` both exit 0.
|
||||
- Threat-model ruling: the manifest detects accidental, independent, stale, and foreign-residue mutation—the class exposed by the five-month-stale `.next` that produced 19 phantom TS2307 errors. It does not defend against a same-UID actor able to rewrite both manifest and marker consistently (CWE-345); no local worktree construction can without an external trust anchor. RM-59 tracks the residual: executor/spine-side attestation outside worktree authority, dependent on RM-12, RM-21, and RM-25.
|
||||
- AC8 concrete proof at `df7530ae`: a clean clone ran in `ci-base:latest` with Docker `--read-only`; its only writable mounts were `/workspace` (the worktree) and `/home/ci` (`HOME`, with `NPM_CONFIG_STORE_DIR=/home/ci/store`). `pnpm install --frozen-lockfile && pnpm -w typecheck` exited 0 with 45/45 uncached tasks. This proves the implemented checkout path requires no writable location outside `$HOME` and the worktree. An initial fixture attempt failed only because Git required `/workspace` safe-directory setup; it is not counted as evidence.
|
||||
|
||||
## Handoff
|
||||
|
||||
1. Keep the newly committed RED tests red until implementing: (a) source-fingerprint marker support for valid incremental `.next` output, and (b) ownership-safe concurrent hook activation.
|
||||
2. The latest automated review rejected oldest-generated-file mtime as a false positive for valid incremental Next output. Use a source-content fingerprint marker written only after successful `next build`; do not continue tuning mtimes.
|
||||
3. For Husky, generation in an isolated temporary Git repo avoids mutating real `core.hooksPath` during staging. Preserve that design. Fix the losing concurrent process so it never removes a peer's completed hook set or restores stale config.
|
||||
4. Codex review runs in a read-only sandbox, so its attempts to run the fixture-writing Node tests report opaque test-file failures. The same tests run normally in the worktree.
|
||||
5. Full `pnpm test` is not green on this host: it exits 97 at the pre-existing Bash `BASH_LINENO` convention guard (#1003), after the changed checkout tests and package tests pass. Do not weaken that gate.
|
||||
File diff suppressed because it is too large
Load Diff
+7
-3
@@ -6,11 +6,15 @@
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test",
|
||||
"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",
|
||||
"test:checkout": "node --test scripts/*.test.mjs",
|
||||
"test": "pnpm test:checkout && turbo run test",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"prepare": "husky"
|
||||
"prepare": "node scripts/install-hooks.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
import { generatedSymlinkManifest, sourceFingerprint } from './preflight.mjs';
|
||||
|
||||
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function run(command, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, options);
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else
|
||||
reject(
|
||||
new Error(signal ? `next build terminated by ${signal}` : `next build exited ${code}`),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
|
||||
async function requireRealDirectory(target, { allowMissing = false } = {}) {
|
||||
try {
|
||||
const stats = await lstat(target);
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
||||
throw new Error(`${target} must be a real directory, not a symbolic link.`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (allowMissing && error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireBuildLock(root) {
|
||||
const workRoot = path.join(root, '.mosaic-test-work');
|
||||
const lock = path.join(workRoot, 'web-build.lock');
|
||||
const nonce = randomUUID();
|
||||
const owner = JSON.stringify({ pid: process.pid, nonce });
|
||||
const deadline = Date.now() + 120_000;
|
||||
await mkdir(workRoot, { recursive: true });
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
await mkdir(lock);
|
||||
await writeFile(path.join(lock, 'owner.json'), owner, { mode: 0o600 });
|
||||
return async () => {
|
||||
const current = await readFile(path.join(lock, 'owner.json'), 'utf8');
|
||||
if (current !== owner) throw new Error('Web build lock ownership changed before release.');
|
||||
const released = `${lock}.released-${nonce}`;
|
||||
await rename(lock, released);
|
||||
await rm(released, { recursive: true, force: true });
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') throw error;
|
||||
let lockOwner;
|
||||
try {
|
||||
lockOwner = JSON.parse(await readFile(path.join(lock, 'owner.json'), 'utf8'));
|
||||
} catch (ownerError) {
|
||||
if (ownerError.code === 'ENOENT') {
|
||||
await delay(25);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Web build lock is unreadable at ${lock}.`, { cause: ownerError });
|
||||
}
|
||||
try {
|
||||
process.kill(lockOwner.pid, 0);
|
||||
} catch (processError) {
|
||||
if (processError.code !== 'ESRCH') throw processError;
|
||||
const stale = `${lock}.stale-${nonce}`;
|
||||
try {
|
||||
await rename(lock, stale);
|
||||
await rm(stale, { recursive: true, force: true });
|
||||
} catch (renameError) {
|
||||
if (renameError.code !== 'ENOENT') throw renameError;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await delay(25);
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for the web build lock at ${lock}.`);
|
||||
}
|
||||
|
||||
export async function buildWeb({
|
||||
root = scriptRoot,
|
||||
fingerprint = sourceFingerprint,
|
||||
runBuild = async (webDir) =>
|
||||
run(path.join(webDir, 'node_modules', '.bin', 'next'), ['build'], {
|
||||
cwd: webDir,
|
||||
stdio: 'inherit',
|
||||
}),
|
||||
} = {}) {
|
||||
const releaseLock = await acquireBuildLock(root);
|
||||
try {
|
||||
const webDir = path.join(root, 'apps', 'web');
|
||||
const nextDir = path.join(webDir, '.next');
|
||||
const certificationMarker = path.join(nextDir, '.mosaic-source-hash');
|
||||
const symlinkManifest = path.join(nextDir, '.mosaic-symlink-manifest');
|
||||
const certificationTemporary = `${certificationMarker}.${randomUUID()}.tmp`;
|
||||
const manifestTemporary = `${symlinkManifest}.${randomUUID()}.tmp`;
|
||||
const before = await fingerprint(root);
|
||||
|
||||
await requireRealDirectory(nextDir, { allowMissing: true });
|
||||
await Promise.all([
|
||||
rm(certificationMarker, { force: true }),
|
||||
rm(symlinkManifest, { force: true }),
|
||||
]);
|
||||
await runBuild(webDir);
|
||||
await requireRealDirectory(nextDir);
|
||||
|
||||
const after = await fingerprint(root);
|
||||
if (after !== before) {
|
||||
throw new Error(
|
||||
'Web build inputs changed during next build; generated output was not certified.',
|
||||
);
|
||||
}
|
||||
|
||||
const manifestContents = await generatedSymlinkManifest(nextDir);
|
||||
const certificationContents = `${JSON.stringify({
|
||||
version: 1,
|
||||
sourceFingerprint: before,
|
||||
symlinkManifestHash: createHash('sha256').update(manifestContents).digest('hex'),
|
||||
})}\n`;
|
||||
await Promise.all([
|
||||
writeFile(certificationTemporary, certificationContents, { mode: 0o600 }),
|
||||
writeFile(manifestTemporary, manifestContents, { mode: 0o600 }),
|
||||
]);
|
||||
// The certification marker is the commit point. Publishing the manifest first
|
||||
// leaves interrupted builds untrusted because the marker remains absent.
|
||||
await rename(manifestTemporary, symlinkManifest);
|
||||
await rename(certificationTemporary, certificationMarker);
|
||||
} finally {
|
||||
await releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await buildWeb();
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { access, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { buildWeb } from './build-web.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `build-web-${process.pid}`);
|
||||
|
||||
async function fixture(name) {
|
||||
const root = path.join(fixtureRoot, name);
|
||||
await mkdir(path.join(root, 'apps', 'web', '.next'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
async function exists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('a successful web build atomically publishes its source and symlink certification', async () => {
|
||||
const root = await fixture('success');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
|
||||
await buildWeb({ root, fingerprint: async () => 'certified', runBuild: async () => {} });
|
||||
|
||||
assert.deepEqual(JSON.parse(await readFile(marker, 'utf8')), {
|
||||
version: 1,
|
||||
sourceFingerprint: 'certified',
|
||||
symlinkManifestHash: '8a5a375cea6a55d24bd5f875856da63feba33adbefb15a92a0007719b84bcf11',
|
||||
});
|
||||
assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n');
|
||||
});
|
||||
|
||||
test('a failed web build leaves no certification marker', async () => {
|
||||
const root = await fixture('failure');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
await writeFile(marker, 'stale\n');
|
||||
await writeFile(manifest, 'stale\n');
|
||||
|
||||
await assert.rejects(
|
||||
buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'before',
|
||||
runBuild: async () => {
|
||||
throw new Error('build failed');
|
||||
},
|
||||
}),
|
||||
/build failed/,
|
||||
);
|
||||
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
});
|
||||
|
||||
test('overlapping web builds are serialized while the marker remains absent', async () => {
|
||||
const root = await fixture('overlap');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
await writeFile(marker, 'stale\n');
|
||||
await writeFile(manifest, 'stale\n');
|
||||
let releaseFirst;
|
||||
let secondEntered = false;
|
||||
const firstEntered = new Promise((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
let markFirstEntered;
|
||||
const firstStarted = new Promise((resolve) => {
|
||||
markFirstEntered = resolve;
|
||||
});
|
||||
|
||||
const first = buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'certified',
|
||||
runBuild: async () => {
|
||||
markFirstEntered();
|
||||
await firstEntered;
|
||||
},
|
||||
});
|
||||
await firstStarted;
|
||||
const second = buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'certified',
|
||||
runBuild: async () => {
|
||||
secondEntered = true;
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 75));
|
||||
assert.equal(secondEntered, false);
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
|
||||
releaseFirst();
|
||||
await Promise.all([first, second]);
|
||||
assert.equal(secondEntered, true);
|
||||
assert.equal(JSON.parse(await readFile(marker, 'utf8')).sourceFingerprint, 'certified');
|
||||
assert.equal(await readFile(manifest, 'utf8'), '{"version":1,"links":[]}\n');
|
||||
});
|
||||
|
||||
test('a build that replaces .next with a symbolic link cannot publish outside the checkout', async () => {
|
||||
const root = await fixture('symbolic-next');
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
const outside = path.join(root, 'outside-generated');
|
||||
await mkdir(outside);
|
||||
|
||||
await assert.rejects(
|
||||
buildWeb({
|
||||
root,
|
||||
fingerprint: async () => 'certified',
|
||||
runBuild: async () => {
|
||||
await rm(nextDir, { recursive: true });
|
||||
await symlink(outside, nextDir);
|
||||
},
|
||||
}),
|
||||
/must be a real directory/,
|
||||
);
|
||||
|
||||
assert.equal(await exists(path.join(outside, '.mosaic-source-hash')), false);
|
||||
assert.equal(await exists(path.join(outside, '.mosaic-symlink-manifest')), false);
|
||||
});
|
||||
|
||||
test('inputs changed during a web build are not certified', async () => {
|
||||
const root = await fixture('changed-inputs');
|
||||
const marker = path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash');
|
||||
const manifest = path.join(root, 'apps', 'web', '.next', '.mosaic-symlink-manifest');
|
||||
const fingerprints = ['before', 'after'];
|
||||
|
||||
await assert.rejects(
|
||||
buildWeb({
|
||||
root,
|
||||
fingerprint: async () => fingerprints.shift(),
|
||||
runBuild: async () => {},
|
||||
}),
|
||||
/inputs changed during next build/,
|
||||
);
|
||||
|
||||
assert.equal(await exists(marker), false);
|
||||
assert.equal(await exists(manifest), false);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { access, mkdir, rename, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = process.cwd();
|
||||
const generated = path.join(root, 'apps', 'web', '.next');
|
||||
const quarantineRoot = path.join(root, '.mosaic-test-work', 'generated-quarantine');
|
||||
|
||||
try {
|
||||
await access(generated);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') process.exit(0);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await mkdir(quarantineRoot, { recursive: true });
|
||||
const quarantine = path.join(quarantineRoot, `web-next-${Date.now()}-${process.pid}`);
|
||||
try {
|
||||
await rename(generated, quarantine);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`MOSAIC_GENERATED_CLEAN_FAILED: could not quarantine apps/web/.next. Fix: sudo rm -rf '${generated}', then rerun pnpm preflight`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await rm(quarantine, { recursive: true, force: true });
|
||||
} catch {
|
||||
console.warn(
|
||||
`Generated state was deactivated but could not be deleted; quarantined at ${quarantine}`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
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 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 assessProviderEvidence(commit, pipelines) {
|
||||
const matches = pipelines.filter((candidate) => candidate.commit === commit);
|
||||
if (matches.length === 0) {
|
||||
return {
|
||||
state: 'absent',
|
||||
detail:
|
||||
'no retained provider record was supplied; retention expiry and never-ran are not inferred',
|
||||
};
|
||||
}
|
||||
const pipelineStates = new Set(['success', 'failure', 'error', 'pending', 'running', 'queued']);
|
||||
const stepStates = new Set([
|
||||
'success',
|
||||
'failure',
|
||||
'error',
|
||||
'pending',
|
||||
'running',
|
||||
'queued',
|
||||
'skipped',
|
||||
]);
|
||||
const numbers = matches.map((candidate) => candidate.number);
|
||||
const malformed = matches.some(
|
||||
(candidate) =>
|
||||
typeof candidate.commit !== 'string' ||
|
||||
candidate.commit.length === 0 ||
|
||||
!Number.isInteger(candidate.number) ||
|
||||
!pipelineStates.has(candidate.status) ||
|
||||
!Array.isArray(candidate.steps) ||
|
||||
candidate.steps.some(
|
||||
(step) =>
|
||||
typeof step?.name !== 'string' ||
|
||||
typeof step?.status !== 'string' ||
|
||||
!stepStates.has(step.status),
|
||||
),
|
||||
);
|
||||
if (malformed || new Set(numbers).size !== numbers.length) {
|
||||
return {
|
||||
state: 'terminal-failure',
|
||||
detail: 'provider records are malformed or have ambiguous pipeline numbers',
|
||||
};
|
||||
}
|
||||
const pipeline = [...matches].sort((left, right) => right.number - left.number)[0];
|
||||
const gateSteps = (pipeline.steps ?? []).filter((step) => step.name === 'gate-verify');
|
||||
if (gateSteps.length !== 1) {
|
||||
return {
|
||||
state: 'terminal-failure',
|
||||
detail: `provider record has ambiguous gate-verify step count ${gateSteps.length}`,
|
||||
};
|
||||
}
|
||||
const [gateStep] = gateSteps;
|
||||
if (pipeline.status === 'success' && gateStep.status === 'success') {
|
||||
return { state: 'terminal-success', detail: 'pipeline and gate-verify step succeeded' };
|
||||
}
|
||||
if (['pending', 'running', 'queued'].includes(pipeline.status)) {
|
||||
return { state: 'current-running', detail: `pipeline is ${pipeline.status}` };
|
||||
}
|
||||
return {
|
||||
state: 'terminal-failure',
|
||||
detail: `pipeline=${pipeline.status ?? 'unknown'}, gate-verify=${gateStep?.status ?? 'absent'}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadProviderEvidence() {
|
||||
const evidenceFile = process.env.GATE_PROVIDER_EVIDENCE_FILE;
|
||||
if (!evidenceFile) return [];
|
||||
const parsed = JSON.parse(await readFile(evidenceFile, 'utf8'));
|
||||
if (!Array.isArray(parsed)) throw new Error('provider evidence file must contain a JSON array');
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function isMainCommit(root, head) {
|
||||
if (process.env.CI_COMMIT_BRANCH === 'main') return true;
|
||||
const result = git(root, ['merge-base', '--is-ancestor', head, 'refs/remotes/origin/main'], {
|
||||
allowFailure: true,
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
export async function verifyHistory({ root, manifest }) {
|
||||
const failures = [];
|
||||
const observations = [];
|
||||
const head = git(root, ['rev-parse', 'HEAD']).stdout.trim();
|
||||
if (!manifest.activationCommit) {
|
||||
failures.push('history activationCommit is missing');
|
||||
return { failures, observations };
|
||||
}
|
||||
const activationCheck = git(
|
||||
root,
|
||||
['merge-base', '--is-ancestor', manifest.activationCommit, head],
|
||||
{ allowFailure: true },
|
||||
);
|
||||
if (activationCheck.status !== 0) {
|
||||
failures.push(
|
||||
`history activation commit ${manifest.activationCommit} is not an ancestor of ${head}`,
|
||||
);
|
||||
return { failures, observations };
|
||||
}
|
||||
const onMain = isMainCommit(root, head);
|
||||
// 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 commits = await listProspectiveCommits(root, manifest.activationCommit, head);
|
||||
for (const commit of commits) {
|
||||
let commitManifest;
|
||||
try {
|
||||
commitManifest = await readManifestAtCommit(root, commit);
|
||||
} catch (error) {
|
||||
failures.push(`${commit}: own-tree registry cannot be read: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
if (commitManifest.schemaVersion !== manifest.schemaVersion) {
|
||||
failures.push(`${commit}: own-tree registry schema is not supported`);
|
||||
continue;
|
||||
}
|
||||
const evidence = assessProviderEvidence(commit, pipelines);
|
||||
if (commit === head) {
|
||||
observations.push(
|
||||
`CURRENT TREE EVALUATED ${commit}: all registered cases ran from this checkout; provider evidence=${evidence.state} (${evidence.detail})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
assessProviderEvidence,
|
||||
listProspectiveCommits,
|
||||
readManifestAtCommit,
|
||||
replayCommit,
|
||||
verifyHistory,
|
||||
} from './gate-history.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `gate-history-${process.pid}`);
|
||||
|
||||
function 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, activationCommit: activation },
|
||||
});
|
||||
assert.deepEqual(result.failures, []);
|
||||
assert.ok(
|
||||
result.observations.some((observation) =>
|
||||
/DOES:.*current tree.*DOES NOT:.*isolated.*RM-60.*RM-59/i.test(observation),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
result.observations.some((observation) =>
|
||||
/INTERMEDIATE REPLAY DEFERRED.*RM-60.*no success is inferred/i.test(observation),
|
||||
),
|
||||
);
|
||||
assert.ok(result.observations.every((observation) => !/INTERMEDIATE INERT/.test(observation)));
|
||||
} finally {
|
||||
if (previousBranch === undefined) delete process.env.CI_COMMIT_BRANCH;
|
||||
else process.env.CI_COMMIT_BRANCH = previousBranch;
|
||||
}
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,866 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { constants } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
access,
|
||||
chmod,
|
||||
copyFile,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
open,
|
||||
readFile,
|
||||
readdir,
|
||||
readlink,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { verifyHistory } from './gate-history.mjs';
|
||||
|
||||
const COPY_SKIP = new Set(['.git', '.mosaic-test-work', '.next', '.turbo', 'coverage', 'dist']);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
root: process.cwd(),
|
||||
manifest: 'gates/gates.manifest.json',
|
||||
skipHistory: false,
|
||||
structureOnly: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const value = argv[index];
|
||||
if (value === '--root') options.root = path.resolve(argv[++index]);
|
||||
else if (value === '--manifest') options.manifest = argv[++index];
|
||||
else if (value === '--skip-history') options.skipHistory = true;
|
||||
else if (value === '--structure-only') options.structureOnly = true;
|
||||
else throw new Error(`unknown option: ${value}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function runInvocation(root, invocation, extraEnvironment = {}) {
|
||||
if (!Array.isArray(invocation) || invocation.length === 0) {
|
||||
return { status: null, stdout: '', stderr: 'missing invocation' };
|
||||
}
|
||||
return spawnSync(invocation[0], invocation.slice(1), {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GATE_VERIFY: '1', ...extraEnvironment },
|
||||
timeout: 300_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function sandboxPath(root, relativePath, label) {
|
||||
if (typeof relativePath !== 'string' || path.isAbsolute(relativePath)) {
|
||||
throw new Error(`${label}: path escapes sandbox (${String(relativePath)})`);
|
||||
}
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const target = path.resolve(resolvedRoot, relativePath);
|
||||
if (target !== resolvedRoot && !target.startsWith(`${resolvedRoot}${path.sep}`)) {
|
||||
throw new Error(`${label}: path escapes sandbox (${relativePath})`);
|
||||
}
|
||||
const parts = path.relative(resolvedRoot, path.dirname(target)).split(path.sep).filter(Boolean);
|
||||
let current = resolvedRoot;
|
||||
for (const part of parts) {
|
||||
current = path.join(current, part);
|
||||
try {
|
||||
if ((await lstat(current)).isSymbolicLink()) {
|
||||
throw new Error(`${label}: path crosses symbolic link (${relativePath})`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') break;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
function expand(value, root) {
|
||||
return String(value)
|
||||
.replaceAll('${ROOT}', root)
|
||||
.replaceAll('${HOME}', process.env.HOME ?? '')
|
||||
.replaceAll('${PATH}', process.env.PATH ?? '');
|
||||
}
|
||||
|
||||
function normalizedJson(value) {
|
||||
if (Array.isArray(value)) return value.map(normalizedJson);
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.sort()
|
||||
.map((key) => [key, normalizedJson(value[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function structuredValuesEqual(left, right) {
|
||||
return JSON.stringify(normalizedJson(left)) === JSON.stringify(normalizedJson(right));
|
||||
}
|
||||
|
||||
function outcomeMatches(outcome, result) {
|
||||
const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
return (
|
||||
result.status === outcome?.exitCode &&
|
||||
(!outcome?.outputPattern || new RegExp(outcome.outputPattern, 'm').test(combined)) &&
|
||||
(!outcome?.notOutputPattern || !new RegExp(outcome.notOutputPattern, 'm').test(combined))
|
||||
);
|
||||
}
|
||||
|
||||
function resultMatches(gateCase, result) {
|
||||
const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
return (
|
||||
outcomeMatches(gateCase.actual, result) &&
|
||||
(!gateCase.reasonPattern || new RegExp(gateCase.reasonPattern, 'm').test(combined))
|
||||
);
|
||||
}
|
||||
|
||||
async function safeSandboxWrite(root, relativePath, contents, label) {
|
||||
const target = await sandboxPath(root, relativePath, label);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
try {
|
||||
if ((await lstat(target)).isSymbolicLink()) {
|
||||
throw new Error(`${label}: target is a symbolic link (${relativePath})`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(
|
||||
target,
|
||||
constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW,
|
||||
0o666,
|
||||
);
|
||||
await handle.writeFile(contents);
|
||||
} catch (error) {
|
||||
if (error.code === 'ELOOP') {
|
||||
throw new Error(`${label}: target is a symbolic link (${relativePath})`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
async function applyFixture(root, fixture = {}) {
|
||||
for (const entry of fixture.writeFiles ?? []) {
|
||||
const target = await safeSandboxWrite(root, entry.path, entry.content, 'fixture write');
|
||||
if (entry.mode !== undefined) await chmod(target, entry.mode);
|
||||
}
|
||||
for (const entry of fixture.replaceFiles ?? []) {
|
||||
const target = await sandboxPath(root, entry.path, 'fixture replace');
|
||||
if ((await lstat(target)).isSymbolicLink()) {
|
||||
throw new Error(`fixture replace: target is a symbolic link (${entry.path})`);
|
||||
}
|
||||
const source = await readFile(target, 'utf8');
|
||||
const occurrences = source.split(entry.find).length - 1;
|
||||
if (occurrences !== 1) {
|
||||
throw new Error(
|
||||
`fixture replace: stale or ambiguous match for ${entry.path} (${occurrences} matches)`,
|
||||
);
|
||||
}
|
||||
await safeSandboxWrite(
|
||||
root,
|
||||
entry.path,
|
||||
source.replace(entry.find, entry.replace),
|
||||
'fixture replace',
|
||||
);
|
||||
}
|
||||
for (const relativePath of fixture.removePaths ?? []) {
|
||||
await rm(await sandboxPath(root, relativePath, 'fixture remove'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function copySandbox(root, destination, selectedPaths) {
|
||||
if (!selectedPaths?.length) {
|
||||
await copyTree(root, destination);
|
||||
return;
|
||||
}
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const relativePath of selectedPaths) {
|
||||
await copyTree(
|
||||
await sandboxPath(root, relativePath, 'sandbox copy source'),
|
||||
await sandboxPath(destination, relativePath, 'sandbox copy destination'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCase(root, gate, gateCase) {
|
||||
let caseRoot = root;
|
||||
if (gateCase.fixture) {
|
||||
caseRoot = await mkdtemp(path.join(path.dirname(root), `.gate-case-${gate.id}-`));
|
||||
await copySandbox(root, caseRoot, gateCase.fixture.copyPaths);
|
||||
await applyFixture(caseRoot, gateCase.fixture);
|
||||
}
|
||||
try {
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(gateCase.environment ?? {}).map(([key, value]) => [
|
||||
key,
|
||||
expand(value, caseRoot),
|
||||
]),
|
||||
);
|
||||
return runInvocation(caseRoot, gateCase.invocation ?? gate.invocation, environment);
|
||||
} finally {
|
||||
if (caseRoot !== root) await rm(caseRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTree(source, destination) {
|
||||
const stats = await lstat(source);
|
||||
if (stats.isSymbolicLink()) {
|
||||
await symlink(await readlink(source), destination);
|
||||
return;
|
||||
}
|
||||
if (stats.isFile()) {
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await copyFile(source, destination);
|
||||
await chmod(destination, stats.mode);
|
||||
return;
|
||||
}
|
||||
if (!stats.isDirectory()) return;
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const child of await readdir(source, { withFileTypes: true })) {
|
||||
if (COPY_SKIP.has(child.name)) continue;
|
||||
const childSource = path.join(source, child.name);
|
||||
const childDestination = path.join(destination, child.name);
|
||||
if (child.name === 'node_modules') {
|
||||
await symlink(childSource, childDestination, 'dir');
|
||||
continue;
|
||||
}
|
||||
await copyTree(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
|
||||
async function executableFiles(root, relativeRoot) {
|
||||
const base = await sandboxPath(root, relativeRoot, 'gate root');
|
||||
const found = [];
|
||||
async function walk(current) {
|
||||
for (const child of await readdir(current, { withFileTypes: true })) {
|
||||
const target = path.join(current, child.name);
|
||||
if (child.isDirectory()) await walk(target);
|
||||
else if (child.isFile()) {
|
||||
try {
|
||||
await access(target, constants.X_OK);
|
||||
found.push(path.relative(root, target).split(path.sep).join('/'));
|
||||
} catch {
|
||||
// Non-executable files are not gates for discovery purposes.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await walk(base);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function rejectUnknownKeys(value, allowed, label, failures) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
failures.push(`${label}: expected an object`);
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) failures.push(`${label}: unknown field ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function rejectDuplicateIds(values, label, failures) {
|
||||
const seen = new Set();
|
||||
for (const value of values ?? []) {
|
||||
if (typeof value?.id !== 'string' || value.id.length === 0) {
|
||||
failures.push(`${label}: missing stable id`);
|
||||
} else if (seen.has(value.id)) {
|
||||
failures.push(`duplicate ${label} id ${value.id}`);
|
||||
} else {
|
||||
seen.add(value.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateClosedSchema(manifest, failures) {
|
||||
if (manifest.schemaVersion !== 1)
|
||||
failures.push(`unsupported schemaVersion ${String(manifest.schemaVersion)}`);
|
||||
rejectUnknownKeys(
|
||||
manifest,
|
||||
new Set([
|
||||
'schemaVersion',
|
||||
'activationCommit',
|
||||
'gateRoots',
|
||||
'governingClaimFiles',
|
||||
'coverageBoundary',
|
||||
'criteria',
|
||||
'proseClaims',
|
||||
'compatibilityScenarios',
|
||||
'mergeAssertions',
|
||||
'gates',
|
||||
]),
|
||||
'manifest',
|
||||
failures,
|
||||
);
|
||||
rejectDuplicateIds(manifest.criteria, 'criterion', failures);
|
||||
for (const criterion of manifest.criteria ?? []) {
|
||||
rejectUnknownKeys(
|
||||
criterion,
|
||||
new Set([
|
||||
'id',
|
||||
'originalText',
|
||||
'currentText',
|
||||
'claimType',
|
||||
'source',
|
||||
'meaningChanges',
|
||||
'caseRefs',
|
||||
]),
|
||||
`criterion ${criterion.id}`,
|
||||
failures,
|
||||
);
|
||||
if (!Array.isArray(criterion.caseRefs) || criterion.caseRefs.length === 0) {
|
||||
failures.push(`${criterion.id}: no declared exercising cases`);
|
||||
} else {
|
||||
if (criterion.caseRefs.some((caseRef) => typeof caseRef !== 'string' || !caseRef)) {
|
||||
failures.push(`${criterion.id}: declared exercising cases must be non-empty strings`);
|
||||
}
|
||||
if (new Set(criterion.caseRefs).size !== criterion.caseRefs.length) {
|
||||
failures.push(`${criterion.id}: duplicate declared exercising case`);
|
||||
}
|
||||
}
|
||||
}
|
||||
rejectDuplicateIds(manifest.proseClaims, 'prose claim', failures);
|
||||
for (const claim of manifest.proseClaims ?? []) {
|
||||
rejectUnknownKeys(
|
||||
claim,
|
||||
new Set(['id', 'criterionId', 'caseRef']),
|
||||
`prose claim ${claim.id}`,
|
||||
failures,
|
||||
);
|
||||
if (typeof claim.caseRef !== 'string' || claim.caseRef.length === 0) {
|
||||
failures.push(`GATE-CLAIM:${claim.id} has no declared exercising case`);
|
||||
}
|
||||
}
|
||||
rejectDuplicateIds(manifest.compatibilityScenarios, 'compatibility scenario', failures);
|
||||
for (const scenario of manifest.compatibilityScenarios ?? []) {
|
||||
rejectUnknownKeys(
|
||||
scenario,
|
||||
new Set([
|
||||
'id',
|
||||
'construction',
|
||||
'caseRefs',
|
||||
'invocation',
|
||||
'expected',
|
||||
'environment',
|
||||
'fixture',
|
||||
]),
|
||||
`compatibility scenario ${scenario.id}`,
|
||||
failures,
|
||||
);
|
||||
if (!Array.isArray(scenario.caseRefs) || scenario.caseRefs.length === 0) {
|
||||
failures.push(`${scenario.id}: compatibility construction has no referenced conditions`);
|
||||
}
|
||||
if (!Array.isArray(scenario.invocation) || scenario.invocation.length === 0) {
|
||||
failures.push(`${scenario.id}: compatibility construction invocation is missing`);
|
||||
}
|
||||
if (typeof scenario.expected?.exitCode !== 'number') {
|
||||
failures.push(`${scenario.id}: compatibility construction exact expected exit is missing`);
|
||||
}
|
||||
}
|
||||
rejectDuplicateIds(manifest.gates, 'gate', failures);
|
||||
for (const gate of manifest.gates ?? []) {
|
||||
rejectUnknownKeys(
|
||||
gate,
|
||||
new Set([
|
||||
'id',
|
||||
'source',
|
||||
'invocation',
|
||||
'deployment',
|
||||
'inertMutation',
|
||||
'cases',
|
||||
'discoveryAliases',
|
||||
]),
|
||||
`gate ${gate.id}`,
|
||||
failures,
|
||||
);
|
||||
rejectDuplicateIds(gate.cases, `case in gate ${gate.id}`, failures);
|
||||
if (!Array.isArray(gate.invocation) || gate.invocation.length === 0) {
|
||||
failures.push(`${gate.id}: exact invocation is missing`);
|
||||
}
|
||||
if (!['none', 'file'].includes(gate.deployment?.kind)) {
|
||||
failures.push(`${gate.id}: unsupported deployment kind ${String(gate.deployment?.kind)}`);
|
||||
}
|
||||
for (const gateCase of gate.cases ?? []) {
|
||||
rejectUnknownKeys(
|
||||
gateCase,
|
||||
new Set([
|
||||
'id',
|
||||
'criterionIds',
|
||||
'mustFail',
|
||||
'invocation',
|
||||
'required',
|
||||
'actual',
|
||||
'reasonPattern',
|
||||
'environment',
|
||||
'fixture',
|
||||
'defect',
|
||||
]),
|
||||
`${gate.id}/${gateCase.id}`,
|
||||
failures,
|
||||
);
|
||||
if (
|
||||
typeof gateCase.required?.exitCode !== 'number' ||
|
||||
typeof gateCase.actual?.exitCode !== 'number'
|
||||
) {
|
||||
failures.push(
|
||||
`${gate.id}/${gateCase.id}: required and actual exact exit codes are mandatory`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
gateCase.invocation &&
|
||||
(!Array.isArray(gateCase.invocation) || gateCase.invocation.length === 0)
|
||||
) {
|
||||
failures.push(`${gate.id}/${gateCase.id}: case invocation must be non-empty`);
|
||||
}
|
||||
if (gateCase.mustFail === true && !gateCase.reasonPattern?.trim()) {
|
||||
failures.push(
|
||||
`${gate.id}/${gateCase.id}: must-fail case requires a non-empty reasonPattern`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateStructure(manifest, failures) {
|
||||
validateClosedSchema(manifest, failures);
|
||||
const criteria = new Map((manifest.criteria ?? []).map((criterion) => [criterion.id, criterion]));
|
||||
const boundCriteria = new Set();
|
||||
const negativeBoundCriteria = new Set();
|
||||
const observedCaseRefs = new Map();
|
||||
|
||||
for (const gate of manifest.gates ?? []) {
|
||||
const negativeCases = (gate.cases ?? []).filter((gateCase) => gateCase.mustFail === true);
|
||||
if (negativeCases.length === 0) failures.push(`${gate.id}: no negative control`);
|
||||
if (!gate.deployment?.kind) failures.push(`${gate.id}: deployment identity is not declared`);
|
||||
|
||||
for (const gateCase of gate.cases ?? []) {
|
||||
for (const criterionId of gateCase.criterionIds ?? []) {
|
||||
if (!criteria.has(criterionId)) {
|
||||
failures.push(`${gate.id}/${gateCase.id}: unknown criterion ${criterionId}`);
|
||||
}
|
||||
boundCriteria.add(criterionId);
|
||||
const caseRef = `${gate.id}/${gateCase.id}`;
|
||||
if (!observedCaseRefs.has(criterionId)) observedCaseRefs.set(criterionId, new Set());
|
||||
observedCaseRefs.get(criterionId).add(caseRef);
|
||||
if (gateCase.mustFail === true) negativeBoundCriteria.add(criterionId);
|
||||
}
|
||||
if (!structuredValuesEqual(gateCase.required, gateCase.actual) && !gateCase.defect?.owner) {
|
||||
failures.push(`${gate.id}/${gateCase.id}: behavior delta requires a tracked owner`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const claim of manifest.proseClaims ?? []) {
|
||||
if (!criteria.has(claim.criterionId)) {
|
||||
failures.push(`GATE-CLAIM:${claim.id} references unknown criterion ${claim.criterionId}`);
|
||||
continue;
|
||||
}
|
||||
const found = findGateCase(manifest, claim.caseRef ?? '');
|
||||
if (!found) {
|
||||
failures.push(`GATE-CLAIM:${claim.id} references missing exercising case ${claim.caseRef}`);
|
||||
} else if (
|
||||
found.gateCase.mustFail !== true ||
|
||||
!found.gateCase.criterionIds?.includes(claim.criterionId)
|
||||
) {
|
||||
failures.push(
|
||||
`GATE-CLAIM:${claim.id} exercising case ${claim.caseRef} does not exercise criterion ${claim.criterionId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const criterion of criteria.values()) {
|
||||
if (!boundCriteria.has(criterion.id)) failures.push(`${criterion.id}: no bound case`);
|
||||
else if (!negativeBoundCriteria.has(criterion.id)) {
|
||||
failures.push(`${criterion.id}: no must-fail case exercises this criterion`);
|
||||
}
|
||||
const declaredRefs = new Set(criterion.caseRefs ?? []);
|
||||
const actualRefs = observedCaseRefs.get(criterion.id) ?? new Set();
|
||||
for (const caseRef of declaredRefs) {
|
||||
const found = findGateCase(manifest, caseRef);
|
||||
if (!found) failures.push(`${criterion.id}: declared exercising case ${caseRef} is missing`);
|
||||
else if (!actualRefs.has(caseRef)) {
|
||||
failures.push(`${criterion.id}: declared exercising case ${caseRef} is not bound`);
|
||||
}
|
||||
}
|
||||
for (const caseRef of actualRefs) {
|
||||
if (!declaredRefs.has(caseRef)) {
|
||||
failures.push(`${criterion.id}: bound to undeclared exercising case ${caseRef}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
criterion.originalText !== criterion.currentText &&
|
||||
(!Array.isArray(criterion.meaningChanges) || criterion.meaningChanges.length === 0)
|
||||
) {
|
||||
failures.push(`${criterion.id}: missing meaning-change provenance`);
|
||||
}
|
||||
}
|
||||
|
||||
const byConstruction = new Map();
|
||||
for (const scenario of manifest.compatibilityScenarios ?? []) {
|
||||
const previous = byConstruction.get(scenario.construction);
|
||||
if (previous && !structuredValuesEqual(previous.expected, scenario.expected)) {
|
||||
failures.push(`${previous.id} and ${scenario.id}: modeled compatibility conflict`);
|
||||
} else {
|
||||
byConstruction.set(scenario.construction, scenario);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateClaims(root, manifest, failures) {
|
||||
const registered = new Set((manifest.proseClaims ?? []).map((claim) => claim.id));
|
||||
const observed = new Set();
|
||||
for (const relativeFile of manifest.governingClaimFiles ?? []) {
|
||||
const contents = await readFile(
|
||||
await sandboxPath(root, relativeFile, 'governing claim file'),
|
||||
'utf8',
|
||||
);
|
||||
for (const match of contents.matchAll(/GATE-CLAIM:([A-Z0-9][A-Z0-9-]*)/g)) {
|
||||
observed.add(match[1]);
|
||||
if (!registered.has(match[1])) {
|
||||
failures.push(`GATE-CLAIM:${match[1]} is unbound in ${relativeFile}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const claimId of registered) {
|
||||
if (!observed.has(claimId)) failures.push(`GATE-CLAIM:${claimId} marker is missing`);
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDiscovery(root, manifest, failures) {
|
||||
const registered = new Set(
|
||||
(manifest.gates ?? []).flatMap((gate) => [gate.source, ...(gate.discoveryAliases ?? [])]),
|
||||
);
|
||||
for (const gateRoot of manifest.gateRoots ?? []) {
|
||||
for (const executable of await executableFiles(root, gateRoot)) {
|
||||
if (!registered.has(executable)) failures.push(`unregistered gate: ${executable}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deploymentFilesEqual(sourcePath, deployedPath) {
|
||||
const [source, deployed] = await Promise.all([readFile(sourcePath), readFile(deployedPath)]);
|
||||
return source.equals(deployed);
|
||||
}
|
||||
|
||||
async function validateDeployment(root, gate, failures, observations) {
|
||||
if (gate.deployment?.kind !== 'file') return;
|
||||
const sourcePath = await sandboxPath(
|
||||
root,
|
||||
gate.deployment.source ?? gate.source,
|
||||
`${gate.id} deployment source`,
|
||||
);
|
||||
const deployedPath = path.isAbsolute(expand(gate.deployment.path, root))
|
||||
? expand(gate.deployment.path, root)
|
||||
: path.resolve(root, expand(gate.deployment.path, root));
|
||||
const source = await readFile(sourcePath);
|
||||
let deployed;
|
||||
try {
|
||||
deployed = await readFile(deployedPath);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
const observedHash = createHash('sha256').update(source).digest('hex');
|
||||
if (
|
||||
!gate.deployment.unavailableOwner ||
|
||||
!gate.deployment.observedSha256 ||
|
||||
gate.deployment.observedSha256 !== observedHash
|
||||
) {
|
||||
failures.push(
|
||||
`${gate.id}: deployed counterpart is unavailable and no current tracked observation matches source`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
observations.push(
|
||||
`DEPLOYED IDENTITY UNAVAILABLE (owner: ${gate.deployment.unavailableOwner}) ${gate.id}: ${deployedPath} is outside this runner; source matches pinned observation ${observedHash}, live equality is not inferred`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!(await deploymentFilesEqual(sourcePath, deployedPath))) {
|
||||
failures.push(`${gate.id}: source versus deployed copy drift`);
|
||||
return;
|
||||
}
|
||||
|
||||
const controlRoot = await mkdtemp(
|
||||
path.join(path.dirname(root), `.gate-deployment-control-${gate.id}-`),
|
||||
);
|
||||
try {
|
||||
const alteredPath = path.join(controlRoot, 'deployed-copy');
|
||||
await writeFile(
|
||||
alteredPath,
|
||||
Buffer.concat([deployed, Buffer.from('\n# gate deployment drift control\n')]),
|
||||
);
|
||||
if (await deploymentFilesEqual(sourcePath, alteredPath)) {
|
||||
failures.push(`${gate.id}: deployed-copy drift negative control was ineffective`);
|
||||
} else {
|
||||
observations.push(`DEPLOYMENT-NEGATIVE-CONTROL ${gate.id}: observed red`);
|
||||
}
|
||||
} finally {
|
||||
await rm(controlRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function validateMutation(root, gate, failures, observations) {
|
||||
const mutation = gate.inertMutation;
|
||||
if (
|
||||
!mutation?.file ||
|
||||
typeof mutation.find !== 'string' ||
|
||||
typeof mutation.replace !== 'string' ||
|
||||
typeof mutation.expected?.exitCode !== 'number'
|
||||
) {
|
||||
failures.push(`${gate.id}: declared inert mutation is incomplete`);
|
||||
return;
|
||||
}
|
||||
const mutationPath = await sandboxPath(root, mutation.file, `${gate.id} mutation source`);
|
||||
let source;
|
||||
try {
|
||||
source = await readFile(mutationPath, 'utf8');
|
||||
} catch (error) {
|
||||
failures.push(`${gate.id}: mutation source unreadable: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
const occurrences = source.split(mutation.find).length - 1;
|
||||
if (occurrences !== 1) {
|
||||
failures.push(
|
||||
`${gate.id}: declared inert mutation is stale or ambiguous (${occurrences} matches)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const targetCase = mutation.caseId
|
||||
? (gate.cases ?? []).find((gateCase) => gateCase.id === mutation.caseId)
|
||||
: (gate.cases ?? []).find((gateCase) => gateCase.mustFail === true);
|
||||
if (!targetCase) {
|
||||
failures.push(
|
||||
mutation.caseId
|
||||
? `${gate.id}: declared mutation case ${mutation.caseId} was not found`
|
||||
: `${gate.id}: declared mutation has no must-fail case`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const sandbox = await mkdtemp(path.join(path.dirname(root), `.gate-sandbox-${gate.id}-`));
|
||||
try {
|
||||
await copySandbox(root, sandbox, mutation.sandboxFiles);
|
||||
await safeSandboxWrite(
|
||||
sandbox,
|
||||
mutation.file,
|
||||
source.replace(mutation.find, mutation.replace),
|
||||
`${gate.id} sandbox mutation write`,
|
||||
);
|
||||
await applyFixture(sandbox, targetCase.fixture);
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries(targetCase.environment ?? {}).map(([key, value]) => [
|
||||
key,
|
||||
expand(value, sandbox),
|
||||
]),
|
||||
);
|
||||
const result = runInvocation(sandbox, targetCase.invocation ?? gate.invocation, environment);
|
||||
if (result.error || result.signal) {
|
||||
failures.push(
|
||||
`${gate.id}: mutation execution crashed${result.signal ? ` with ${result.signal}` : ''}${result.error ? `: ${result.error.message}` : ''}`,
|
||||
);
|
||||
} else if (!outcomeMatches(mutation.expected, result)) {
|
||||
failures.push(
|
||||
`${gate.id}: mutation produced unexpected outcome ${String(result.status)}; expected ${JSON.stringify(mutation.expected)}`,
|
||||
);
|
||||
} else if (resultMatches(targetCase, result)) {
|
||||
failures.push(`${gate.id}: declared inert mutation was ineffective`);
|
||||
} else {
|
||||
observations.push(`META-NEGATIVE-CONTROL ${gate.id}: observed red`);
|
||||
}
|
||||
} finally {
|
||||
await rm(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function findGateCase(manifest, caseRef) {
|
||||
if (typeof caseRef !== 'string') return undefined;
|
||||
const separator = caseRef.indexOf('/');
|
||||
if (separator < 1) return undefined;
|
||||
const gateId = caseRef.slice(0, separator);
|
||||
const caseId = caseRef.slice(separator + 1);
|
||||
const gate = (manifest.gates ?? []).find((candidate) => candidate.id === gateId);
|
||||
const gateCase = (gate?.cases ?? []).find((candidate) => candidate.id === caseId);
|
||||
return gate && gateCase ? { gate, gateCase } : undefined;
|
||||
}
|
||||
|
||||
async function runCompatibilityScenario(root, manifest, scenario, failures, observations) {
|
||||
const referenced = [];
|
||||
for (const caseRef of scenario.caseRefs ?? []) {
|
||||
const found = findGateCase(manifest, caseRef);
|
||||
if (!found) {
|
||||
failures.push(
|
||||
`${scenario.id}: compatibility construction references missing case ${caseRef}`,
|
||||
);
|
||||
} else {
|
||||
referenced.push({ caseRef, ...found });
|
||||
}
|
||||
}
|
||||
if (referenced.length !== (scenario.caseRefs ?? []).length) return;
|
||||
|
||||
const sandbox = await mkdtemp(path.join(path.dirname(root), `.gate-compat-${scenario.id}-`));
|
||||
try {
|
||||
await copySandbox(root, sandbox);
|
||||
const environment = {};
|
||||
const writeSignatures = new Map();
|
||||
const removedPaths = new Set();
|
||||
const fixtures = [...referenced.map(({ gateCase }) => gateCase.fixture), scenario.fixture];
|
||||
for (const fixture of fixtures.filter(Boolean)) {
|
||||
for (const entry of fixture.writeFiles ?? []) {
|
||||
const signature = JSON.stringify({ content: entry.content, mode: entry.mode });
|
||||
const previous = writeSignatures.get(entry.path);
|
||||
if (previous !== undefined && previous !== signature) {
|
||||
failures.push(`${scenario.id}: incompatible fixture writes for ${entry.path}`);
|
||||
return;
|
||||
}
|
||||
if (removedPaths.has(entry.path)) {
|
||||
failures.push(`${scenario.id}: fixture both writes and removes ${entry.path}`);
|
||||
return;
|
||||
}
|
||||
writeSignatures.set(entry.path, signature);
|
||||
}
|
||||
for (const removed of fixture.removePaths ?? []) {
|
||||
if (writeSignatures.has(removed)) {
|
||||
failures.push(`${scenario.id}: fixture both writes and removes ${removed}`);
|
||||
return;
|
||||
}
|
||||
removedPaths.add(removed);
|
||||
}
|
||||
await applyFixture(sandbox, fixture);
|
||||
}
|
||||
for (const source of [
|
||||
...referenced.map(({ gateCase }) => gateCase.environment),
|
||||
scenario.environment,
|
||||
]) {
|
||||
for (const [key, value] of Object.entries(source ?? {})) {
|
||||
if (environment[key] !== undefined && environment[key] !== value) {
|
||||
failures.push(`${scenario.id}: incompatible environment values for ${key}`);
|
||||
return;
|
||||
}
|
||||
environment[key] = value;
|
||||
}
|
||||
}
|
||||
const expandedEnvironment = Object.fromEntries(
|
||||
Object.entries(environment).map(([key, value]) => [key, expand(value, sandbox)]),
|
||||
);
|
||||
const result = runInvocation(sandbox, scenario.invocation, expandedEnvironment);
|
||||
if (result.error || result.signal || !outcomeMatches(scenario.expected, result)) {
|
||||
failures.push(
|
||||
`${scenario.id}: combined compatibility construction ${scenario.construction} observed ${String(result.status)}${result.signal ? ` signal ${result.signal}` : ''}${result.error ? ` error ${result.error.message}` : ''}; expected ${JSON.stringify(scenario.expected)}`,
|
||||
);
|
||||
} else {
|
||||
observations.push(`COMPATIBILITY ${scenario.id}: observed expected outcome`);
|
||||
}
|
||||
} finally {
|
||||
await rm(sandbox, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyRegistry(options) {
|
||||
const failures = [];
|
||||
const observations = [];
|
||||
const manifestPath = path.resolve(options.root, options.manifest);
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
||||
|
||||
validateStructure(manifest, failures);
|
||||
if (options.structureOnly) return { failures, manifest, observations };
|
||||
|
||||
async function collectPhaseFailure(label, action) {
|
||||
try {
|
||||
return await action();
|
||||
} catch (error) {
|
||||
failures.push(`${label}: ${error.message}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
await collectPhaseFailure('governing claim validation failed', () =>
|
||||
validateClaims(options.root, manifest, failures),
|
||||
);
|
||||
await collectPhaseFailure('gate discovery failed', () =>
|
||||
validateDiscovery(options.root, manifest, failures),
|
||||
);
|
||||
|
||||
for (const gate of manifest.gates ?? []) {
|
||||
await collectPhaseFailure(`${gate.id}: deployment validation failed`, () =>
|
||||
validateDeployment(options.root, gate, failures, observations),
|
||||
);
|
||||
for (const gateCase of gate.cases ?? []) {
|
||||
const result = await collectPhaseFailure(
|
||||
`${gate.id}/${gateCase.id}: case execution failed`,
|
||||
() => runCase(options.root, gate, gateCase),
|
||||
);
|
||||
if (!result) continue;
|
||||
const combined = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
try {
|
||||
if (!outcomeMatches(gateCase.actual, result)) {
|
||||
failures.push(
|
||||
`${gate.id}/${gateCase.id}: observed exit ${String(result.status)}${result.signal ? ` signal ${result.signal}` : ''}${result.error ? ` error ${result.error.message}` : ''} or output disagrees with registry actual ${JSON.stringify(gateCase.actual)}`,
|
||||
);
|
||||
}
|
||||
if (gateCase.reasonPattern && !new RegExp(gateCase.reasonPattern, 'm').test(combined)) {
|
||||
failures.push(`${gate.id}/${gateCase.id}: did not fail for its stated reason`);
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(`${gate.id}/${gateCase.id}: outcome validation failed: ${error.message}`);
|
||||
}
|
||||
if (!structuredValuesEqual(gateCase.required, gateCase.actual) && gateCase.defect?.owner) {
|
||||
observations.push(
|
||||
`DEFECT (owner: ${gateCase.defect.owner}) ${gate.id}/${gateCase.id}: required ${JSON.stringify(gateCase.required)}, actual ${JSON.stringify(gateCase.actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await collectPhaseFailure(`${gate.id}: mutation validation failed`, () =>
|
||||
validateMutation(options.root, gate, failures, observations),
|
||||
);
|
||||
}
|
||||
|
||||
for (const scenario of manifest.compatibilityScenarios ?? []) {
|
||||
await collectPhaseFailure(`${scenario.id}: compatibility validation failed`, () =>
|
||||
runCompatibilityScenario(options.root, manifest, scenario, failures, observations),
|
||||
);
|
||||
}
|
||||
|
||||
return { failures, manifest, observations };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const { failures, observations, manifest } = await verifyRegistry(options);
|
||||
if (!options.skipHistory && failures.length === 0) {
|
||||
const history = await verifyHistory({ root: options.root, manifest });
|
||||
failures.push(...history.failures);
|
||||
observations.push(...history.observations);
|
||||
}
|
||||
for (const observation of observations) process.stdout.write(`${observation}\n`);
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) process.stderr.write(`GATE VERIFY FAILED: ${failure}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const defects = observations.filter((line) => line.startsWith('DEFECT ')).length;
|
||||
process.stdout.write(
|
||||
`registry observations matched; open behavior deltas: ${defects}; required-behavior conformance is not asserted while deltas remain\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
process.stderr.write(`GATE VERIFY FAILED: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) await main();
|
||||
@@ -0,0 +1,568 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmod, copyFile, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
|
||||
const verifier = path.join(process.cwd(), 'scripts', 'gate-verify.mjs');
|
||||
const fixtureBase = path.join(process.cwd(), '.mosaic-test-work', `gate-verify-${process.pid}`);
|
||||
|
||||
async function fixture(name = 'case') {
|
||||
const root = path.join(fixtureBase, name);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await mkdir(path.join(root, 'gates'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
function baseManifest() {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
activationCommit: null,
|
||||
gateRoots: ['gates'],
|
||||
governingClaimFiles: [],
|
||||
coverageBoundary: { included: ['meta fixture'], excluded: [], trackedBy: 'RM-54' },
|
||||
criteria: [
|
||||
{
|
||||
id: 'META-CRIT-1',
|
||||
originalText: 'The fixture rejects its bad input.',
|
||||
currentText: 'The fixture rejects its bad input.',
|
||||
claimType: 'integrity',
|
||||
source: 'fixture',
|
||||
meaningChanges: [],
|
||||
caseRefs: ['meta-fixture/rejects-bad-input'],
|
||||
},
|
||||
],
|
||||
compatibilityScenarios: [],
|
||||
proseClaims: [],
|
||||
gates: [
|
||||
{
|
||||
id: 'meta-fixture',
|
||||
source: 'gates/meta-fixture.sh',
|
||||
invocation: ['gates/meta-fixture.sh'],
|
||||
deployment: { kind: 'none', reason: 'test fixture only' },
|
||||
inertMutation: {
|
||||
file: 'gates/meta-fixture.sh',
|
||||
find: 'exit 7',
|
||||
replace: 'exit 0',
|
||||
expected: { exitCode: 0 },
|
||||
},
|
||||
cases: [
|
||||
{
|
||||
id: 'rejects-bad-input',
|
||||
criterionIds: ['META-CRIT-1'],
|
||||
mustFail: true,
|
||||
invocation: ['gates/meta-fixture.sh'],
|
||||
required: { exitCode: 7 },
|
||||
actual: { exitCode: 7 },
|
||||
reasonPattern: 'META_REJECT',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function writeGate(root, contents = '#!/bin/sh\necho META_REJECT >&2\nexit 7\n') {
|
||||
const target = path.join(root, 'gates', 'meta-fixture.sh');
|
||||
await writeFile(target, contents);
|
||||
await chmod(target, 0o755);
|
||||
}
|
||||
|
||||
async function writeManifest(root, manifest) {
|
||||
await writeFile(path.join(root, 'gates', 'gates.manifest.json'), `${JSON.stringify(manifest)}\n`);
|
||||
}
|
||||
|
||||
function verify(root, extraArgs = []) {
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
verifier,
|
||||
'--root',
|
||||
root,
|
||||
'--manifest',
|
||||
'gates/gates.manifest.json',
|
||||
'--skip-history',
|
||||
...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('an externally inerted failure branch makes verification nonzero and names the gate', async () => {
|
||||
const root = await fixture('external-inert');
|
||||
await writeGate(root, '#!/bin/sh\nexit 0\n');
|
||||
await writeManifest(root, baseManifest());
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture/);
|
||||
});
|
||||
|
||||
test('the verifier applies the declared inert mutation and observes its own control red', async () => {
|
||||
const root = await fixture('internal-meta');
|
||||
await writeGate(root);
|
||||
await writeManifest(root, baseManifest());
|
||||
|
||||
const result = verify(root);
|
||||
assert.equal(result.status, 0, output(result));
|
||||
assert.match(output(result), /META-NEGATIVE-CONTROL.*meta-fixture.*observed red/i);
|
||||
});
|
||||
|
||||
test('a mutation crash is rejected instead of counted as an observed-red control', async () => {
|
||||
const root = await fixture('mutation-crash');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].inertMutation.replace = 'this is not valid shell (';
|
||||
manifest.gates[0].inertMutation.expected = { exitCode: 0 };
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*mutation.*unexpected outcome/i);
|
||||
assert.doesNotMatch(output(result), /META-NEGATIVE-CONTROL.*observed red/i);
|
||||
});
|
||||
|
||||
test('a stale declared mutation case id is rejected instead of falling back', async () => {
|
||||
const root = await fixture('stale-case-id');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].inertMutation.caseId = 'case-that-does-not-exist';
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*case-that-does-not-exist.*not found/i);
|
||||
});
|
||||
|
||||
test('duplicate stable ids and unsupported schema versions are rejected', async () => {
|
||||
const root = await fixture('closed-schema');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.schemaVersion = 99;
|
||||
manifest.criteria.push({ ...manifest.criteria[0] });
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /unsupported schemaVersion 99/i);
|
||||
assert.match(output(result), /duplicate criterion id META-CRIT-1/i);
|
||||
});
|
||||
|
||||
test('manifest-controlled fixture paths cannot escape the sandbox', async () => {
|
||||
const root = await fixture('path-traversal');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].fixture = {
|
||||
writeFiles: [{ path: '../../escaped-by-manifest', content: 'bad' }],
|
||||
};
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /path escapes sandbox/i);
|
||||
});
|
||||
|
||||
test('fixture writes reject a final symlink and preserve its outside target', async () => {
|
||||
const root = await fixture('final-symlink');
|
||||
await writeGate(root);
|
||||
const outside = path.join(fixtureBase, 'outside-sentinel');
|
||||
await writeFile(outside, 'preserve-me\n');
|
||||
const linked = path.join(root, 'linked-sentinel');
|
||||
await symlink(outside, linked);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].fixture = {
|
||||
writeFiles: [{ path: 'linked-sentinel', content: 'overwritten\n' }],
|
||||
};
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /fixture write.*symbolic link/i);
|
||||
assert.equal(await readFile(outside, 'utf8'), 'preserve-me\n');
|
||||
});
|
||||
|
||||
test('an executable below a gate root without an entry is rejected', async () => {
|
||||
const root = await fixture('unregistered');
|
||||
await writeGate(root);
|
||||
const extra = path.join(root, 'gates', 'forgotten.sh');
|
||||
await writeFile(extra, '#!/bin/sh\nexit 1\n');
|
||||
await chmod(extra, 0o755);
|
||||
await writeManifest(root, baseManifest());
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /unregistered gate.*forgotten\.sh/i);
|
||||
});
|
||||
|
||||
test('a must-fail case without a reason diagnostic is rejected', async () => {
|
||||
const root = await fixture('missing-reason');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].reasonPattern = '';
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*rejects-bad-input.*reasonPattern/i);
|
||||
});
|
||||
|
||||
test('a gate with zero must-fail cases is rejected', async () => {
|
||||
const root = await fixture('no-negative');
|
||||
await writeGate(root, '#!/bin/sh\nexit 0\n');
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].inertMutation = {
|
||||
file: 'gates/meta-fixture.sh',
|
||||
find: 'exit 0',
|
||||
replace: 'exit 1',
|
||||
};
|
||||
manifest.gates[0].cases = [
|
||||
{
|
||||
id: 'positive',
|
||||
criterionIds: ['META-CRIT-1'],
|
||||
mustFail: false,
|
||||
invocation: ['gates/meta-fixture.sh'],
|
||||
required: { exitCode: 0 },
|
||||
actual: { exitCode: 0 },
|
||||
reasonPattern: '',
|
||||
},
|
||||
];
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*no negative control/i);
|
||||
});
|
||||
|
||||
test('reordered but equivalent outcome fields do not create a false behavior delta', async () => {
|
||||
const root = await fixture('reordered-outcomes');
|
||||
await writeGate(root);
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].cases[0].required = { exitCode: 7, outputPattern: 'META_REJECT' };
|
||||
manifest.gates[0].cases[0].actual = { outputPattern: 'META_REJECT', exitCode: 7 };
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.equal(result.status, 0, output(result));
|
||||
assert.doesNotMatch(output(result), /behavior delta requires|DEFECT \(owner:/);
|
||||
});
|
||||
|
||||
test('a required-versus-actual delta without a tracked owner is rejected', async () => {
|
||||
const root = await fixture('ownerless-delta');
|
||||
await writeGate(root, '#!/bin/sh\nexit 0\n');
|
||||
const manifest = baseManifest();
|
||||
manifest.gates[0].inertMutation.find = 'exit 0';
|
||||
manifest.gates[0].inertMutation.replace = 'exit 7';
|
||||
manifest.gates[0].cases[0].actual.exitCode = 0;
|
||||
await writeManifest(root, manifest);
|
||||
|
||||
const result = verify(root);
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(output(result), /meta-fixture.*delta.*tracked owner/i);
|
||||
});
|
||||
|
||||
test('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);
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
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/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { access, lstat, mkdir, readFile, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { execFile, spawn } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function run(command, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, options);
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(signal ? `husky terminated by ${signal}` : `husky exited ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function pathExists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function directorySnapshot(root) {
|
||||
const snapshot = [];
|
||||
async function walk(current) {
|
||||
const children = await readdir(current, { withFileTypes: true });
|
||||
for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const target = path.join(current, child.name);
|
||||
const relative = path.relative(root, target);
|
||||
const stats = await lstat(target);
|
||||
if (child.isDirectory()) {
|
||||
snapshot.push([relative, 'directory', stats.mode & 0o777]);
|
||||
await walk(target);
|
||||
} else {
|
||||
snapshot.push([
|
||||
relative,
|
||||
'file',
|
||||
stats.mode & 0o777,
|
||||
(await readFile(target)).toString('base64'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return JSON.stringify(snapshot);
|
||||
}
|
||||
|
||||
async function directoriesMatch(left, right) {
|
||||
return (await directorySnapshot(left)) === (await directorySnapshot(right));
|
||||
}
|
||||
|
||||
export async function installHooks({
|
||||
root = process.cwd(),
|
||||
disabled = process.env.HUSKY === '0',
|
||||
quarantineRoot = path.join(root, '.mosaic-test-work', 'husky-quarantine'),
|
||||
runHusky = async (_stagingHooks, stagingRepo) => {
|
||||
await execFileAsync('git', ['init', '--quiet', stagingRepo]);
|
||||
await run(path.join(root, 'node_modules', '.bin', 'husky'), ['.husky'], {
|
||||
cwd: stagingRepo,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
},
|
||||
activateHooks = async () => {
|
||||
await run('git', ['config', 'core.hooksPath', '.husky/_'], { cwd: root, stdio: 'inherit' });
|
||||
},
|
||||
} = {}) {
|
||||
if (disabled) return;
|
||||
|
||||
const huskyDir = path.join(root, '.husky');
|
||||
const active = path.join(huskyDir, '_');
|
||||
const nonce = `${Date.now()}-${process.pid}`;
|
||||
const stagingRepo = path.join(root, '.mosaic-test-work', `husky-stage-${nonce}`);
|
||||
const stagingHooks = path.join(stagingRepo, '.husky');
|
||||
const quarantined = path.join(quarantineRoot, `${path.basename(root)}-${nonce}`);
|
||||
await mkdir(huskyDir, { recursive: true });
|
||||
await mkdir(quarantineRoot, { recursive: true });
|
||||
|
||||
const previousComplete = (await pathExists(active)) && (await pathExists(path.join(active, 'h')));
|
||||
let previousQuarantined = false;
|
||||
try {
|
||||
if ((await pathExists(active)) && !previousComplete) {
|
||||
await rename(active, quarantined);
|
||||
previousQuarantined = true;
|
||||
}
|
||||
await mkdir(stagingRepo, { recursive: true });
|
||||
await runHusky(stagingHooks, stagingRepo);
|
||||
const staged = path.join(stagingHooks, '_');
|
||||
if (!(await pathExists(path.join(staged, 'h')))) {
|
||||
throw new Error('husky did not produce its required h shim');
|
||||
}
|
||||
if (previousComplete) {
|
||||
if (!(await directoriesMatch(active, staged))) {
|
||||
throw new Error('existing complete hook set differs from the installed Husky version');
|
||||
}
|
||||
await rm(stagingRepo, { recursive: true, force: true });
|
||||
} else {
|
||||
await rename(staged, active);
|
||||
await rm(stagingRepo, { recursive: true, force: true });
|
||||
}
|
||||
await activateHooks();
|
||||
if (previousQuarantined) {
|
||||
try {
|
||||
await rm(quarantined, { recursive: true, force: true });
|
||||
} catch {
|
||||
console.warn(
|
||||
`Previous hook state was deactivated but remains quarantined at ${quarantined}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const cleanupFailures = [];
|
||||
try {
|
||||
if (await pathExists(stagingRepo)) {
|
||||
await rename(stagingRepo, `${quarantined}-staging`);
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
cleanupFailures.push(`staging hooks: ${cleanupError.message}`);
|
||||
}
|
||||
const cleanup =
|
||||
cleanupFailures.length === 0
|
||||
? 'No partial hook set was activated.'
|
||||
: `Automatic cleanup was incomplete (${cleanupFailures.join('; ')}).`;
|
||||
throw new Error(
|
||||
`Hook installation failed: ${error.message}. ${cleanup} Fix: rm -rf .husky/_ && git config core.hooksPath .husky/_ && pnpm install --frozen-lockfile`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
try {
|
||||
await installHooks();
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { installHooks } from './install-hooks.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `hooks-${process.pid}`);
|
||||
const quarantineRoot = path.join(fixtureRoot, 'quarantine');
|
||||
|
||||
async function fixture(name) {
|
||||
const root = path.join(fixtureRoot, name);
|
||||
await mkdir(path.join(root, '.husky'), { recursive: true });
|
||||
return root;
|
||||
}
|
||||
|
||||
async function exists(target) {
|
||||
try {
|
||||
await access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('an interrupted install quarantines the partial active hook set and fails loudly', async () => {
|
||||
const root = await fixture('interrupted');
|
||||
let restoredHooksPath = 'not-called';
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot,
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'partial');
|
||||
throw new Error('simulated interruption');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async (value) => {
|
||||
restoredHooksPath = value;
|
||||
},
|
||||
}),
|
||||
(error) => {
|
||||
assert.match(error.message, /Hook installation failed/);
|
||||
assert.match(error.message, /pnpm install --frozen-lockfile/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(await exists(path.join(root, '.husky', '_')), false);
|
||||
assert.equal(restoredHooksPath, 'not-called');
|
||||
const quarantined = await readdir(quarantineRoot);
|
||||
assert.equal(quarantined.length, 1);
|
||||
});
|
||||
|
||||
test('a failed replacement restores a previously complete active hook set', async () => {
|
||||
const root = await fixture('rollback');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'previous-complete');
|
||||
let previousRemainedActiveDuringStaging = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'rollback-quarantine'),
|
||||
runHusky: async () => {
|
||||
previousRemainedActiveDuringStaging =
|
||||
(await readFile(activeShim, 'utf8')) === 'previous-complete';
|
||||
throw new Error('simulated replacement failure');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => '.husky/_',
|
||||
restoreHooksPath: async () => {},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(previousRemainedActiveDuringStaging, true);
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'previous-complete');
|
||||
});
|
||||
|
||||
test('a mismatched complete hook set fails loudly instead of reporting a stale install as current', async () => {
|
||||
const root = await fixture('mismatch');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'old-complete');
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'mismatch-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'new-complete');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => '.husky/_',
|
||||
restoreHooksPath: async () => {},
|
||||
}),
|
||||
/Hook installation failed.*pnpm install --frozen-lockfile/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'old-complete');
|
||||
});
|
||||
|
||||
test('a competing successful installer is not removed by the losing process', async () => {
|
||||
const root = await fixture('concurrent');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
let restored = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'concurrent-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'ours');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'peer');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {
|
||||
restored = true;
|
||||
},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'peer');
|
||||
assert.equal(restored, false);
|
||||
});
|
||||
|
||||
test("a competing installer that replaces this installer's active set is preserved", async () => {
|
||||
const root = await fixture('concurrent-after-rename');
|
||||
const active = path.join(root, '.husky', '_');
|
||||
const activeShim = path.join(active, 'h');
|
||||
let restored = false;
|
||||
|
||||
await assert.rejects(
|
||||
installHooks({
|
||||
root,
|
||||
quarantineRoot: path.join(fixtureRoot, 'concurrent-after-rename-quarantine'),
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'ours');
|
||||
},
|
||||
activateHooks: async () => {
|
||||
await rm(active, { recursive: true, force: true });
|
||||
await mkdir(active, { recursive: true });
|
||||
await writeFile(activeShim, 'peer');
|
||||
throw new Error('our activation lost to peer');
|
||||
},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {
|
||||
restored = true;
|
||||
},
|
||||
}),
|
||||
/Hook installation failed/,
|
||||
);
|
||||
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'peer');
|
||||
assert.equal(restored, false);
|
||||
});
|
||||
|
||||
test('an explicit interactive HUSKY=0 opt-out preserves existing hooks without running installer', async () => {
|
||||
const root = await fixture('disabled');
|
||||
const activeShim = path.join(root, '.husky', '_', 'h');
|
||||
await mkdir(path.dirname(activeShim), { recursive: true });
|
||||
await writeFile(activeShim, 'preserved');
|
||||
let ran = false;
|
||||
|
||||
await installHooks({
|
||||
root,
|
||||
disabled: true,
|
||||
runHusky: async () => {
|
||||
ran = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(ran, false);
|
||||
assert.equal(await readFile(activeShim, 'utf8'), 'preserved');
|
||||
});
|
||||
|
||||
test('a successful install leaves a complete active hook set', async () => {
|
||||
const root = await fixture('success');
|
||||
|
||||
await installHooks({
|
||||
root,
|
||||
quarantineRoot,
|
||||
runHusky: async (stagingHooks) => {
|
||||
await mkdir(path.join(stagingHooks, '_'), { recursive: true });
|
||||
await writeFile(path.join(stagingHooks, '_', 'h'), 'complete');
|
||||
},
|
||||
activateHooks: async () => {},
|
||||
readHooksPath: async () => null,
|
||||
restoreHooksPath: async () => {},
|
||||
});
|
||||
|
||||
assert.equal(await exists(path.join(root, '.husky', '_', 'h')), true);
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { constants } from 'node:fs';
|
||||
import { access, lstat, readFile, readdir, readlink } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
export const MISSING_DEPS_EXIT = 42;
|
||||
export const GENERATED_STATE_EXIT = 43;
|
||||
|
||||
const scriptRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
async function entries(root) {
|
||||
const result = [];
|
||||
async function walk(current) {
|
||||
let children;
|
||||
try {
|
||||
children = await readdir(current, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return;
|
||||
throw error;
|
||||
}
|
||||
for (const child of children) {
|
||||
const target = path.join(current, child.name);
|
||||
result.push(target);
|
||||
if (child.isDirectory() && !child.isSymbolicLink()) await walk(target);
|
||||
}
|
||||
}
|
||||
await walk(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function generatedSymlinkManifest(nextDir) {
|
||||
const links = [];
|
||||
for (const target of (await entries(nextDir)).sort()) {
|
||||
const stats = await lstat(target);
|
||||
if (!stats.isSymbolicLink()) continue;
|
||||
links.push({
|
||||
path: path.relative(nextDir, target).split(path.sep).join('/'),
|
||||
target: await readlink(target),
|
||||
});
|
||||
}
|
||||
return `${JSON.stringify({ version: 1, links })}\n`;
|
||||
}
|
||||
|
||||
const webSourceRoots = (root) => [
|
||||
path.join(root, 'apps', 'web', 'src'),
|
||||
path.join(root, 'apps', 'web', 'public'),
|
||||
path.join(root, 'apps', 'web', 'next-env.d.ts'),
|
||||
path.join(root, 'apps', 'web', 'next.config.ts'),
|
||||
path.join(root, 'apps', 'web', 'postcss.config.mjs'),
|
||||
path.join(root, 'apps', 'web', 'package.json'),
|
||||
path.join(root, 'apps', 'web', 'tsconfig.json'),
|
||||
path.join(root, 'packages', 'design-tokens', 'src'),
|
||||
path.join(root, 'packages', 'design-tokens', 'package.json'),
|
||||
path.join(root, 'packages', 'design-tokens', 'tsconfig.json'),
|
||||
path.join(root, 'package.json'),
|
||||
path.join(root, 'tsconfig.base.json'),
|
||||
path.join(root, 'pnpm-lock.yaml'),
|
||||
path.join(root, 'pnpm-workspace.yaml'),
|
||||
path.join(root, 'turbo.json'),
|
||||
];
|
||||
|
||||
// next.config.ts currently reads no server-only environment. Add any future
|
||||
// server-side build inputs here; all resolved NEXT_PUBLIC_* inputs are automatic.
|
||||
const serverBuildEnvironmentKeys = [];
|
||||
|
||||
function publicBuildEnvironment(root) {
|
||||
const webDir = path.join(root, 'apps', 'web');
|
||||
const requireFromWeb = createRequire(path.join(scriptRoot, 'apps', 'web', 'package.json'));
|
||||
const requireFromNext = createRequire(requireFromWeb.resolve('next/package.json'));
|
||||
const { loadEnvConfig, resetEnv, updateInitialEnv } = requireFromNext('@next/env');
|
||||
const originalEnvironment = { ...process.env };
|
||||
updateInitialEnv(originalEnvironment);
|
||||
try {
|
||||
const { combinedEnv } = loadEnvConfig(webDir, false, { info() {}, error() {} }, true);
|
||||
return Object.fromEntries(
|
||||
Object.entries(combinedEnv).filter(
|
||||
([key, value]) =>
|
||||
value !== undefined &&
|
||||
(key.startsWith('NEXT_PUBLIC_') || serverBuildEnvironmentKeys.includes(key)),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
resetEnv();
|
||||
}
|
||||
}
|
||||
|
||||
export async function sourceFingerprint(root = process.cwd()) {
|
||||
const files = [];
|
||||
for (const sourceRoot of webSourceRoots(root)) {
|
||||
try {
|
||||
const stats = await lstat(sourceRoot);
|
||||
if (stats.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Web build input must not be a symbolic link: ${path.relative(root, sourceRoot)}`,
|
||||
);
|
||||
}
|
||||
if (stats.isFile()) files.push(sourceRoot);
|
||||
if (stats.isDirectory()) {
|
||||
for (const target of await entries(sourceRoot)) {
|
||||
const targetStats = await lstat(target);
|
||||
if (targetStats.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Web build input must not be a symbolic link: ${path.relative(root, target)}`,
|
||||
);
|
||||
}
|
||||
if (targetStats.isFile()) files.push(target);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const digest = createHash('sha256');
|
||||
for (const [key, value] of Object.entries(publicBuildEnvironment(root)).sort()) {
|
||||
digest.update(`env:${key}\0${value.length}\0${value}\0`);
|
||||
}
|
||||
for (const target of files.sort()) {
|
||||
const contents = await readFile(target);
|
||||
digest.update(path.relative(root, target).split(path.sep).join('/'));
|
||||
digest.update('\0');
|
||||
digest.update(String(contents.length));
|
||||
digest.update('\0');
|
||||
digest.update(contents);
|
||||
digest.update('\0');
|
||||
}
|
||||
return digest.digest('hex');
|
||||
}
|
||||
|
||||
export async function runPreflight({ root = process.cwd(), uid = process.getuid?.() } = {}) {
|
||||
const binDir = path.join(root, 'node_modules', '.bin');
|
||||
const requiredBinaries = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest'];
|
||||
const missingBinaries = [];
|
||||
for (const binary of requiredBinaries) {
|
||||
try {
|
||||
await access(path.join(binDir, binary), constants.X_OK);
|
||||
} catch {
|
||||
missingBinaries.push(binary);
|
||||
}
|
||||
}
|
||||
if (missingBinaries.length > 0) {
|
||||
return {
|
||||
code: MISSING_DEPS_EXIT,
|
||||
message: `MOSAIC_PREFLIGHT_MISSING_DEPS: dependency installation is missing ${missingBinaries.join(', ')}; run pnpm install --frozen-lockfile`,
|
||||
};
|
||||
}
|
||||
|
||||
const buildLock = path.join(root, '.mosaic-test-work', 'web-build.lock');
|
||||
try {
|
||||
await lstat(buildLock);
|
||||
return {
|
||||
code: GENERATED_STATE_EXIT,
|
||||
message: `MOSAIC_PREFLIGHT_GENERATED_STATE: web build is in progress or interrupted at ${buildLock}; wait for it to finish or rerun pnpm build to recover the stale lock`,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
let generated = [];
|
||||
try {
|
||||
const nextStats = await lstat(nextDir);
|
||||
if (!nextStats.isDirectory() || nextStats.isSymbolicLink()) {
|
||||
return {
|
||||
code: GENERATED_STATE_EXIT,
|
||||
message:
|
||||
'MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next must be a real directory, not a symbolic link, and is not trustworthy; run pnpm clean:generated, then rerun the gate',
|
||||
};
|
||||
}
|
||||
generated = [nextDir, ...(await entries(nextDir))];
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
|
||||
if (generated.length > 0) {
|
||||
const foreign = [];
|
||||
for (const target of generated) {
|
||||
const stats = await lstat(target);
|
||||
if (uid !== undefined && stats.uid !== uid) foreign.push(path.relative(root, target));
|
||||
}
|
||||
|
||||
// Detects accidental, independent, stale, and foreign-residue mutation of
|
||||
// generated state: the class this check was born from was a five-month-stale
|
||||
// .next whose validator referenced deleted pages and produced 19 phantom TS2307
|
||||
// errors indistinguishable from real type errors.
|
||||
//
|
||||
// Does NOT defend against an actor with same-UID write access to the generated
|
||||
// tree, which can regenerate both the manifest and marker consistently
|
||||
// (CWE-345). No local construction can, absent a trust anchor outside that
|
||||
// actor's authority. RM-59 tracks executor/spine-side attestation.
|
||||
let certification = null;
|
||||
let certifiedManifest = null;
|
||||
try {
|
||||
const [certificationContents, manifestContents] = await Promise.all([
|
||||
readFile(path.join(nextDir, '.mosaic-source-hash'), 'utf8'),
|
||||
readFile(path.join(nextDir, '.mosaic-symlink-manifest'), 'utf8'),
|
||||
]);
|
||||
try {
|
||||
const parsed = JSON.parse(certificationContents);
|
||||
if (
|
||||
parsed.version === 1 &&
|
||||
typeof parsed.sourceFingerprint === 'string' &&
|
||||
typeof parsed.symlinkManifestHash === 'string'
|
||||
) {
|
||||
certification = parsed;
|
||||
certifiedManifest = manifestContents;
|
||||
}
|
||||
} catch {
|
||||
// Invalid certification is handled as untrusted generated state below.
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
const stale = certification?.sourceFingerprint !== (await sourceFingerprint(root));
|
||||
const actualManifest = await generatedSymlinkManifest(nextDir);
|
||||
const certifiedManifestHash =
|
||||
certifiedManifest === null
|
||||
? null
|
||||
: createHash('sha256').update(certifiedManifest).digest('hex');
|
||||
const changedSymlinks =
|
||||
certification?.symlinkManifestHash !== certifiedManifestHash ||
|
||||
certifiedManifest !== actualManifest;
|
||||
if (foreign.length > 0 || stale || changedSymlinks) {
|
||||
const reasons = [
|
||||
foreign.length > 0 ? `foreign-owned paths: ${foreign.slice(0, 3).join(', ')}` : '',
|
||||
stale ? 'generated source fingerprint does not match web source/configuration' : '',
|
||||
changedSymlinks
|
||||
? 'generated symbolic-link manifest does not match the certified build'
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
return {
|
||||
code: GENERATED_STATE_EXIT,
|
||||
message: `MOSAIC_PREFLIGHT_GENERATED_STATE: apps/web/.next is not trustworthy (${reasons.join('; ')}); run pnpm clean:generated, then rerun the gate`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { code: 0, message: 'checkout preflight passed' };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const result = await runPreflight();
|
||||
const stream = result.code === 0 ? process.stdout : process.stderr;
|
||||
stream.write(`${result.message}\n`);
|
||||
process.exitCode = result.code;
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
await main();
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { chmod, mkdir, rm, symlink, utimes, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { runPreflight, sourceFingerprint } from './preflight.mjs';
|
||||
|
||||
const fixtureRoot = path.join(process.cwd(), '.mosaic-test-work', `preflight-${process.pid}`);
|
||||
|
||||
const requiredBins = ['eslint', 'husky', 'prettier', 'tsc', 'turbo', 'vitest'];
|
||||
|
||||
async function fixture(name) {
|
||||
const root = path.join(fixtureRoot, name);
|
||||
await mkdir(path.join(root, 'apps', 'web', 'src', 'app'), { recursive: true });
|
||||
await writeFile(path.join(root, 'apps', 'web', 'src', 'app', 'page.tsx'), 'export default 1;\n');
|
||||
return root;
|
||||
}
|
||||
|
||||
async function installRequiredBins(root) {
|
||||
const binDir = path.join(root, 'node_modules', '.bin');
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await Promise.all(
|
||||
requiredBins.map(async (name) => {
|
||||
const target = path.join(binDir, name);
|
||||
await writeFile(target, '');
|
||||
await chmod(target, 0o755);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function certifyGeneratedState(root, links = []) {
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
await mkdir(nextDir, { recursive: true });
|
||||
const manifest = `${JSON.stringify({ version: 1, links })}\n`;
|
||||
const manifestHash = createHash('sha256').update(manifest).digest('hex');
|
||||
await writeFile(path.join(nextDir, '.mosaic-symlink-manifest'), manifest);
|
||||
await writeFile(
|
||||
path.join(nextDir, '.mosaic-source-hash'),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
sourceFingerprint: await sourceFingerprint(root),
|
||||
symlinkManifestHash: manifestHash,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('missing dependencies have a dedicated exit code and install remediation', async () => {
|
||||
const root = await fixture('missing-deps');
|
||||
const result = await runPreflight({ root });
|
||||
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_MISSING_DEPS/);
|
||||
assert.match(result.message, /run pnpm install/i);
|
||||
});
|
||||
|
||||
test('a partial dependency install keeps the dedicated missing-deps result', async () => {
|
||||
const root = await fixture('partial-deps');
|
||||
await mkdir(path.join(root, 'node_modules', '.bin'), { recursive: true });
|
||||
await writeFile(path.join(root, 'node_modules', '.bin', 'tsc'), '', { mode: 0o755 });
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /turbo/);
|
||||
});
|
||||
|
||||
test('a dangling required dependency shim keeps the dedicated missing-deps result', async () => {
|
||||
const root = await fixture('dangling-deps');
|
||||
await installRequiredBins(root);
|
||||
const turbo = path.join(root, 'node_modules', '.bin', 'turbo');
|
||||
await rm(turbo);
|
||||
await symlink(path.join(root, 'node_modules', 'missing-turbo'), turbo);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 42);
|
||||
assert.match(result.message, /turbo/);
|
||||
});
|
||||
|
||||
test('installed dependencies pass when generated state is absent', async () => {
|
||||
const root = await fixture('clean');
|
||||
await installRequiredBins(root);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
|
||||
});
|
||||
|
||||
test('foreign-owned generated Next state is identified separately from source errors', async () => {
|
||||
const root = await fixture('foreign-next');
|
||||
await installRequiredBins(root);
|
||||
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
||||
await mkdir(path.dirname(generated), { recursive: true });
|
||||
await writeFile(generated, 'generated output');
|
||||
|
||||
const result = await runPreflight({ root, uid: (process.getuid?.() ?? 0) + 1 });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /foreign-owned/);
|
||||
});
|
||||
|
||||
test('a generated marker mismatch is identified separately from source errors', async () => {
|
||||
const root = await fixture('stale-next');
|
||||
await installRequiredBins(root);
|
||||
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
||||
await mkdir(path.dirname(generated), { recursive: true });
|
||||
await writeFile(generated, 'stale generated output');
|
||||
await writeFile(path.join(root, 'apps', 'web', '.next', '.mosaic-source-hash'), 'old-source');
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /apps\/web\/\.next/);
|
||||
assert.match(result.message, /pnpm clean:generated/);
|
||||
});
|
||||
|
||||
test('generated-state symbolic links are accepted only when exactly build-certified', async (t) => {
|
||||
await t.test('apps/web/.next itself is rejected when it is a symbolic link', async () => {
|
||||
const root = await fixture('symbolic-next-root');
|
||||
await installRequiredBins(root);
|
||||
await writeFile(path.join(root, 'outside-generated'), 'not a Next build\n');
|
||||
await symlink(path.join(root, 'outside-generated'), path.join(root, 'apps', 'web', '.next'));
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /symbolic link/);
|
||||
});
|
||||
|
||||
await t.test('apps/web/.next is rejected when it is not a directory', async () => {
|
||||
const root = await fixture('non-directory-next-root');
|
||||
await installRequiredBins(root);
|
||||
await writeFile(path.join(root, 'apps', 'web', '.next'), 'not a Next build\n');
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /MOSAIC_PREFLIGHT_GENERATED_STATE/);
|
||||
assert.match(result.message, /real directory/);
|
||||
});
|
||||
|
||||
await t.test('an added descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-added');
|
||||
await installRequiredBins(root);
|
||||
await certifyGeneratedState(root);
|
||||
await symlink('/etc/hosts', path.join(root, 'apps', 'web', '.next', 'reviewer-symlink'));
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a removed certified descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-removed');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../dependency-one', link);
|
||||
await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
|
||||
await rm(link);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a retargeted certified descendant symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-retargeted');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(root, 'apps', 'web', '.next', 'dependency-link');
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../dependency-one', link);
|
||||
await certifyGeneratedState(root, [{ path: 'dependency-link', target: '../dependency-one' }]);
|
||||
await rm(link);
|
||||
await symlink('../dependency-two', link);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('a manifest edited to whitelist a rogue symlink is rejected', async () => {
|
||||
const root = await fixture('symbolic-next-tampered-manifest');
|
||||
await installRequiredBins(root);
|
||||
await certifyGeneratedState(root);
|
||||
const nextDir = path.join(root, 'apps', 'web', '.next');
|
||||
await symlink('/etc/hosts', path.join(nextDir, 'reviewer-symlink'));
|
||||
await writeFile(
|
||||
path.join(nextDir, '.mosaic-symlink-manifest'),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
links: [{ path: 'reviewer-symlink', target: '/etc/hosts' }],
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const result = await runPreflight({ root });
|
||||
assert.equal(result.code, 43);
|
||||
assert.match(result.message, /symbolic-link manifest/);
|
||||
});
|
||||
|
||||
await t.test('unchanged canonical-style descendant symlinks are accepted', async () => {
|
||||
const root = await fixture('symbolic-next-certified');
|
||||
await installRequiredBins(root);
|
||||
const link = path.join(
|
||||
root,
|
||||
'apps',
|
||||
'web',
|
||||
'.next',
|
||||
'standalone',
|
||||
'node_modules',
|
||||
'dependency',
|
||||
);
|
||||
await mkdir(path.dirname(link), { recursive: true });
|
||||
await symlink('../.pnpm/dependency', link);
|
||||
await certifyGeneratedState(root, [
|
||||
{ path: 'standalone/node_modules/dependency', target: '../.pnpm/dependency' },
|
||||
]);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), {
|
||||
code: 0,
|
||||
message: 'checkout preflight passed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('the source fingerprint includes inherited TypeScript configuration', async () => {
|
||||
const root = await fixture('inherited-typescript-config');
|
||||
const config = path.join(root, 'tsconfig.base.json');
|
||||
await writeFile(config, '{"compilerOptions":{"strict":true}}\n');
|
||||
const first = await sourceFingerprint(root);
|
||||
await writeFile(config, '{"compilerOptions":{"strict":false}}\n');
|
||||
const second = await sourceFingerprint(root);
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
test('the source fingerprint rejects symbolic-link build inputs', async () => {
|
||||
const root = await fixture('symbolic-source');
|
||||
await writeFile(path.join(root, 'outside.ts'), 'export default 1;\n');
|
||||
await symlink(path.join(root, 'outside.ts'), path.join(root, 'apps', 'web', 'src', 'linked.ts'));
|
||||
|
||||
await assert.rejects(sourceFingerprint(root), /must not be a symbolic link/);
|
||||
});
|
||||
|
||||
test('the source fingerprint includes expanded public web build environment', async () => {
|
||||
const root = await fixture('public-build-environment');
|
||||
const envFile = path.join(root, 'apps', 'web', '.env.production');
|
||||
await writeFile(
|
||||
envFile,
|
||||
'RM01_GATEWAY_URL=https://one.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n',
|
||||
);
|
||||
const first = await sourceFingerprint(root);
|
||||
await writeFile(
|
||||
envFile,
|
||||
'RM01_GATEWAY_URL=https://two.example\nNEXT_PUBLIC_RM01_URL=$RM01_GATEWAY_URL\n',
|
||||
);
|
||||
const second = await sourceFingerprint(root);
|
||||
|
||||
assert.notEqual(first, second);
|
||||
});
|
||||
|
||||
test('a matching generation marker accepts incremental output with mixed mtimes', async () => {
|
||||
const root = await fixture('incremental-next');
|
||||
await installRequiredBins(root);
|
||||
const generated = path.join(root, 'apps', 'web', '.next', 'types', 'validator.ts');
|
||||
await mkdir(path.dirname(generated), { recursive: true });
|
||||
await writeFile(generated, 'unchanged generated output');
|
||||
await utimes(generated, new Date('2020-01-01T00:00:00Z'), new Date('2020-01-01T00:00:00Z'));
|
||||
const fresh = path.join(root, 'apps', 'web', '.next', 'types', 'routes.ts');
|
||||
await writeFile(fresh, 'fresh generated output');
|
||||
await certifyGeneratedState(root);
|
||||
|
||||
assert.deepEqual(await runPreflight({ root }), { code: 0, message: 'checkout preflight passed' });
|
||||
});
|
||||
Reference in New Issue
Block a user