Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b705a4dae9 | ||
|
|
27350d9f8d | ||
|
|
f8b65463ab | ||
|
|
fb3221af27 | ||
|
|
910b6f4166 | ||
|
|
1324385bba | ||
|
|
d4f68040ab | ||
|
|
ba98f88891 | ||
|
|
4fcbf7c07e | ||
|
|
28f1e272fe |
@@ -8,7 +8,6 @@ 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 preflight && pnpm typecheck && pnpm lint && pnpm format:check
|
||||
pnpm typecheck && pnpm lint && pnpm format:check
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@mosaicstack:registry=https://git.mosaicstack.dev/api/packages/mosaicstack/npm/
|
||||
# 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
|
||||
# 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
|
||||
|
||||
@@ -201,21 +201,8 @@ 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
|
||||
|
||||
@@ -243,7 +230,6 @@ 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": "node ../../scripts/build-web.mjs",
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -13,13 +13,3 @@ Correct for the CI container (runs as root), fatal for EVERY non-root local chec
|
||||
### **D-2 / hygiene — husky `prepare` fails `EPERM` copying into root-owned `.husky/_/`.** Repo working
|
||||
|
||||
tree has root-owned dirs (`.husky/`, repo root) under a non-root agent. Worked around with the intended `HUSKY=0` escape hatch (does NOT disable the existing pre-commit/pre-push hooks).
|
||||
|
||||
<!-- board-roll: 2 entries rolled from BOARD.md -->
|
||||
|
||||
### **D-5 / P-QUEUE-001 + P-CONFORMANCE-001 — KEYSTONE: an inert gate that erased its own evidence.**
|
||||
|
||||
Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ the CI format gate did not block. An unrelated later PR (#872) then reformatted that file via its own `lint-staged`, so `main` went green again and nobody learned the gate had failed to fire. Verified blob-level under the repo's own config. **Detection must be per-merge-commit against that commit's own tree** — a "is main green today" check reports all-clear on this exact defect. Binding on RM-02/RM-55. Full chain in `TASKS.md` §1a. NOT quiet-patched, by Mos's ruling: patching the symptom destroys the signal.
|
||||
|
||||
### **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.
|
||||
|
||||
+17
-16
@@ -1,6 +1,6 @@
|
||||
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
||||
|
||||
**Phase:** EXECUTING — P0 open. RM-01 MERGED; RM-02 (keystone gate registry) is next.
|
||||
**Phase:** EXECUTING — first PR merged; RM-01 in flight; all 3 decisions ruled.
|
||||
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
|
||||
|
||||
## Head
|
||||
@@ -16,13 +16,14 @@
|
||||
|
||||
## In-flight
|
||||
|
||||
| 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 |
|
||||
| Task | Owner | State |
|
||||
| ------------------------------------------------- | --------------- | ------------------------------------------------------------------ |
|
||||
| PR #1026 docs (mission record + backlog) | mos-remediation | OPEN, retargeted to main, rebased; diff verified docs-only |
|
||||
| PR #1025 hygiene | — | **MERGED** 52414605; rev-974 APPROVE + CI #2158 8/8 terminal-green |
|
||||
| DECISION-1 wire-in point (charter change) | Mos / Jason | ESCALATED — both planners reject the charter's target |
|
||||
| DECISION-2 rollback artifact + availability trade | Jason | ESCALATED |
|
||||
| DECISION-3 RM-03 vs parked PR #1023 ownership | Mos | ESCALATED |
|
||||
| RM-01 reproducible checkout (unblocks everything) | unassigned | READY TO DISPATCH |
|
||||
|
||||
## Fleet seats
|
||||
|
||||
@@ -38,14 +39,6 @@
|
||||
- Freeze: LIFTED for this workstream only.
|
||||
- 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 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.
|
||||
|
||||
@@ -79,6 +72,14 @@ PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv
|
||||
|
||||
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.
|
||||
|
||||
### **D-5 / P-QUEUE-001 + P-CONFORMANCE-001 — KEYSTONE: an inert gate that erased its own evidence.**
|
||||
|
||||
Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ the CI format gate did not block. An unrelated later PR (#872) then reformatted that file via its own `lint-staged`, so `main` went green again and nobody learned the gate had failed to fire. Verified blob-level under the repo's own config. **Detection must be per-merge-commit against that commit's own tree** — a "is main green today" check reports all-clear on this exact defect. Binding on RM-02/RM-55. Full chain in `TASKS.md` §1a. NOT quiet-patched, by Mos's ruling: patching the symptom destroys the signal.
|
||||
|
||||
### **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:END -->
|
||||
|
||||
## Decisions log
|
||||
|
||||
@@ -7,15 +7,10 @@ mechanically until Build 3 (rotation) makes it automatic.
|
||||
|
||||
## On resume (do in order, before any orchestration action)
|
||||
|
||||
1. `cd /src/mosaic-stack`, then **`git fetch origin remediation/state`**.
|
||||
⚠ **The live board is on the rolling branch `remediation/state`, NOT on `main`.** `main` carries only
|
||||
periodic snapshots, so reading the board from `main` will silently give you a STALE tick. Read the
|
||||
live files at `origin/remediation/state` (e.g. `git show origin/remediation/state:docs/remediation/BOARD.md`),
|
||||
or check that branch out. Every tick is pushed there immediately, so its HEAD is always the newest state.
|
||||
1. `cd /src/mosaic-stack` and confirm you are on the remediation working branch.
|
||||
2. Read `docs/remediation/MISSION.md` — the charter (goal, 4 builds, 15 decisions, sequencing, directives).
|
||||
3. Read `docs/remediation/BOARD.md` **at `origin/remediation/state`** — the LIVE state: current phase,
|
||||
in-flight tasks, fleet seat assignments, gate status. Single source of in-flight truth (kept < 8 KB;
|
||||
older entries roll to `BOARD-LEDGER.md` via `board-roll.sh`).
|
||||
3. Read `docs/remediation/BOARD.md` — the LIVE state: current phase, in-flight tasks, fleet seat assignments,
|
||||
gate status. This is your single source of in-flight truth (kept small).
|
||||
4. Read the discussion checkpoint for full rationale if needed:
|
||||
`../jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md` (or the jarvis-brain repo path).
|
||||
5. **Residency attestation (fail-closed):** restate from the reloaded files — (a) the goal in one line, (b) the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Mosaic Stack Remediation — Mission Charter
|
||||
|
||||
**Owner:** project orchestrator `mos-remediation` (Claude, launched in `/src/mosaic-stack`).
|
||||
**Origin:** 2026-07-16..31 fleet lifecycle postmortem. **Status:** EXECUTING (planning complete; RM-01 in flight).
|
||||
**Origin:** 2026-07-16..31 fleet lifecycle postmortem. **Status:** PLANNING (task decomposition).
|
||||
**HOLD lifted** for this workstream by Jason, 2026-07-31 — "begin full mosaic fleet operation on this."
|
||||
|
||||
## Goal
|
||||
@@ -10,104 +10,23 @@ Convert the 15 accepted postmortem remediation proposals into a working, **dogfo
|
||||
**North star:** anything with a deterministic right answer moves OUT of the LLM into a deterministic
|
||||
gate/program; the LLM handles only genuine judgment.
|
||||
|
||||
### First-class principle — observe the property, not the exit code
|
||||
|
||||
> **No write is done until the requested PROPERTY is observed. A success exit code is not evidence.**
|
||||
>
|
||||
> **Success output is designed to be believed.** That is the whole reason the inert-gate class exists
|
||||
> and why P-WRAPPER-001's tri-state (`verified` / `written-unverified` / `failed`) is not optional. The
|
||||
> failure is not carelessness — a green is _engineered_ to be trusted, so trusting it is the default
|
||||
> behaviour of a competent operator, not a lapse.
|
||||
>
|
||||
> Promoted to the charter by Mos (2026-07-31) after the orchestrator committed this exact error: a
|
||||
> `--draft` flag was silently dropped by a wrapper fallback that still exited 0, and the PR was reported
|
||||
> as a draft on the strength of the exit code rather than an observed `draft: true` (D-12). Twelve
|
||||
> failure instances were banked in that session; **three of them were the orchestrator's own.** That
|
||||
> ratio is the point — the mechanism must catch the mechanic too, or it is not a mechanism.
|
||||
>
|
||||
> Operationally: after any write, read back the property you required. Applies to gates, wrappers, PR
|
||||
> flags, commit authorship, file installs, and message delivery alike.
|
||||
|
||||
### First-class principle — pre-registration prevents retrofitting, and nothing else
|
||||
|
||||
> **A pre-registered check set can fail in three distinct ways:**
|
||||
>
|
||||
> | mode | the set is… | found as |
|
||||
> | --------------------------- | ------------------------------------------------- | -------- |
|
||||
> | **WRONG** | a check does not test what it claims | D-8 |
|
||||
> | **INCOMPLETE** | green while a criterion's requirement is untested | D-17 |
|
||||
> | **INTERNALLY INCONSISTENT** | two criteria cannot both hold | D-18 |
|
||||
>
|
||||
> **Pre-registration protects against exactly one thing: retrofitting a check to fit the implementation
|
||||
> it is supposed to judge.** It confers neither correctness, nor coverage, nor consistency. "We
|
||||
> pre-registered the checks" has been treated as though it settled the question — it settles one of
|
||||
> three.
|
||||
>
|
||||
> Promoted to the charter by Mos (2026-07-31). All three modes were found on this mission's own **first
|
||||
> delivery**, by the machinery applied to its own work — not by inspection, and not by looking for them.
|
||||
>
|
||||
> **Enforceable form — RM-02's four clauses.** The registry must establish that: (1) each check is
|
||||
> **right** — proven red for its own stated reason before its green counts; (2) the set **covers** —
|
||||
> every criterion bound to a case that actually exercises it; (3) no two criteria **conflict** —
|
||||
> mutual unsatisfiability is a registry defect discoverable by construction; (4) when a criterion's
|
||||
> meaning changes, the registry **retains original text, restatement, and reason**, so evolution stays
|
||||
> auditable. A criterion with no case that can fail for its own reason is unregistered in substance,
|
||||
> however it reads in the manifest.
|
||||
|
||||
### Corollary — never ship an integrity claim dressed as a property
|
||||
|
||||
> A verification artifact that can be forged by whoever it is meant to catch verifies nothing. If a
|
||||
> manifest, marker, ledger, or receipt is writable by the same actor whose behaviour it certifies, it
|
||||
> **certifies the attack.** Such an artifact must sit inside the integrity envelope it belongs to,
|
||||
> publish atomically, and carry a **tamper negative-control observed red** — otherwise its integrity is
|
||||
> a _claim_, not a _property_.
|
||||
>
|
||||
> **If it cannot be made tamper-evident, say so and reconsider the approach.** Laundering foreign
|
||||
> content as certified is the only unacceptable outcome; an honest "this cannot be verified" is always
|
||||
> available and always preferable.
|
||||
|
||||
### First-class principle — when a property cannot exist at the layer it was specified
|
||||
|
||||
> Some required properties are **impossible at the layer that asked for them** — not hard, impossible.
|
||||
> A local check cannot defend against an actor who can rewrite the check itself. When that happens,
|
||||
> there are exactly three honest moves, and all three are mandatory:
|
||||
>
|
||||
> 1. **Implement what the layer _can_ guarantee.** Partial protection against the class it was actually
|
||||
> born from is worth having.
|
||||
> 2. **State the boundary precisely, in BOTH directions.** What it does _not_ defend, **and** beside it
|
||||
> what it _does_. A reader who sees only the negative dismisses the check as worthless; one who sees
|
||||
> only the positive over-trusts it. **Both together is the honest artifact** — either alone misleads.
|
||||
> 3. **Record where the real guarantee will come from — as a TRACKED DEPENDENCY, not prose.** It must
|
||||
> name a task that someone must close. _A documented gap with no owner becomes a permanent gap that
|
||||
> reads as intentional._
|
||||
>
|
||||
> **A written-down gap is acceptable engineering. An implied-fixed gap is this mission's core failure in
|
||||
> a new costume** — a verification artifact that verifies nothing, with a green to prove it.
|
||||
>
|
||||
> Promoted to the charter by Mos (2026-07-31) from D-19. Origin: the RM-01 symlink manifest could not be
|
||||
> made tamper-evident against a same-UID actor (CWE-345), because the manifest and its marker share one
|
||||
> writable tree. The implementing seat **escalated rather than relabelling self-authentication as
|
||||
> tamper-resistance** — the corollary above firing on its first real adversarial test, on the cheapest
|
||||
> 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.
|
||||
|
||||
## Decision record (authoritative, immutable)
|
||||
|
||||
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
|
||||
- Site + `annotations.json`: `jarvis-brain/docs/postmortem-spec/site/` (committed, origin/main).
|
||||
- Discussion checkpoint (rich rationale per proposal): `jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md`.
|
||||
- Postmortem report: mosaicstack/stack PR #107 (merged 88f4ee04).
|
||||
- MACP wiring scout (verdict c=STRANDED): [`MACP-WIRING-SCOUT.md`](./MACP-WIRING-SCOUT.md) (copied into this dir; TODO discharged). Its findings are sound; its _recommended wire-in point_ is superseded by DECISION-1.
|
||||
- MACP wiring scout (verdict c=STRANDED): `/tmp/macp-wiring-investigation.md` (copy into this dir — see TODO).
|
||||
|
||||
## The plan — 15 proposals collapse to 4 builds + hygiene
|
||||
|
||||
| Build | Absorbs | What it is |
|
||||
| ------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **1. One choke-point service** (mechanical enforcer) | MISSION, STATE, AUDIT, WRAPPER, QUEUE | Deterministic program every task/data mutation flows through. **Wire the stranded `@mosaicstack/macp`** — typed tasks, gate-runner, event ledger, credential binding, tri-state write outcomes. ⚠ **Target CORRECTED 2026-07-31 (DECISION-1, Mos):** a new production Node `TaskExecutor` on the **live** dispatch path (`packages/mosaic` launch + `packages/coord`), which Coord/Forge/live-dispatch submit through. **NOT** `mosaic_orchestrator.py::run_single_task` — that controller is `"enabled": false` and references a dispatcher absent from this checkout; wiring it would strand the executor, reproducing this mission's own disease. The Python rail is **deleted**, not ported. Both planners reached this independently. |
|
||||
| **2. One durable spine + hot path** | (storage under everything) | **PG system-of-record + Redis hot queue** (transactional-outbox). Mission/tasks/state-claims/audit-ledger/comms-inbox all land here. |
|
||||
| **3. Rotation lifecycle** (finish the Mission Control Plane) | LIFECYCLE, CONTRACT, GUIDE, RECOVERY | Coordinator daemon: contract-hash binding, compaction-detected → rotate-not-compact, checkpoint→fresh-session→rehydrate, broker-independent recovery. Deterministic, not an LLM. Reuse `packages/coord`; existing PRD at `docs/mission-control/`. |
|
||||
| **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. |
|
||||
| Build | Absorbs | What it is |
|
||||
| ------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **1. One choke-point service** (mechanical enforcer) | MISSION, STATE, AUDIT, WRAPPER, QUEUE | Deterministic program every task/data mutation flows through. **Wire the stranded `@mosaicstack/macp`** in at `mosaic_orchestrator.py::run_single_task` — typed tasks, gate-runner, event ledger, credential binding, tri-state write outcomes. |
|
||||
| **2. One durable spine + hot path** | (storage under everything) | **PG system-of-record + Redis hot queue** (transactional-outbox). Mission/tasks/state-claims/audit-ledger/comms-inbox all land here. |
|
||||
| **3. Rotation lifecycle** (finish the Mission Control Plane) | LIFECYCLE, CONTRACT, GUIDE, RECOVERY | Coordinator daemon: contract-hash binding, compaction-detected → rotate-not-compact, checkpoint→fresh-session→rehydrate, broker-independent recovery. Deterministic, not an LLM. Reuse `packages/coord`; existing PRD at `docs/mission-control/`. |
|
||||
| **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. |
|
||||
|
||||
## The finding that sets the cost
|
||||
|
||||
@@ -118,7 +37,6 @@ orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retir
|
||||
## Sequencing (skeleton — adversarial decomposition refines this)
|
||||
|
||||
1. **Spine + choke-point service** (builds 1+2) — foundation; unlocks MISSION/STATE/AUDIT/WRAPPER/QUEUE at one integration point.
|
||||
(Per DECISION-1, a P0 phase of provable-gate + activation work precedes this; see `TASKS.md` §3.)
|
||||
2. **Rotation daemon** (build 3) on that spine — the drift fix proper.
|
||||
3. **Comms service** (build 4) — envelope → service → PG/Redis → adapters; retire direct-tmux.
|
||||
4. **Hygiene + conformance** (build 5) — fleet convergence, allowlist sync, dogfood harness.
|
||||
@@ -132,11 +50,6 @@ orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retir
|
||||
(WRAPPER), auto-sync sweep (WORKFLOW), #1018 stale-consumed (INBOX). The fleet is its own test bed.
|
||||
- **Orchestration tracking → DB**, hard cutover ("rip off the bandaid"), NO flat-file interim. jarvis-brain
|
||||
PDA flat-files untouched. Current flat-file tracking runs as-is/unhardened until DB tracking is real, then one clean replace.
|
||||
- ⚠ **QUALIFIED 2026-07-31 (DECISION-2, Mos):** the DB spine **must NOT be a single-point hard-stop.**
|
||||
A broker-independent / degraded mode **and** a rehearsed rollback artifact are **design requirements**
|
||||
(P-RECOVERY-001), binding now on RM-12, RM-13, RM-23, RM-36 and RM-53. This **supersedes** the earlier
|
||||
orchestrator recommendation to pre-commit "no DB ⇒ the fleet stops" — that answer is _not_ on record.
|
||||
Only the specific availability _target_ remains open, queued for Jason; it does **not** block current work.
|
||||
|
||||
## The 15 decisions (one-line; full rationale in the checkpoint)
|
||||
|
||||
|
||||
+11
-526
@@ -93,520 +93,6 @@ and must not be cited as merge evidence. Rely on reviewer clearance + real CI.
|
||||
Three independent live instances in a single session — format gate, agent context reset, queue guard —
|
||||
is the class confirmed, not anecdote.
|
||||
|
||||
### D-20 — the orchestrator's own documentation overclaimed, and a reviewer disproved it empirically
|
||||
|
||||
`rev-974` blocked PR #1027 a second time. **The defect was not in the code — it was in this file**, at
|
||||
D-18's entry, written by the orchestrator.
|
||||
|
||||
Two faults, both mine:
|
||||
|
||||
1. **D-18's AC2 restatement omitted the scope clause** that D-19 later established as mandatory
|
||||
("within an accidental/independent-mutation threat model").
|
||||
2. **D-18 asserted that the tampered-manifest control turns integrity "from a claim into a property."**
|
||||
It does not, and _cannot_. That sentence was written **before** D-19 proved the property impossible
|
||||
at this layer, and was never revised when D-19 landed.
|
||||
|
||||
**The reviewer did not merely read it — it disproved it.** It performed a **same-UID consistent
|
||||
manifest + marker rewrite**, and **preflight passed**. My documented claim was falsified by experiment.
|
||||
Code, README, scratchpad and PR body all stated both threat-model directions correctly; **this file was
|
||||
the only place still overclaiming.**
|
||||
|
||||
**This is two banked findings firing on the orchestrator at once:**
|
||||
|
||||
- **The integrity-claim corollary** — I wrote an integrity _claim_ in the voice of an integrity
|
||||
_property_, in the very document that defines the rule against doing so.
|
||||
- **D-14 (propagation)** — D-19 superseded D-18's assertion. I propagated the consequence into the
|
||||
charter and the delivery conditions, but **not back into D-18 itself.** A ruling that fails to
|
||||
propagate _backwards_ into the finding it supersedes is the same defect as one that fails to
|
||||
propagate forwards, and I did not audit for that direction.
|
||||
|
||||
**Corrected in place**, with the original wording quoted and the empirical disproof recorded, rather
|
||||
than silently rewritten — the same standard demanded of any restated criterion.
|
||||
|
||||
**Requirement on RM-02 (fifth clause).** Documentation asserting a _security or integrity_ property is
|
||||
itself a claim requiring a negative control. Where a document states "X is guaranteed", the registry
|
||||
must hold a case that **fails if X is not guaranteed** — and that case must have been observed red.
|
||||
**Prose is not exempt from the mission's own evidentiary standard**, and prose in the _governing_
|
||||
document least of all: it is the artifact most likely to be quoted as authority long after the code has
|
||||
moved on.
|
||||
|
||||
**Reviewer credit.** rev-974 was briefed that its highest-priority check was "confirm the PR claims no
|
||||
more than it can deliver, and a softened or omitted boundary is a finding even though the code works."
|
||||
It applied that instruction **to the orchestrator's own governing document** and produced an experiment
|
||||
to settle it. That is the review standard this mission is trying to make ordinary.
|
||||
|
||||
### D-19 — an integrity property that cannot exist at the layer it was specified
|
||||
|
||||
Implementing D-18's manifest, the seat + a Codex security review reached **CWE-345**: the symlink
|
||||
manifest and the source-hash marker both live in the **same same-UID writable generated tree**, so an
|
||||
actor with that UID can plant a rogue link, regenerate _both_, retain the fingerprint, and pass. **No
|
||||
local cryptographic construction fixes self-authentication** without a key outside that actor's
|
||||
authority; relocating the marker changes the path, not the authority.
|
||||
|
||||
The seat **escalated rather than describing self-authentication as tamper-resistant** — the explicit
|
||||
failure mode the charter corollary demands. That is the corollary working, on its first real test.
|
||||
|
||||
**Ruling — Option A: scope AC2 to accidental / independent / stale mutation; retain the design.**
|
||||
Rationale, recorded so it can be challenged:
|
||||
|
||||
1. **The undefendable boundary is not the weak link.** An actor with same-UID write can already edit the
|
||||
source, the tests, `scripts/preflight.mjs` itself, and `.husky/*`. If they have that, _nothing_ in the
|
||||
local checkout is trustworthy — hardening the manifest buys no real security while **implying
|
||||
protection that does not exist**, which is worse than the gap.
|
||||
2. **What AC2 is actually for.** These checks exist because a five-month-stale `.next` produced 19
|
||||
phantom `TS2307` errors indistinguishable from real ones (**D-5**). That is staleness, drift and
|
||||
foreign residue — and against that class the design demonstrably works.
|
||||
3. **A real trust anchor arrives later, from this mission's own architecture.** An anchor must live
|
||||
outside the actor's authority; for a fleet running as one user that means a separate service —
|
||||
precisely the **choke-point executor + PG spine** of Builds 1–2, which verify outside the worktree's
|
||||
authority. Hand-rolling key distribution for a local preflight now would duplicate that work badly.
|
||||
4. Option C (structural policy, no manifest) is strictly worse — it cannot detect a **removed** expected link.
|
||||
|
||||
**Option A is acceptable only with honest labelling**, or it becomes the disease it is meant to cure.
|
||||
Conditions (last two added/sharpened by Mos):
|
||||
|
||||
- Threat model stated verbatim in the code **and** the PR; the words _tamper-proof / tamper-evident /
|
||||
secure_ **barred** from that context; the scope carried in AC2's restatement; every control kept
|
||||
RED-first including manifest-only tamper.
|
||||
- **State the boundary in BOTH directions.** Not only what it does _not_ defend (same-UID write; no
|
||||
local construction can) but, beside it, what it **does** defend: accidental / independent / stale /
|
||||
foreign-residue mutation — the **D-5** class it was born from (the five-month `.next` and its 19
|
||||
phantom `TS2307`s). _A reader who sees only the negative dismisses the check as worthless; one who
|
||||
sees only the positive over-trusts it. Both together is the honest artifact._
|
||||
- **The residual risk is a HARD TRACKED DEPENDENCY EDGE, not a comment.** It is **RM-59**, owned by the
|
||||
choke-point executor + spine work (`depends_on: RM-12, RM-21, RM-25`), and the AC2 scope note must
|
||||
cite that id. _"Record where the real guarantee comes from" only holds if the record is a live
|
||||
dependency someone must close._ **A documented gap with no owner becomes a permanent gap that reads
|
||||
as intentional.**
|
||||
|
||||
**The generalizable rule.** When a required property **cannot exist at the layer where it was
|
||||
specified**, the honest moves are: implement what the layer _can_ guarantee, **state the boundary
|
||||
precisely**, and record where the real guarantee will come from. **A known gap that is written down is
|
||||
acceptable; a gap that is implied fixed is not.** Silence here would have shipped a verification
|
||||
artifact that verifies nothing — with a green to prove it.
|
||||
|
||||
### D-18 — two pre-registered criteria were mutually unsatisfiable, discoverable only at implementation
|
||||
|
||||
Implementing D-17's fix surfaced a conflict **between** pre-registered criteria:
|
||||
|
||||
- **AC2** (as written) — reject symlinked generated state.
|
||||
- **AC4** — the canonical `pnpm -w build` succeeds and leaves no residue.
|
||||
|
||||
Verified independently rather than taken on report: `apps/web/next.config.ts:4` sets
|
||||
`output: 'standalone'`, and the built tree contains **42 legitimate pnpm dependency symlinks** under
|
||||
`.next/standalone/node_modules`. A blanket descendant-symlink rejection makes the canonical build fail
|
||||
its own preflight with exit 43. **AC2 read literally is unsatisfiable alongside AC4 under this
|
||||
configuration**, and nothing short of building the tree would have revealed it.
|
||||
|
||||
**Third distinct failure mode of a pre-registered check set**, completing the chain:
|
||||
|
||||
| finding | a pre-registered check set can be… |
|
||||
| ------- | ------------------------------------------------------------------ |
|
||||
| D-8 | **wrong** — a check that does not test what it claims |
|
||||
| D-17 | **incomplete** — green while a criterion's requirement is untested |
|
||||
| D-18 | **internally inconsistent** — two criteria that cannot both hold |
|
||||
|
||||
The implementing seat escalated instead of silently picking a winner. That matters: **quietly resolving
|
||||
a conflict between pre-registered criteria destroys the point of pre-registering them** — the registration
|
||||
exists so that changes of meaning are auditable rather than absorbed.
|
||||
|
||||
**Resolution (orchestrator ruling).** Approved a **build-certified symlink manifest**: `.next` itself is
|
||||
still rejected as a symlink; descendants are rejected unless _exactly_ certified by a manifest the build
|
||||
publishes atomically. Strictly **stronger** than blanket rejection — it also catches a **retargeted**
|
||||
symlink, which blanket rejection cannot distinguish from a legitimate one.
|
||||
|
||||
**AC2 restated (recorded, not absorbed).** _Generated state must reject `.next` itself being a symlink
|
||||
or non-directory, and must reject any descendant symlink not exactly certified by the build manifest —
|
||||
added, removed, retargeted, or manifest-only-tampered all fail with exit 43 — **within an accidental /
|
||||
independent-mutation threat model.**_
|
||||
|
||||
> ⚠ **This entry is superseded in part by [D-19](#d-19). Do not read D-18 standalone.** The scope clause
|
||||
> above is load-bearing: the design **cannot** defend against a same-UID actor, which can rewrite the
|
||||
> manifest and the marker consistently (CWE-345). D-18 was written **before** that impossibility was
|
||||
> established.
|
||||
|
||||
**Hardening required before this counts.** The manifest is itself generated state, so **a manifest
|
||||
writable by whoever plants a rogue symlink certifies the attack** — that is the one way this design
|
||||
fails. It must sit inside the same ownership/fingerprint envelope, published atomically via the existing
|
||||
marker mechanism, with negative controls **observed red first** for: added, removed, retargeted,
|
||||
**manifest-only-tampered**, plus a positive control that the canonical build passes.
|
||||
|
||||
> ⚠ **CORRECTED (D-20).** This paragraph originally ended: _"without it, integrity is a claim rather
|
||||
> than a property."_ **That overclaimed**, by implying the control makes integrity a _property_. It does
|
||||
> not, and cannot. The manifest-only-tamper control detects **independent** mutation of the manifest;
|
||||
> it confers **no authenticity** against an actor who rewrites manifest _and_ marker together.
|
||||
> `rev-974` disproved the original wording empirically — a same-UID consistent manifest+marker rewrite
|
||||
> **passed preflight**. Integrity here remains a scoped **drift-detection** property, never an
|
||||
> authenticity one. See D-19 and the charter principle on properties that cannot exist at their
|
||||
> specified layer.
|
||||
|
||||
**Requirement on RM-02 (fourth clause).** The registry must detect **conflicts between registered
|
||||
criteria**, not only wrongness and coverage. Two criteria that cannot simultaneously hold is a registry
|
||||
defect discoverable by construction — and when a criterion is restated, the registry must retain the
|
||||
original text, the restatement, and the reason, so the evolution stays auditable.
|
||||
|
||||
### D-17 — a pre-registered criterion passed a green suite without being satisfied
|
||||
|
||||
`rev-974` returned **CHANGES REQUESTED** on PR #1027 with one blocking finding, and it is the sharpest
|
||||
instance of the session's theme because it occurred **inside our own verification machinery**.
|
||||
|
||||
**AC2** was pre-registered before any code was written, and explicitly required that **symlinked
|
||||
generated state be rejected**. The implementation does not do it:
|
||||
|
||||
```sh
|
||||
ln -s /etc/hosts apps/web/.next/reviewer-symlink
|
||||
pnpm preflight # → "checkout preflight passed", exit 0
|
||||
# → required: generated-state exit 43
|
||||
```
|
||||
|
||||
The acceptance suite was **21/21 green** throughout. Confirmed independently rather than relayed:
|
||||
`scripts/preflight.mjs:82-92` rejects symlinks on the **source** path; `:28` merely _skips_ symlinked
|
||||
directories rather than rejecting them; and the **generated-state** path at `:141-163` `lstat`s and
|
||||
checks `uid` (ownership) but **never** calls `isSymbolicLink()`. The suite's only symlink cases
|
||||
(`preflight.test.mjs:59`, `:115`) cover the turbo binary and a _source_ file. No generated-state case
|
||||
exists anywhere.
|
||||
|
||||
**So: criterion pre-registered, suite green, requirement unmet.** Nobody was careless — the coverage gap
|
||||
is _invisible from a green_, which is the entire problem.
|
||||
|
||||
**This sharpens D-8 rather than repeating it.** D-8 established that pre-registration does not confer
|
||||
_correctness_ (a check can be wrong when written). D-17 establishes the adjacent failure:
|
||||
**pre-registration does not confer _coverage_** — a suite can be green, and every registered criterion
|
||||
can appear satisfied, while a criterion's actual requirement is untested. The two together mean a
|
||||
registry of checks needs **two** properties, not one: each check must be _right_, and the set must
|
||||
_actually exercise_ what it claims.
|
||||
|
||||
**Requirement on RM-02 (third clause).** The registry must bind each acceptance criterion to the
|
||||
**specific case that exercises it**, and prove that case red before trusting its green. A criterion with
|
||||
no case that can fail for _that criterion's stated reason_ is unregistered in substance however it
|
||||
appears in the manifest. This is mutation testing pointed at the **criterion-to-case mapping**, not
|
||||
merely at the gate.
|
||||
|
||||
**Credit where due:** the reviewer also declined to re-run AC8, stating plainly that the PR carried it
|
||||
forward with no runnable command rather than silently substituting a different boundary test. That is
|
||||
the D-8 clause working a second time, in the same review that produced D-17.
|
||||
|
||||
### D-16 — the local test gate and the CI test gate disagree by environment
|
||||
|
||||
Mos flagged a shape worth chasing: if `pnpm test` exits non-zero on a _pre-existing_ guard, then either
|
||||
`main` is red and merges step around it (the #868 shape again), or CI does not run that path. **Both
|
||||
branches turned out wrong, and the truth is a third thing.** Established by running it, not by asking:
|
||||
|
||||
CI runs **exactly** `pnpm test` (`.woodpecker/ci.yml`, `test` step) — the same command. So the path _is_
|
||||
exercised. Yet:
|
||||
|
||||
| environment | result |
|
||||
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| CI container | `test` step **green** (#2158, #2167) |
|
||||
| this host, clean worktree | **exit 97** — `WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated on bash 5.2.15(1)-release … expected [3 4], probe reported [3 5] (#973)`, after `PASS=18 FAIL=0` |
|
||||
| this host, main checkout | **exit 1** — a _different_, second defect (below) |
|
||||
|
||||
The guard is **environment-dependent**: it aborts on this host's bash and not in CI's container. `git
|
||||
diff origin/main...` confirms PR #1027 touches **zero** files under `packages/mosaic`, so the guard is
|
||||
genuinely pre-existing and unrelated — **f10-coder's report was accurate in every particular**, and
|
||||
`main` is equally affected on this host.
|
||||
|
||||
**So it is not "merges step around a red" — it is worse in one specific way: the local gate and the CI
|
||||
gate do not agree about what passing means.** No agent on this host can obtain a green `pnpm test` at
|
||||
all, on any branch, including `main`. A gate an operator cannot run is a gate that only CI enforces,
|
||||
and a gate only CI enforces cannot be a pre-push gate. This is the hermeticity/portability class
|
||||
already live as #1007 (PR #1024).
|
||||
|
||||
**Second, independent defect found while establishing the above.** In the main checkout the same
|
||||
package fails differently — exit 1 — because a test **scans the working tree** and asserts on files it
|
||||
finds, picking up `apps/coordinator/venv/**` (third-party `site-packages`: `pi = math.pi` in `rich`,
|
||||
`setuptools`, `mypy`). **A test whose result depends on untracked files present in the tree is not
|
||||
hermetic.** This is the _same_ contamination source that made `pnpm format:check` unpassable (D-1/D-7
|
||||
hygiene) — one untracked foreign tree silently breaking two independent gates.
|
||||
|
||||
**Requirements.** RM-01/RM-04: a gate must produce the same verdict on a developer host and in CI, or
|
||||
declare loudly that it cannot run here — never diverge silently. RM-02 registers both as cases: the
|
||||
environment-divergence guard, and a hermeticity control asserting a suite's verdict is unchanged by the
|
||||
presence of untracked directories. Coordinate with #1007/#1024 rather than opening a third lane.
|
||||
|
||||
**Ownership (Mos, 2026-07-31).** The hermeticity fix **is** PR #1024, which sits in **Jason's parked
|
||||
delivery stack** — so, like #1023, its disposition is Jason's. Marked `SUPERSEDED-PENDING-JASON`
|
||||
alongside #1023. D-16 strengthens the urgency but does not transfer ownership: **we do not open a third
|
||||
lane on a parked PR.** The one-line escalation for Jason: _two independent gates
|
||||
(`format:check`, `pnpm test`) were broken by a single untracked directory, and a third
|
||||
(`pnpm test`) disagrees between host and CI — non-hermetic gates make every green host-dependent._
|
||||
|
||||
**Sharpened statement of the class (Mos).** A pre-push gate an operator _cannot run locally_ is a gate
|
||||
only CI enforces — so pointing `.husky/pre-push` at it **misrepresents where the gate lives**. Combined
|
||||
with the shared root cause across two gates, the finding is: **non-hermetic gates make every green
|
||||
host-dependent.** A gate that only appears to pass depending on which host runs it is this mission's
|
||||
exact subject, one meta-level up.
|
||||
|
||||
**#1027 disposition (Mos):** proceeds on **CI-green**. CI is the authoritative gate; the local exit-97
|
||||
is a known host-specific guard abort, irrelevant to the merge decision.
|
||||
|
||||
### D-15 — token scope is not repository permission (a THIRD capability layer)
|
||||
|
||||
`f10-coder` was provisioned with `gitea-mosaicstack-f10-coder.token`, scopes `write:repository` +
|
||||
`write:issue`, and the mint was verified by "repo access returns 200". It then failed to push:
|
||||
|
||||
```
|
||||
remote: error: User permission denied for writing.
|
||||
remote: error: pre-receive hook declined
|
||||
```
|
||||
|
||||
Verified objectively rather than inferred (per the charter principle):
|
||||
|
||||
| probe | result |
|
||||
| ------------------------------------------------------ | ------------------------------------------- |
|
||||
| `GET /repos/mosaicstack/stack/collaborators/f10-coder` | **404** — not a collaborator |
|
||||
| repo permissions as seen by **its own token** | `admin: false`, `push: false`, `pull: true` |
|
||||
|
||||
**Capability has at least three independent layers, and satisfying two proves nothing about the third:**
|
||||
|
||||
1. **Token file exists** → raw-API authentication works (D-11b).
|
||||
2. **`tea` login exists** → tea-dependent wrapper paths work (D-13).
|
||||
3. **Repository permission granted** (collaborator/team membership) → _writes_ are actually authorised.
|
||||
|
||||
A token can carry `write:repository` scope and still be refused, because **scope bounds what a token
|
||||
may attempt; repository permission decides what the user may do.** They are different systems.
|
||||
|
||||
**This is the charter principle failing on the very check meant to confirm capability.** The mint was
|
||||
validated by an HTTP 200 on a _read_. A 200 proves reachability; it does not prove the property that
|
||||
was required, which was **write**. Both the provisioner and I accepted it — the same
|
||||
`written-unverified` treated as `verified` as D-12, one layer up, on a check whose entire purpose was
|
||||
verification.
|
||||
|
||||
**Requirements.** RM-50's pre-dispatch capability check must probe the **effective permission for the
|
||||
operation intended** — for push authority, assert `permissions.push == true` as that seat, not token
|
||||
existence and not a 200 on a read. RM-04's registry reconciliation covers all three layers, with a
|
||||
must-fail control for each. A capability check that cannot fail on a seat lacking write permission is
|
||||
itself an inert gate.
|
||||
|
||||
### D-14 — a ruled decision did not propagate to the authoritative record
|
||||
|
||||
DECISION-1 (the corrected choke-point wire-in target) was ruled by the coordinator and applied to
|
||||
`TASKS.md`. **`MISSION.md` — the charter, the document a cold-starting seat reads first — kept the
|
||||
superseded target for hours.** It was flagged `CONTESTED` in a board note, then the ruling landed and
|
||||
nobody edited the charter. A seat resuming from the charter would have read the _rejected_ target as
|
||||
authoritative and wired the choke point into a disabled rail — the precise failure the ruling existed
|
||||
to prevent.
|
||||
|
||||
Caught by hand, during an unrelated edit. Nothing would have caught it otherwise.
|
||||
|
||||
**This is P-MISSION-001 turned on ourselves.** The mission's own thesis is that convention exists and
|
||||
_enforcement_ is the gap: a decision that lives in a chat ruling and a board note, but not in the
|
||||
source of truth, has not actually been made — it has been _agreed_. The two are different, and the
|
||||
difference is exactly what this mission is about.
|
||||
|
||||
> ⚠ **AMENDED by D-20 — propagation is BIDIRECTIONAL.** As first written, this requirement was read by
|
||||
> both the orchestrator and the coordinator as _forward_ propagation only: a ruling reaches the
|
||||
> documents that state the new rule. **D-20 proved that insufficient.** When D-19 superseded part of
|
||||
> D-18, the consequence propagated forward into the charter and the delivery conditions but **never
|
||||
> backward into D-18 itself**, which went on asserting a withdrawn claim — and a reviewer disproved it
|
||||
> by experiment. **A supersession must update BOTH the documents that render the new rule AND the
|
||||
> finding it retires, with the retired wording quoted rather than deleted.** Backward propagation is
|
||||
> the same defect as forward; neither of us audited that direction until it bit.
|
||||
|
||||
**Requirement (not merely a fix).** A ruled decision must propagate **mechanically** to the
|
||||
authoritative record; it must not depend on someone remembering to edit a second file. Concretely, once
|
||||
mission state is DB-backed (RM-53 / the P-MISSION cutover):
|
||||
|
||||
- a decision is a **record**, not prose duplicated across documents;
|
||||
- documents _render_ decisions rather than restating them, so there is one place to be wrong;
|
||||
- and where duplication is unavoidable, a check asserts the authoritative record and the derived
|
||||
document agree — with a must-fail control proving divergence is detected.
|
||||
|
||||
Until then, the interim rule: **the same commit that records a ruling updates every document that
|
||||
states it — and every finding it supersedes.** Interim rules are exactly what the DB cutover exists to replace.
|
||||
|
||||
**The rule found a second instance within minutes of being written.** Auditing the charter against all
|
||||
rulings to date (rather than waiting to be bitten again) surfaced that **DECISION-2 had also not
|
||||
propagated**: `MISSION.md`'s standing directives still stated the DB hard-cutover with no mention of
|
||||
Mos's binding qualification that _the spine must not be a single-point hard-stop_ (degraded mode +
|
||||
rollback artifact required). A seat reading the charter would have designed toward an availability
|
||||
posture the coordinator had explicitly rejected — and would have found the superseded
|
||||
"no DB ⇒ the fleet stops" recommendation nowhere contradicted. Now corrected in place.
|
||||
|
||||
**Two un-propagated rulings out of two rulings that touched charter text.** The propagation gap is not
|
||||
an oversight that happened once; without a mechanism it is the _default outcome_. That is the argument
|
||||
for making this a requirement rather than a discipline.
|
||||
|
||||
### D-13 — two credential registries that can disagree (why the `--draft` fallback fired at all)
|
||||
|
||||
Diagnosing D-12's root cause surfaced a distinct defect. There are **two parallel credential
|
||||
registries**, and capability in one does not imply capability in the other:
|
||||
|
||||
| registry | contents for identity `mos-dt-0` on `mosaicstack` |
|
||||
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
|
||||
| token files — `~/.config/mosaic/secrets/gitea-tokens/` | `gitea-mosaicstack-mos-dt-0.token` **EXISTS** |
|
||||
| `tea login list` | **NO** `mosaicstack` login for `mos-dt-0` (only `mosaicstack-mos` and `mosaicstack-rev-974`) |
|
||||
|
||||
So `get_gitea_token` succeeds and every raw-API path works, while every **tea-dependent** wrapper path
|
||||
fails its login validation and silently degrades to the API fallback — which is exactly what dropped
|
||||
`--draft`. **tea is not "stale"; the login simply does not exist for that identity.**
|
||||
|
||||
This matters beyond one flag: capability was declared authoritative by the token-file set (D-11b), but
|
||||
that registry does not govern the tea path. A seat can be _fully provisioned_ by the authoritative
|
||||
registry and still lose functionality with no error — only a warning, and only on the degraded path.
|
||||
|
||||
**Requirements.** RM-04 (activation coherence): the two registries must be reconciled — one source of
|
||||
truth, or a startup check asserting they agree, with a must-fail control proving disagreement is
|
||||
detected. RM-50: the pre-dispatch capability check must verify capability for the **path actually
|
||||
used**, not merely token-file presence.
|
||||
|
||||
**Confirmed working despite the gap** (so this is degradation, not outage): pushes, `pr-merge.sh`,
|
||||
PR/issue creation via API fallback, comment posting, and all reads. Impact is confined to
|
||||
tea-only features — `--draft`, `--labels`, `--milestone`.
|
||||
|
||||
**Reconciliation run by Mos (the manual form of RM-04's assert-agreement, done once by hand).** For
|
||||
`git.mosaicstack.dev`, the token-file registry holds **six** seats; `tea` holds logins for **two**:
|
||||
|
||||
| state | seats |
|
||||
| ------------------------------------------------ | -------------------------------------------------------- |
|
||||
| token file present, **no** mosaicstack tea login | `f10-coder`, `jarvis`, `mos-admin`, `mos-dt-0`, `pepper` |
|
||||
| token file present **and** tea login present | `rev-974` (only) |
|
||||
|
||||
**Five of six provisioned seats are silently degraded on tea-only features.** This is _systemic_, not
|
||||
a one-off — which is why the fix is registry reconciliation (RM-04) and not a per-seat mint. Minting
|
||||
one seat would clear a symptom and leave the class live.
|
||||
|
||||
Mos deliberately deferred the mint: it is not on RM-01's critical path, and additively editing shared
|
||||
`tea` config underneath running work is a change he declined to make without cause. Full remediation —
|
||||
mint the five missing logins **and** wire the startup must-fail assertion that _detects_ disagreement —
|
||||
lands as RM-04 at a non-critical seam, or immediately if any seat needs a tea-only feature to progress.
|
||||
|
||||
**Correction of record:** this supersedes D-11(b)'s claim that the token-file set is _the_ authoritative
|
||||
capability registry. It is **necessary but not sufficient**. Capability is **per-path**: the token file
|
||||
governs the raw-API path, the tea login governs the tea path, and the two can disagree silently.
|
||||
|
||||
### D-12 — a requested SAFETY flag was silently degraded, and I did not check
|
||||
|
||||
I created PR #1027 with `pr-create.sh ... -d` (draft) because it carries **partial, unproven work**.
|
||||
`tea` authentication was stale, so the wrapper fell back to its raw-API path — which cannot set draft —
|
||||
and emitted:
|
||||
|
||||
```
|
||||
Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup.
|
||||
```
|
||||
|
||||
The PR was created **not-draft**. I read the success output, saw the PR number, and moved on. I then
|
||||
reported to the coordinator that the PR was "opened as draft". **It was open, mergeable, and marked
|
||||
ready for ~25 minutes**, protected only by the words "DRAFT" and "do not merge" in its title and body —
|
||||
i.e. by prose a human might read, not by the platform control I asked for. Detected only because a
|
||||
watcher polled `draft:` and the value disagreed with my belief. Corrected by setting the `WIP:` title
|
||||
prefix (Gitea's draft mechanism); `draft: True` verified after.
|
||||
|
||||
**Three distinct failures, and the third is mine:**
|
||||
|
||||
1. **Silent degradation of a safety flag.** The fallback path dropped `--draft` and still exited 0. A
|
||||
fallback that cannot honour a _safety_ argument must fail, not proceed — degrading `--labels` is a
|
||||
nuisance; degrading `--draft` publishes unproven work as ready to merge.
|
||||
2. **The warning went to stderr and nothing consumed it.** It was correct, specific, and ignored — a
|
||||
warning nobody acts on is indistinguishable from no warning.
|
||||
3. **I did not verify the flag took effect.** I checked that the PR existed, not that it had the
|
||||
property I required. This is the mission's own thesis turned on me: **I trusted a success exit code
|
||||
over an observed state**, on exactly the class of tool this mission exists to distrust.
|
||||
|
||||
**Requirements.** RM-02: a wrapper that cannot honour a safety-relevant argument must exit non-zero —
|
||||
registered with a must-fail control asserting `--draft` on a degraded path fails rather than proceeds.
|
||||
RM-24 (tri-state write outcomes): this is precisely `written-unverified` being treated as `verified` —
|
||||
the PR write succeeded, the _requested property_ was never confirmed, and no one looked.
|
||||
|
||||
### D-11 — seat identity did not survive into git, and seat capability is invisible at dispatch
|
||||
|
||||
Two defects, one dispatch (RM-01 → `f10-coder`):
|
||||
|
||||
**(a) Identity drift — P-WRAPPER-001, reproduced on our own delivery.** The seat's commits are
|
||||
authored `mosaic-coder <[email protected]>` — the generic fallback. **You cannot tell from
|
||||
git history which seat did this work.** Recorded, not rewritten: the drift is the evidence.
|
||||
|
||||
> **Mechanism, corrected (Mos).** My original framing here was wrong, and the error was in the brief
|
||||
> before it was in the finding. `MOSAIC_GIT_IDENTITY` resolves the **token** (which per-slot credential
|
||||
> the wrappers act with). The **commit author** comes from `git config user.name` / `user.email`, which
|
||||
> is a **separate setting** — it fell back to the generic value because nothing set it. Exporting the
|
||||
> identity could never have fixed authorship. **My worker brief instructed only the export, so the
|
||||
> seat did exactly what it was told and the commits were still mis-attributed.**
|
||||
>
|
||||
> **The requirement is coherence: token and authorship must agree.** A seat acting with
|
||||
> `gitea-mosaicstack-f10-coder` must also commit as `f10-coder <[email protected]>`.
|
||||
> Either half alone is identity drift — one produces the right credential with the wrong author, the
|
||||
> other the reverse. That coherence _is_ P-WRAPPER-001, and it belongs in seat setup, not in prose
|
||||
> instructions a seat may follow correctly and still end up wrong.
|
||||
|
||||
**(b) Capability opacity.** Nothing at dispatch time revealed that `f10-coder` had no credential for
|
||||
the target provider. Per-slot tokens live at `~/.config/mosaic/secrets/gitea-tokens/`; the seat holds
|
||||
`gitea-usc-f10-coder` but not `gitea-mosaicstack-f10-coder`. This surfaced only when the seat failed
|
||||
**mid-task, after ~$9 and 69% of its context.** The orchestrator (me) selected a seat without any way
|
||||
to check it could act on the target repo — and there was no way to check.
|
||||
|
||||
`get_gitea_token` behaved **correctly**: it refused to fall through and borrow another slot's token,
|
||||
failing loud precisely to protect gate-16 attribution. The tooling was right; the _dispatch-time
|
||||
information_ did not exist.
|
||||
|
||||
**This is P-RECOVERY-001's "honest capability labeling" applied to seats rather than services.** A seat
|
||||
should declare what it can actually do — which providers, which repos, which credentials — and that
|
||||
declaration must be **checkable before dispatch**, not discovered by failure after the budget is spent.
|
||||
|
||||
**Requirements.**
|
||||
|
||||
- **RM-04 (activation coherence)** gains the identity-binding half: seat setup must set **both** the
|
||||
token identity **and** `git config user.name`/`user.email`, coherently. Verified by an
|
||||
exit-asserting test that makes a commit and asserts its author — never assumed from an instruction
|
||||
in a brief.
|
||||
- **RM-50 (roster ownership)** gains per-seat capability declaration plus a **pre-dispatch capability
|
||||
check**. Mos (who owns provisioning) confirms the check is mechanically trivial: **capability is
|
||||
token-file existence.** Before dispatching seat `X` to provider `Y`, test that
|
||||
`~/.config/mosaic/secrets/gitea-tokens/gitea-<Y>-<X>.token` exists; if absent, provision it or pick a
|
||||
provisioned seat. **The token-file set is the authoritative capability registry.** A one-second check
|
||||
would have replaced a mid-task failure that cost ~$9 and 69% of a seat's context.
|
||||
|
||||
### D-10 — the queue guard's failure modes are exactly backwards
|
||||
|
||||
`ci-queue-wait.sh` — a **required** pre-push/pre-merge gate — was observed this session doing both of
|
||||
these:
|
||||
|
||||
- **Fails OPEN on an unknown result.** `state=unknown ⇒ exit 0`, five times, during real pushes and
|
||||
real merges. It also evaluates `branch=main` rather than the branch being acted on.
|
||||
- **Fails CLOSED on credential resolution.** In a worker seat it aborted with
|
||||
`Gitea token not found`, hard-blocking a legitimate push of completed, tested work. The worker
|
||||
correctly stopped (Constitution gate 8). The identical command run from that worker's _own worktree_
|
||||
in another shell succeeded, so the checkout and remote were fine — the difference was the worker's
|
||||
process environment.
|
||||
|
||||
**A gate that waves through work it never checked, and blocks work that is ready, has its failure
|
||||
modes inverted.** Availability failures (cannot reach the provider, cannot resolve a credential)
|
||||
should degrade to a loud, auditable _inability to assert_ — never to a hard stop on delivery, and
|
||||
never to a silent pass. Correctness failures (unknown, malformed, terminal-failure) are what must
|
||||
block.
|
||||
|
||||
This is also the **Pi-brick shape** (P-RECOVERY-001): a gate whose own unavailability prevents the
|
||||
work needed to recover from it.
|
||||
|
||||
**Requirement on RM-03, extending its existing two defects:** the guard must distinguish
|
||||
`CANNOT_ASSERT` (credential/transport/provider unavailable — loud, audited, does not silently pass and
|
||||
does not permanently block) from `ASSERTED_NOT_READY` (a real non-green CI state — blocks). Both are
|
||||
registered R-002 cases with must-fail controls; neither may exit 0 silently.
|
||||
|
||||
### D-9 — the comms path shell-interprets message bodies (injection-shaped, found by accident)
|
||||
|
||||
Sending a status message with `agent-send.sh -m "...`backticks`..."` caused bash to **execute** the
|
||||
backticked text as command substitution. The recipient received a mangled body plus a
|
||||
`No such file or directory` error; the intended sentence never arrived. The message was reported as
|
||||
delivered.
|
||||
|
||||
This is the **same class** as the already-noted `pr-create.sh` backtick-quoting bug (M2 scratchpad):
|
||||
**two tools in the comms path treat a message body as shell input.** A body that can execute on the
|
||||
sender is a _correctness_ bug before it is ever a security one — and note the failure mode: the
|
||||
send reported success while silently transmitting something other than what was written. Silent
|
||||
corruption with a success receipt is precisely the pattern this mission exists to eliminate.
|
||||
|
||||
**Requirement on RM-40 / RM-42 (comms/v1), hardened by Mos.** The envelope must carry its payload
|
||||
**verbatim** and must not be subject to shell interpretation at **any** hop — sender, transport, or
|
||||
adapter. Concretely: **file/stdin transport, never argv interpolation.**
|
||||
|
||||
**Standing interim rule, effective now (Mos).** Until the envelope lands, use `agent-send.sh -f
|
||||
<file>` for any message body containing special characters — **never `-m`**. Passing a file sidesteps
|
||||
argv interpolation entirely. **This rule is mandatory in every worker brief this mission issues**,
|
||||
alongside the D-8 "if a check is unrunnable, say so" clause. Round-trip fidelity (send a body containing backticks, `$(…)`, quotes, and newlines; assert
|
||||
byte-identical receipt) is a required registered test case under RM-02, including a must-fail control
|
||||
proving the assertion can detect corruption.
|
||||
|
||||
### D-8 — a PRE-REGISTERED acceptance check that was not runnable as written
|
||||
|
||||
On PR #1025 the author (me) pre-registered AC2 with the fixture snippet `mkdir -p apps/*/venv/lib`.
|
||||
@@ -811,18 +297,17 @@ spread is itself information, and X1 says we calibrate on real merged PRs.
|
||||
|
||||
### P5 — Retirements, hygiene, conformance
|
||||
|
||||
| id | task | src | depends_on | est (S/O) | tier |
|
||||
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | -------------------------- | ---------------- | ------ |
|
||||
| RM-50 | One roster-owned socket/host; quarantine unmanaged; **deterministic reaper for stale sessions AND dead-session disk scratch** (D-7) | O+S+live | RM-04 | 14K / 150K | sonnet |
|
||||
| RM-51 | Auto-sync **allowlist** (never auto-stage unknown paths) + worktree/lease isolation | O+S | RM-02 | 8K / 110K | sonnet |
|
||||
| RM-52 | Retire the Python controller + duplicate MACP islands (3 → 1) | O+S | RM-26, RM-27, RM-25, RM-28 | 14K / 110K | codex |
|
||||
| RM-53 | Flat-file orchestration → DB hard cutover, with rehearsed rollback artifact | O+S | RM-27, RM-30, RM-34, RM-29 | (in S-10) / 200K | opus |
|
||||
| RM-54 | Fleet-wide inert-gate audit against the RM-02 registry | O | RM-02 | — / 120K | sonnet |
|
||||
| RM-55 | **Conformance harness:** fault-inject the live failure classes on real artifacts | O+S | RM-35, RM-41, RM-53 | 18K / 260K | opus |
|
||||
| RM-56 | Retirement proof: CI asserts all three retirements are complete **and stay complete** | O | RM-52, RM-45, RM-53 | — / 90K | codex |
|
||||
| RM-57 | Operator cutover docs + activation proof; map all 15 decisions to evidence | S | RM-04, RM-36, RM-45, RM-55 | 6K / — | codex |
|
||||
| RM-59 | **Close the D-19 residual risk** — generated-state verification anchored **outside** the worktree's authority (executor/spine-side attestation), retiring the same-UID self-authentication gap | mos-remediation (D-19) | RM-12, RM-21, RM-25 | 20K | opus |
|
||||
| RM-58 | **Mechanical pre-dispatch context reset** — the orchestrator resets a seat out-of-band and verifies it, rather than asking the agent to reset itself | mos-remediation (D-4) | RM-31, RM-50 | 8K | sonnet |
|
||||
| id | task | src | depends_on | est (S/O) | tier |
|
||||
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -------------------------- | ---------------- | ------ |
|
||||
| RM-50 | One roster-owned socket/host; quarantine unmanaged; **deterministic reaper for stale sessions AND dead-session disk scratch** (D-7) | O+S+live | RM-04 | 14K / 150K | sonnet |
|
||||
| RM-51 | Auto-sync **allowlist** (never auto-stage unknown paths) + worktree/lease isolation | O+S | RM-02 | 8K / 110K | sonnet |
|
||||
| RM-52 | Retire the Python controller + duplicate MACP islands (3 → 1) | O+S | RM-26, RM-27, RM-25, RM-28 | 14K / 110K | codex |
|
||||
| RM-53 | Flat-file orchestration → DB hard cutover, with rehearsed rollback artifact | O+S | RM-27, RM-30, RM-34, RM-29 | (in S-10) / 200K | opus |
|
||||
| RM-54 | Fleet-wide inert-gate audit against the RM-02 registry | O | RM-02 | — / 120K | sonnet |
|
||||
| RM-55 | **Conformance harness:** fault-inject the live failure classes on real artifacts | O+S | RM-35, RM-41, RM-53 | 18K / 260K | opus |
|
||||
| RM-56 | Retirement proof: CI asserts all three retirements are complete **and stay complete** | O | RM-52, RM-45, RM-53 | — / 90K | codex |
|
||||
| RM-57 | Operator cutover docs + activation proof; map all 15 decisions to evidence | S | RM-04, RM-36, RM-45, RM-55 | 6K / — | codex |
|
||||
| RM-58 | **Mechanical pre-dispatch context reset** — the orchestrator resets a seat out-of-band and verifies it, rather than asking the agent to reset itself | mos-remediation (D-4) | RM-31, RM-50 | 8K | sonnet |
|
||||
|
||||
**Critical path:** `RM-01 → RM-02 → RM-10 → RM-11 → RM-12 → RM-21 → RM-23 → RM-31 → RM-33 → RM-34 → RM-53 → RM-55`.
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# 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.
|
||||
+3
-6
@@ -6,14 +6,11 @@
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev",
|
||||
"lint": "turbo run lint",
|
||||
"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",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
|
||||
"prepare": "node scripts/install-hooks.mjs"
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/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();
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
#!/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}`,
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/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;
|
||||
}
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,254 +0,0 @@
|
||||
#!/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();
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
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