Compare commits

..
Author SHA1 Message Date
mos-dt-0andClaude Opus 5 cc097f36c5 docs(remediation): re-run failed; RM-02 blocked on RM-61, declared bound honoured
Pipeline #2188 at the same head: ci-postgres the sole failure, everything else success
including gate-verify. f10-coder independently confirmed and applied the pre-registered
rule itself.

RM-02 blocked. No ad-hoc exemption, no third roll. Recorded why: two failures against one
earlier clean run makes 'best of five' feel reasonable, and the point of declaring the
bound in advance is that it binds when the result is inconvenient. A third roll would
have been indistinguishable from diligence, including to the person doing it.

Data for RM-61: the artifact hit twice consecutively at f9746b23 while #2184 was clean at
38f1b249, so it may cluster rather than be uniformly random — which makes a one-shot
re-roll close to worthless and strengthens the ruling that it must not become policy.

And the illustration: the anti-inert-gate registry is blocked by an artifact that makes
gate evidence unreliable. The thesis demonstrating itself on its own keystone.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-01 01:34:20 -05:00
mos-dt-0andClaude Opus 5 56d8e8a3d5 docs(remediation): record the bounded re-trigger as a stopgap; mark the first end-to-end merge stack
The re-trigger guardrail, recorded so it cannot become practice: one bounded,
pre-declared attempt at the same head with the response to each outcome fixed before
triggering. Re-running until the desired answer appears is p-hacking the pipeline, and
silently nobody can tell it from diligence — including the person doing it. Per Mos: a
per-PR free re-roll would be D-21 normalisation wearing a new hat. RM-61 must make the
re-roll unnecessary, not codify it, and a clean re-run does not retire RM-61.

Milestone: RM-03/#1032 completed independent review -> CI terminal-green -> merge-gate GO
-> coordinator -> held for owner, the first time end-to-end. GO posted under the gate's
own minted identity by a seat that structurally cannot merge. Head verified unmoved after
the verdict, so the commit-bound GO stands.

The first delivery through the complete stack is also the last one gated by a check that
could not fail — RM-03 is that check's fix.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-01 01:22:32 -05:00
mos-dt-0andClaude Opus 5 4589659bc1 docs(remediation): board — RM-03 GATE-READY, merge-gate assigned, held for Jason
First genuine gate-ready of the mission. Head triple equal at 78ec47cd, latest review
APPROVED at the current head, CI 9/9 by JSON scan with two-observer agreement.

Recorded the shape worth remembering: the queue guard is still inert for this merge
because RM-03 is the fix — the last merge to pass through the broken gate is the one
that fixes it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-01 01:15:05 -05:00
mos-dt-0andClaude Opus 5 357a636a3a docs(remediation): promote redundant-observation to charter; bank D-35 (tool caught what attention could not)
Charter gains a fifth principle from D-33: two observers of the same evidence,
disagreeing, catch what neither catches alone — applied to evidence GATHERING, not just
judgement. A summary that resembles an enumeration is more dangerous than one that
obviously summarises; prefer the machine-readable record and state counts so divergence
is detectable.

D-35: while editing merge-gate.md to fix D-33, the coordinator recalled the mandate
string instead of reading it and the Edit tool's exact-match REJECTED it. Third instance
for that author, inside the turn fixing another instance of the same class — and the
only one that did not reach a document, because a mechanism caught it.

A rule that fails in its authors but is caught by a tool has told you where it belongs.
RM-02 should enforce verbatim citation by construction, as the Edit tool did by accident
of design.

Fix landed by annotating the source: merge-gate.md mandates 3 and 4 now require the
scan and count from the JSON record. The shared wrapper was deliberately NOT modified
mid-flight — three lanes are reading it; fix at a seam.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-01 00:53:27 -05:00
mos-dt-0andClaude Opus 5 9e205e8201 docs(remediation): bank D-34 — a context reset silently strips a seat's credential identity
rev-974 finished its RM-02 review and could not post it: pr-review.sh failed with
'Gitea token not found'. Verified — with MOSAIC_GIT_IDENTITY unset the token does not
resolve; with it set it resolves. The token file was correct throughout.

My own context reset wiped the seat's exported identity and my rehydration brief did not
re-establish it. git config mosaic.gitIdentity is persistent and per-worktree;
MOSAIC_GIT_IDENTITY is ephemeral and per-context. rev-974 works from ~/agent-work with no
git-config fallback, so it had no warning and the loss surfaced only when it next needed
a credential.

Intersection of two banked findings, created by acting on one: D-31 prescribes rotation,
D-11a requires identity coherence, and nothing said rotation is a credential-affecting
operation. The fix for one failure introduced another.

RM-34: rotation must re-establish AND verify seat identity before handing over work.
RM-50: prefer the durable binding (mosaic.gitIdentity in the seat's repo) so identity
survives a reset by construction. Interim: every rehydration brief re-exports identity.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-01 00:51:21 -05:00
mos-dt-0andClaude Opus 5 73a313301b docs(remediation): bank D-33 — the full-step-scan tool prunes a step in default output
coder-mos1 scanned pipeline #2186 with -f json and found 9/9 child steps; my scan of the
same pipeline in the wrapper's default text mode showed 8. Verified: text mode omits
clone (JSON shows the ci workflow entry, clone, and the 8 visible ones).

Every full step scan performed tonight enumerated 8 of 9 real child steps and reported
it as complete. clone succeeded throughout so no verdict changes — but the method was
incomplete and its user did not know.

The merge-gate mandate requires a FULL step scan and requires verdicts to enumerate the
step count. A verdict citing 8 where 9 exist is non-conforming evidence, and a gate using
this wrapper's default output would produce exactly that while believing it complied.

The shape is the session's thesis aimed at the detection tool: the doc says read the
artifact not the summary, and text mode IS a summary that looks like an artifact. Compare
D-24 — mergeable was a true answer to a different question; this is a true answer to a
smaller one.

Caught only because two observers' counts disagreed; neither alone would have found it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-01 00:49:01 -05:00
mos-dt-0andClaude Opus 5 eddf718a5c docs(remediation): board — RM-02 frozen at 38f1b249 with a fully clean 9/9 scan
First run on this branch where ci-postgres passed, so no exemption question arises.
Recorded that the artifact's intermittency is evidence FOR RM-61's negative-control
requirement: intermittent means delivery is a coin flip until the signature is proven to
discriminate from a real database failure.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-01 00:35:51 -05:00
mos-dt-0andClaude Opus 5 c83a3cd3e2 docs(remediation): bank D-32 — the repaired gate shipped with a documented switch to skip it
rev-974 blocked RM-03: pr-merge.sh still ships --skip-queue-guard at five sites
including a worked example. It proved this rather than reading it — stubbed the guard to
exit 99, ran a real fixture merge with the flag, and observed the guard never called,
provider payload created, 'merged successfully', exit 0.

Disqualifying because RM-03 repairs a mandatory gate that has never been able to fail;
shipping that repair with a documented bypass means the gate merely requires one flag
instead of zero, and every merge-side CANNOT_ASSERT/HOLD semantic is skipped. L0 bars
equivalent skip switches, and the merge-gate role doc names this exact hazard about
force_merge — adjacent to the field you came to edit, at the moment you are most
motivated to reach for it.

Generalizable: repairing a gate is incomplete while any supported path skips it. The
failure changes from 'cannot block' to 'can be told not to' — the same outcome one
keystroke later. Removing the bypass is part of the repair.

Everything else passed independently, including the 160 KiB stdin transport where #1023
regressed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-01 00:33:18 -05:00
mos-dt-0andClaude Opus 5 bf8c6f4a89 docs(remediation): bank D-31 — seat ran past 100% context with 28 uncommitted files
coder-mos1 hit 102.5%/372k on RM-03 with 28 modified files uncommitted and its branch
still at base — a full context window spent with nothing durable. Caught by polling seat
state, not by any system signal. Nothing warned anyone: no threshold alert, no
pre-compaction hook.

The founding failure mode of this mission — compaction destroying in-flight work —
nearly hit the mission's own delivery twice in one night. RM-01 survived because it had
committed work when checkpointed; this one had none. Commit-then-rotate works;
rotate-without-commit loses everything.

RM-34: a context threshold is not enough — rotation must force a durable checkpoint and
refuse to rotate a seat with uncommitted work. RM-50: seat context is observable state
and must be monitored; relying on an orchestrator to poll is instructions-not-enforcement
applied to lifecycle. Brief doctrine: commit early, commit WIP.

Adjacent: the diff spans 5 guides and 11 templates against a brief scoped to one script.
Queried, not assumed — but a 28-file diff from a one-script brief is a scope signal
regardless of the answer.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-01 00:07:59 -05:00
mos-dt-0andClaude Opus 5 bdf8932328 docs(remediation): RM-61 terminal-green exemption ruled B, with sequencing correction
Condition 1 tested: the signature is stable — pods "wp-svc-<ULID>-ci-postgres" not
found across all five observed failures, an orchestration-layer lookup miss structurally
unlike a service-level failure.

But discrimination is UNPROVEN: every observation co-occurs with a demonstrably working
database, and we have never seen a real ci-postgres failure on this provider. If
PostgreSQL crashes and the pod is GC'd, the status query may also return pod-not-found —
the exemption would over-match and mask a real failure.

So conditions 1 and 2 are not independent: 2 is the evidence for 1. Build the negative
control first, prove a real failure produces a different signature, then adopt the
exemption. Kill criterion stated in advance: if an injected real failure also yields
pod-not-found, B is unsafe and we do A.

Filed as RM-61, unassigned — no free write-capable seat; flagged rather than stacked
onto a busy lane.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-01 00:04:32 -05:00
mos-dt-0andClaude Opus 5 16d11cd6cd docs(remediation): bank D-29 (syntactic criterion binding) and D-30 (false comparator, mine)
D-29: rev-974 moved two criterion bindings to an unrelated case, reformatted the
manifest, and gate:verify still exited 0. The registry verifies a criterion HAS a
binding, not that the bound case can fail for that criterion's stated reason —
RM02-REQ-03 unsatisfied. The keystone reproduced the defect it exists to eliminate, in
its own coverage check. Caught only by mutating and re-running; inspection would have
passed it.

D-30: my review brief claimed the ci-postgres FAIL appeared on #2167 — it did not
(#2167 is OK). My own banked D-21 says so explicitly; I restated from memory instead of
reading my record. D-26 recurring, in a reviewer brief, hours after promoting
render-not-restate to the charter.

The harm is not the inaccuracy but that I supplied a reviewer with fabricated supporting
evidence for my own reading. It refuted me — query-for-refutation paying for itself in
the same document that introduced it.

Requirement: briefs cite evidence from the record with identifiers, never from recall.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 23:59:25 -05:00
mos-dt-0andClaude Opus 5 8ce77fcb0d docs(remediation): board — RM-02 frozen at 9b4d4beb, CI green, review in flight
Head triple verified independently twice (orchestrator + f10-coder): local = origin =
PR #1030 = pipeline #2182. Full step scan rather than a tests-complete inference.
Queue guard not offered as evidence.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 23:41:52 -05:00
mos-dt-0andClaude Opus 5 cdd5568adb docs(remediation): promote query-for-refutation to charter doctrine
Per Mos. A subordinate asked to confirm a hypothesis will agree — the bias is induced by
the query, not by the answerer's diligence. 'The DB flaked, please confirm' and 'confirm
or refute, with the log line that proves it' are different instruments returning
different answers to the same question.

This matters most with agent subordinates, which are agreeable by construction, so the
discipline cannot rest on the answerer being rigorous — it must be built into how the
question is asked.

Origin: the orchestrator's ci-postgres hypothesis was refuted by the implementing seat
with log evidence and then settled by reproduction. Phrased for confirmation, it would
have returned agreement and D-21 would have been re-classified on a false premise,
silently corrupting a banked finding.

Operational form: state the hypothesis as yours, ask for evidence that kills it, say
what would change your mind, and reproduce when the answer is consequential. Added to
KICKSTART's standing invariants so it survives compaction.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 23:21:15 -05:00
mos-dt-0andClaude Opus 5 ff4b45b025 docs(remediation): RM-03 CANNOT_ASSERT semantics ruled (option B)
Resolves an ambiguity in the orchestrator's own brief, which required CANNOT_ASSERT to
neither silently pass nor permanently block without distinguishing push from merge.
coder-mos1 stopped and asked rather than inferring authorisation; Codex security
independently flagged CWE-693.

Ruled B: merge fails CLOSED (proceeding without exact-head CI evidence is D-23's
condition in a narrower costume), push degrades AUDITED (blocking during an outage
bricks delivery — the Pi-brick class we already banked). A temporary block pending
evidence is not a permanent block, and merge is separately gated by the merge-gate and
coordinator, so nothing is stranded.

Conditions: distinct exit code or the tri-state is destroyed; the audit record asserted
by a registered case, not assumed, or 'audited' is a claim dressed as a property;
retryable and self-clearing; cases observed RED first; no silent degraded merge path —
break-glass belongs to RM-05.

Option C rejected: it bricks push during an outage and defers the degraded path, which
in this codebase means a silent bypass appears under incident pressure. Three are
already on the books.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 23:17:01 -05:00
mos-dt-0andClaude Opus 5 f47cf45b0c docs(remediation): bank D-28 — a swallowed diagnostic destroyed a fail-closed check's evidence
RM-02 CI failed on four sandbox tests. Bubblewrap is installed by the gate step but not
the test step, so spawnSync returned ENOENT; replayCommit replaced the spawn diagnostic
with an empty string, so the classifier could not prove Bubblewrap provenance and
correctly refused to treat it as expected sandbox unavailability.

The refusal was right; the information loss was the bug. For a classifier, error text is
not decoration — it is the input. Widening acceptance to make the test pass would be a
finding, not a fix.

Linkages: D-16 again (local has bwrap, CI does not — local and CI disagree a third
time), and diagnostic-preservation registered as an RM-02 gate requirement with a
must-fail control proving a swallowed message is detected.

Process note: the orchestrator's hypothesis that the coincident ci-postgres FAIL caused
this was WRONG and was refuted with log evidence. D-21 stands unchanged as a teardown
artifact and is NOT upgraded — it was about to be re-classified on a false premise.
Asking the seat to confirm or refute rather than accept is what prevented that.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 23:10:53 -05:00
mos-dt-0andClaude Opus 5 e7c9160c7b docs(remediation): board — delivery-gate doctrine change recorded with render-vs-restate root cause
Per Mos's item 4. The gate definition was incomplete from setup because the gates were
restated from memory rather than referenced, and the omission propagated into every
worker brief since — then recurred inside the correction itself (D-26).

Board now carries the referenced sources, the gate order, freeze-after-GO, the
coordinator/orchestrator split, and the queue-guard zero-information field form.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 23:01:07 -05:00
mos-dt-0andClaude Opus 5 2d58779675 docs(remediation): bank D-27 — role file certifies the inert queue guard as enforced
rev-974 found this while loading the canonical gate sources, i.e. because we switched
from restating to reading. Verified in roles.local/merge-gate.md: mandate 3 requires
verifying 'CI queue guard clear', mandate 4 requires enumerating 'the queue-guard
outcome' in every durable verdict, the merge path annotates it 'real', and the control
table at :290 certifies it ' enforced … genuinely aborts'.

The document is correct about the WIRING and wrong about the CONTROL: set -euo pipefail
with an unguarded exit does abort, but the guard cannot produce a non-zero exit for any
input (D-23). A mechanism correctly wired to a sensor that never fires, certified as
enforced.

Compounding: the verdict format REQUIRES the queue-guard outcome as evidence, so a
conforming verdict must include a meaningless field — the role file instructs the gate
to manufacture evidence, in the mandate that exists to stop bare conclusions.

Interim: record the field labelled ZERO-INFORMATION (inert, owner RM-03) rather than
omitting it — omission makes the verdict non-conforming; silent citation is worse.

Escalated: operator-owned file, coordinator's to fix. Recommend annotation not deletion
— the requirement is correct once RM-03 lands.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 22:55:28 -05:00
mos-dt-0andClaude Opus 5 cbbe3e1ce2 docs(remediation): gate definition now REFERENCES canonical roles; bank D-26
The delivery-gate definition was incomplete from setup — the merge-gate verdict step
was missing because the gates were restated from memory rather than referenced, and the
omission propagated into every worker brief since.

Then it recurred inside the correction: the correcting message was itself a restatement
and dropped five things, including a security precondition (an unminted gate seat fails
open to the owner's admin account — the #3084 breach mechanism) and a citation to a file
that does not exist.

D-26 is the definitive case because every condition favoured success: attention maximal,
actor knew the rule best, subject was the rule itself. A rule its own enforcer cannot
follow while enforcing it is not a discipline — it is a requirement for a mechanism.

MISSION.md and KICKSTART.md now reference fleet/roles.local/merge-gate.md and
fleet/roles/validator.md and state only the gate ORDER. Every reference resolves; a
dangling pointer is worse than a restatement.

Precondition independently verified: merge-gate token is least-privilege,
pull=True push=False admin=False — structurally cannot merge.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 22:53:13 -05:00
mos-dt-0andClaude Opus 5 9a7ab73952 docs(remediation): charter — Builds 1-2 justified by two independent impossibility proofs; RM-60 option analysis
Per Mos. The choke-point executor and PG spine are not a design preference — they are
forced. Twice during the mission's own first deliveries, work stopped against a security
property that cannot exist at the layer needing it: D-19 (audited party controls the
manifest certifying it — artifact integrity) and D-25 (audited party controls the code
entering the sandbox — execution integrity). Both reduce to self-verification by the
audited party is not verification, and both resolve only via an authority outside its
control.

Neither proof was sought; both arrived while shipping something else, from different
directions, at different layers. An architecture forced by two independent impossibility
proofs is stronger evidence than one argued for.

RM-60 records Mos's sharper option analysis: A (unprivileged userns) does NOT fix the
vulnerability — it grants a capability and leaves the ORDERING defect untouched, so B is
required regardless; A without B is kernel exposure bought for nothing. B is the correct
primitive, generalises to RM-59 and the choke-point executor, and may not need A at all.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 22:09:38 -05:00
mos-dt-0andClaude Opus 5 2b85afe4ea docs(remediation): bank D-25, accept RM02-REQ-10 revision, open RM-60
RM-02's per-commit replay needs isolation; the privileged CI step was correctly refused
because PR-controlled code executes before Bubblewrap establishes any boundary — the
untrusted party would obtain the capability meant to contain it. Ordering defect, not a
hardening problem.

D-25 is D-19 one layer down: audited party controls the manifest (artifact integrity)
became audited party controls the code entering the sandbox (execution integrity). Both
reduce to self-verification by the audited party is not verification, and both resolve
via an authority outside its control — twice now this mission has derived Builds 1-2
from a security impossibility rather than design preference.

Option C ruled independently by the orchestrator and rev-974 before either saw the
other. rev-974 added the condition I missed and it matters most: post-merge replay is
DETECTION, not PREVENTION, with a defined quarantine/revert response, and must never be
presented as equivalent to a pre-merge gate.

RM02-REQ-10 revision formally recorded and accepted under RM-02's own clause 4 —
original text, restatement, reason — discharging rev-974's review-readiness condition.
RM-60 opened for the external pre-execution trust boundary, cross-referenced with RM-59.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 22:05:50 -05:00
mos-dt-0andClaude Opus 5 eabe04bc5e docs(remediation): bank D-24 — mergeable:true cited as merge-readiness; D-23 corrected backward
Mos verified #1023 before relaying and corrected my framing. Verified independently:
review id-58 REQUEST_CHANGES at f6334080, the current head — the block is live, and its
findings are that #1023's own fix is defective (unknown still exits 0, payload-as-argv
hits ARG_MAX, tests do not assert exits).

mergeable:true is git-mergeability, not readiness. The charter's first principle
committed by the orchestrator inside an escalation about proxies. Fifth instance of mine.

The mechanism, named because it will recur: mergeable is not a lie, it is a true answer
to a different question. A false field would have been caught; a true-but-adjacent field
passes every sniff test — which is why the discipline must be mechanical, not attentive.

D-23 corrected backward per D-14-as-amended, original framing quoted.

RM-02: readiness cases must assert the decision-relevant property — latest review state,
CI terminal status on the exact head, required approvals — never a structural proxy.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 20:50:46 -05:00
mos-dt-0andClaude Opus 5 36018be8d1 docs(remediation): bank D-23 — the mandatory queue guard has never worked, for any input
RM-02's registered fixtures found this before the registry was built. Worse than the
banked unknown=>exit 0: ci-queue-wait.sh:266 pipes the payload into a classifier
starting 'python3 - <<PY', so python reads its PROGRAM from stdin and json.load never
sees the JSON. Proven empirically — success, failure, pending and malformed all
classify as unknown, and unknown exits 0.

The mandatory pre-push/pre-merge guard therefore returns PASS for every possible input,
including a genuinely failing CI. The six observed meaningless greens were not an edge
case; they are the only output it can produce.

PR #1023 is exactly this fix, open and parked, and its body diagnoses it precisely.
Two aggravating details from that body: pr-ci-wait.sh:38 documented the bug and remedy
and it was never backported to the mandatory sibling (D-14 propagation with a
security-adjacent blast radius); and the fix was opened without re-verification.

Escalated for Jason's #1023 disposition. RM-02 records required-vs-actual with
DEFECT (owner: RM-03); no RM-03 lane opened, guard not edited.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 20:44:34 -05:00
mos-dt-0andClaude Opus 5 8c496993e4 docs(remediation): bank D-22 — the registry would have verified the wrong artifact
The gate that actually runs is the installed copy at ~/.config/mosaic/tools/git/;
a repo-scoped registry would test packages/mosaic/framework/tools/git/. Byte-identical
today (sha256 19cda2f7009c536e both) but nothing asserts it.

A registry that verifies the wrong artifact is worse than none — it manufactures
confidence, and the divergence would be invisible from every signal we have: registry
green, CI green, deployed gate arbitrarily different.

D-1/P-ACTIVATION applied to the enforcement mechanism itself rather than to config.

RM-02 gains: for every registered gate with a deployed counterpart, assert
repo-source == deployed-copy with a must-fail control; where none exists, record that
explicitly.

Found by design review before implementation — the only finding tonight not found by
execution, which is the design-first gate on keystone tasks paying for itself.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 20:14:13 -05:00
mos-dt-0andClaude Opus 5 e85a088f85 docs(remediation): bank D-21 — a FAIL that means nothing is a green that means nothing, sign-flipped
ci-postgres reports FAIL under an overall-success pipeline on #2170 and #2175 while all
eight functional steps pass. Most likely a service-pod teardown artifact; the test
step's pg_isready fail-fast guard passed, so the database was available during the run.

Low severity, but the pattern matters: a red that is routinely present and routinely
correct to ignore trains operators and agents to discount reds, and the discounting
generalises. Six instances of a meaningless green are already banked; a meaningless
FAIL is the same erosion with the sign flipped.

Recorded rather than normalised — the first time it is waved through without a note is
when it becomes background noise.

RM-55: treat a non-terminal-success sub-step under an overall-success pipeline as a
reportable anomaly, and make the CI contract state which steps may fail without failing
the pipeline. An implicit allowance is indistinguishable from a bug.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 20:08:34 -05:00
mos-dt-0andClaude Opus 5 8a795df0ce docs(remediation): D-14 amended by D-20 — propagation is bidirectional
ci/woodpecker/pr/ci Pipeline was successful
Both the orchestrator and the coordinator read D-14 as forward propagation only: a
ruling reaches the documents stating the new rule. D-20 proved that insufficient —
when D-19 superseded part of D-18, the consequence went forward into the charter and
the delivery conditions but never backward into D-18, which kept asserting a withdrawn
claim until a reviewer disproved it by experiment.

A supersession must update BOTH the documents rendering the new rule AND the finding it
retires, with the retired wording quoted rather than deleted. Interim rule updated
accordingly.

Applying D-14's own rule to D-14, in the same commit that records the amendment.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 19:53:36 -05:00
mos-dt-0andClaude Opus 5 f5a0566198 docs(remediation): board — RM-01 MERGED (f58b3699); RM-02 keystone is next
ci/woodpecker/pr/ci Pipeline was canceled
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 19:50:36 -05:00
mos-dt-0andClaude Opus 5 1e7a0701fd docs(remediation): fix D-18 overclaim (D-20) — reviewer empirically disproved my own documentation
rev-974 blocked #1027 a second time, and the defect was in this file, not the code.

D-18's AC2 restatement omitted the accidental/independent scope D-19 made mandatory,
and D-18 asserted the tampered-manifest control turns integrity 'from a claim into a
property'. It does not and cannot — that sentence predated D-19 and was never revised
when D-19 landed. rev-974 disproved it by experiment: a same-UID consistent
manifest+marker rewrite passed preflight.

Two banked findings firing on the orchestrator at once: the integrity-claim corollary
(I wrote a claim in the voice of a property, in the document defining the rule against
it), and D-14 propagation — I carried D-19's consequence forward into the charter and
the delivery conditions but never back into the finding it superseded. Backwards
propagation is the same defect as forwards and I did not audit for it.

Corrected in place with the original wording quoted and the disproof recorded, not
silently rewritten.

RM-02 fifth clause: documentation asserting a security or integrity property is itself
a claim requiring a negative control observed red. Prose is not exempt from the
mission's evidentiary standard, and governing prose least of all.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 19:48:00 -05:00
mos-dt-0andClaude Opus 5 43f69bf167 docs(remediation): promote D-19 to charter; bind residual risk to RM-59 as a tracked edge
Per Mos, with his two additions.

Charter gains a fourth first-class principle: when a property cannot exist at the layer
that specified it, three honest moves are mandatory — implement what the layer can
guarantee; state the boundary in BOTH directions (what it does not defend AND what it
does, since either alone misleads); and record the real guarantee as a TRACKED
DEPENDENCY, not prose. A written-down gap is acceptable engineering; an implied-fixed
gap is the mission's core failure in a new costume.

The residual risk is now RM-59 (depends_on RM-12, RM-21, RM-25) — a real backlog task
owned by the choke-point executor and spine, which verify from outside the worktree's
authority. Mos's point: 'record where the 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.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 19:23:15 -05:00
mos-dt-0andClaude Opus 5 f9435c2a03 docs(remediation): bank D-19 — an integrity property that cannot exist at its specified layer
Implementing the D-18 manifest hit CWE-345: manifest and marker share one same-UID
writable tree, so that actor can regenerate both and pass. No local construction fixes
self-authentication without a key outside its authority. The seat escalated rather than
calling it tamper-resistant — the charter corollary working on its first real test.

Ruling: Option A, scope AC2 to accidental/independent/stale mutation, retain the design.
The undefendable boundary is not the weak link (same-UID write already owns the source,
the tests, preflight.mjs and .husky); AC2 exists for the D-5 staleness class, against
which the design works; and a real anchor arrives with the choke-point executor and PG
spine, which verify outside the worktree's authority.

Acceptable only with honest labelling: threat model verbatim in code and PR, the words
tamper-proof/tamper-evident/secure barred there, scope carried in AC2's restatement,
residual risk named as backlog, all controls kept RED-first.

Generalizable: when a property cannot exist at the layer it was specified, implement
what the layer can guarantee, state the boundary precisely, and record where the real
guarantee comes from. A known gap written down is acceptable; a gap implied fixed is not.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 19:20:05 -05:00
mos-dt-0andClaude Opus 5 3b191b6b34 docs(remediation): promote the three-mode taxonomy and the integrity-claim corollary to the charter
Per Mos. Two first-class principles alongside observe-the-property:

1. Pre-registration prevents RETROFITTING and nothing else. A pre-registered check set
   can be WRONG (D-8), INCOMPLETE (D-17), or INTERNALLY INCONSISTENT (D-18). It confers
   neither correctness nor coverage nor consistency. All three modes were found on this
   mission's own first delivery, by the machinery applied to its own work. RM-02's four
   clauses are the enforceable form.

2. Never ship an integrity claim dressed as a property. A verification artifact
   writable by the actor whose behaviour it certifies certifies the attack. It must sit
   inside its integrity envelope, publish atomically, and carry a tamper negative
   control observed red. If it cannot be made tamper-evident, say so and reconsider —
   an honest 'this cannot be verified' is always available and always preferable to
   laundering foreign content as certified.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 19:14:06 -05:00
mos-dt-0andClaude Opus 5 85bdbe3383 docs(remediation): bank D-18 — pre-registered criteria AC2 and AC4 were mutually unsatisfiable
Implementing D-17's fix surfaced a conflict between criteria, not within one. AC2
(reject symlinked generated state) cannot hold alongside AC4 (canonical build
succeeds): verified independently that apps/web sets output:'standalone' and the built
tree carries 42 legitimate pnpm symlinks under .next/standalone. Only building the
tree reveals it.

Completes the chain: a pre-registered check set can be WRONG (D-8), INCOMPLETE (D-17),
or INTERNALLY INCONSISTENT (D-18).

Ruling: build-certified symlink manifest — .next itself still rejected; descendants
rejected unless exactly certified. Stronger than blanket rejection because it catches
retargeting. AC2 restated and RECORDED with provenance rather than absorbed, since
silently resolving a conflict between pre-registered criteria destroys the point of
registering them.

Hardening: the manifest is generated state, so a manifest writable by whoever plants a
rogue symlink certifies the attack. Must be inside the integrity envelope, published
atomically, with negative controls observed red first including manifest-tampered.

RM-02 fourth clause: the registry must detect conflicts between criteria, and retain
original text plus restatement plus reason when a criterion changes meaning.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 19:11:52 -05:00
mos-dt-0andClaude Opus 5 ae4baf145d docs(remediation): bank D-17 — pre-registration confers neither correctness nor coverage
rev-974 blocked PR #1027: AC2 was pre-registered and explicitly required rejecting
symlinked generated state; the implementation accepts it (ln -s into .next, preflight
exits 0 instead of 43) while the acceptance suite ran 21/21 green throughout.

Confirmed independently: preflight.mjs:82-92 rejects symlinks on the SOURCE path,
:28 merely skips symlinked dirs, and the generated-state path :141-163 checks uid but
never isSymbolicLink(). The suite's only symlink cases cover the turbo binary and a
source file.

Sharpens D-8 rather than repeating it. D-8: pre-registration does not confer
CORRECTNESS. D-17: it does not confer COVERAGE — a suite can be green with every
criterion appearing satisfied while a criterion's actual requirement is untested.

RM-02 third clause: the registry must bind each criterion to the specific case that
exercises it and prove that case red before trusting its green. Mutation testing
pointed at the criterion-to-case mapping, not just the gate.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 19:07:58 -05:00
mos-dt-0andClaude Opus 5 4512312b9f docs(remediation): D-16 ownership — #1024 is Jason-pending; CI is the authoritative gate for #1027
Propagating Mos's ruling in the same commit that records it, per D-14's interim rule.

The hermeticity fix IS PR #1024, which sits in Jason's parked delivery stack, so its
disposition is his — marked SUPERSEDED-PENDING-JASON alongside #1023. D-16 strengthens
urgency without transferring ownership; we do not open a third lane on a parked PR.

Class sharpened: 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.
Non-hermetic gates make every green host-dependent.

#1027 proceeds on CI-green; the local exit-97 is a host-specific guard abort and is
not a merge consideration.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 18:43:25 -05:00
mos-dt-0andClaude Opus 5 e233f196b5 docs(remediation): bank D-16 — local and CI test gates disagree by environment
Ran down Mos's flag. Both hypotheses were wrong: CI runs exactly 'pnpm test' (same
command, path IS exercised) and is green, while this host exits 97 on an
environment-dependent BASH_LINENO guard (#973) that aborts on bash 5.2.15 and not in
CI's container. PR #1027 touches zero files under packages/mosaic, so the guard is
genuinely pre-existing — f10-coder's report was accurate in every particular, and main
is equally affected here.

Not 'merges step around a red' but something worse in one respect: the local gate and
the CI gate disagree about what passing means. No agent on this host can get a green
pnpm test on any branch. A gate only CI can run cannot be a pre-push gate.

Second defect found while establishing this: in the main checkout the same package
fails differently (exit 1) because a test scans the working tree and picks up
apps/coordinator/venv third-party site-packages. A test whose verdict depends on
untracked files is not hermetic — same contamination source that broke format:check,
one foreign tree breaking two independent gates.

Coordinate with #1007/#1024 rather than opening a third lane.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 18:40:53 -05:00
mos-dt-0andClaude Opus 5 3477e933df docs(remediation): bank D-15 — token scope is not repository permission
f10-coder holds gitea-mosaicstack-f10-coder.token with write:repository scope and was
verified at mint by 'repo access returns 200'. It cannot push: collaborator lookup
404s and its own token reports push:false, pull:true.

Capability has three independent layers — token file (raw-API auth), tea login (tea
path), repository permission (actual write authority) — and satisfying two proves
nothing about the third. Scope bounds what a token may ATTEMPT; repository permission
decides what the user may DO.

The charter principle failing on the check meant to confirm capability: a 200 on a
READ was accepted as evidence of WRITE. written-unverified treated as verified, by
both provisioner and orchestrator, one layer above D-12.

RM-50's pre-dispatch check must assert effective permission for the intended operation
(permissions.push == true as that seat), not token existence or a read returning 200.
A capability check that cannot fail on a seat lacking write permission is itself an
inert gate.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 18:32:27 -05:00
mos-dt-0andClaude Opus 5 d0ad6e942b docs(remediation): propagate DECISION-2 to charter — second un-propagated ruling, found by applying D-14
Applied D-14's own interim rule immediately instead of waiting to be bitten again, and
it caught a second instance within minutes: 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, with the superseded 'no DB means the
fleet stops' recommendation nowhere contradicted.

Two un-propagated rulings out of two that touched charter text. Without a mechanism,
propagation failure is the default outcome, not an oversight — which is the argument
for D-14 being a requirement rather than a discipline.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 18:12:16 -05:00
mos-dt-0andClaude Opus 5 7f686aaf6d docs(remediation): bank D-14 decision-propagation requirement; record D-13 reconciliation
D-14: DECISION-1 was ruled and applied to TASKS.md while MISSION.md — the charter a
cold-starting seat reads first — kept the superseded wire-in target for hours, flagged
CONTESTED on the board but never corrected in the source of truth. A resuming seat
would have read the rejected target as authoritative. Caught by hand during an
unrelated edit; nothing would have caught it otherwise.

P-MISSION-001 turned on ourselves: a decision that lives in a ruling and a board note
but not in the authoritative record has been AGREED, not MADE. Requirement: rulings
must propagate mechanically — decisions as records, documents rendering rather than
restating, and a must-fail check where duplication is unavoidable. Interim rule until
the DB cutover: the same commit that records a ruling updates every document stating it.

D-13 updated with Mos's reconciliation: 5 of 6 mosaicstack-provisioned seats have no
tea login — systemic, not a one-off. Supersedes D-11(b): the token-file set is
necessary but NOT sufficient; capability is per-path.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 18:11:01 -05:00
mos-dt-0andClaude Opus 5 f13222b76b docs(remediation): charter reflects DECISION-1 corrected wire-in target and current status
The charter's build-1 cell still named mosaic_orchestrator.py::run_single_task after
DECISION-1 ruled against it. Leaving the charter contradicting the ruled decision would
mislead any future reader who starts there — corrected in place with the rationale and
provenance, rather than only in TASKS.md.

Also: status PLANNING -> EXECUTING; scout TODO discharged (file is in-tree) with a note
that its findings stand but its recommended wire-in point is superseded.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 18:07:16 -05:00
mos-dt-0andClaude Opus 5 d1e7ba19ca docs(remediation): promote observe-the-property principle to charter; bank D-13 dual credential registries
Charter gains a first-class principle per Mos: 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 why the inert-gate class exists and why the P-WRAPPER
tri-state is not optional. Recorded with its provenance: the orchestrator committed
this exact error (D-12), and three of the session's twelve instances were its own.

D-13: diagnosing D-12 found two parallel credential registries that can disagree.
gitea-mosaicstack-mos-dt-0.token EXISTS, but tea has no mosaicstack login for that
identity — so raw-API paths work while tea-dependent wrapper paths silently degrade.
tea is not stale; the login does not exist. Capability declared authoritative by the
token-file set does not govern the tea path.

RM-04 must reconcile the registries (or assert agreement at startup, with a must-fail
control). RM-50's pre-dispatch check must verify capability for the path actually used.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 18:06:18 -05:00
mos-dt-0andClaude Opus 5 0ffdcf14bd docs(remediation): bank D-12 — pr-create silently dropped --draft; I trusted exit 0 over observed state
PR #1027 was created with -d because it carries partial unproven work. tea auth was
stale, the wrapper fell back to the raw API which cannot set draft, warned on stderr,
and exited 0. The PR sat open and mergeable for ~25 minutes, protected only by the
word DRAFT in its title and body. I had reported it to the coordinator as a draft.

Three failures: a fallback silently degrading a SAFETY flag (nuisance for --labels,
dangerous for --draft); a correct warning nobody consumed; and my own failure to
verify the flag took effect — I checked the PR existed, not that it had the property
I required. That is written-unverified treated as verified, by me, on exactly the
class of tool this mission exists to distrust.

Fixed via the WIP: title prefix; draft:True verified after.

RM-02 gains a must-fail control: a wrapper that cannot honour a safety-relevant
argument must exit non-zero. RM-24 gains this as its canonical tri-state example.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 18:03:29 -05:00
mos-dt-0andClaude Opus 5 be10bdc828 docs(remediation): board tick — 2 PRs merged, RM-01 on rotated seat, dispatch doctrine
Board now carries the pre-dispatch capability check (token-file existence), the
token/authorship coherence rule, and the accreted worker-brief doctrine, so a
compacted or fresh seat inherits them mechanically rather than by recall.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 18:01:55 -05:00
mos-dt-0andClaude Opus 5 2201c30284 docs(remediation): correct D-11(a) mechanism — token identity and commit authorship are separate
Per Mos. MOSAIC_GIT_IDENTITY resolves the TOKEN; commit author comes from git config
user.name/user.email, a separate setting that fell back to the generic value. The
export could never have fixed authorship — my worker brief instructed only the export,
so the seat did what it was told and the commits were still mis-attributed. The error
was in the brief before it was in the finding.

Requirement is COHERENCE: token and authorship must agree; either half alone is drift.
Belongs in seat setup, verified by an exit-asserting test that commits and asserts the
author — not in prose a seat can follow correctly and still end up wrong.

Capability check mechanized: capability IS token-file existence. Before dispatching
seat X to provider Y, test gitea-<Y>-<X>.token exists. The token-file set is the
authoritative capability registry.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 17:59:06 -05:00
mos-dt-0andClaude Opus 5 75dfe2fa75 docs(remediation): bank D-11 — identity drift + seat capability opacity at dispatch
RM-01's seat was briefed to export MOSAIC_GIT_IDENTITY; its commits are authored as
the generic mosaic-coder fallback, so git history cannot say which seat did the work.
P-WRAPPER-001 reproduced on our own delivery.

Separately, nothing at dispatch time revealed the seat lacked a credential for the
target provider — discovered only when it failed mid-task after ~$9 and 69% context.
get_gitea_token behaved correctly by refusing to borrow another slot's token; the
dispatch-time information simply did not exist.

RM-50 gains per-seat capability declaration + pre-dispatch check; RM-04 gains
identity-binding verified by an exit-asserting test rather than assumed from an
export in a brief.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 17:56:34 -05:00
mos-dt-0andClaude Opus 5 409bf23e6a docs(remediation): bank D-10 — the queue guard's failure modes are inverted
Same required gate, both directions wrong: fails OPEN on state=unknown (5 meaningless
greens this session, wrong branch too), and fails CLOSED on credential resolution,
hard-blocking a worker's completed work. The identical command succeeded from that
worker's own worktree in another shell, so the checkout was fine.

A gate that waves through unchecked work and blocks ready work has its failure modes
backwards. Availability failures must degrade to a loud, audited CANNOT_ASSERT;
correctness failures must block. Also the Pi-brick shape: a gate whose unavailability
prevents recovery from it.

RM-03 extended to a third requirement: distinguish CANNOT_ASSERT from
ASSERTED_NOT_READY, both registered with must-fail controls, neither exiting 0 silently.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 17:52:42 -05:00
mos-dt-0andClaude Opus 5 31c3191305 docs(remediation): rolling-branch resume pointer + D-9 interim comms rule
KICKSTART resume now points at origin/remediation/state for the live board, per Mos's
cadence ruling: every tick is pushed to the rolling branch immediately (durable
per-tick, no per-tick main PR); main carries periodic snapshots only. Reading the
board from main would silently yield a stale tick, so the step warns explicitly.

D-9 hardened: comms/v1 must use file/stdin transport, never argv interpolation.
Standing interim rule until then — agent-send -f for any body with special
characters, never -m. Mandatory in every worker brief alongside the D-8 clause.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 17:50:26 -05:00
mos-dt-0andClaude Opus 5 6a067174ed docs(remediation): bank D-9 — comms path shell-interprets message bodies
agent-send.sh -m with backticks executes them as command substitution: the recipient
got a mangled body plus a shell error, and the send still reported success. Same class
as the known pr-create.sh backtick bug — two tools in the comms path treating a
message body as shell input.

The failure mode is the mission's own pattern: silent corruption with a success
receipt.

Requirement on RM-40/RM-42: comms/v1 must carry payloads verbatim with no shell
interpretation at any hop, with a byte-identical round-trip test (backticks, command
substitution, quotes, newlines) registered under RM-02 including a must-fail control.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-31 17:48:17 -05:00
47 changed files with 1138 additions and 2409 deletions
-1
View File
@@ -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
View File
@@ -1 +1 @@
pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check
pnpm typecheck && pnpm lint && pnpm format:check
+4 -4
View File
@@ -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
-14
View File
@@ -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)
+1 -1
View File
@@ -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",
+16
View File
@@ -23,3 +23,19 @@ Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ th
### **D-4 / P-LIFECYCLE + hygiene — a dispatched agent silently IGNORED an in-message context reset.**
planner-sol was at 64.3%/372k; the brief asked it to reset first; it began work on dirty context anyway. Only an out-of-band `/new` driven by the orchestrator guaranteed clean state. Confirms the postmortem thesis: **instructions are not enforcement.** Reset must be a mechanical pre-dispatch step, not a request.
<!-- board-roll: 2 entries rolled from BOARD.md -->
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
<!-- board-roll: 1 entry rolled from BOARD.md -->
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
+44 -20
View File
@@ -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 — RM-03 **GATE-READY**, merge-gate assigned, HELD for Jason. RM-02 CI in flight.
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
## Head
@@ -16,13 +16,49 @@
## 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 |
| ------------------------------ | ------------- | ------------------------------------------------------------------------------------ |
| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (#1027) |
| RM-03 queue guard | merge-gate | **GATE-READY** @ `78ec47cd` — rev-974 APPROVED (id 66) + CI 9/9 JSON; HELD for Jason |
| RM-02 gate registry ★keystone | f10-coder | masking blocker fixed @ `f9746b23`; CI #2187 running; **not** gate-ready |
| RM-61 terminal-green exemption | unassigned | ruled B; negative control FIRST, then adopt — or fall to A |
| RM-59 / RM-60 | Jason (infra) | tracked deps; RM-60 option **B** |
| #1023 queue-guard attempt | Jason | SUPERSEDED-PENDING-JASON — RM-03 supersedes it |
**RM-03 gate-ready evidence** (each element observed, not relayed): head triple all equal at
`78ec47cd97bf0abc49334401f08c5b86e5be3a1a`; latest review **id 66 APPROVED at the current head** (id 64
`REQUEST_CHANGES` correctly superseded); `Closes #1019`; CI #2186 **9/9 JSON child-step scan**, confirmed
independently by two observers whose counts **agreed**. Reviewer attacked rather than read — real-failure
payload → exit 3 `ASSERTED_NOT_READY`, bypass re-attack rejected, 170 KiB payload rc0, both tri-state arms.
> ⚠ **The queue guard is still inert for this merge** — RM-03 _is_ the fix and is not merged. The gate
> records `queue-guard: ZERO-INFORMATION (inert, D-23, owner RM-03)`.
> **The last merge to pass through the broken gate is the one that fixes it.** After #1032 lands, that
> field becomes real evidence for the first time since it was written.
## Delivery gates — DOCTRINE CHANGE 2026-08-01
**The gate definition was incomplete from mission setup: the merge-gate verdict step was missing.**
Root cause (**D-26**): the gates were **restated from memory** into `MISSION.md`/`KICKSTART.md` instead of
**referenced**, and the omission propagated into every worker brief issued since. It then recurred _inside
the correction_ — which dropped five details including a security precondition and cited a file that does
not exist.
**Fix is render-not-restate, mechanically.** `MISSION.md` and `KICKSTART.md` now **reference**
`~/.config/mosaic/fleet/roles.local/merge-gate.md` and `~/.config/mosaic/fleet/roles/validator.md` and
state only the gate **order**:
> independent review (author ≠ reviewer) → remediation → **CI terminal-green at the exact head, full step
> scan** → **merge-gate verdict `GO`/`NO-GO`/`HOLD`** (commit-bound; **VOID on head move**; posted durably
> with enumerated evidence under its own minted identity) → **coordinator head-pinned merge**
- **After a `GO`, pushes freeze** — a doc tweak voids the verdict. Gate-ready is a **freeze point**.
- The coordinator assigns the gate seat; the orchestrator owns getting a PR _gate-ready_.
- **Queue guard is ZERO-INFORMATION until RM-03 lands** (D-23) — record it as
`queue-guard: ZERO-INFORMATION (inert, D-23, owner RM-03)`. _A mandated field must not become a
manufactured one._
- Gate seat identity: `gitea-mosaicstack-merge-gate` minted least-privilege, verified `push=False`
it structurally cannot merge.
## Fleet seats
@@ -67,18 +103,6 @@
<!-- BOARD-ROLL:START -->
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
<!-- BOARD-ROLL:END -->
## Decisions log
+12 -2
View File
@@ -25,8 +25,18 @@ mechanically until Build 3 (rotation) makes it automatic.
## Standing invariants (never violate)
- **North star:** deterministic-right-answer → code/gate; LLM only for judgment.
- **Delivery gates:** author≠reviewer; PRE-REGISTERED diff-blind checks committed before reading the diff;
CI terminal-green; completion = merged PR + closed issue. rev-974 = the mosaicstack reviewer identity.
- **Delivery gates — REFERENCE the canonical files, never restate them:**
`~/.config/mosaic/fleet/roles.local/merge-gate.md` (verdict authority) and
`~/.config/mosaic/fleet/roles/validator.md` (validator role). Order:
independent review (author ≠ reviewer, `rev-974`; pre-registered diff-blind checks committed before the
diff is read) → remediation → **CI terminal-green at the exact head by full step scan**
**merge-gate verdict `GO`/`NO-GO`/`HOLD`**, commit-bound and **void the instant the head moves**, posted
durably with enumerated evidence under its own minted identity → **coordinator head-pinned merge**.
The queue guard runs but is **zero-information until RM-03 lands** (D-23) and must not be cited as evidence.
**After a `GO`, freeze pushes** — even a doc tweak voids the verdict. The coordinator assigns the gate seat.
- **Query for refutation, never for confirmation.** A subordinate asked to confirm a hypothesis will
agree — the bias is in the question, not the answerer, and agent seats are agreeable by construction.
State the hypothesis as yours, ask for the evidence that KILLS it, and reproduce when it matters.
- **Dogfooding:** every fix validated against its live seed case (MISSION.md lists them).
- **Tracking → DB** (hard cutover); do NOT re-invest in flat-file tracking. jarvis-brain PDA is off-limits.
- **Git identity:** export `MOSAIC_GIT_IDENTITY=<your-seat>` so wrappers author correctly and survive respawn.
+98 -2
View File
@@ -91,6 +91,54 @@ gate/program; the LLM handles only genuine judgment.
> seat in the loop. Residual risk bound to **RM-59** (`depends_on: RM-12, RM-21, RM-25`), where the
> choke-point executor and spine verify from _outside_ the worktree's authority.
### First-class principle — query for refutation, never for confirmation
> **A subordinate asked to confirm a hypothesis will agree. Ask it to refute, with evidence.**
>
> The bias is induced by the **query**, not by the answerer's diligence. _"The DB flaked — please
> confirm"_ and _"confirm or refute this, with the log line that proves it"_ are different instruments,
> and they return different answers to the same question. The first harvests agreement; only the second
> can return **"you are wrong, and here is why."**
>
> This matters most with agent subordinates, which are **agreeable by construction**: fluent, eager to
> be useful, and structurally disinclined to tell the dispatcher their premise is false. A confirmation
> query aimed at one is close to a guaranteed yes — so the discipline cannot rest on the answerer being
> rigorous. **It has to be built into how the question is asked.**
>
> Promoted to the charter by Mos (2026-08-01). Origin: the orchestrator hypothesised that a coincident
> `ci-postgres` failure caused a CI test failure and asked the implementing seat to **confirm or refute**
> it. The seat **refuted it** with the log (`ci-postgres:5432 - accepting connections`, migrations
> completed) and identified the real cause. Reproduction on an identical head then settled it. Had the
> query been phrased for confirmation, the agreement would have been returned, **D-21 would have been
> re-classified on a false premise**, and a banked finding would have been silently corrupted.
>
> **Operationally:** state your hypothesis explicitly, mark it as yours, ask for _evidence that kills
> it_, and say what evidence would change your mind. A hypothesis you cannot describe how to falsify is
> not yet a hypothesis. Where the answer is consequential, **reproduce** rather than accept — two
> independent runs beat one confident report.
### First-class principle — redundant observation on evidence-bearing steps
> **Two observers of the same evidence, disagreeing, catch what neither catches alone.** Apply redundancy
> not only to judgement calls but to **evidence gathering itself** — the step everyone assumes is
> mechanical and therefore skips.
>
> Promoted by Mos (2026-08-01) from **D-33**. A seat scanned a pipeline with `-f json` and reported 9
> steps; the orchestrator scanned the same pipeline in the wrapper's default text mode and reported 8.
> **The default output omits `clone`.** Every "full step scan" that night had been 8-of-9 and was stated
> as complete in good faith. No verdict changed — but the _method_ was wrong, invisibly, and **only the
> disagreement between two counts surfaced it.**
>
> The reason it survived: **a summary that resembles an enumeration is more dangerous than one that
> obviously summarises.** A labelled list of named steps with states _looks_ like the artifact, so nobody
> checks it against the record. Compare D-24 — `mergeable` was a _true answer to a different question_;
> this was a _true answer to a smaller one_. Neither is a lie; both pass every sniff test.
>
> **Operationally:** where a step _produces evidence a decision rests on_, have it produced twice by
> different means, and treat **any divergence as a finding rather than as noise to reconcile**. Prefer the
> machine-readable record over the human-readable rendering — _read the artifact, not the summary_ — and
> state the counts observed so a divergence is detectable at all.
## Decision record (authoritative, immutable)
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
@@ -109,6 +157,31 @@ gate/program; the LLM handles only genuine judgment.
| **4. Comms service** | AUTHORITY, INBOX (+ versioning roadmap) | Envelope (comms/v1) → sole-path service → PG/Redis → pluggable adapters (tmux→Matrix/Discord/Slack/Telegram). Version the protocol, not participants. |
| **+ Hygiene & proof** | FLEET, WORKFLOW, CONFORMANCE | One roster-owned socket/host + stale GC; allowlist auto-sync; the conformance harness that fault-injects the failure classes and proves builds 14 hold. |
## Why Builds 12 are necessary — two independent impossibility proofs
**The choke-point executor and PG spine are not a design preference. They are forced.** Twice during
the mission's own first deliveries, work stopped against a security property that **cannot exist** at
the layer that needed it — and both times the only resolution was an authority _outside_ the audited
party's control, which is precisely what Builds 12 provide.
| | the audited party controls… | so what fails | found as |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------- | -------- |
| **Artifact integrity** | the manifest that certifies its own generated tree (same-UID write ⇒ regenerate manifest _and_ marker consistently, CWE-345) | tamper-evidence | **D-19** |
| **Execution integrity** | the code that enters the sandbox (PR-controlled config executes _before_ the boundary exists) | isolation | **D-25** |
Both reduce to one sentence:
> **Self-verification by the audited party is not verification.**
And to one consequence: **the anchor must live outside the audited party's authority.** A local check
cannot defend against an actor who can rewrite the check; a repo cannot grant a capability to
PR-controlled config and simultaneously prevent that config from using it.
**An architecture forced by two independent impossibility proofs is stronger evidence than one argued
for.** Neither proof was sought — both arrived while trying to ship something else, from different
directions (a symlink manifest; a CI sandbox), at different layers. RM-59 and RM-60 are the two tracked
dependencies this creates, and they are the same dependency in different clothes.
## The finding that sets the cost
**Built-but-unwired disease.** `@mosaicstack/macp` is stranded (nothing calls it); `packages/coord` primitives
@@ -161,6 +234,29 @@ orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retir
- **Project orchestrator** `mos-remediation` (this seat) owns the mission; coordinates under Mos (lead).
- **Adversarial task decomposition:** `planner-opus` (robustness) + `planner-sol` (pragmatic) each decompose
the plan independently; orchestrator reconciles into `TASKS.md`/DB tasks. Oppositional by design.
- **Delivery gates (non-negotiable):** author≠reviewer, PRE-REGISTERED diff-blind acceptance checks committed
before reading the diff, CI terminal-green, completion = merged PR + closed issue. rev-974 = mosaicstack reviewer.
- **Delivery gates — REFERENCE, do not restate.** The authoritative definitions live at
[`~/.config/mosaic/fleet/roles.local/merge-gate.md`](file:///home/hermes/.config/mosaic/fleet/roles.local/merge-gate.md)
(verdict authority) and
[`~/.config/mosaic/fleet/roles/validator.md`](file:///home/hermes/.config/mosaic/fleet/roles/validator.md)
(validator/certificate role). **Read them; do not paraphrase them.** Restating an authoritative source
is lossy every time — see D-26, where a subset restated from memory dropped a security precondition.
**Gate order** (the sequence only; the definitions are in the files above):
1. Independent review, **author ≠ reviewer** (`rev-974` on mosaicstack), with PRE-REGISTERED diff-blind
acceptance checks committed before the diff is read
2. Remediation of findings
3. **CI terminal-green** at the exact full-40 head, by **full step scan**
4. **Merge-gate verdict — `GO` / `NO-GO` / `HOLD`** (class `merge-gate`; "Ultron" is an _instance name_,
display data, never an authority source). **Bound to a commit and VOID the instant the head moves.**
`HOLD` persists until replaced; a `NO-GO` answered by an empty commit must be re-issued as `NO-GO`.
Posted durably on the PR under the gate's own minted identity, **enumerating** its evidence — a bare
"GO — gates verified" is non-conforming.
5. **Coordinator merge**, head-pinned. The gate never merges; it holds `push=False` by design.
**The CI queue guard runs but is ZERO-INFORMATION until RM-03 lands** (D-23: it returns pass for every
possible input). It must not be cited as evidence by any gate, including the coordinator's own merge path.
**Assignment:** the coordinator assigns the merge-gate seat; the orchestrator does not. The orchestrator
owns getting a PR _gate-ready_.
- **Compaction survival:** see `KICKSTART.md` in this dir — the resume procedure. Persist typed state, not transcript.
+737 -12
View File
@@ -93,6 +93,729 @@ 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.
### RM02-REQ-10 — requirement revision, formally RECORDED AND ACCEPTED
Recorded under **RM-02's own clause 4** (meaning-changes retain original text, restatement, and reason).
RM-02's specification is subject to RM-02's rules; the recursion is deliberate. `rev-974` made the PR
**not review-ready until the coordinator records and accepts this** — discharged here.
**ORIGINAL (as dispatched):**
> _"PER-MERGE-COMMIT ASSERTION: assert that every merged commit passed every required gate, evaluated
> AGAINST THAT COMMIT'S OWN TREE — not against current main."_
**RESTATEMENT (accepted 2026-08-01):**
> PR CI performs **unprivileged, fail-closed, current-tree** verification only. **Isolated per-commit
> replay is deferred** to a protected post-merge/main authority, where it constitutes **detection with a
> defined quarantine/revert response — explicitly NOT a pre-merge gate.** Inability to establish the
> sandbox is a hard non-zero, never a skip.
**REASON:** isolated replay on PR CI would require granting namespace capability to PR-controlled
config, which the same PR could then use directly — a trust boundary that does not exist at the repo
layer (D-25). Not deferred for cost or effort; deferred because it is **impossible** here.
**Unblocked by: RM-60.** Accepted by `mos-remediation`; independently ruled by `rev-974`.
### RM-60 — why option B, and why option A does not work even alone (Mos, 2026-08-01)
Recorded because the reasoning is easy to get backwards, and getting it backwards buys standing kernel
exposure for nothing.
- **A (runner enables unprivileged user namespaces / rootless sandbox) DOES NOT FIX THE VULNERABILITY.**
It makes a sandbox _possible_; it says nothing about **when** the boundary is established relative to
PR-controlled code. **The defect is an ORDERING defect** — PR config executes before the boundary
exists. A grants a capability and leaves the ordering untouched, so B would still be required to close
it. **A without B is kernel exposure bought for nothing.**
- **B (protected default-branch pipeline config, or an immutable trusted launcher) IS the missing
thing.** A launcher established from **protected** configuration, outside the PR author's control,
entering isolation _before_ any PR-controlled executable is evaluated — that is the pre-execution
trust boundary, and it is the same "authority outside the audited party" that **D-19 and D-25 both
prove mandatory**. It may not need A at all: a protected launcher can use whatever isolation the
runner already supports.
- **B generalises; A does not.** The protected-launcher pattern is what **RM-59** needs, and is a small
instance of the **choke-point executor** the spine needs. A buys one narrow capability at a standing
kernel-exposure cost.
**Therefore B is not the cautious choice — it is the correct primitive.** A is a workaround that does
not work on its own.
**Status:** on Jason's board for a morning decision, with this reasoning, alongside the queue-guard item
(D-23), cross-referenced to RM-59. **Deliberately not actioned overnight:** enabling runner namespace
exposure or changing provider protected-pipeline posture is a host security-posture change belonging to
infrastructure authority. Nothing is blocked meanwhile — RM-02's head is unprivileged and fail-closed,
the privileged experiment stays uncommitted and out of branch history.
### RM-03 — CANNOT_ASSERT semantics: RULED (option B), 2026-08-01
Recorded because it resolves an **ambiguity in the orchestrator's own brief**, and because the reasoning
generalises beyond the queue guard.
The brief required that `CANNOT_ASSERT` "must NOT silently pass and must NOT permanently block" —
**without distinguishing push from merge.** Those need different answers. `coder-mos1` stopped and asked
rather than inferring authorisation from an ambiguous instruction plus a "carry on"; a Codex security
review independently flagged **CWE-693** on the same point.
**Ruling — B: push degrades audited/exit 0; merge returns a distinct non-zero/HOLD until the provider
recovers.** The asymmetry is the substance, not a compromise:
| action | consequence | posture |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------- |
| **merge** | proceeding without exact-head CI evidence is **D-23's condition in a narrower costume** — the guard passing exactly when its answer matters most | **fail CLOSED** |
| **push** | blocking during a provider outage **bricks delivery** — the Pi-brick class (P-RECOVERY-001), a gate whose own unavailability prevents recovery from it | **degrade, AUDITED** |
**"Must not permanently block" is satisfied:** a _temporary_ block pending evidence is not a _permanent_
one. Merge HOLD clears on provider recovery, and merge is separately gated by the merge-gate verdict and
the coordinator, so a non-zero `CANNOT_ASSERT` strands nothing.
**Conditions.** Distinct exit code (`CANNOT_ASSERT` never conflated with `ASSERTED_NOT_READY`, or the
tri-state is destroyed) · **the audit record is ASSERTED by a registered case, not assumed** — otherwise
"audited" is an integrity _claim_ dressed as a _property_ · retryable and self-clearing, documented ·
registered cases observed RED first for each arm · **no silent degraded merge path, ever** — any
authorised degraded merge is break-glass (loud, audited, expiring) and belongs to RM-05, not here.
**Option C (always non-zero) rejected:** cleaner to specify, worse in practice. It bricks push during an
outage and defers the degraded path to "later", which in this codebase means **a silent bypass appears
under incident pressure**. Three such bypasses are already on the books; do not create the conditions
for a fourth.
### RM-61 — the terminal-green exemption: ruled B, with a sequencing correction
**Ruling (Mos, 2026-08-01):** exempt the #1000 teardown artifact from the terminal-green scan via a
**named, bounded, tracked** CI-contract exemption — signature-scoped, negative-control-proven, retiring
when #1000 is fixed. Rejected: **A-first** (blocks all delivery on infra work) and **C** (case-by-case
acceptance = silent normalisation with no audit trail).
**Condition 1 tested — the signature IS stable.** All five observed failures share one shape:
```
pods "wp-svc-<ULID>-ci-postgres" not found # #2170 #2175 #2180 #2181 #2182
```
A Kubernetes **orchestration-layer lookup miss** — the pod object was gone when status was collected —
structurally unlike a service-level failure (container exit code, image-pull error, crash, connection
refused).
> ⚠ **BUT THE DISCRIMINATION IS UNPROVEN, AND THIS REORDERS THE CONDITIONS.**
> Every observation is of the artifact co-occurring with a **demonstrably working** database — #2180/#2181
> test logs show PostgreSQL accepting connections and migrations completing. **We have never observed a
> real `ci-postgres` failure on this provider.** So the signature is _consistent_; it is not yet shown to
> _discriminate_.
>
> The dangerous case is concrete: if PostgreSQL genuinely crashes and the pod is garbage-collected, the
> status query may **also** return pod-not-found — in which case the exemption **over-matches and masks a
> real failure**, which is precisely what condition 1 exists to prevent.
>
> **Therefore conditions 1 and 2 are not independent — condition 2 is the _evidence for_ condition 1.**
> Correct order: **build the negative control first**, use it to establish that a real failure produces a
> _different_ signature, and only then adopt the exemption. Adopting first and testing after would run the
> exemption live on an unverified assumption.
>
> **Kill criterion, stated in advance:** if an injected real `ci-postgres` failure also yields
> pod-not-found, the signature does **not** discriminate, **B is unsafe, and we do A** (fix #1000). Naming
> the falsifier before running the test is the point — an exemption we cannot prove wrong is an exemption
> we cannot trust.
**Ownership gap:** unassigned. `f10-coder` is on RM-02's blockers, `coder-mos1` on RM-03, `rev-974` is the
reviewer, and no other seat holds mosaicstack write. Flagged to the coordinator rather than stacked onto a
busy lane. Not blocking today — RM-02's code blockers are independently disqualifying — but the next
otherwise-clean PR meets this.
### RM-02 CI artifact — the bounded re-trigger is a STOPGAP, not a policy
Recorded so it cannot quietly become practice.
RM-02's pipeline #2187 came back **overall success with `ci-postgres` FAIL** — every functional child
green, `gate-verify` included. RM-61's exemption is not built, so **no authority to exempt it exists**.
The orchestrator used **one bounded, pre-declared CI re-trigger** at the same head as an interim, with the
response to each outcome fixed _before_ triggering:
- **clean** ⇒ a genuine terminal-green **observation** at that head (the mandate says green _at the head_,
not green _on the first roll_) ⇒ report gate-ready;
- **fails again** ⇒ RM-02 **waits for RM-61**. No ad-hoc exemption. **No third roll.**
**Why it was declared in advance.** Re-running until the desired answer appears is **p-hacking the
pipeline**, and done silently _nobody can distinguish it from diligence — including the person doing it_.
A bounded attempt count with a pre-declared response to each outcome is the only defence, and it is the
same discipline as naming a kill criterion before a test or committing acceptance checks before reading a
diff.
**Guardrail (Mos), non-negotiable.** **This must not become standing practice.** A per-PR "free re-roll"
on the artifact is **D-21 normalisation wearing a new hat** — routine artifact becomes routine re-roll,
and the erosion is identical. It is a one-time interim **for this PR**, under declared uncertainty, with
RM-61 as the real fix. **RM-61 must make the re-roll unnecessary, not codify it** — build the
discrimination, not a retry mechanism.
**A clean re-run does NOT retire RM-61.** The re-roll is a die whose bias remains unmeasured; only RM-61's
negative control measures it. RM-61 stays required regardless of this run's outcome.
> **OUTCOME — the re-run FAILED, and the declared response was honoured.** Pipeline **#2188**, same head
> `f9746b23`, JSON scan 11 entries: `ci-postgres` the **sole** failure, every other child success
> including `gate-verify`. Confirmed independently by `f10-coder`, which applied the pre-registered rule
> itself — _"I do not call terminal-green, do not request a third roll, and do not infer an exemption."_
>
> **RM-02 is BLOCKED on RM-61.** No ad-hoc exemption. **No third roll.**
>
> Worth stating why not, because the temptation was legible: two failures against one earlier success
> (#2184 was clean at the previous head) makes _"best of five"_ feel reasonable. **The entire point of
> declaring the bound in advance is that it binds when the result is inconvenient.** A bound honoured
> only when it agrees with you is not a bound — and a third roll would have been indistinguishable from
> diligence, _including to the person doing it_.
>
> **Data point for RM-61:** the artifact hit **twice consecutively** at `f9746b23` while #2184 was clean
> at `38f1b249` — so it may **cluster** rather than being uniformly random per run. That makes the single
> bounded re-roll a _weaker_ mitigation than it appeared, and strengthens the ruling that it must never
> become policy: **if failures cluster, a one-shot re-roll is close to worthless.**
### The sharpest illustration the mission has produced
**RM-02 — the anti-inert-gate registry, built so a gate that has silently stopped enforcing can be
DETECTED — is itself blocked by an infrastructure artifact that makes gate evidence unreliable.**
Its code is complete. Its own harness passes in CI. Two independent reviews cleared its substance. It
cannot be certified terminal-green because the CI record contains a failure **nobody can yet prove is
meaningless**.
That is not an obstacle to the mission. It is the mission's thesis demonstrating itself on its own
keystone deliverable.
### ★ MILESTONE — the full merge stack completed end-to-end for the first time (RM-03 / PR #1032)
`independent review → CI terminal-green at the exact head → merge-gate verdict → coordinator → held for owner`
The merge-gate returned **GO** (comment 20392, posted under its **own minted identity** — the #3084
fail-open-to-admin foreclosed), enumerating: JSON **9/9** step scan, head triple equal at `78ec47cd`,
review **id 66 APPROVED** at the current head, `Closes #1019`, **`queue-guard: ZERO-INFORMATION`**, and
head stability post-verdict. Verified independently after the verdict: the head has **not** moved, so the
commit-bound GO remains valid.
**What makes it worth marking:** every element was _observed_, none relayed; the queue-guard field was
recorded as the meaningless value it currently is rather than cited as evidence; and the verdict was
posted durably by an identity that **structurally cannot merge** (`push=False`). The gate that authorises
merges cannot perform them.
And the shape holds: **RM-03 is the fix for the queue guard, so this — the first delivery through the
complete stack — is also the last one gated by a check that could not fail.**
### D-35 — render-not-restate was enforced by a TOOL where attention had failed three times
While editing `roles.local/merge-gate.md` to fix D-33, the coordinator **recalled the mandate string
instead of reading it** — and the Edit tool's **exact-match requirement rejected the edit**. The source
was then re-read and the change applied correctly.
**Third instance for that author, occurring inside the turn that was fixing a different instance of the
same class.** It did not reach the record, because a **mechanism** caught what attention had not.
**This is the argument for RM-02 in miniature, demonstrated live.** The rule has now failed in both its
authors, repeatedly, under maximal attention, while each was actively enforcing it on the other (D-26,
D-30, and this). Every failure that _reached_ a document was caught by a human noticing later; **the one
failure that did not reach a document was caught by a tool refusing an inexact match.**
> **A rule that fails in its authors but is caught by a tool has told you exactly where it belongs.**
**Requirement.** This is the pattern RM-02 generalises: exact-match/verbatim enforcement on citations and
authoritative text, so a recalled-but-wrong quotation **cannot be written at all** rather than being
detected downstream. The Edit tool did it by accident of design; the registry should do it by intent.
**Fix landed durably, and by the right method:** the coordinator annotated the _source_
`merge-gate.md` mandate 3 now requires the full step scan **from the JSON/API record**, names the
default-text `clone`-pruning as D-33, and requires the gate to **state the count it observed**; mandate 4
requires the count read from JSON, with _"a count that does not match the JSON record is
non-conforming."_ Every future gate assignment inherits it — **annotate the source, do not restate per
assignment.**
**Deliberately not done:** the shared `pipeline-status.sh` wrapper was **not** modified. Other consumers
depend on its current output; changing it mid-flight would be an unreviewed change to a shared tool at a
moment when three lanes are reading it. Fix at a seam, tracked.
### D-34 — a context reset silently strips a seat's credential identity
`rev-974` completed its RM-02 review, then **could not post it**:
`pr-review.sh … → "Gitea token not found for review post"`. It stopped per the wrapper-failure gate
rather than reaching for a raw CLI — correctly.
Diagnosed rather than guessed:
| environment | `get_gitea_token git.mosaicstack.dev` |
| -------------------------------------------------- | ------------------------------------- |
| `MOSAIC_GIT_IDENTITY` unset (a freshly-reset seat) | **FAILS** — empty |
| `MOSAIC_GIT_IDENTITY=rev-974` | resolves, 40 chars |
The token file was present and correct throughout. **The orchestrator's own context reset wiped the
seat's exported identity**, and the rehydration brief did not re-establish it. The seat had posted
reviews 6164 earlier the same night because that export was still live.
**The split that makes this invisible:** `git config mosaic.gitIdentity` is **persistent and
per-worktree**; `MOSAIC_GIT_IDENTITY` is **ephemeral and per-context**. A seat working inside a
configured worktree survives a reset by falling back to git config. `rev-974` works from `~/agent-work`,
which carries no such config — so it had **no fallback and no warning**, and the loss surfaced only at
the moment it next tried to act on a credential.
**This is the intersection of two banked findings, and it was created by acting on one of them.** D-31
prescribes rotation as the response to context exhaustion; D-11a establishes that identity must cohere.
**Rotating a seat is a credential-affecting operation** — and nothing in the rotation procedure said so.
The fix for one failure quietly introduced another.
**Requirements.** RM-34 (rotation daemon): a reset must **re-establish seat identity as part of the
rotation**, not leave it to the rehydration prose — and must **verify** it (resolve a token, or assert
the identity is set) before the seat is handed work. RM-50: prefer the **durable** binding — set
`mosaic.gitIdentity` in each seat's working repo so identity survives a context reset by construction,
rather than depending on an env var that a reset destroys. Interim: **every rehydration brief re-exports
the seat identity**, and a rotated seat's first credential-touching action is treated as unverified until
it succeeds once.
### D-33 — the tool used for "full step scans" silently prunes a step in its default output
`coder-mos1` reported a **9/9** child-step scan of pipeline #2186 using `-f json`. My scan of the same
pipeline, using the same wrapper in its **default text mode**, showed **8**. Verified:
| mode | steps enumerated |
| -------------------------------------------- | ----------------------------------------------------------- |
| `pipeline-status.sh -n 2186` (text, default) | **8** — begins at `ci-postgres` |
| `pipeline-status.sh -n 2186 -f json` | **10** — the `ci` workflow entry, **`clone`**, plus those 8 |
**The default output omits `clone`.** Every "full step scan" performed tonight — RM-01, RM-02 across four
pipelines, RM-03 — enumerated **8 of 9 real child steps**, and reported that as complete. `clone`
succeeded in every case, so **no verdict changes**; the _method_ was incomplete and its user did not know.
**Why this is more than a cosmetic gap.** The merge-gate mandate requires CI verified _"by a **full step
scan** — a `tests-complete` step is a pruning-tell, never a completion-tell"_, and requires every verdict
to enumerate _"pipeline id + the step-scan result (step count, any SKIPs…)"_. **A verdict citing 8 steps
where 9 exist is non-conforming evidence** — and if the gate uses this wrapper's default output, it will
produce exactly that while believing it complied.
**The shape is the session's own thesis, aimed at the tool built to detect it.** The doc's instruction is
literally _"read the artifact, not the summary."_ The text mode **is** a summary. It looks like an
artifact — a labelled list of named steps with states — which is precisely why nobody checked it against
the raw record. **A summary that resembles an enumeration is more dangerous than one that obviously
summarises.** Compare D-24: `mergeable` was a _true answer to a different question_; here the step list is
a _true answer to a smaller question_.
Caught only because a seat used `-f json` and its count disagreed with mine. **A disagreement between two
observers surfaced it; neither observer alone would have.**
**Requirements.** RM-02 registers a case asserting the scan enumerates the **full** child-step set
(compare text-mode count against the JSON record, must-fail on divergence). The merge-gate must scan from
the **JSON/API record**, never the text summary, and state the step count it saw. The wrapper's default
output should either include `clone` or **stop presenting itself as a complete step list**.
### D-32 — the repaired gate shipped with a documented switch to skip it
`rev-974` blocked RM-03 (PR #1032) on a single finding, and it is the one that mattered:
**`pr-merge.sh` still ships `--skip-queue-guard`.**
Verified independently at five sites in `packages/mosaic/framework/tools/git/pr-merge.sh` — usage line
`:3`, help text `:29` ("Skip CI queue guard wait before merge"), a worked **example** at `:38`, the flag
handler `:58`, and the conditional wrapping the entire `ci-queue-wait.sh` invocation at `:119`.
**The reviewer did not report this from reading — it proved it.** It replaced the fixture queue guard
with an `exit 99` stub, ran a real fixture merge with `--skip-queue-guard`, and observed: **the guard was
never called, the provider payload was created, the wrapper printed "merged successfully", exit 0.**
**Why this is disqualifying rather than a nitpick.** RM-03 repairs a mandatory merge gate that has never
once been able to fail (D-23). Shipping that repair while leaving a **documented, advertised,
worked-example** flag that skips the repaired gate means the gate still cannot be relied upon — **it
merely requires one flag instead of zero.** Every merge-side `CANNOT_ASSERT`/`HOLD` semantic the task
built is bypassed by it. L0 is explicit: _"Never use workarounds that bypass quality gates — `--no-verify`
and equivalent skip switches are off-limits."_ This is an equivalent skip switch, sitting in the merge path.
The merge-gate role doc names this exact hazard about `force_merge`, and the description transfers
without modification:
> _a `--no-verify` equivalent, **adjacent to the field you came to edit, at the exact moment you are most
> motivated to reach for it** — red gate, queued PR._
**The generalizable rule.** **Repairing a gate is incomplete while any supported path skips it.** A fix
that restores a gate's ability to fail, but leaves a switch that turns it off, has changed the failure
from _"the gate cannot block"_ to _"the gate can be told not to"_ — which under incident pressure is the
same outcome reached one keystroke later. Removing the bypass is **part of the repair**, not a follow-up.
**Required:** remove `--skip-queue-guard` from merge-capable execution — flag, usage, help text and
example alike (**an undocumented-but-working bypass is worse than a documented one**) — plus a RED-first
regression proving a merge-capable invocation cannot reach a provider merge call with the guard unrun.
`--dry-run` may retain the skip since it does not merge, provided dry-run and merge-capable paths can
never both be true.
**Everything else in RM-03 passed independent verification**, including the 160 KiB stdin transport where
#1023 regressed, the full classification matrix, the audited push-0/merge-75 tri-state, and the scope
disposition. A strong PR with one hole in it.
### D-31 — a seat ran past 100% context with 28 uncommitted files and no commits
`coder-mos1` reached **102.5% / 372k context** on RM-03 with **28 modified files uncommitted** and its
branch still at the base commit — i.e. **a full context window spent (~\$27) with nothing durable**. The
orchestrator caught it by polling seat state, not by any signal from the system.
**Nothing warned anyone.** No threshold alert, no pre-compaction hook, no "you are about to lose this"
prompt. The seat did not report distress because from inside, running low on context feels like ordinary
work right up until it is not. **The one failure mode the mission was founded on — compaction destroying
in-flight work — very nearly happened to the mission's own delivery, twice in one night** (RM-01 was
checkpointed at 69%; this one was already past the window).
**What made RM-01 survivable and this one nearly not:** RM-01 had _committed_ work when it was
checkpointed, so rotation was cheap. Here the work existed only in the worktree and the seat's context.
**Commit-then-rotate works; rotate-without-commit loses everything.**
**Requirements.**
- **RM-34 (rotation daemon):** a context-threshold trigger is not enough — it must **force a durable
checkpoint before rotating**, and refuse to rotate a seat with uncommitted work until that work is
committed or explicitly discarded. Rotation without a commit is data loss with extra steps.
- **RM-50 (fleet ownership):** seat context level is **observable state** and must be monitored, with a
threshold alert to the coordinator. Relying on an orchestrator to poll is exactly the "instructions,
not enforcement" pattern (D-4) applied to lifecycle.
- **Worker briefs (immediate, mechanical):** _commit early, commit WIP, an imperfect commit that exists
beats perfect work that vanishes._ Added to standing brief doctrine rather than left to judgement.
**Adjacent observation — scope drift, unverified.** The same diff spans 5 framework guides and 11 agent
templates against a brief scoped to the queue guard and its tests. Queried rather than assumed: if the
edits are consequential to the tri-state change they stay; if drive-by, they split. Recorded here because
**a 28-file diff from a brief that named one script is a scope signal regardless of the answer.**
### D-29 — the registry's own coverage clause was SYNTACTIC, not semantic
`rev-974` blocked PR #1030 with a finding that lands in the keystone's own enforcement:
> It **moved** `RM02-MEANING-PROVENANCE` and `RM02-PROSE-CONTROL` from the stale-build-lock case to an
> **unrelated type-error case**, reformatted the manifest, and **`pnpm gate:verify` still exited 0.**
So the registry verifies that a criterion **has a binding**, not that the bound case **can fail for that
criterion's stated reason**. That is exactly what **RM02-REQ-03 (clause 2, from D-17)** requires, and it
is not satisfied — `gates/gates.manifest.json:471-488` + `scripts/gate-verify.mjs:410-417`.
**The keystone reproduced the defect it was built to eliminate, in its own coverage check.** D-17 said a
green suite can leave a criterion untested; here the tool that enforces that clause enforces only its
_syntax_. A binding that can be pointed anywhere without complaint is a binding that proves nothing —
**D-24's "true answer to a different question" wearing a manifest.**
Caught only because the reviewer **mutated the binding and re-ran**, rather than reading the manifest and
confirming the entries existed. Inspection would have passed it.
### D-30 — the orchestrator fed a reviewer a false comparator supporting its own conclusion
In the review brief for #1030 I stated the `ci-postgres` FAIL had "appeared under overall-success on
**#2167**/#2170/#2175". `rev-974` refuted it: **#2167's `ci-postgres` step is `OK`.** Verified —
#2158 `OK`, **#2167 `OK`**, #2170 `FAIL`, #2175 `FAIL`, #2182 `FAIL`.
**My own banked D-21 says so explicitly:** _"Observed on #2170 and #2175 (**not on #2158, #2167**, or the
manual #2171)."_ I contradicted my own written record — because I **restated it from memory instead of
reading it.** That is **D-26 recurring**, committed by the orchestrator, in a reviewer brief, hours after
promoting render-not-restate to the charter.
**The specific harm is worse than the inaccuracy.** I supplied a reviewer with **fabricated supporting
evidence for my own reading**, in a brief that also asked it to accept my `ci-postgres` interpretation.
Had it deferred, the false comparator would have propagated into the review record and strengthened a
conclusion it never supported. It refuted me instead — which is the query-for-refutation rule paying for
itself in the same document that introduced it.
**Requirement.** A brief that supplies evidence must **cite it from the record, not from recall**
findings are quoted with their identifier, not paraphrased. RM-02 registers it: where a document cites a
finding, the citation must match the banked text, with a must-fail control on divergence.
### D-28 — a swallowed diagnostic destroyed the evidence a fail-closed check needed
RM-02's CI run failed on four `scripts/gate-history.test.mjs` sandbox tests. **The fail-closed logic
behaved correctly; the defect was that it was denied the evidence to decide.**
Chain: Bubblewrap is installed by the **gate** step but not the **test** step, so `spawnSync('bwrap', …)`
returned `status=null` / `ENOENT`. `replayCommit` then **replaced the spawn diagnostic with an empty
string** (`historical frozen dependency install failed:`). With the underlying error destroyed, the
classifier **could not prove Bubblewrap provenance** — and, correctly, **refused** to treat an
unprovable condition as expected sandbox unavailability.
**The refusal was right. The information loss was the bug.** A fail-closed check is only as good as the
evidence reaching it: strip the diagnostic and a correct classifier is forced into a correct-but-opaque
refusal that looks like a defect in the thing being tested. **Error text is not decoration — for a
classifier it is the input.**
**Fix requirements** (and what review must scrutinise): preserve `install.error.message` in replay
diagnostics, and accept **only Bubblewrap-provenance** `EPERM`/`EACCES`/`ENOENT` as terminal sandbox
refusal — unrelated command errors stay rejected. **Widening the acceptance to make the test pass would
be a real finding**, not a fix: it would convert a precise fail-closed check into a permissive one.
**Two linkages worth recording:**
- **D-16 again** — it did not reproduce locally because `bwrap` _is_ installed there. Local and CI
disagreeing about what passing means, a third time.
- **Diagnostic-preservation is a gate requirement, not hygiene.** RM-02 registers it: where a check
classifies on the basis of an error, the case must assert the **diagnostic survives** to the
classifier, with a must-fail control proving a swallowed message is detected.
**Process note.** The orchestrator's hypothesis — that the coincident `ci-postgres` FAIL caused the test
failure — was **wrong**, and refuted with direct evidence: the log shows `ci-postgres:5432 - accepting
connections` and migrations completing. **D-21 therefore stands unchanged as a teardown artifact and is
NOT upgraded.** It was about to be re-classified as "intermittently takes out the test step" on a false
premise; asking the seat to _confirm or refute_ rather than accept is what prevented a finding being
corrupted by a plausible guess.
### D-27 — the authoritative role file certifies an inert control as `✅ enforced`
Found by `rev-974` while loading the canonical gate sources — i.e. found _because_ we switched from
restating to reading (D-26). Verified independently in
`~/.config/mosaic/fleet/roles.local/merge-gate.md`:
| line | text | problem |
| --------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------- |
| :59 (mandate 3) | verify "**CI queue guard clear**" from primary evidence | mandates verifying a check that cannot fail |
| :75 (mandate 4) | every durable verdict must enumerate "**the queue-guard outcome**" | requires a meaningless field _in the evidence record_ |
| :128-129 | coordinator merge step 2 runs `ci-queue-wait.sh --purpose merge`; annotated "**real**" | the sensor never fires |
| **:290** | control table: **"CI queue guard \| ✅ enforced \| … genuinely aborts"** | **a security-control table certifying an inert control** |
**The precise defect, because the distinction matters.** The document is _correct about the wiring_ and
_wrong about the control_. `set -euo pipefail` with an unguarded exit genuinely does abort — that
plumbing is real. What is false is the conclusion `✅ enforced`, because **the guard cannot produce a
non-zero exit for any input** (D-23). So this is **a mechanism correctly wired to a sensor that never
fires**, documented as enforced in the governing role file — the inert-gate class, certified.
Note the compounding: the verdict format **requires** the queue-guard outcome as enumerated evidence.
A conforming verdict is therefore obliged to include a field that means nothing — the role file
**instructs the merge-gate to manufacture evidence from an inert check**, in the same mandate that
exists to stop bare conclusions being recorded as verdicts.
**Interim resolution (confirmed with `rev-974`, with one refinement).** Follow the stricter D-23 ruling.
But do **not omit** the queue-guard field — omitting a required field makes the verdict non-conforming.
**Record it, labelled `ZERO-INFORMATION (inert, owner RM-03)`.** That satisfies the enumeration mandate
and states the truth simultaneously; a silently-omitted field and a silently-cited one are both worse.
**Not ours to edit.** `roles.local/merge-gate.md` is an operator-owned override; the correction belongs
to the coordinator. **Recommended fix is annotation, not deletion** — the requirement is _correct once
RM-03 lands_, so removing it would create a gap the moment the guard starts working. Annotate the three
evidence sites and correct `:290` to distinguish _wiring real_ from _control inert_.
**This is D-20's clause meeting an authoritative framework document.** Prose asserting a security
property needs a negative control observed red; `✅ enforced` is exactly such an assertion, and it would
fail that control today. The rule was written after the orchestrator overclaimed in its own governing
document — it now catches the same defect in the framework's.
### D-26 — render-not-restate failed at maximal attention, committed by the rule's own enforcer
**The definitive justification for making render-not-restate mechanical.**
The mission's delivery-gate definition was **incomplete from setup**: the merge-gate verdict step was
missing. Root cause, stated by the coordinator: when `MISSION.md` / `KICKSTART.md` were written, the
gates were **restated from memory** — _"author ≠ reviewer, diff-blind checks, CI-green, merged-PR
completion"_ — a subset of an authoritative document that was not open at the time. The omission then
**propagated into every worker brief issued for the rest of the mission.**
**Then it happened again, in the correction.** The message correcting the restatement error was itself a
restatement: delivered from memory, without the source open, and it dropped or altered five things —
| stated | actual (`fleet/roles.local/merge-gate.md`) |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| verdict is `APPROVE`/`BLOCK` | **`GO` / `NO-GO` / `HOLD`** |
| — | **verdict is commit-bound and VOID the instant the head moves**; `HOLD` persists; an empty-commit answer to `NO-GO` must be re-issued as `NO-GO` |
| — | the gate verifies mechanical gates **itself from primary evidence** (reviewed-SHA == CI-SHA == head, full step scan, `Closes #N`, queue guard) |
| — | the verdict must be **posted durably, enumerating evidence**; a bare "GO — gates verified" is **non-conforming** |
| cite `guides/CANONICAL-DELIVERY-CYCLE.md` | **that file does not exist** — the path was taken from another brief and cited without checking it resolves |
**And the omission was safety-relevant.** The role doc carries a hard install precondition: an unminted
gate seat's credentials **fail open to the owner's admin account**, so its verdicts would post as admin —
_"the exact mechanism of the #3084 breach this seat exists to prevent."_ Verified: gate identities existed
for USC only, none for mosaicstack (D-15's class, on the seat that authorises merges). Assigning the gate
at that moment would have reproduced the breach it was created to stop.
**Why this is the definitive case, and not just a third instance.** The failure occurred when every
condition favoured success: **attention was maximal** (a deliberate correction), **the actor knew the rule
best** (its author and enforcer), and **the subject was the rule itself** — the message was _about_
render-vs-restate while committing it. A discipline that fails under those conditions does not fail from
carelessness; it fails because **human recall of a structured document is lossy by construction, and the
loss is silent.**
> **A rule that its own enforcer cannot follow while enforcing it is not a discipline. It is a
> requirement for a mechanism.**
**Requirement.** Governing documents must **reference** authoritative sources, never paraphrase them, and
**every reference must resolve** — a dangling pointer to a non-existent authority is _worse_ than a
restatement, because it reads as authoritative and cannot be checked. RM-02 registers both: a link-resolution
case (every cited authority path exists) with a must-fail control, and the D-20 clause that prose asserting a
property needs a negative control.
**Resolved:** `MISSION.md` and `KICKSTART.md` now **reference** `fleet/roles.local/merge-gate.md` and
`fleet/roles/validator.md` and state only the gate _order_. The precondition was cleared properly —
`gitea-mosaicstack-merge-gate.token` minted at **least privilege**, independently verified by this
orchestrator as the seat's own token: `pull=True, push=False, admin=False` — it **structurally cannot merge**,
which is what a verdict authority must be.
### D-25 — self-verification by the audited party, a second time, one layer down
RM-02's per-commit replay needs isolation. Docker denied Bubblewrap namespace creation; the obvious fix
— a privileged CI step — was **correctly refused** by the implementing seat and rated CRITICAL by a
security review, because **PR-controlled `package.json`/verifier code executes _before_ Bubblewrap
establishes any boundary.** The untrusted party would obtain the very capability meant to contain it.
UID 65534 and later capability drops do not repair an _ordering_ defect.
The seat's own formulation, promoted here verbatim because it is the whole argument:
> **"Repo-only code cannot both grant namespace capability to PR config and prevent that same PR from
> using the capability directly."**
**This is D-19 again, one layer down — and the repetition is the finding.**
| | audited party controls… | so verification fails at… |
| -------- | -------------------------------- | ------------------------- |
| **D-19** | the manifest that certifies it | **artifact** integrity |
| **D-25** | the code that enters the sandbox | **execution** integrity |
Both reduce to: **self-verification by the audited party is not verification.** Both resolve the same
way — an authority _outside_ the audited party's control. **That is now twice this mission has
independently derived the necessity of its own architecture (Builds 12: choke-point executor + spine)
from a security impossibility rather than from design preference.**
**Ruling — Option C**, reached **independently** by the orchestrator and by `rev-974` before either saw
the other's reasoning (the same convergence signal the two planners produced). Ship what the layer can
guarantee — unprivileged, fail-closed, current-tree verification on PR CI — and defer isolated
per-commit replay to a protected authority.
**Condition from rev-974 that the orchestrator's ruling missed, and it matters most:**
**Protected post-merge replay is DETECTION, not PREVENTION.** It must be documented as such, with its
response defined (quarantine / revert), and **must never be presented as equivalent to a pre-merge
gate.** Presenting detection as prevention would be D-20's overclaim in a new location — and would be
especially corrosive here, since the whole task exists to make gates trustworthy.
**Also binding:** inability to establish the sandbox must be a **hard non-zero**, never skip-or-pass;
the meta-negative-control must not be weakened to fit the unprivileged environment (any gate whose
mutation check genuinely cannot run unprivileged is registered as a `DEFECT` with an owner, never
quietly dropped); and the privileged experiment stays **uncommitted**, out of branch history.
**Tracked dependency: RM-60** — an external pre-execution trust boundary (protected default-branch
pipeline config, or an immutable trusted launcher) owned by infra/provider authority, cross-referenced
with **RM-59**. Both are the same dependency wearing different clothes: _the anchor must live outside
the audited party._
### D-24 — the orchestrator cited `mergeable: true` as evidence of merge-readiness
Escalating D-23, I wrote that the fix "exists and is parked, open and mergeable" — framing that invited
the reading _merge this and the gate is fixed_. **Mos verified #1023 before relaying, and corrected me.**
`mergeable: true` is **git-mergeability**: the absence of textual conflicts. It says nothing about review
state, CI, or correctness. Verified independently after the correction: **review id-58,
`REQUEST_CHANGES`, at commit `f6334080` — the current head.** The block is live, and its findings are
that #1023's _own fix_ is defective.
**This is the charter's first principle — observe the property, not the proxy — committed by the
orchestrator, inside an escalation about a proxy being mistaken for a property.** I spent the session
demanding that discipline of others, then read a JSON field whose name resembled the question I cared
about and reported it as the answer. **Fifth instance of mine.**
Worth naming precisely, because it will recur: **`mergeable` is not a lie — it is a _true answer to a
different question_.** A false field would have been caught. A true-but-adjacent field passes every sniff
test, which is exactly why the discipline must be **mechanical rather than attentive**.
**Requirement on RM-02.** A registered case asserting readiness must assert the **decision-relevant
property** — latest review state, CI terminal status on the exact head, required approvals — never a
transport-level or structural proxy. A case that reads an adjacent field is D-17 (the set does not cover)
in a convincing disguise.
### D-23 — the mandatory CI queue guard has never worked, for any input, ever
RM-02's registered fixtures found this **before the registry was even built** — the sharpest possible
argument for the task. It is materially worse than the already-banked "unknown ⇒ exit 0" (D-6/D-10).
`ci-queue-wait.sh:266` pipes the provider payload into a classifier that begins `python3 - <<'PY'`.
**`python3 -` reads its _program_ from stdin, and the heredoc supplies that program** — so
`json.load(sys.stdin)` never sees the piped JSON, EOFs, and falls through to `unknown`.
Proven empirically, not inferred:
```
payload={"state":"success"} -> unknown
payload={"state":"failure"} -> unknown
payload={"state":"pending"} -> unknown
payload=not-json -> unknown
```
**Every input classifies as `unknown`; `unknown` exits 0.** So the mandatory pre-push / pre-merge guard
returns **PASS for every possible input — including a genuinely FAILING CI.** It has never distinguished
anything. My six observed "meaningless greens" were not an edge case; they were **the only output it can
produce.**
**A fix was ATTEMPTED and is parked — but it is NOT ready.** PR **#1023** — _"parse the payload it was handed, not stdin"_
diagnoses the defect precisely in its own body: _"binds stdin to the program text, so
`json.load(sys.stdin)` EOFs, the state falls through to `unknown`, and the guard exits 0 on every
invocation, every branch, every repo, both platforms."_
Two aggravating details from that PR's own body:
1. **`pr-ci-wait.sh:38` documented this exact bug and its remedy — and it was never backported to the
mandatory sibling.** A known defect, with a known fix, written down in one tool and never applied
where it was load-bearing. That is D-14 propagation failure with a security-adjacent blast radius.
2. It was _"opened per board instruction without re-verification"_ — the fix for an unverified gate was
itself shipped unverified.
> ⚠ **CORRECTED (D-24), propagated backward per D-14-as-amended.** This entry originally read
> _"The fix already exists and is parked … open, mergeable"_ — framing that invited _merge it and the
> gate is fixed_. **`mergeable: true` is git-mergeability — the absence of conflicts — not readiness.**
> Verified independently: #1023's latest review is **rev-974 id-58, `REQUEST_CHANGES`, at commit
> `f6334080`, which IS the current head** — the block is **live, not superseded**. That review found
> #1023's own fix defective: `unknown` still exits 0, payload-as-argv hits **ARG_MAX**, and its tests do
> not assert exit outcomes. So "merge #1023 and the guard is fixed" is false twice over.
**Escalation, for Jason, on #1023's disposition.** Every merge gated on this guard since it was
introduced was **ungated**. The guard cannot fail. This does not mean those merges were bad — CI itself
ran and was checked by humans and reviewers — but the _automated_ gate contributed **zero** signal
throughout, while being cited as merge evidence. The standing doctrine ("treat its green as
zero-information") was more literally correct than anyone realised when it was written.
**RM-02 records `required` vs `actual` per case with `DEFECT (owner: RM-03)`** and stays sensitive to any
change in either direction. No RM-03 lane was opened; `ci-queue-wait.sh` was not edited; source and
installed copy remain byte-identical at the observed SHA-256.
### D-22 — the registry would have verified the wrong artifact
Surfaced while ruling RM-02's design. The gate that **actually runs** when the fleet invokes a wrapper is
the **installed** copy:
```
~/.config/mosaic/tools/git/ci-queue-wait.sh ← what executes
packages/mosaic/framework/tools/git/ci-queue-wait.sh ← what a repo-scoped registry would test
```
Checked: today they are byte-identical (`sha256 19cda2f7009c536e` both). **But nothing asserts that.**
There is no check, anywhere, that the deployed gate matches its source.
**A registry that verifies the wrong artifact is worse than no registry**, because it manufactures
confidence: every case would go green against a file that is not the one enforcing anything. Note this
would have been invisible from every signal we have — the registry's own tests would pass, CI would
pass, and the deployed gate could be arbitrarily different.
This is the **D-1 / P-ACTIVATION class applied to the gates themselves**: the same "committed config
that only works on one runtime" defect, one level up, where the _enforcement mechanism_ rather than the
config drifts from source.
**Requirement on RM-02.** For every registered gate with a deployed counterpart, a case asserting
**repo-source == deployed-copy**, with a must-fail control (mutate the deployed copy ⇒ detected). Where
a gate has no deployed counterpart, record that fact explicitly rather than leaving it ambiguous.
**Caught before implementation began** — the only finding tonight found by _design review_ rather than
by execution, which is an argument for the design-first gate on keystone tasks paying for itself.
### D-21 — a service step reports FAIL under an overall-success pipeline (normalised red)
Observed on pipelines **#2170** and **#2175** (not on #2158, #2167, or the manual #2171): the
`ci-postgres` **service** step reports **FAIL** (`pods "wp-svc-…-ci-postgres" not found`) while the
pipeline's overall status is **success** and all eight functional steps are `OK`.
Most likely mechanism: the service pod is reachable during the run and has been reaped by the time its
terminal state is collected. Supporting evidence that the database really was available — the `test`
step runs `pnpm --filter @mosaicstack/db run db:migrate` behind a `pg_isready` loop written to fail fast
if Postgres never comes up, and that step passed.
**Severity is low; the pattern is not.** A red that is _routinely_ present and _routinely_ correct to
ignore trains every operator and every agent to discount reds — and the discounting generalises to reds
that matter. This mission already has six instances of a green that meant nothing (D-5, D-6, D-10);
**a FAIL that means nothing is the same defect with the sign flipped.** Both erode the signal that gates
exist to carry.
Not chased further because it is CI-infrastructure behaviour rather than repository code, and both
merges it touched had every functional step green with the overall status successful. **Recorded rather
than normalised**, which is the whole point: the first time this is waved through without a note is the
moment it becomes background noise.
**Requirement on RM-55 (conformance harness):** treat a non-terminal-success sub-step under an
overall-success pipeline as a **reportable anomaly**, not a cosmetic artifact — and make the CI contract
state explicitly which steps are permitted to fail without failing the pipeline. An implicit allowance
is indistinguishable from a bug.
### 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
@@ -811,18 +1534,20 @@ 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-61 | **CI-contract exemption for the #1000 teardown artifact** — signature-scoped, negative-control-proven, bounded, retiring with #1000 (ruled B, Mos 2026-08-01) | mos-remediation | — (unassigned; no free write-capable seat) | 15K | sonnet |
| RM-60 | **External pre-execution trust boundary for CI (option B — the correct primitive, not the cautious one)** — protected default-branch pipeline config or an immutable trusted launcher that enters the sandbox **before** any PR-controlled executable/config is evaluated; unblocks isolated per-commit replay (RM02-REQ-10) | mos-remediation (D-25) | infra/provider authority (Mos + Jason) | 25K | opus |
| 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 |
**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: AC1AC8 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.
-120
View File
@@ -1,120 +0,0 @@
# RM-03 — CI Queue Guard Repair
- **Task:** RM-03
- **Issue:** #1019
- **Branch:** `fix/rm-03-queue-guard`
- **Owner:** coder-mos1
- **Reviewer:** rev-974 (independent; author != reviewer)
- **Started:** 2026-08-01
## Objective
Repair the mandatory CI queue guard so it reads provider payloads, blocks asserted non-green CI, distinguishes provider unavailability from a real non-green result, and inspects the branch actually being pushed or merged.
## Constraints
- Worktree only: `/home/hermes/agent-work/rm-03`; never mutate `/src/mosaic-stack`.
- JSON payload travels through stdin; never argv. Large payload must remain below no ARG_MAX dependency.
- TDD is mandatory. Every behavior case must be observed red before implementation.
- No bypass flags or hook suppression.
- Do not cite the existing guard's green as evidence; D-23 establishes it is zero-information.
- Gate-ready is a frozen exact head. Any push after a merge-gate verdict voids that verdict.
- No merge: coordinator holds the merge hand pending Jason.
## Design
1. Feed JSON to `python3 -c` on stdin, including pending-context rendering.
2. Classify valid green as `READY`; pending/failure/no-status/malformed/mixed as `ASSERTED_NOT_READY`; provider/credential/transport inability as `CANNOT_ASSERT`.
3. `ASSERTED_NOT_READY` exits nonzero. `CANNOT_ASSERT` emits a loud diagnostic and appends a local JSONL audit record. Push degrades to exit 0; merge holds with distinct retryable exit 75 until provider recovery, then self-clears without manual reset. Inability to write the audit exits nonzero.
4. Derive the current branch when `-B` is omitted. The merge wrapper passes the exact PR head branch, repository, and full commit SHA—not its `main` base—so fork PRs cannot resolve against an adjacent base-repository branch.
## Test matrix
| Case | Required outcome |
| --- | --- |
| success | exit 0; terminal-success |
| pending | nonzero after bounded timeout |
| failure | nonzero |
| no-status | nonzero |
| malformed | nonzero |
| >=150 KiB payload | unchanged classification; never rc126 |
| provider unreachable on push | loud audited CANNOT_ASSERT; degraded exit 0 |
| provider unreachable on merge | loud audited CANNOT_ASSERT; retryable exit 75/HOLD |
| audit unavailable | nonzero |
| implicit push branch | provider URL uses checked-out feature branch |
| merge wrapper | queue guard receives exact PR head branch/repository/full SHA |
## RED-first evidence
Observed against the unmodified `origin/main` implementation before source edits:
- `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` → rc 1 with 15 failed assertions.
- Success payload was reported `state=unknown`.
- Pending, failure, no-status, and malformed payloads each exited 0 and omitted `ASSERTED_NOT_READY`.
- The 160 KiB payload produced rc 141 because Python never consumed the pipe; it did not classify success.
- Provider-unreachable exited 7 with no `CANNOT_ASSERT` audit record.
- Implicit push queried `/branches/main`, not `/branches/fix/rm-03-fixture`.
- Audit-unavailable emitted no audit diagnostic.
- A credential-resolution hard-block mutant was then run before trusting that added case: `credential-unresolvable` returned rc 1 and omitted `CANNOT_ASSERT`; the matrix returned rc 1 with two named assertion failures.
- Review-blocker controls were observed red: structurally invalid `statuses` string and null-entry payloads each exited 0 as `terminal-success`; unsupported-platform discovery exited 1 without diagnostic or audit (seven named assertion failures total).
- After the push/merge asymmetry ruling, merge-side provider unavailability was observed red at rc 0; its registered case required distinct retryable rc 75.
- Aggregate `state=success` with zero contexts was observed red: it exited 0 as `terminal-success`; the registered case requires `no-status`/nonzero.
- Fork/exact-head controls were observed red: `pr-merge.sh` omitted the fork repository and full SHA, and an ignored-arguments mutant re-resolved through `/branches/` instead of the exact fork commit (two named failures).
- GitHub check-run-only success/pending/failure were each misclassified as `no-status`; the RED run had five named failures and proved the Checks API was never queried.
- The first merge-pin control was unrunnable because one `local` declaration referenced a variable before assignment under `set -u`; this was disclosed and corrected rather than counted. The runnable RED then showed Gitea payload `{"Do":"squash"}` lacked `head_commit_id`; a separate GitHub run showed `gh pr merge 123 --squash` lacked `--match-head-commit`.
- A stale-verdict mutant removed the `--expect-head` comparison and was observed red because a moved head reached the provider merge call.
- `bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh` initially returned rc 1; captured call was `--purpose merge -B main -t 900 -i 15`.
Logs remain untracked under the worktree as `.mosaic-test-work-red-*.log` and will not be committed.
## Progress
- [x] Mission, remediation charter, task evidence, board, issue #1019, and superseded PR #1023 read.
- [x] Isolated worktree created and identity configured coherently.
- [x] Mutant tests authored and observed red.
- [x] Implementation green.
- [x] Baseline and focused situational gates green; full package suite has an unrelated framework-shell environment abort recorded below.
- [ ] Independent review clean (rev-974 requested changes at `44ffa99a`; bypass remediation committed and awaiting re-review).
- [ ] PR CI terminal-green at exact head by full step scan.
- [ ] Merge-gate verdict issued against frozen head.
## Scope disposition
- The five framework guides are consequential documentation: they define the purpose-aware tri-state contract, including audited push degradation and merge HOLD.
- The agent templates are consequential because they ship the same queue-guard instructions into newly seeded agent contracts; leaving them binary/stale would contradict the repaired tool.
- `pr-merge.sh` is consequential: it must inspect the PR's exact head branch/repository/SHA and enforce the exact-head merge pin.
- `pr-metadata.sh` is consequential only as the normalized source of that head branch/repository/SHA. Its diff is limited to exposing those fields on GitHub and Gitea.
- `test-pr-merge-gitea-empty-uid.sh` changes because exact-head Gitea merges now always use the API path (the only path that can send `head_commit_id`), superseding the prior tea-empty-identity fallback behavior.
## Review remediation
- rev-974 independently proved that the documented `--skip-queue-guard` merge option bypassed an exit-99 guard stub, reached the provider merge payload, printed success, and exited 0 at head `44ffa99a`.
- RED-first reproduction was added to `test-pr-merge-head-pin.sh` before the production fix: `FAIL merge-bypass: --skip-queue-guard reached the provider merge path`, suite rc 1. The test-only commit is `241113e6`.
- Production remediation `37aae650` removes the option from parsing, usage, help, and examples. Every merge-capable path now invokes the queue guard; `--dry-run` alone omits it and has a regression proving that it exits before provider dispatch and creates no merge payload.
- Existing Gitea merge tests now exercise a successful guard response rather than bypassing the guard.
## Risks / boundaries
- The local JSONL audit is durable operational evidence but not tamper-resistant against the same UID. RM-03 does not claim otherwise.
- Push-side audited exit 0 is an explicit owner ruling (Option B), accepted to avoid bricking recovery work; merge-side CANNOT_ASSERT remains retryable exit 75/HOLD. The automated security reviewer continues to flag the deliberate push availability tradeoff.
- Source/deployed-copy equality is owned by RM-02/D-22; this branch changes repository source and its tests only.
## Test evidence
Fresh after rescue checkpoint `b7175012`:
- Focused situational matrix: tri-state, GitHub checks pagination, branch-absent, merge head branch/repository/SHA, exact-head pin, and Gitea exact-head API regressions all passed.
- `bash -n` on the three production shell scripts passed.
- `shellcheck -x -P packages/mosaic/framework/tools/git ...` on all changed shell scripts passed.
- `pnpm typecheck` passed (45/45 Turbo tasks).
- `pnpm lint` passed (25/25 Turbo tasks).
- `pnpm format:check` passed.
- `pnpm --filter @mosaicstack/mosaic test`: Vitest passed 1508/1508 on the confirmation run; framework-shell then aborted at the pre-existing wake coordinate assertion with exit 97: `BASH_LINENO ... probe reported [3 5], expected [3 4] ... (#973)`. This is outside the RM-03 diff and is disclosed rather than substituted or called green.
- The prior package-suite attempt had one transient, out-of-diff `install-ordering-guard.spec.ts` failure (1/1508); its isolated rerun passed 19/19 and the confirmation full Vitest run passed 1508/1508.
- After bypass remediation: all six focused RM-03 queue/merge regressions passed, including bypass refusal and dry-run non-dispatch; shell syntax and source-aware ShellCheck passed; `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` passed.
- Fresh `test:framework-shell` reached and passed every RM-03 test, then again aborted at the unrelated wake coordinate assertion with exit 97; it remains explicitly non-green rather than substituted.
- An ad hoc raw Prettier invocation over `.template` and `.sh` files was unrunnable because no parser is registered for those extensions; it was not used as a substitute for canonical `pnpm format:check`.
## Final evidence
Pending.
+3 -6
View File
@@ -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",
@@ -893,16 +893,14 @@ Woodpecker note:
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
```bash
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
```
Behavior:
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero.
- Credential, transport, or provider unavailability is `CANNOT_ASSERT`: the guard emits a loud diagnostic and durable JSONL audit record. For push it exits 0 so recovery work is not bricked. For merge it returns distinct retryable exit 75 and holds until provider recovery; rerunning then self-clears without manual reset. This result is never evidence that CI was clear. If the audit cannot be written, the guard exits nonzero.
- `pr-merge.sh` resolves and guards the exact PR head repository and full SHA automatically, including fork PRs.
- If pipeline state is running/queued/pending, wait until queue clears.
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
## Gitea as Unified Platform
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
- PR target for delivery is `main`.
- Direct pushes to `main` are prohibited.
- Merge to `main` MUST be squash-only.
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent).
## Review Checklist
@@ -79,7 +79,7 @@ For implementation work, you MUST run this cycle in order:
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. `push` - push immediately after queue guard passes.
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`.
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes).
@@ -93,8 +93,8 @@ For implementation work, you MUST run this cycle in order:
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
> without asking.
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists)
5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop.
@@ -425,11 +425,11 @@ git push
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
- Before merging, run queue guard:
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
- Ensure PR exists for the task branch (create/update via wrappers if needed):
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
- Merge via wrapper:
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
- Wait for terminal CI status:
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
- Close linked issue after merge + green CI:
@@ -630,7 +630,7 @@ Construct this from the task row and pass to worker via Task tool:
**MANDATORY:** This ALWAYS includes linting. If the project has a linter configured
(ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched.
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
## Git Scripts
@@ -638,9 +638,8 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
@@ -23,12 +23,10 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a
# Milestones
~/.config/mosaic/tools/git/milestone-create.sh
# CI queue guard (required before push/merge; defaults to the checked-out branch)
# CI queue guard (required before push/merge)
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
```
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
### Code Review (Codex)
```bash
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -88,7 +88,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable)
@@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -97,7 +97,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable)
@@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -101,7 +101,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable)
@@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -87,7 +87,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable)
@@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -84,7 +84,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable)
@@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -85,7 +85,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable)
@@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -7,9 +7,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
BRANCH=""
TARGET_REPO=""
HEAD_SHA=""
BRANCH="main"
TIMEOUT_SEC=900
INTERVAL_SEC=15
PURPOSE="merge"
@@ -17,12 +15,10 @@ REQUIRE_STATUS=0
usage() {
cat <<EOF
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
Options:
-B, --branch BRANCH Branch head to inspect (default: current branch)
-R, --repo OWNER/REPO Repository containing the branch (default: origin repo)
--sha FULL_SHA Inspect this exact 40-character commit instead of resolving the branch
-B, --branch BRANCH Branch head to inspect (default: main)
-t, --timeout SECONDS Max wait time in seconds (default: 900)
-i, --interval SECONDS Poll interval in seconds (default: 15)
--purpose VALUE Log context: push|merge (default: merge)
@@ -31,65 +27,63 @@ Options:
Examples:
$(basename "$0")
$(basename "$0") --purpose push -t 600 -i 10
$(basename "$0") --purpose push -B main -t 600 -i 10
EOF
}
# get_remote_host and get_gitea_token are provided by detect-platform.sh
get_state_from_status_json() {
# Python source comes from -c so the provider payload remains on stdin.
# Never move the payload to argv: commit-status responses can exceed ARG_MAX.
python3 -c '
python3 - <<'PY'
import json
import sys
try:
payload = json.load(sys.stdin)
if not isinstance(payload, dict):
raise ValueError("status payload is not an object")
except Exception:
print("malformed")
print("unknown")
raise SystemExit(0)
raw_statuses = payload.get("statuses", [])
raw_state = payload.get("state", "")
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
print("malformed")
raise SystemExit(0)
statuses = raw_statuses
state = raw_state.lower()
statuses = payload.get("statuses") or []
state = (payload.get("state") or "").lower()
pending_values = {"pending", "queued", "running", "waiting"}
failure_values = {"failure", "error", "failed"}
success_values = {"success"}
if state in pending_values:
print("pending")
raise SystemExit(0)
if state in failure_values:
print("terminal-failure")
raise SystemExit(0)
if state in success_values:
print("terminal-success")
raise SystemExit(0)
values = []
for item in statuses:
if not isinstance(item, dict):
print("malformed")
raise SystemExit(0)
raw_value = item.get("status") or item.get("state")
if not isinstance(raw_value, str) or not raw_value:
print("malformed")
raise SystemExit(0)
values.append(raw_value.lower())
continue
value = (item.get("status") or item.get("state") or "").lower()
if value:
values.append(value)
if any(value in pending_values for value in values) or state in pending_values:
print("pending")
elif any(value in failure_values for value in values) or state in failure_values:
print("terminal-failure")
elif values and all(value in success_values for value in values) and state in {"", "success"}:
print("terminal-success")
elif not values:
if not values and not state:
print("no-status")
elif any(v in pending_values for v in values):
print("pending")
elif any(v in failure_values for v in values):
print("terminal-failure")
elif values and all(v in success_values for v in values):
print("terminal-success")
else:
print("unknown")
'
PY
}
print_pending_contexts() {
python3 -c '
python3 - <<'PY'
import json
import sys
@@ -110,61 +104,17 @@ for item in statuses:
if not isinstance(item, dict):
continue
name = item.get("context") or item.get("name") or "unknown-context"
value = str(item.get("status") or item.get("state") or "unknown").lower()
value = (item.get("status") or item.get("state") or "unknown").lower()
target = item.get("target_url") or item.get("url") or ""
if value in pending_values:
found = True
suffix = f" ({target})" if target else ""
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
if target:
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
else:
print(f"[ci-queue-wait] pending: {name}={value}")
if not found:
print("[ci-queue-wait] no pending contexts")
'
}
record_cannot_assert() {
local reason="$1"
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
echo "Error: CANNOT_ASSERT and audit directory is unavailable; refusing degraded pass." >&2
return 70
fi
if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
import datetime
import json
import os
import sys
path, reason, platform, purpose, branch, repo = sys.argv[1:]
record = {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"outcome": "CANNOT_ASSERT",
"reason": reason,
"platform": platform,
"purpose": purpose,
"disposition": "hold" if purpose == "merge" else "degraded-pass",
"branch": branch,
"repo": repo,
}
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
try:
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
finally:
os.close(fd)
PY
then
echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2
return 70
fi
if [[ "$PURPOSE" == "merge" ]]; then
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2
return 75
fi
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2
return 0
}
github_get_branch_head_sha() {
@@ -178,87 +128,7 @@ github_get_commit_status_json() {
local owner="$1"
local repo="$2"
local sha="$3"
local work_root status_file checks_file
work_root="${AGENT_WORK_ROOT:-${HOME:-}/.cache/mosaic/ci-queue-wait}"
mkdir -p "$work_root" || return 1
status_file=$(mktemp "$work_root/github-status.XXXXXX") || return 1
checks_file=$(mktemp "$work_root/github-checks.XXXXXX") || {
rm -f "$status_file"
return 1
}
if ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/statuses?per_page=100" > "$status_file" ||
! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100&filter=latest" > "$checks_file"; then
rm -f "$status_file" "$checks_file"
return 1
fi
python3 - "$status_file" "$checks_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
status_pages = json.load(handle)
with open(sys.argv[2], encoding="utf-8") as handle:
check_pages = json.load(handle)
if not isinstance(status_pages, list) or not isinstance(check_pages, list):
raise SystemExit(1)
# The statuses endpoint is newest-first and can contain retries for one context.
# Keep only the newest entry per context after flattening every page.
combined = []
seen_contexts = set()
for page in status_pages:
if not isinstance(page, list):
raise SystemExit(1)
for status in page:
if not isinstance(status, dict):
raise SystemExit(1)
context = status.get("context")
if not isinstance(context, str) or not context or context in seen_contexts:
continue
seen_contexts.add(context)
combined.append(status)
check_runs = []
reported_total = 0
for page in check_pages:
if not isinstance(page, dict):
raise SystemExit(1)
page_runs = page.get("check_runs") or []
total_count = page.get("total_count")
if not isinstance(page_runs, list) or not isinstance(total_count, int):
raise SystemExit(1)
reported_total = max(reported_total, total_count)
check_runs.extend(page_runs)
if len(check_runs) < reported_total:
raise SystemExit(1)
for run in check_runs:
if not isinstance(run, dict):
raise SystemExit(1)
status = run.get("status")
conclusion = run.get("conclusion")
if status != "completed":
value = "pending"
elif conclusion == "success":
value = "success"
elif conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"}:
value = "failure"
else:
value = "unknown"
combined.append({
"context": run.get("name") or "github-check",
"status": value,
"target_url": run.get("html_url") or run.get("details_url") or "",
})
json.dump({"state": "", "statuses": combined}, sys.stdout)
PY
local status=$?
rm -f "$status_file" "$checks_file"
return "$status"
gh api "repos/${owner}/${repo}/commits/${sha}/status"
}
gitea_get_branch_head_sha() {
@@ -304,14 +174,6 @@ while [[ $# -gt 0 ]]; do
BRANCH="$2"
shift 2
;;
-R|--repo)
TARGET_REPO="$2"
shift 2
;;
--sha)
HEAD_SHA="$2"
shift 2
;;
-t|--timeout)
TIMEOUT_SEC="$2"
shift 2
@@ -344,89 +206,45 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
echo "Error: timeout and interval must be integer seconds." >&2
exit 1
fi
if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2
exit 1
fi
if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then
echo "Error: --repo must be OWNER/REPO." >&2
exit 1
fi
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
echo "Error: --purpose must be push or merge." >&2
exit 1
fi
OWNER="unknown"
REPO="unknown"
PLATFORM="unknown"
if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then
record_cannot_assert "repository-owner-unresolvable"
exit $?
fi
if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then
record_cannot_assert "repository-name-unresolvable"
exit $?
fi
if ! detect_platform > /dev/null; then
PLATFORM="${PLATFORM:-unknown}"
record_cannot_assert "unsupported-platform"
exit $?
fi
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
detect_platform > /dev/null
PLATFORM="${PLATFORM:-unknown}"
if [[ -n "$TARGET_REPO" ]]; then
OWNER="${TARGET_REPO%%/*}"
REPO="${TARGET_REPO##*/}"
fi
if [[ -z "$BRANCH" ]]; then
if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then
record_cannot_assert "current-branch-unresolvable"
exit $?
fi
fi
if [[ "$PLATFORM" == "github" ]]; then
if ! command -v gh >/dev/null 2>&1; then
record_cannot_assert "github-cli-unavailable"
exit $?
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
exit 1
fi
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
if [[ -z "$HEAD_SHA" ]]; then
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
record_cannot_assert "branch-head-unavailable"
exit $?
fi
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
exit 1
fi
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
elif [[ "$PLATFORM" == "gitea" ]]; then
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
record_cannot_assert "remote-host-unresolvable"
exit $?
fi
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
record_cannot_assert "credential-unresolvable"
exit $?
HOST=$(get_remote_host) || {
echo "Error: Could not determine remote host." >&2
exit 1
}
TOKEN=$(get_gitea_token "$HOST") || {
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
exit 1
}
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
exit 0
fi
if [[ -z "$HEAD_SHA" ]]; then
if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
record_cannot_assert "branch-head-unavailable"
exit $?
fi
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
exit 0
fi
if [[ -z "$HEAD_SHA" ]]; then
record_cannot_assert "branch-head-unavailable"
exit $?
fi
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
exit 1
fi
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
else
record_cannot_assert "unsupported-platform"
exit $?
echo "Error: Unsupported platform '${PLATFORM}'." >&2
exit 1
fi
START_TS=$(date +%s)
@@ -435,20 +253,14 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
while true; do
NOW_TS=$(date +%s)
if (( NOW_TS > DEADLINE_TS )); then
echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
exit 124
fi
if [[ "$PLATFORM" == "github" ]]; then
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
record_cannot_assert "status-provider-unreachable"
exit $?
fi
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
else
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
record_cannot_assert "status-provider-unreachable"
exit $?
fi
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
fi
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
@@ -459,24 +271,21 @@ while true; do
printf '%s' "$STATUS_JSON" | print_pending_contexts
sleep "$INTERVAL_SEC"
;;
terminal-success)
exit 0
;;
no-status)
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
else
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
exit 1
fi
exit 3
echo "[ci-queue-wait] no status contexts present; proceeding."
exit 0
;;
terminal-failure|malformed|unknown)
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3
terminal-success|terminal-failure|unknown)
# Queue guard only blocks on pending/running/queued states.
exit 0
;;
*)
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
exit 0
;;
esac
done
+59 -43
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
set -euo pipefail
@@ -12,8 +12,8 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
MERGE_METHOD="squash"
DELETE_BRANCH=false
SKIP_QUEUE_GUARD=false
DRY_RUN=false
EXPECT_HEAD=""
usage() {
cat <<EOF
@@ -25,14 +25,15 @@ Options:
-n, --number NUMBER PR number to merge (required)
-m, --method METHOD Merge method: squash only (default: squash)
-d, --delete-branch Delete the head branch after merge
--skip-queue-guard Skip CI queue guard wait before merge
--dry-run Run metadata/login preflight without merging
--expect-head SHA Refuse unless the PR head matches this full commit SHA
-h, --help Show this help message
Examples:
$(basename "$0") -n 42 # Merge PR #42
$(basename "$0") -n 42 -m squash # Squash merge
$(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
EOF
exit "${1:-1}"
}
@@ -52,13 +53,14 @@ while [[ $# -gt 0 ]]; do
DELETE_BRANCH=true
shift
;;
--dry-run)
DRY_RUN=true
--skip-queue-guard)
SKIP_QUEUE_GUARD=true
shift
;;
--expect-head)
EXPECT_HEAD="$2"
shift 2
--dry-run)
DRY_RUN=true
SKIP_QUEUE_GUARD=true
shift
;;
-h|--help)
usage 0
@@ -84,36 +86,18 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
exit 1
fi
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
exit 1
fi
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
exit 1
fi
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
exit 1
fi
if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
echo "Error: PR head moved: expected $EXPECT_HEAD, found $HEAD_SHA." >&2
exit 1
fi
if [[ "$DRY_RUN" != true ]]; then
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
"$SCRIPT_DIR/ci-queue-wait.sh" \
--purpose merge \
-B "$HEAD_BRANCH" \
-R "$HEAD_REPO" \
--sha "$HEAD_SHA" \
-B "$BASE_BRANCH" \
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
fi
@@ -122,22 +106,31 @@ PLATFORM=$(detect_platform)
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
is_known_tea_empty_identity_failure() {
local error_file="$1"
python3 - "$error_file" <<'PY'
import re
import sys
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
error = handle.read()
known_empty_identity = re.search(
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
error,
flags=re.IGNORECASE | re.DOTALL,
)
raise SystemExit(0 if known_empty_identity else 1)
PY
}
merge_gitea_with_api() {
local host="$1" api_url token basic_auth body_file raw_code payload
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
import json
import sys
head_sha, delete_branch = sys.argv[1:]
payload = {"Do": "squash", "head_commit_id": head_sha}
if delete_branch == "true":
payload["delete_branch_after_merge"] = True
print(json.dumps(payload, separators=(",", ":")))
PY
)
payload='{"Do":"squash"}'
token=$(get_gitea_token "$host" || true)
if [[ -n "$token" ]]; then
@@ -209,7 +202,7 @@ fi
case "$PLATFORM" in
github)
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
cmd=(gh pr merge "$PR_NUMBER" --squash)
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
"${cmd[@]}"
;;
@@ -218,9 +211,32 @@ case "$PLATFORM" in
echo "Error: Cannot determine host from origin remote URL" >&2
exit 1
}
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
# tea cannot express it, so exact-head merges use the authenticated API path.
merge_gitea_with_api "$HOST"
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
if [[ -n "$TEA_LOGIN" ]]; then
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
rm -f "$TEA_ERROR_FILE"
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
cat "$TEA_ERROR_FILE" >&2
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
rm -f "$TEA_ERROR_FILE"
merge_gitea_with_api "$HOST"
else
cat "$TEA_ERROR_FILE" >&2
rm -f "$TEA_ERROR_FILE"
exit 1
fi
else
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
merge_gitea_with_api "$HOST"
fi
# Delete branch after merge if requested
if [[ "$DELETE_BRANCH" == true ]]; then
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
fi
;;
*)
echo "Error: Could not detect git platform" >&2
@@ -109,7 +109,7 @@ PY
detect_platform > /dev/null
if [[ "$PLATFORM" == "github" ]]; then
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
write_metadata "$METADATA"
elif [[ "$PLATFORM" == "gitea" ]]; then
OWNER=$(get_repo_owner)
@@ -182,25 +182,6 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
data.get('head_ref'),
head_ref,
)
head_sha = first_non_empty(
nested(data, 'head', 'sha'),
nested(data, 'head', 'id'),
data.get('head_sha'),
)
head_repo = first_non_empty(
nested(data, 'head', 'repo', 'full_name'),
nested(data, 'head', 'repo', 'name_with_owner'),
)
if not head_repo:
head_repo_owner = first_non_empty(
nested(data, 'head', 'repo', 'owner', 'login'),
nested(data, 'head', 'repo', 'owner', 'username'),
nested(data, 'head', 'repo', 'owner_name'),
)
head_repo_name = first_non_empty(nested(data, 'head', 'repo', 'name'))
if head_repo_owner and head_repo_name:
head_repo = f'{head_repo_owner}/{head_repo_name}'
base_ref = first_non_empty(
nested(data, 'base', 'ref'),
nested(data, 'base', 'name'),
@@ -226,8 +207,6 @@ normalized = {
'state': data.get('state'),
'author': nested(data, 'user', 'login') or '',
'headRefName': head_ref,
'headRefOid': head_sha,
'headRepository': head_repo,
'baseRefName': base_ref,
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
@@ -13,7 +13,7 @@
# Covers:
# (a) 404 branch-absent -> exit 0, "queue clear" message.
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
set -euo pipefail
@@ -62,7 +62,7 @@ case "$mode" in
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
500) code=500; body='{"message":"internal server error"}' ;;
no-status) code=200; body='{}' ;;
terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
terminal-success) code=200; body='{"state":"success"}' ;;
*)
echo "curl stub: unknown mode=$mode" >&2
exit 2
@@ -91,7 +91,6 @@ run_ci_queue_wait() {
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export GITEA_TOKEN="stub-token"
export GITEA_URL="https://git.example.test"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
)
}
@@ -132,26 +131,19 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
fail=1
fi
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
set +e
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
status_c=$?
set -e
if [[ "$status_c" -ne 0 ]]; then
echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
echo "$out_c" >&2
fail=1
elif [[ "$out_c" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL(c): expected a loud CANNOT_ASSERT diagnostic" >&2
if [[ "$status_c" -eq 0 ]]; then
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
echo "$out_c" >&2
fail=1
elif [[ "$out_c" == *"queue clear"* ]]; then
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
echo "$out_c" >&2
fail=1
elif [[ ! -s "$WORK_DIR/audit/ci-queue-wait.jsonl" ]]; then
echo "FAIL(c): expected a durable CANNOT_ASSERT audit record" >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
@@ -1,95 +0,0 @@
#!/usr/bin/env bash
# GitHub Actions uses Checks API check-runs, not only legacy commit statuses.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-github-checks}"
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" checkout -q -b fix/github-checks
git -C "$REPO_DIR" remote add origin https://github.com/acme/widgets.git
cat > "$STUB_DIR/gh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
endpoint=""
for arg in "$@"; do
[[ "$arg" == repos/* ]] && endpoint="$arg"
done
printf '%s\n' "$*" >> "${MOSAIC_GH_CALL_LOG:?}"
case "$endpoint" in
repos/acme/widgets/branches/fix/github-checks)
printf '%s\n' '0123456789abcdef0123456789abcdef01234567'
;;
repos/acme/widgets/commits/*/statuses?per_page=100)
printf '%s\n' '[[]]'
;;
repos/acme/widgets/commits/*/check-runs?per_page=100\&filter=latest)
case "${MOSAIC_GH_CHECK_MODE:?}" in
success) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"success"}]}]' ;;
pending) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"in_progress","conclusion":null}]}]' ;;
failure) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"failure"}]}]' ;;
late-failure) printf '%s\n' '[{"total_count":2,"check_runs":[{"name":"first-page","status":"completed","conclusion":"success"}]},{"total_count":2,"check_runs":[{"name":"later-page","status":"completed","conclusion":"failure"}]}]' ;;
*) exit 2 ;;
esac
;;
*) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;
esac
SH
chmod +x "$STUB_DIR/gh"
run_guard() {
local mode="$1"
(
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_GH_CHECK_MODE="$mode"
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
)
}
failures=0
assert_case() {
local mode="$1" expected_rc="$2" expected_state="$3" output rc
set +e
output=$(run_guard "$mode" 2>&1)
rc=$?
set -e
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
echo "FAIL github-$mode: expected rc=0, got $rc" >&2
failures=$((failures + 1))
elif [[ "$expected_rc" == nonzero && "$rc" -eq 0 ]]; then
echo "FAIL github-$mode: expected rc!=0, got 0" >&2
failures=$((failures + 1))
fi
if [[ "$output" != *"state=$expected_state"* ]]; then
echo "FAIL github-$mode: expected state=$expected_state, got:" >&2
printf '%s\n' "$output" >&2
failures=$((failures + 1))
fi
}
set -e
: > "$WORK_DIR/gh-calls.log"
assert_case success zero terminal-success
assert_case pending nonzero pending
assert_case failure nonzero terminal-failure
assert_case late-failure nonzero terminal-failure
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
echo "FAIL: expected every case to query all Checks API pages" >&2
failures=$((failures + 1))
fi
if [[ "$failures" -ne 0 ]]; then
echo "GitHub check-runs regression failed ($failures assertions)" >&2
exit 1
fi
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
@@ -1,232 +0,0 @@
#!/usr/bin/env bash
# Exit-asserting RM-03 regression harness for ci-queue-wait.sh.
# Every case is a process-level assertion: a classifier-only green cannot satisfy it.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}"
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
FEATURE_BRANCH="fix/rm-03-fixture"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
cat > "$STUB_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
url=""
has_write_out=0
for arg in "$@"; do
case "$arg" in
-w) has_write_out=1 ;;
http://*|https://*) url="$arg" ;;
esac
done
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
case "$url" in
*/branches/*)
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
exit 7
fi
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
if [[ "$has_write_out" -eq 1 ]]; then
printf '%s\n200' "$body"
else
printf '%s' "$body"
fi
;;
*/status)
case "${MOSAIC_STUB_STATUS_MODE:?}" in
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
failure) printf '%s' '{"state":"failure","statuses":[{"status":"failure"}]}' ;;
no-status) printf '%s' '{"state":"","statuses":[]}' ;;
aggregate-success-no-status) printf '%s' '{"state":"success","statuses":[]}' ;;
malformed) printf '%s' 'not-json' ;;
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
large-success)
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
;;
unreachable) exit 7 ;;
*) echo "unknown status mode" >&2; exit 2 ;;
esac
;;
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
esac
SH
chmod +x "$STUB_DIR/curl"
run_guard() {
local status_mode="$1"
local audit_log="${2:-$AUDIT_LOG}"
shift 2 || true
(
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
if [[ "$status_mode" == "credential-unresolvable" ]]; then
export HOME="$WORK_DIR/empty-home"
mkdir -p "$HOME"
unset GITEA_TOKEN GITEA_URL MOSAIC_GIT_IDENTITY
export MOSAIC_STUB_STATUS_MODE=success
else
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_STUB_STATUS_MODE="$status_mode"
fi
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
)
}
failures=0
run_assertion() {
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
local output rc
shift 4
set +e
output=$(run_guard "$status_mode" "$AUDIT_LOG" "$@" 2>&1)
rc=$?
set -e
case "$expected_rc" in
zero)
if [[ "$rc" -ne 0 ]]; then
echo "FAIL $name: expected rc=0, got rc=$rc" >&2
failures=$((failures + 1))
fi
;;
nonzero)
if [[ "$rc" -eq 0 ]]; then
echo "FAIL $name: expected rc!=0, got rc=0" >&2
failures=$((failures + 1))
fi
;;
not126)
if [[ "$rc" -eq 126 ]]; then
echo "FAIL $name: payload transport hit ARG_MAX (rc=126)" >&2
failures=$((failures + 1))
fi
;;
esac
if [[ "$output" != *"$required_text"* ]]; then
echo "FAIL $name: output missing '$required_text' (rc=$rc)" >&2
printf '%s\n' "$output" >&2
failures=$((failures + 1))
fi
}
set -e
: > "$WORK_DIR/urls.log"
run_assertion success zero success 'state=terminal-success'
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
run_assertion large-payload not126 large-success 'state=terminal-success'
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
failures=$((failures + 1))
fi
# Merge cannot proceed without exact-head evidence. CANNOT_ASSERT is retryable exit 75,
# distinct from ASSERTED_NOT_READY (3/124), and still writes its audit record.
merge_audit_lines_before=$(wc -l < "$AUDIT_LOG")
set +e
merge_unreachable_output=$(MOSAIC_TEST_PURPOSE=merge run_guard unreachable "$AUDIT_LOG" 2>&1)
merge_unreachable_rc=$?
set -e
if [[ "$merge_unreachable_rc" -ne 75 ]]; then
echo "FAIL merge-provider-unreachable: expected rc=75, got rc=$merge_unreachable_rc" >&2
failures=$((failures + 1))
fi
if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
failures=$((failures + 1))
fi
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
failures=$((failures + 1))
fi
# A feature-branch push with no -B must inspect the checked-out feature branch.
if ! grep -q "/branches/$FEATURE_BRANCH" "$WORK_DIR/urls.log"; then
echo "FAIL implicit-branch: provider was not queried for $FEATURE_BRANCH" >&2
failures=$((failures + 1))
fi
# Merge callers can pin both a fork repository and the exact reviewed head SHA.
exact_sha=0123456789abcdef0123456789abcdef01234567
: > "$WORK_DIR/urls.log"
run_assertion exact-fork-head zero success 'state=terminal-success' \
-B fix/rm-03-fixture -R contributor/widgets-fork --sha "$exact_sha"
if ! grep -q "/repos/contributor/widgets-fork/commits/$exact_sha/status" "$WORK_DIR/urls.log"; then
echo "FAIL exact-fork-head: status URL did not bind fork repository and exact SHA" >&2
failures=$((failures + 1))
fi
if grep -q '/branches/' "$WORK_DIR/urls.log"; then
echo "FAIL exact-fork-head: explicit SHA must not be re-resolved through a branch" >&2
failures=$((failures + 1))
fi
# Platform/repository discovery failures use the same audited CANNOT_ASSERT path.
audit_lines_before=$(wc -l < "$AUDIT_LOG")
git -C "$REPO_DIR" remote set-url origin https://gitlab.com/acme/widgets.git
set +e
unsupported_output=$(run_guard success "$AUDIT_LOG" 2>&1)
unsupported_rc=$?
set -e
git -C "$REPO_DIR" remote set-url origin https://git.example.test/acme/widgets.git
if [[ "$unsupported_rc" -ne 0 ]]; then
echo "FAIL unsupported-platform: expected degraded rc=0, got rc=$unsupported_rc" >&2
failures=$((failures + 1))
fi
if [[ "$unsupported_output" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL unsupported-platform: expected loud CANNOT_ASSERT diagnostic" >&2
failures=$((failures + 1))
fi
audit_lines_after=$(wc -l < "$AUDIT_LOG")
if [[ "$audit_lines_after" -le "$audit_lines_before" ]]; then
echo "FAIL unsupported-platform: expected an additional audit record" >&2
failures=$((failures + 1))
fi
# A degraded pass is forbidden if the audit receipt cannot be written.
mkdir -p "$WORK_DIR/not-a-directory"
printf 'file' > "$WORK_DIR/not-a-directory/parent"
set +e
audit_failure_output=$(run_guard unreachable "$WORK_DIR/not-a-directory/parent/audit.jsonl" 2>&1)
audit_failure_rc=$?
set -e
if [[ "$audit_failure_rc" -eq 0 ]]; then
echo "FAIL audit-unavailable: expected rc!=0, got rc=0" >&2
failures=$((failures + 1))
fi
if [[ "$audit_failure_output" != *"audit"* ]]; then
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
failures=$((failures + 1))
fi
if [[ "$failures" -ne 0 ]]; then
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
exit 1
fi
echo "ci-queue-wait tri-state regression passed (all outcome classes)"
@@ -1,5 +1,5 @@
#!/bin/bash
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
set -euo pipefail
@@ -79,24 +79,15 @@ emit_response() {
printf '200'
fi
}
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then
emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}'
exit 0
fi
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
exit 0
fi
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
POST_DATA="$post_data" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["POST_DATA"])
assert payload == {
"Do": "squash",
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
}, payload
PY
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
echo "unexpected merge payload: $post_data" >&2
exit 96
fi
emit_response '{"merged":true,"message":"mock merge complete"}'
exit 0
fi
@@ -116,8 +107,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
export GITEA_TOKEN="redacted-test-token"
OUTPUT="$SANDBOX/output.log"
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
echo "--- output ---" >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
echo "--- mock log ---" >&2
@@ -136,6 +127,38 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
exit 1
fi
cat > "$MOCK_BIN/tea" <<'EOF'
#!/bin/bash
set -euo pipefail
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
printf '\n' >> "$PR_MERGE_TEST_LOG"
if [[ "$*" == *"login list"* ]]; then
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
exit 0
fi
if [[ "$*" == *"pr merge"* ]]; then
echo 'tea network timeout' >&2
exit 2
fi
exit 0
EOF
chmod +x "$MOCK_BIN/tea"
: > "$LOG_FILE"
if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected arbitrary tea failure to remain blocking." >&2
exit 1
fi
if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
exit 1
fi
if ! grep -q 'tea network timeout' "$OUTPUT"; then
echo "Expected arbitrary tea error to be preserved in output." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1
fi
cat > "$MOCK_BIN/tea" <<'EOF'
#!/bin/bash
set -euo pipefail
@@ -154,8 +177,8 @@ EOF
chmod +x "$MOCK_BIN/tea"
unset GITEA_LOGIN
: > "$LOG_FILE"
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
echo "Expected the exact-head API path not to depend on a tea login." >&2
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
exit 1
@@ -192,7 +215,7 @@ cd "$REPO_DIR"
git remote set-url origin https://github.com/mosaicstack/stack.git
: > "$LOG_FILE"
rm -f "$SENTINEL"
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected GitHub metacharacter PR number to be rejected." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1
@@ -217,7 +240,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
export GITEA_LOGIN="git.mosaicstack.dev"
: > "$LOG_FILE"
rm -f "$SENTINEL"
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected Gitea metacharacter PR number to be rejected." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1
@@ -237,4 +260,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
exit 1
fi
echo "pr-merge.sh Gitea exact-head API regression passed"
echo "pr-merge.sh Gitea fallback regression passed"
@@ -1,156 +0,0 @@
#!/usr/bin/env bash
# shellcheck disable=SC2030,SC2031 # Provider arms isolate PATH/credentials in subshells.
# The commit whose CI was guarded must be the commit the provider atomically merges.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}"
SHA=0123456789abcdef0123456789abcdef01234567
make_fixture() {
local name="$1" remote="$2"
local root="$WORK_DIR/$name"
local tools="$root/tools/git"
mkdir -p "$tools" "$root/repo"
cp "$SCRIPT_DIR/pr-merge.sh" "$tools/pr-merge.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
git -C "$root/repo" init -q
git -C "$root/repo" remote add origin "$remote"
cat > "$tools/pr-metadata.sh" <<SH
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
SH
cat > "$tools/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
exit 0
SH
chmod +x "$tools"/*.sh
}
rm -rf "$WORK_DIR"
make_fixture gitea https://git.example.test/acme/widgets.git
make_fixture github https://github.com/acme/widgets.git
cat > "$WORK_DIR/gitea/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
payload=""
for ((i=1; i<=$#; i++)); do
if [[ "${!i}" == "-d" ]]; then
j=$((i + 1))
payload="${!j}"
fi
done
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
printf '200'
SH
chmod +x "$WORK_DIR/gitea/curl"
set +e
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123
) >"$WORK_DIR/gitea.out" 2>&1
gitea_rc=$?
set -e
if [[ "$gitea_rc" -ne 0 ]]; then
echo "FAIL gitea-pin: merge fixture returned $gitea_rc" >&2
cat "$WORK_DIR/gitea.out" >&2
exit 1
fi
python3 - "$WORK_DIR/gitea-payload.json" "$SHA" <<'PY'
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert set(payload) <= {"Do", "head_commit_id", "delete_branch_after_merge"}, payload
assert payload.get("Do") == "squash", payload
assert payload.get("head_commit_id") == sys.argv[2], payload
PY
# A merge-gate verdict is commit-bound. A stale expected head must fail before merge.
wrong_sha=ffffffffffffffffffffffffffffffffffffffff
rm -f "$WORK_DIR/gitea-payload-stale.json"
set +e
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-stale.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha"
) >"$WORK_DIR/gitea-stale.out" 2>&1
stale_rc=$?
set -e
if [[ "$stale_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-stale.json" ]]; then
echo "FAIL stale-verdict: moved head was not refused before provider merge" >&2
exit 1
fi
# A merge-capable path cannot bypass the mandatory queue guard. The legacy
# --skip-queue-guard option must be rejected before any provider merge call.
cat > "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
exit 99
SH
chmod +x "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh"
rm -f "$WORK_DIR/gitea-payload-bypass.json"
set +e
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-bypass.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard
) >"$WORK_DIR/gitea-bypass.out" 2>&1
bypass_rc=$?
set -e
if [[ "$bypass_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-bypass.json" ]]; then
echo "FAIL merge-bypass: --skip-queue-guard reached the provider merge path" >&2
exit 1
fi
# Dry-run is the only path that may omit the guard because it exits before the
# provider merge dispatch. Prove the exit and absence of a merge payload.
rm -f "$WORK_DIR/gitea-payload-dry-run.json"
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-dry-run.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run
) >"$WORK_DIR/gitea-dry-run.out" 2>&1
if [[ -e "$WORK_DIR/gitea-payload-dry-run.json" ]]; then
echo "FAIL dry-run: non-merging preflight reached the provider merge path" >&2
exit 1
fi
cat > "$WORK_DIR/github/gh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" > "${MOSAIC_GH_MERGE_LOG:?}"
SH
chmod +x "$WORK_DIR/github/gh"
(
cd "$WORK_DIR/github/repo"
export PATH="$WORK_DIR/github:$PATH"
export MOSAIC_GH_MERGE_LOG="$WORK_DIR/github-call.log"
"$WORK_DIR/github/tools/git/pr-merge.sh" -n 123
) >"$WORK_DIR/github.out" 2>&1
if ! grep -q -- "--match-head-commit $SHA" "$WORK_DIR/github-call.log"; then
echo "FAIL github-pin: merge command omitted --match-head-commit $SHA" >&2
cat "$WORK_DIR/github-call.log" >&2
exit 1
fi
echo "PR merge exact-head pin regression passed (Gitea + GitHub)"
@@ -1,66 +0,0 @@
#!/usr/bin/env bash
# RM-03: pr-merge must guard the PR head branch, not its main base branch.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-queue-branch}"
FIXTURE_DIR="$WORK_DIR/tools/git"
CALL_LOG="$WORK_DIR/queue-call.log"
rm -rf "$WORK_DIR"
mkdir -p "$FIXTURE_DIR"
cp "$SCRIPT_DIR/pr-merge.sh" "$FIXTURE_DIR/pr-merge.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$FIXTURE_DIR/detect-platform.sh"
cat > "$FIXTURE_DIR/pr-metadata.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/rm-03-fixture","headRefOid":"0123456789abcdef0123456789abcdef01234567","headRepository":"contributor/widgets-fork"}'
SH
cat > "$FIXTURE_DIR/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
printf '%s\n' "$*" > "${MOSAIC_QUEUE_CALL_LOG:?}"
exit 42
SH
chmod +x "$FIXTURE_DIR"/*.sh
set +e
(
cd "$WORK_DIR"
export MOSAIC_QUEUE_CALL_LOG="$CALL_LOG"
"$FIXTURE_DIR/pr-merge.sh" -n 123
) >/dev/null 2>&1
rc=$?
set -e
if [[ "$rc" -ne 42 ]]; then
echo "FAIL: expected queue stub rc=42 to propagate, got $rc" >&2
exit 1
fi
if [[ ! -s "$CALL_LOG" ]]; then
echo "FAIL: merge wrapper did not invoke the queue guard" >&2
exit 1
fi
if ! grep -q -- '-B fix/rm-03-fixture' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive PR head branch" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if grep -q -- '-B main' "$CALL_LOG"; then
echo "FAIL: merge queue guard still received the main base branch" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '-R contributor/widgets-fork' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive the fork head repository" >&2
cat "$CALL_LOG" >&2
exit 1
fi
if ! grep -q -- '--sha 0123456789abcdef0123456789abcdef01234567' "$CALL_LOG"; then
echo "FAIL: merge queue guard did not receive the exact PR head SHA" >&2
cat "$CALL_LOG" >&2
exit 1
fi
echo "pr-merge queue branch/repository/SHA regression passed"
+1 -1
View File
@@ -25,7 +25,7 @@
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
-146
View File
@@ -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();
}
-149
View File
@@ -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);
});
-34
View File
@@ -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}`,
);
}
-146
View File
@@ -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;
}
}
-208
View File
@@ -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);
});
-254
View File
@@ -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();
}
-274
View File
@@ -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' });
});