Compare commits

..
Author SHA1 Message Date
coder-mos1andmos-dt-0 58b971aba3 fix(rm-03): make CI queue guard fail on asserted non-readiness (#1032)
Co-authored-by: coder-mos1 <[email protected]>
2026-08-01 18:55:26 +00:00
coder-mos1andmos-dt-0 f4fd5967fc RM-61: prove ci-postgres teardown discrimination (#1033)
Co-authored-by: coder-mos1 <[email protected]>
2026-08-01 14:54:04 +00:00
mos-dt-0andMos f65e9ea656 docs(remediation): mission-state snapshot at the RM-01 seam (#1028)
Co-authored-by: mos-dt-0 <[email protected]>
2026-08-01 01:08:11 +00:00
mos-dt-0andMos f58b3699a6 RM-01: reproducible checkout — the pre-push gate fails on code, not environment (#1027)
Co-authored-by: mos-dt-0 <[email protected]>
2026-08-01 00:50:12 +00:00
mos-dt-0andMos 01e966f36d docs(remediation): mission charter + reconciled execution backlog (#1026)
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 22:47:03 +00:00
mos-dt-0andMos 524146055d fix(hygiene): .prettierignore must exclude Python build/test artifacts (#1025)
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 22:24:49 +00:00
mos-dt-0andMos 06e0d40352 feat(quality): CI test-membership guard — enumeration can no longer silently under-run the disk (#1017) (#1018)
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 14:08:41 +00:00
Mos 166ee8c90f fix(wake): close fd 9 in the detector's sleep child so a dead detector's lock dies with it (#993) 2026-07-31 13:48:33 +00:00
mos-dt-0andMos 826a8b3b26 fix(git): pr-review.sh — surface the provider's stated reason, drop the hardcoded #865 attribution (#1006)
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 10:52:58 +00:00
mos-dt-0andMos a4280b9c98 fix(wake): #984 fatal source guard + #985 absorb re-scan — #973 follow-up batch (#1001)
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-31 10:16:22 +00:00
Mos 4fb44f6345 fix(wake): three-valued grep verdicts — has_match/count_lines across all ten suites (closes #973) (#983) 2026-07-31 08:39:04 +00:00
Mos 089615f63b feat(git): push-guard — refuse verifications satisfied by the null case (closes #975) (#974) 2026-07-31 03:35:24 +00:00
mos-dt-0andMos 76eef39a29 docs(wake): #953 dead-letter retention recorded as load-bearing at the write site (#968)
Closes #953.

GATE RECORD: review CLEAR at this exact head (author != reviewer, pre-registered diff-blind checks) + terminal-green CI at this exact head + queue guard clear.
CI CAVEAT (#973): green on the wake suites is currently WEAKER THAN IT LOOKS, IN BOTH DIRECTIONS. grep error/spawn exit codes are read as absence across 257 assertion sites in six idiom forms; 36 inverted (&&-fail) sites — including 19 credential-security canaries — fail toward GREEN under load. These greens were obtained on solo reruns after load-correlated FALSE reds (2115/2116/2118; main itself was red). This merge's safety therefore rests on the CONTENT review, not on the green. Remediation charter fa551c2d0 is authored and in flight.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 23:25:38 +00:00
mos-dt-0andMos 47f8689231 fix(wake): #952 quarantine-audit clean sweep names BOTH unprovable residual classes (#967)
Closes #952.

GATE RECORD: review CLEAR at this exact head (author != reviewer, pre-registered diff-blind checks) + terminal-green CI at this exact head + queue guard clear.
CI CAVEAT (#973): green on the wake suites is currently WEAKER THAN IT LOOKS, IN BOTH DIRECTIONS. grep error/spawn exit codes are read as absence across 257 assertion sites in six idiom forms; 36 inverted (&&-fail) sites — including 19 credential-security canaries — fail toward GREEN under load. These greens were obtained on solo reruns after load-correlated FALSE reds (2115/2116/2118; main itself was red). This merge's safety therefore rests on the CONTENT review, not on the green. Remediation charter fa551c2d0 is authored and in flight.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 23:25:34 +00:00
mos-dt-0andMos 8d1d6e5e76 feat(wake): #958 A11 preimage.sh — durable provenance for the operator-side preimage definition (#964)
Closes #958.

The preimage definition (source-adapter.sh) is the single most consequential file in the wake pipeline — every observed_hash is a sha256 of what it emits — and it was UNVERSIONED: no git history, no backup. When it was edited at 07:27 on 2026-07-30, attribution was recoverable only because an agent transcript happened to still be on disk. A11 gives that file durable provenance (option (2) of #958: recorded content-addressed, not in-band).

DESIGN, per the pre-registration:
- Provenance is OUT-OF-BAND (never on the adapter's stdout) — an in-band record would advance observed_hash for every source at once and manufacture the re-baseline it exists to explain (B3).
- The obligation never depends on the provenance path: a missing/corrupt store cannot halt the detector or swallow a wake (#940 advisory-fields precedent; B4).
- DESC_FMT=d1 is NOT the provenance record — the tag versions the descriptor FORMAT; a behaviour change that keeps descriptor shape re-baselines every hash and leaves the tag unchanged (B6).

CREDENTIAL HARD GATE (rebuilt after the first verdict FAILED it): byte capture is now RECORD-ONLY BY DEFAULT (extras opt in via WAKE_PREIMAGE_CAPTURE), not allow-by-default-refuse-on-shape — because a shape list can only refuse the secrets someone already enumerated, and the tool's own usage text recommended adding detector.env (where HMAC material lives). Deny is evaluated on BOTH raw and resolved path forms with resolved anchors, ordered before allow — closing the realpath-before-deny ordering defect that let a renamed symlink target through.

VERIFICATION (reviewer, mos-dt, independent of the author's claims):
- Seven decoy cases by planted-marker-then-grep-whole-state-dir: known cred path / same-name symlink / RENAMED-target symlink / prefixed secret / prefixless secret / opted-in-symlink-to-DENIED-target all REFUSED; opted-in-symlink-to-ALLOWED-target CAPTURED (positive control that the harness can capture at all, and that C was not closed by breaking every symlink).
- Polarity-completeness self-test RE-RUN with the shape list stubbed always-allow AND both deny lists stubbed — case D still safe: the flip is complete, the shape list is not load-bearing. Each stub proven live first (a stub that silently fails to apply reports the dangerous state as safe).
- B11: rm-then-change fails LOUD (rc=1), refuses to re-baseline, leaves the ledger absent; absent-with-emptied-objects still first-installs cleanly (absent-is-not-corrupt not paid for by breaking first install).
- RED-first reproduced exactly P13-P16 pre-fix; each refusal corroborated three ways (loud stderr, ledger row captured:false WITH a hash so attribution survives refusal, objects/ holding only the adapter).

KNOWN RESIDUAL (filed #969, non-gating): the deny check is both-forms but the suite needles only the resolved form — a deny reduced to resolved-only survives 17/17 green and would leak a renamed-symlink case. No reachable leak at this head (shipped code correct on all seven decoys); it constrains a FUTURE edit. Doctrine: a both-forms fix needs a needle per form; a fixture that satisfies its assertion through a DIFFERENT rule is testing the rule it did not mean to test.

Authored by pepper (sb-it-1-dt); independently reviewed by mos-dt (sb-it-1-dt) under diff-blind pre-registration (7242688b1, predating first read) — NOT CLEAR on the first verdict (B2/B11 failed by decoy), CLEAR at 8aff7d8 after the polarity rebuild. Manifest version 0.7.0.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 21:48:06 +00:00
mos-dt-0andMos 6a7fce34bb fix(wake): #946 digest ack watermark clamped at quarantined seqs — disclose AND clamp (#951)
Closes #946.

The digest omitted a quarantined entry's claim from disclosure while still advancing the ack watermark it instructed the consumer to run — converting a fail-safe HOLD into a silent DISCARD, through the documented normal path. Measured: the burial instruction was re-issued FIVE times, four fresh digests plus one system-initiated redelivery fired purely because the entry had gone unconsumed for 1826s. That redelivery is the proof of the 'indefinitely' half: the mechanism re-asserted itself with no new information.

SCOPE — this was NOT a missing check in the consume path. Measured before the fix: dead-letter occurrences were digest.sh 27, store.sh 0, ack.sh 0, detector.sh 0, reconcile.sh 0. Quarantine was owned ENTIRELY by the renderer; the store that advances the watermark had zero knowledge the ledger existed, so an entry could be quarantined by one subsystem and consumed by another with no possible interaction. The fix is therefore a deliberate cross-module decision — option (b), quarantine recorded into a store-owned file, preserving the existing direction of dependency — pre-registered by the consumer before any diff existed.

VERIFICATION
- Pipeline 2107 terminal SUCCESS at e11bc6622, read clone-inclusive from the provider API rather than through `pipeline-status.sh` (which filters `.type != "clone"` per workflow and would hide a clone failure behind an all-green table): 9/9 children success, exit 0 each, no non-success member. Its `test` step runs all nine wake harnesses via turbo -> packages/mosaic `test` -> `test:framework-shell`.
- Independent review by the consumer on the affected lane: eleven pre-registered acceptance checks, authored and delivered BEFORE the diff was read — the file list deliberately unlooked-at, because a filename alone would have disclosed which option was chosen. All eleven resolved, no blocker.
- The check that decides it: a RAW `ack.sh consumed --upto N` with no digest involved must refuse to advance past a quarantined seq — the case an agent hits when a digest is MISSED, and the one that would have sunk a disclosure-only fix. Covered at the head as a named assertion (T13 ordinary-path bypass), written independently of the reviewer's list.
- Mutation: one asserted site disabled -> TWELVE assertions die, every one BEHAVIOURAL, ZERO count assertions, including one killing across the module boundary the fix spans.
- RED control at base a6b5f6a: 34 and 10 assertions fail, matching the body exactly.
- Coordinator re-verify by a different instrument than the reviewer used: static reference counts across the base/head boundary — store.sh 0 -> 49, ack.sh 0 -> 6, `--agent` unchanged at 4 (so #949 correctly stayed out). A fix present-but-inert passes the count and fails the mutation; a fix behaviourally correct but smuggling #949 passes the mutation and fails the count. Neither result is reachable by repeating the other.

KNOWN RESIDUALS
- The `consumed-hashes` repair criterion is met only for keys that RE-EMIT. A corrupted row whose key never recurs stays false indefinitely; the sweep covers those, and the known-false row named in the acceptance criteria had already self-healed by re-emission rather than by design — safe by population, not by design.
- The audit's clean-sweep message names one unprovable class; a second exists (a surviving dead-letter row with an empty `observed_hash` cannot be convicted either). Wording, not logic. Filed separately.
- The audit's provability bound makes dead-letter RETENTION load-bearing for auditability. Nothing prunes it today, so this is latent — but any future rotation or size cap silently converts provable rows into unprovable ones with no signal at either end. This is not a defect; it is a property that BECAME load-bearing and is recorded nowhere. Filed separately.
- `test-wake-detector.sh` D4 fails at this head AND identically at base, with an empty diff over detector files — pre-existing, tracked, not introduced here.

Authored by pepper (sb-it-1-dt); reviewed independently by mos-dt (sb-it-1-dt). The mos-dt-0 commit and fork identity does not identify the author — attribution collapse tracked separately.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 15:41:03 +00:00
mos-dt-0andMos a6b5f6a01a fix(wake): #944 path becomes a hard-locator arm — detector-shape actionable entries pass the §2.1 gate (#945)
Closes #944.

_has_hard_locator accepted only repo+issue / 40-hex sha / file — the forge vocabulary. The detector emits path (+snapshot_sha when attested) and NEVER emits file/issue/sha, so predicate and sole producer shared ZERO keys and every class=actionable board_file entry dead-lettered. Latent since #920, whose harness pinned the detector's own emission shape as its malformed example — the suite certified the gap it was written to guard.

Fix: `path` becomes a hard-locator arm, and ONLY path. Bare path-less snapshot_sha is a deliberate NON-arm (would widen past the board_file vocabulary); Q11(d) asserts it still quarantines at 7/40/64 chars, spanning the detector's ^[0-9a-f]{7,64}$ attestation range.

VERIFICATION
- Pipeline 2105 terminal-green at fa36da8. Its `test` step reaches all nine wake harnesses via turbo -> packages/mosaic `test` -> `test:framework-shell`, which names each suite explicitly. The two-levels-down indirection matters: no search of .woodpecker/* can see it, and that is exactly why this question was got wrong earlier today and then corrected. CI therefore DOES attest the quarantine suite and the detector suite at this head.
- Independent review (mos-dt, consumer on the affected lane) PASS at fa36da8, from a detached worktree: predicate provably unmoved from fb3c3c3 (comment-stripped sha256 identical, _has_hard_locator body byte-identical), RED control at base reproduced exactly 8 failures all Q11 including "got 6".
- Third reviewer (wake-judge) ACCEPT on both judgment calls: the Q1 assertion reversal is a legitimate correction (the flip was forced, not elective — base fixtures red 19 assertions against the head predicate) and path-alone satisfies §2.1, whose operative test is "one targeted call, never a search" — two of its four named exemplars already resolve to current state. Requiring path+snapshot_sha jointly would permanently dead-letter a declared source class and conflict with #940's advisory-fields ruling.
- Judge's mutation criterion met: with the reconciled exemption disabled, the gate-level assertion ("an ORIENTATION-tier enumeration must NOT be quarantined") dies at this head and did not exist as a casualty before F1.
- D4 (detector lock re-acquisition) fails intermittently at base AND head; git diff base..head over the detector files is EMPTY, so it is out of this PR's surface on structural grounds rather than on a re-roll. Known defect, fix identified (detector.sh:516, fd 9 leaked into sleep), tracked separately.

KNOWN RESIDUALS
- ENUM-B is now the sole address-free reconciled fixture, so the exemption's gate-level guard is a population of one. Safe by population, not by design. Author follow-up: assert ENUM-B carries no hard-locator arm so the harness guards its own premise.
- The binding spec (CONVERGED-DESIGN.md §2.1, separate repo) still enumerates four forge tokens and reads narrower than the shipped gate. Tracked as #948, sequenced after the dragon-lin reseed.
- Hard-locator arms are type-loose: repo/file/path accept any non-null JSON value. Pre-existing; `sha` fails closed only by accident of test(). Tracked separately.

Authored by pepper (sb-it-1-dt); reviewed independently by mos-dt (sb-it-1-dt) and wake-judge. The mos-dt-0 commit/fork identity does not identify the author — attribution collapse tracked in #3092.

Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 14:01:48 +00:00
mos-dt-0andMos 539b475a92 fix(wake): #943 whole-string validation for WAKE_SNAPSHOT_TS_FUTURE_SLACK + version 0.6.13
grep is line-oriented, so a multi-line knob value passed the per-line anchors and was still fatal in arithmetic. Replaced with a case pattern matching the whole string, so an embedded or leading newline rejects. Manifest bumped 0.6.12 -> 0.6.13: three materially different detectors had shipped under one version string, and version= is the component sole self-identity claim.

Authored-by: pepper
Reviewed-by: mos-dt (independent, at this head; transfer proven by blob-hash equality)
Merged-by: Mos
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 11:56:18 +00:00
mos-dt-0andMos 3e47fc076f fix(wake): #942 harden WAKE_SNAPSHOT_TS_FUTURE_SLACK — validate shape AND force base-10
The slack knob was interpolated raw into $((...)) under set -u: a malformed value was FATAL to the poll, falsifying the poll-never-fails invariant, and a negative value inverted the guard to deny-all. Shape validation alone was insufficient — bash reads a leading zero as octal, so 08/09 passed the regex yet were fatal and 0300 silently meant 192. Now validated ^[0-9]{1,9}$ with a loud fallback to 300, then forced to base-10 via 10# so the knob means what the operator wrote.

Authored-by: pepper
Reviewed-by: mos-dt (independent, found both the original defect and the radix residual)
Merged-by: Mos
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 11:17:51 +00:00
mos-dt-0andMos 8710d0f6d7 feat(wake): #940 snapshot-datable digests — fd-3 snapshot-metadata channel (#941)
Adapter emits snapshot sha/ts out-of-band on fd 3 so a changing value never enters the delta-gate hash. Detector validates advisorily (sha regex, epoch sanity before arithmetic, future-skew slack); malformed metadata is dropped loudly and never gates the wake. Digest renders snapshot_sha/snapshot_ts plus a git-show re-verify hint. Adapters that never write fd 3 are byte-identical.

Reviewed-by: Mos (design, independent)
Reviewed-by: mos-dt (artifact, hardening §2)
Co-authored-by: mos-dt-0 <[email protected]>
2026-07-30 10:55:18 +00:00
jason.woltjeandMos b981b4ec10 fix(wake): #934 mount-free, privilege-invariant seq-integrity fault injection (T9/T11 run in non-priv CI) (#936)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 16:34:58 +00:00
jason.woltjeandMos 9e81ffd7fc fix(wake): #932 stop reconciler re-enumerating already-CONSUMED detector state (#935)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 15:48:40 +00:00
jason.woltjeandMos 9becaf877f fix(wake): #917 gate the final observed_seq cursor write + observed.set/cursor consistency (defense-in-depth) (#933)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 15:14:38 +00:00
jason.woltjeandMos 17087efe15 fix(wake): #925 framework-ship canon fallback-wake (systemd timer + schema bound + A10 install/validate) — F7 out of the box (#931)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 13:44:51 +00:00
jason.woltjeandMos 0ea41e848b fix(wake): #913 installer adoption gaps — _lib dep-check fail-loud + mosaic-wake.service systemd-search-path link+validate (#930)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 12:37:29 +00:00
jason.woltjeandMos 347c1d57c1 fix(wake): #924 route dead-letter quarantine alarm via WAKE_ALARM_SINK_CMD with per-observed_seq dedup (G2a) (#929)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 11:45:09 +00:00
jason.woltjeandMos 13e6ce5e5c fix(wake): #927 enqueue TOCTOU — move stale-tmp cleanup off the hot enqueue path (no concurrent in-flight-write clobber) (#928)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 10:56:40 +00:00
jason.woltjeandMos 90265ef550 test(wake): #923 de-flake T10 concurrent-enqueue race (deterministic barrier) (#926)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 10:23:07 +00:00
jason.woltjeandMos 937a276208 fix(wake): #920 quarantine render-refused drain entry (no head-of-line block) + reconciler enumerations render orientation-tier (reconciled:true, render-tier not class=digest) (#922)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 08:45:51 +00:00
jason.woltjeandMos 712c770b7a fix(wake): #912 exercise the digest/HMAC trust suite in real CI (fix runner divergence + openssl + hard-require) (#921)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 08:11:28 +00:00
jason.woltjeandMos d967a4a926 fix(wake): #914 digest renderer — WAKE_AGENT-prefixed ack line + digest-class locator threading (#916)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 07:13:07 +00:00
jason.woltjeandMos c585ac3326 test(wake): #918 de-flake T7 ack-no-network-block (sub-second timing) (#919)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 06:46:28 +00:00
jason.woltjeandMos e2ec927b1c fix(wake): #908 unify observed_seq on a single store-side allocator (dissolve detector-private-counter seam) (#915)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 06:02:02 +00:00
jason.woltjeandMos 2378665eaf feat(wake): W7 A10 idempotent installer + mosaic-wake.service (component-manifest, Gate-A, blank-reset retire, snapshot-guard, fail-closed install-validate) (#911)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 04:19:09 +00:00
jason.woltjeandMos 003cdaa1a6 feat(wake): W6 — off-host dead-man beacon + pluggable alarm-sink adapter (#910)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 02:30:28 +00:00
jason.woltjeandMos 320f5bfb6f feat(wake): W5 — synthetic-canary FN-oracle + source-parity reconciler (#909)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 02:04:19 +00:00
jason.woltjeandMos 5df47e735e feat(wake): W4 — per-host delta-gated detector daemon (fail-loud source semantics, enqueues to W2 store) (#907)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 01:04:55 +00:00
jason.woltjeandMos dd1391fd76 fix(wake): digest hard-locator gate covers top-level .claim entries (Closes #905) (#906)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 00:58:40 +00:00
jason.woltjeandMos 10d957d095 feat(wake): W3 — cumulative-state digest renderer + non-circular HMAC signer (#904)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-26 00:32:12 +00:00
jason.woltjeandMos dc45eb7c30 feat(kbn): land KBN-101 Envelope A v6 (rc.20) — declarative sink-RBAC + RLS write-source (Form A) (#902)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 23:58:18 +00:00
jason.woltjeandMos 28f022d9c0 feat(wake): W2 — three-cursor durable store + RECEIVED/CONSUMED ack-wrapper + watch-list schema (#903)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 23:58:08 +00:00
jason.woltjeandMos 2726fab5e0 chore(framework): wire agent-send.test.sh into CI test:framework-shell (W1 follow-up) (#901)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 23:23:24 +00:00
jason.woltjeandMos ab6e8e80dc fix(framework): send-message.sh fail-loud submission verdict + regression tests (Patch 6) (#895)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:57:11 +00:00
jason.woltjeandMos 1933c6cb1d feat(framework): accept digest message class in agent-send.sh (W1) (#894)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:56:46 +00:00
jason.woltjeandMos 48a0c86093 fix(lease-broker): recovery_runtime_unittest wait_ready() connect-probe (co-equal CI flake, cherry-pick #898) (#900)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:26:38 +00:00
jason.woltjeandMos 79c8647fd9 fix(lease-broker): wait_ready() polls real connect-readiness not socket-file existence (flaky CI race) (#898)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:10:15 +00:00
jason.woltjeandMos 2483dada33 fix(framework): pr-review.sh -r/--repo + -H/--host overrides + UA + repo preflight (Patches 5/5c) (#896)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 22:08:08 +00:00
jason.woltjeandMos 4c117afe03 docs(framework): add WAKE-DOCTRINE.md guide (W0) (#893)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 21:57:51 +00:00
jason.woltjeandMos 2698ddb7b5 feat(comms): P1 presence — minimal Synapse + fleet presence room + mosaic.presence heartbeat + liveness (#888)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 21:03:18 +00:00
jason.woltjeandMos fabde1c834 docs(rfc): add RFC-001 (MACP/Matrix-native comms) + RFC-002 (install/config/topology) (#886)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 21:02:54 +00:00
jason.woltjeandMos 3c7890f17f fix(framework): detect-platform get_gitea_token fail-loud on absent per-slot token (Patch 2b) (#890)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-25 20:51:19 +00:00
jason.woltjeandMos 529c177830 fix(update): mosaic update runs the install-ordering guard post-reseed (#882 --sync-only bypass) (#883)
Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 22:18:34 +00:00
jason.woltjeandMos a32ce4c8f9 feat(869-c4): activation version-coupling assertion (Part of #869)
Part of #869

Mos (id-11) Gate-16 merge: independent APPROVE @90eb48fa (fail-closed identity locks byte-unchanged verified), author id2 != approver id11, clean mosaic-coder author, CI green wp1992. #869 Point-1 CODE COMPLETE (C1/C3/C5/C2/C4).

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 19:07:27 +00:00
jason.woltjeandMos d351caad36 feat(869-c2): install-ordering enforcement-hook guard (Part of #869)
Part of #869

Mos (id-11) Gate-16 merge: independent APPROVE @b6f36564 (8/8, verified vs real production settings template), author id2 != approver id11, clean mosaic-coder author, CI green wp1988.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 18:48:33 +00:00
jason.woltjeandMos 76b86a246e feat(869-c5): mosaic doctor activation-check (Part of #869)
Part of #869

Mos (id-11) Gate-16 merge: independent APPROVE @e75e3238 (8/8), author id2 != approver id11, clean mosaic-coder author, CI green wp1987.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 18:38:21 +00:00
jason.woltjeandMos 4422231bdb feat: per-agent Gitea identity resolution (#873)
Closes #873

Mos (id-11) Gate-16 merge: independent APPROVE @4b472a22 (author-blocker dissolved via (a) re-author, identical tree hash to tech-approved head), author id2 != approver id11, clean mosaic-coder commit-author, CI green wp1985. Framework train COMPLETE 6/6.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 18:09:34 +00:00
jason.woltjeandMos 8504216964 fix(pr-review): case-insensitive _belongs slug compare (#875)
Closes #875

Mos (id-11) Gate-16 merge: independent APPROVE @9d8d58ae, author id2 != approver id11, clean mosaic-coder commit-author, CI green wp1982.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:53:26 +00:00
jason.woltjeandMos 7edc9b3121 fix(gitea): direct REST comment/review with fail-closed read-back (#865)
Closes #865

Mos (id-11) Gate-16 merge: fresh confirmatory independent APPROVE @8ac7e70f (1241-case fuzz 0 fail-open), author id2 != approver id11, clean commit-author, CI green wp1966.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:25:32 +00:00
jason.woltjeandMos 2f50c0876b feat(869-c3): lease-broker supervisor unit (Part of #869)
Part of #869

Mos (id-11) Gate-16 merge: independent APPROVE @75235ef8 (9/9, no live host mutation), author id2 != approver id11, CI green wp1971.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:19:48 +00:00
jason.woltjeandMos db90da347e feat(869-c1): activation-capability probe (Part of #869)
Part of #869

Mos (id-11) Gate-16 merge: independent 3-round APPROVE @c5a2bcc5, author id2 != approver id11, CI green wp1973.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:14:57 +00:00
jason.woltjeandMos 48fd1df28a fix(ci-queue-wait): treat absent branch (404) as queue-clear (#872)
Closes #872

Mos (id-11) Gate-16 merge: independent review APPROVE @23cbdaf8, author jason.woltje(id2) != approver Mos(id11), CI green wp1974.

Co-authored-by: jason.woltje <[email protected]>
Co-committed-by: jason.woltje <[email protected]>
2026-07-23 17:08:57 +00:00
185 changed files with 36814 additions and 611 deletions
+1
View File
@@ -8,6 +8,7 @@ coverage
.env.local
*.tsbuildinfo
.pnpm-store
__pycache__/
docs/reports/
# Step-CA dev password — real file is gitignored; commit only the .example
+1 -1
View File
@@ -1 +1 @@
pnpm typecheck && pnpm lint && pnpm format:check
pnpm preflight && 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/
# Pin the pnpm store to the same path the ci-base image warms (Dockerfile.ci),
# so the pipeline `pnpm install --prefer-offline` consumes the baked store
# instead of repopulating a fresh one.
store-dir=/root/.local/share/pnpm/store
# HOME resolves to /root in the ci-base image, preserving its warmed-store path.
# Non-root checkouts use their own HOME. Override without editing this file via
# NPM_CONFIG_STORE_DIR (pnpm's environment form of the store-dir setting).
store-dir=${HOME}/.local/share/pnpm/store
+8
View File
@@ -4,6 +4,14 @@ pnpm-lock.yaml
**/node_modules
**/drizzle
**/.next
# Python build/test artifacts — same category as node_modules/dist/.next above.
# Prettier must never scan generated trees; without these a local venv poisons
# `pnpm format:check` with thousands of third-party files.
**/venv
**/__pycache__
**/.mypy_cache
**/.pytest_cache
**/htmlcov
.claude/
docs/tess/TASKS.md
docs/scratchpads/
+11
View File
@@ -41,6 +41,11 @@ steps:
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
# Test-membership guard (#1017): also first link of test:framework-shell.
# Invoked from BOTH surfaces it audits (F2, PR #1018) — the guard is link
# [0] of the pnpm chain, so severing that chain would silence it together
# with everything it guards; this direct line keeps one instrument running.
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
# Blocking gate (#791): a framework upgrade must never write or delete an
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
@@ -98,6 +103,12 @@ steps:
DATABASE_URL: postgresql://mosaic:mosaic@ci-postgres:5432/mosaic
commands:
- *enable_pnpm
# openssl (#912) is the wake HMAC signer: the digest H1/H2, beacon B12,
# and install I8 legs hard-require it in CI. It is baked into ci-base via
# Dockerfile.ci, but ci-base only rebuilds on push-to-main/tag — this
# `apk add` guarantees openssl is present on PR pipelines too (and is a
# fast no-op once the rebuilt image already ships it).
- apk add --no-cache openssl
# postgresql-client (pg_isready) is baked into ci-base.
# Wait up to 60s for CI postgres to be ready; fail fast if it never comes up.
- |
+4 -1
View File
@@ -25,7 +25,10 @@ FROM node:24-alpine
# postgresql-client used by the test step's pg_isready readiness probe. `bash`,
# `git`, and `jq` are baked here too — framework shell tests and the shipped
# Codex review wrappers require them without per-run installation in ci.yml.
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq
# `openssl` (#912) is the non-circular HMAC signer for the wake trust layer:
# the digest H1/H2, beacon B12, and install I8 legs hard-require it in CI so the
# §4 G6 evidence comes from an actually-run HMAC leg, not a skipped one.
RUN apk add --no-cache python3 make g++ postgresql-client bash git jq openssl
# Pin pnpm to the repo's packageManager version via corepack.
RUN corepack enable && corepack prepare [email protected] --activate
+14
View File
@@ -201,8 +201,21 @@ git clone [email protected]:mosaicstack/stack.git
cd stack
# Install dependencies. The local tier uses in-process PGlite; leave DATABASE_URL unset.
# The pnpm store defaults to $HOME/.local/share/pnpm/store. Override it without
# editing the checkout with NPM_CONFIG_STORE_DIR=$HOME/another-store if needed.
pnpm install
# Verify dependencies and generated state before running source-quality gates.
# Missing dependencies exit 42; stale/foreign apps/web/.next state exits 43.
# The web build certifies its exact standalone symlink manifest; added, removed,
# retargeted, or manifest-only-tampered generated links also exit 43. This detects
# accidental, independent, stale, and foreign-residue mutation—the class exposed by
# a five-month-stale .next that produced 19 phantom TS2307 errors.
# It does NOT defend against a same-UID actor that can rewrite both manifest and
# marker consistently (CWE-345). RM-59 tracks the required executor/spine-side
# trust anchor outside worktree authority.
pnpm preflight
# Optional local queue service only. This does not start PostgreSQL.
docker compose up -d valkey
@@ -230,6 +243,7 @@ Gateway start command until KBN-101-02 makes that state fail closed.
### Quality Gates
```bash
pnpm preflight # Checkout/dependency/generated-state validation
pnpm typecheck # TypeScript type checking (all packages)
pnpm lint # ESLint (all packages)
pnpm test # Vitest (all packages)
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "0.0.2",
"private": true,
"scripts": {
"build": "next build",
"build": "node ../../scripts/build-web.mjs",
"dev": "next dev",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
+5
View File
@@ -96,3 +96,8 @@
- [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755)
- [M1 logical identity and fencing architecture](architecture/mos-runtime-portability-m1.md)
- [M1 connector lease operations](guides/mos-connector-lease-operations.md)
## Comms evolution — Matrix-native MACP (design, draft)
- [RFC-001 — MACP: a Mosaic-native, Matrix-native comms layer](rfcs/RFC-001-MACP-MATRIX-NATIVE.md) — Synapse + Mosaic appservice backbone, MACP v1 protocol, presence/escalation, federation, strangler migration off the Hermes MCP bridge.
- [RFC-002 — Install, configuration & topology for the Matrix/MACP comms system](rfcs/RFC-002-INSTALL-CONFIG-TOPOLOGY.md) — open-source install topology modes, ACME cert provisioning, pluggable secret backend, and config precedence.
+1
View File
@@ -12,6 +12,7 @@
| [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) | Mission/authority boundaries, exact role chain, gate model, mandatory SecReview triggers, Certifier final/no-merge rule, and collision-free slice ownership |
| [`TASKS.md`](./TASKS.md) | Dependency-ordered, bounded P0P3 slices with IN/OUT scope, dependencies, shared contracts, file ownership, evidence, and USC coder2/3/4/5 parallelization |
| [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) | rc.16 direct-Drizzle current storage-wrapper hold: legacy N-1/uncertified/non-operative pending -02/-03/-06/-08; exact README commented/user-guide executable forms fail before masking and source-consistency rejects runner-delegation copy; held future bootstrap → TLS/roles → run → verify → readiness; plus prior production boundary, pgvector owner, attestation, inventory, manifests, DDL classifier, TLS/bootstrap, activation, and certification contract; foundation prerequisite of KBN-100 and real-role gate before KBN-105 |
| [`KBN-101-ENVELOPE-A.md`](./KBN-101-ENVELOPE-A.md) | KBN-101 Envelope A (v6) — RATIFIED, part of the frozen SSOT: rc.20 declarative sink-RBAC + per-role connection-selection + RLS `WITH CHECK`/`USING` write-source + `FORCE ROW LEVEL SECURITY` + sink-resident `task_status_write_override`; adds owner card KBN-101-10 + responsibility-widenings; authority Jason B1 + Mos OPTION A/Q1/Q2 |
| [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) | Remediated v1 integration contract: proof authority, exact failures/routes/DTOs/MCP ownership, concrete current-main field migration map, relational invariants, Coordinator split, recovery delivery |
| [`contracts/kanban-schema.v1.ts`](./contracts/kanban-schema.v1.ts) | Drizzle target declarations including exact owner/principal membership, project congruence, tags/archive, proposals, persisted assignments, monotonic fences, durable retry, immutable evidence/audit |
| [`contracts/mechanical-coordinator.v1.ts`](./contracts/mechanical-coordinator.v1.ts) | Pure snapshot decision engine separated from persistence/service adapter; ID-bound approvals, bigint-safe fences, durable retry/quarantine, artifact-backed checkpoints, exact failures |
+53 -38
View File
@@ -138,16 +138,24 @@ Before any preflight that can decide migration state, `mosaic-db-migrator` acqui
Role creation, passwords, membership, database ownership, certificates, and Vault values are platform/IaC/operator work—not Drizzle/application migrations. Application SQL must not issue credential/role management statements or embed credentials.
| Role | Attributes and ownership | Membership / session use |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `mosaic_platform_database_owner` | `NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; platform-only database owner after bootstrap. | Never granted to application roles. |
| external platform bootstrap actor | Provider/operator/IaC-controlled, externally audited **superuser** identity outside the Mosaic role graph and Vault/application configuration. | Creates/transitions the database and roles, then retires from application use. It alone `SET ROLE`s the extension owner for `CREATE EXTENSION`, `ALTER EXTENSION ... UPDATE`, or `ALTER EXTENSION ... SET SCHEMA`, records the action, and `RESET ROLE`s. |
| `mosaic_schema_owner` | `NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; owns only `mosaic`/`drizzle` schemas and application/ledger objects. It has only `USAGE` on `mosaic_extensions` for fixed legacy type resolution. | Never an application login; no ownership, `CREATE`, `ALTER`, `DROP`, extension/member-change, or default-privilege authority in `mosaic_extensions`; its migrator subphase never receives temporary `CREATE` there. |
| `mosaic_extension_owner` | Dedicated `NOLOGIN SUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS` extension owner, distinct from platform/schema/migrator/runtime. It is used solely to own/maintain untrusted `vector`, `mosaic_extensions`, and every owner-bearing extension member there. | `rolcanlogin=false`, `rolsuper=true`, and **zero members** are catalog-proven. No application role has membership, `SET ROLE`, credential, or inheritable grant. An externally controlled, audited platform-bootstrap **superuser** session alone executes `SET ROLE mosaic_extension_owner` for fresh creation, approved-owner update/relocation, or shadow bootstrap, then `RESET ROLE`; no persistent membership is ever granted. |
| `mosaic_migrator` | `LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`. | Only `mosaic_schema_owner`; runner verifies `session_user=mosaic_migrator`, `SET ROLE mosaic_schema_owner`, then `current_user=mosaic_schema_owner`. |
| `mosaic_data_importer` | `LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; dedicated data-copy identity, not a DDL or schema owner. | It is used only after runner preparation/verification through the KBN-101-02 file-reference interface. It has no owner/migrator/extension membership and cannot `SET ROLE mosaic_extension_owner`, or `ALTER`/catalog-or-extension-member `UPDATE`/`DROP`/change extension membership. Its bounded data-copy DML is not extension authority. |
| `mosaic_runtime_capability` | `NOLOGIN` and no ownership/administrative attributes. | Holds only named runtime grants. |
| `mosaic_runtime` | `LOGIN INHERIT` with no ownership/administrative attributes. | Only `mosaic_runtime_capability WITH INHERIT TRUE, SET FALSE, ADMIN FALSE`; never owner/migrator member. |
| Role | Attributes and ownership | Membership / session use |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mosaic_platform_database_owner` | `NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; platform-only database owner after bootstrap. | Never granted to application roles. |
| external platform bootstrap actor | Provider/operator/IaC-controlled, externally audited **superuser** identity outside the Mosaic role graph and Vault/application configuration. | Creates/transitions the database and roles, then retires from application use. It alone `SET ROLE`s the extension owner for `CREATE EXTENSION`, `ALTER EXTENSION ... UPDATE`, or `ALTER EXTENSION ... SET SCHEMA`, records the action, and `RESET ROLE`s. |
| `mosaic_schema_owner` | `NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; owns only `mosaic`/`drizzle` schemas and application/ledger objects. It has only `USAGE` on `mosaic_extensions` for fixed legacy type resolution. | Never an application login; no ownership, `CREATE`, `ALTER`, `DROP`, extension/member-change, or default-privilege authority in `mosaic_extensions`; its migrator subphase never receives temporary `CREATE` there. |
| `mosaic_extension_owner` | Dedicated `NOLOGIN SUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS` extension owner, distinct from platform/schema/migrator/runtime. It is used solely to own/maintain untrusted `vector`, `mosaic_extensions`, and every owner-bearing extension member there. | `rolcanlogin=false`, `rolsuper=true`, and **zero members** are catalog-proven. No application role has membership, `SET ROLE`, credential, or inheritable grant. An externally controlled, audited platform-bootstrap **superuser** session alone executes `SET ROLE mosaic_extension_owner` for fresh creation, approved-owner update/relocation, or shadow bootstrap, then `RESET ROLE`; no persistent membership is ever granted. |
| `mosaic_migrator` | `LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`. | Only `mosaic_schema_owner`; runner verifies `session_user=mosaic_migrator`, `SET ROLE mosaic_schema_owner`, then `current_user=mosaic_schema_owner`. |
| `mosaic_data_importer` | `LOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`; dedicated data-copy identity, not a DDL or schema owner. | It is used only after runner preparation/verification through the KBN-101-02 file-reference interface. It has no owner/migrator/extension membership and cannot `SET ROLE mosaic_extension_owner`, or `ALTER`/catalog-or-extension-member `UPDATE`/`DROP`/change extension membership. Its bounded data-copy DML is not extension authority. |
| `mosaic_runtime_capability` | `NOLOGIN` and no ownership/administrative attributes. | Holds only named runtime grants. |
| `mosaic_runtime` | `LOGIN INHERIT` with no ownership/administrative attributes. | Only `mosaic_runtime_capability WITH INHERIT TRUE, SET FALSE, ADMIN FALSE`; never owner/migrator member. |
| `mosaic_runtime_user_capability` | `NOLOGIN`, no ownership/admin attrs. | Holds the **User (god)** rung's named grants incl. `INSERT(status)`, `UPDATE(status)` + the status-transition RLS `WITH CHECK` policy on `tasks`; **plus the SOLE `INSERT/UPDATE/DELETE` grant on `task_status_write_override`** (B-1); never granted to Orchestrator/base. |
| `mosaic_runtime_orchestrator_capability` | `NOLOGIN`, no ownership/admin attrs. | Holds the **Orchestrator (near-god)** rung's grants; identical to User **except** (i) every `tasks` write policy **subqueries** the override table and is REJECTED at the sink when an active User deny row exists for that `(workspace_id, task_id, orchestrator)` (B-1); (ii) `INSERT(status)` is pinned by `WITH CHECK` to a **non-terminal initial status** (N-3) — only User may INSERT an arbitrary/terminal status; (iii) it has only `SELECT` (never write) on the override table. Scope is tier/task-identity only (F6). |
| `mosaic_runtime_user` | **`LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`**, no ownership; **non-owner of every sink table + the override table**. | Member of `mosaic_runtime_user_capability` only (`WITH INHERIT TRUE, SET FALSE, ADMIN FALSE`); the connection-selection **User-rung** credential. **[AD-1]** Requires `CONNECT`, denied `TEMPORARY`. |
| `mosaic_runtime_orchestrator` | **`LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`**, no ownership; **non-owner of every sink table + the override table**. | Member of `mosaic_runtime_orchestrator_capability` only (same INHERIT/SET/ADMIN flags); the **Orchestrator-rung** credential. **[AD-1]** Requires `CONNECT`, denied `TEMPORARY`. |
> The existing `mosaic_runtime` is confirmed already `LOGIN INHERIT` with no admin attrs (frozen L150) and non-owner; it becomes the **others / deny-by-default** base rung. On the sink it retains `SELECT` (workspace-scoped `USING`, B-2), `INSERT (explicit non-status column list)` (F1), and column-level `UPDATE` on **non-status, non-invariant, non-key, non-tenancy** columns only (F7), each `USING` workspace-scoped; it has **no** `INSERT(status)`, **no** `UPDATE(status)`, **no** `DELETE`, **no** `TRUNCATE` on `tasks`, **no** write on the override table, and RLS denies its status/override writes by default. The rung set is **fixed at three**.
>
> **F2 note:** the three rung LOGIN roles' `NOBYPASSRLS` + `NOSUPERUSER` + **non-ownership** are load-bearing — `FORCE ROW LEVEL SECURITY` collapses for any SUPERUSER/BYPASSRLS role and RLS is silently bypassed by a table **owner**. These attributes are declared in -00 bootstrap, catalog-proven in -00 tests, and re-verified at every checkout by -01 (extended L222 assertion).
The fixed application/runtime schema is **`mosaic`**. Every application/runtime pooled connection executes and verifies exactly `SET search_path TO pg_catalog, mosaic` before its first application query; connection checkout repeats this after reset/reconnect. Transactional application operations use `SET LOCAL search_path TO pg_catalog, mosaic` and verify it before query execution. `public` and `$user` are forbidden in all runtime paths.
@@ -173,30 +181,36 @@ Before relocation, the runner records a parameterized catalog inventory and depe
No SQL identifier may come from URL/config/environment/operator input. Catalog comparisons use parameter values. The fixed identifiers above are constants; the external bootstrap artifact alone may use server-side `format('%I', fixed_allowlisted_identifier)` after allowlist validation. Raw client-side interpolation for identifiers, `SET search_path`, database, schema, role, table, or extension names is forbidden. Tests include injection-shaped values, a poisoned pooled-session reset, and transaction `SET LOCAL` restoration negatives.
External bootstrap executes `REVOKE CONNECT, TEMPORARY ON DATABASE <fixed_database> FROM PUBLIC`, then grants `CONNECT` only to `mosaic_runtime`, `mosaic_migrator`, and the time-bounded external bootstrap actor while it is required. Certification fails if an unrelated login retains `CONNECT` or either application login retains `TEMPORARY`. Runtime receives `USAGE` on `mosaic`, named table/sequence grants through `mosaic_runtime_capability`, and `USAGE` on `drizzle` plus `SELECT` only on `drizzle.__drizzle_migrations` and `drizzle.__mosaic_migration_manifest`. `INSERT`, `UPDATE`, `DELETE`, `TRUNCATE`, and DDL rights on ledger/manifest are revoked. Revoke public CREATE and function EXECUTE; `SECURITY DEFINER` is forbidden unless a separately reviewed exception pins trusted path and grants only the capability role. Database TEMPORARY, role management, extension, schema, and object ownership are denied.
External bootstrap executes `REVOKE CONNECT, TEMPORARY ON DATABASE <fixed_database> FROM PUBLIC`, then grants `CONNECT` only to `mosaic_runtime`, `mosaic_runtime_user`, `mosaic_runtime_orchestrator`, `mosaic_migrator`, and the time-bounded external bootstrap actor while it is required. Certification fails if an unrelated login retains `CONNECT` or any of the three application logins retains `TEMPORARY`. Runtime receives `USAGE` on `mosaic`, named table/sequence grants through `mosaic_runtime_capability`, and `USAGE` on `drizzle` plus `SELECT` only on `drizzle.__drizzle_migrations` and `drizzle.__mosaic_migration_manifest`. **[NB-5]** The two new capability roles `mosaic_runtime_user_capability` and `mosaic_runtime_orchestrator_capability` additionally receive the identical baseline grants: `USAGE` on `mosaic`, `USAGE` on `drizzle` plus `SELECT` only on `drizzle.__drizzle_migrations` and `drizzle.__mosaic_migration_manifest`, and the relevant sequence `USAGE`/`SELECT` grants — mirroring `mosaic_runtime_capability` — so the frozen §6 L222 runtime verify does not fail closed on missing inherited/sequence/ledger grants for the two new rungs. `INSERT`, `UPDATE`, `DELETE`, `TRUNCATE`, and DDL rights on ledger/manifest are revoked. Revoke public CREATE and function EXECUTE; `SECURITY DEFINER` is forbidden unless a separately reviewed exception pins trusted path and grants only the capability role. Database TEMPORARY, role management, extension, schema, and object ownership are denied.
Immutable KBN relations, after KBN-100 creates them, grant runtime only `SELECT, INSERT` and explicitly deny `UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER`: `task_events`, `artifacts`, `task_checkpoints`, `task_checkpoint_artifacts`, and `approval_decision_artifacts`. KBN-100 retains RESTRICT/no-cascade semantics. Foundation certification verifies the role/schema boundary only; post-KBN-100 certification verifies this real deployed-role matrix.
> **rc.20 (Envelope A) — authorized by Jason's declarative-RBAC B1 ruling + Mos OPTION A + Mos Q1 (TIER-LEVEL) + Mos Q2 (RLS):** adds (i) the fixed **User/Orchestrator/others** runtime rung-roles (per-ROLE, deny-by-default); each rung **LOGIN** role is `NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`, **NON-OWNER** of every sink/override table, **added to the §4 L176 `CONNECT` allowlist**, and **asserted `TEMPORARY`-denied**. **No per-federated-user role or credential is created** — federated identity maps to a rung **at authorization time only**, and per-writer **attribution lives in metadata columns** (app-supplied `actor_id` on `task_events`), **never** in DB credentials/roles. (ii) Two new fixed rung DSN secrets **`mosaic-db-runtime-user-url`** and **`mosaic-db-runtime-orchestrator-url`** (Gateway-only, `0600 10001:10001`), extending the frozen runtime-URL secret row. (iii) **RLS `WITH CHECK` write-source policies (including the B2-INSERT per-rung INSERT tenant binding `workspace_id = current_setting('mosaic.workspace_id', true)::uuid`, composed as a SINGLE compound `WITH CHECK` or `AS RESTRICTIVE` — never multi-permissive-intended-to-AND, per the v5 composition mandate) + per-rung `SELECT`/`UPDATE` `USING` tenant policies + `FORCE ROW LEVEL SECURITY`** on the sink table `tasks` **and on the new sink-resident `task_status_write_override` table** (tighten-only, deny-by-default) — a §10 schema-v1 **mechanism addition** beyond the prior grant/revoke-only model, authorized by Mos Q2 as coordinator co-authorization with Jason's B1. **The two new rung capability roles additionally receive the baseline frozen §4 L176 runtime grants (`USAGE ON mosaic`, `drizzle` ledger `SELECT`, sequence grants) [NB-5] so they satisfy the frozen §6 L222 runtime verify.** (iv) The **`task_status_write_override` table** (keyed `(workspace_id, task_id, tier)`, no per-writer key), written only by the User rung, read by the Orchestrator/base rung `tasks` write-policy subquery, homes the User-override **at the sink**. The sink DDL (grants + RLS `USING`/`WITH CHECK` + override table + FORCE + unique key + trigger/view/rule invariants) is homed in the **KBN-100 producer** after it creates `tasks`, with `FORCE RLS` as the **terminal** migration step after all backfill/repair DML, per the §4 L178 producer/consumer precedent.
>
> **Frozen-invariant note (authorization basis):** touching the frozen role graph, the §4 L176 CONNECT allowlist, and adding RLS + the override table are §10 contract-change classes (MISSION-MANIFEST §10; SHARED-CONTRACT L82 (rc.6 non-effect) / L88-90 (rc.5 non-effect), both amended by this rc.20; frozen §4 L176). They are authorized here by **Jason B1 + Mos OPTION A + Mos Q1 + Mos Q2**. The addition is strictly a **fixed per-ROLE** set + a **tighten-only** RLS mechanism + a **task/tier-scoped** override table. It stops exactly at the HALT boundary: **no** per-federated-user topology is introduced.
## 5. Deployable verified-TLS bootstrap
`mosaicstack/stack` is the named repository/control plane. Ownership is intentionally non-overlapping: **KBN-101-00 exclusively owns** the versioned external bootstrap interface `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, and `infra/pg-bootstrap/README.md`, plus its bootstrap tests—roles, extension-owner transition, and no renderer/deployment manifests. **KBN-101-05 exclusively owns** `tools/db/render-postgres-secrets.ts`, its tests, the current `docker-compose.yml`, `docker-compose.federated.yml`, `deploy/portainer/federated-test.stack.yml`, `tools/federation-harness/docker-compose.two-gateways.yml`, and `apps/gateway/Dockerfile`; it consumes the versioned KBN-101-00 bootstrap interface and owns no bootstrap SQL. The named **Mosaic deployment control plane / Jason** is activation authority; the environment-specific IaC/Vault owner supplies only approved input secret versions and may not substitute an unreviewed current-repository artifact. The KBN-101-05 renderer is the only deployment handoff: it reads secret-provider references, validates owners/modes/digests/SANs, writes each output atomically (`mkstemp` on the target tmpfs, `fsync`, `chmod`/`chown`, atomic rename), and records only secret-version identifiers and hashes.
`KBN-101-05` changes the Gateway image to fixed non-root `USER 10001:10001`, the importer image to fixed non-root `USER 10002:10002`, and the migrator process to fixed non-root `USER 10003:10003`; image and renderer tests freeze every UID:GID. A root-only migrator launch wrapper may open the private signing key before dropping to `10003:10003`, but the migrator process never regains root. Gateway CA and Gateway leaf-certificate mounts, and its own Gateway private key only when it terminates its HTTPS listener, must be readable by `10001:10001`; PostgreSQL private keys and migration-only material are never mounted there, and no secret is world-readable. PostgreSQL is not assigned a guessed UID/GID: its image must first be pinned by digest, and an image-inspection plus rendered Compose/Swarm test freezes the image's effective PostgreSQL UID:GID before the renderer selects mount owner/group. A digest, service UID/GID, rendered secret `uid`/`gid`/`mode`, or container `USER` mismatch is a KBN-101-05 failure. Mosaic applications never generate, self-sign, copy, or persist production certificates; the external bootstrap actor receives them only through the deployment secret mechanism and no plaintext development exception exists for production-like modes.
| Material | Vault target / deployment secret | Mount, injection, and authorized consumer |
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- |
| Runtime URL | `secret-{env}/mosaic-stack/database/runtime` (`url`) → `mosaic-db-runtime-url-v1` | Gateway only: `/run/secrets/mosaic-db-runtime-url`, `0600`, `10001:10001`; entrypoint maps it to `DATABASE_URL` only at process exec. It is denied to every migrator, storage runtime, fleet, ordinary CLI, and test except an explicit runtime-negative fixture. |
| Migration URL | `secret-{env}/mosaic-stack/database/migrator` (`url`) → `mosaic-db-migrator-url-v1` | Each one-shot migrator only: `/run/secrets/mosaic-db-migrator-url`, `0600`, fixed migrator UID:GID asserted by the image/render test; entrypoint maps it only to `DATABASE_MIGRATION_URL`. It is denied to Gateway, storage runtime, fleet, and ordinary CLI. |
| Importer target URL + provider version | **Vault KV-v2** `secret-{env}/mosaic-stack/database/importer` (`url`) with the same successful response `data.metadata.version` → generation-pinned `mosaic-db-importer-url-v<version>` / `mosaic-db-importer-version-v<version>` | The renderer consumes value and authenticated provider version as one generation, never derives version from DSN bytes. It renders separate immutable copies: migrator-attestation producer only gets `/run/secrets/mosaic-migrate-target-url` and `/run/secrets/mosaic-migrate-target-version`, each `10003:10003` `0400`; importer only gets the same two fixed paths, each `10002:10002` `0400`. Each consumer receives its own read-only mount, never a shared writable file. Runtime, Gateway, ordinary CLI, fleet, and unrelated containers receive neither file nor version. |
| Attestation signing key | `secret-{env}/mosaic-stack/database/migrate-target-attestation` (`private_key`) → `mosaic-db-migrate-target-ed25519-v1` | Runner root-wrapper only: fixed `/run/secrets/mosaic-db-migrate-target-ed25519`, `root:root` `0400`, referenced only by `MOSAIC_DB_ATTESTATION_SIGNING_KEY_FILE`; it opens once then drops to migrator `10003:10003`. No importer/runtime/Gateway mount or log/export is permitted. |
| Attestation public key ring | versioned deployment public-key bundle → `mosaic-db-migrate-target-ed25519-public-v1` | Importer only: pinned `/run/mosaic-attestations/migrate-target.ed25519.pub`, `10002:10002` `0400`; active/overlap key IDs are explicit and revoked IDs fail closed. It contains no private key. |
| Handoff verifier inputs | root-owned non-secret generation descriptor (expected provider version, URL SHA-256, generation ID) plus pinned public verifier key | Privileged controller only: `0400`; no importer URL bytes or private key. It verifies artifact bindings before copy/start. |
| Target attestation artifact | runner-produced non-secret file | Migrator writes only `/run/mosaic-attestations-producer/migrate-target.v1.json`, `10003:10003` `0400`, on a producer-only mount. After runner success, privileged deployment handoff controller verifies and atomically copies the exact signed bytes to a distinct importer-only `/run/mosaic-attestations/migrate-target.v1.json` mount, `10002:10002` `0400`, seals it read-only, then creates importer. No importer write access, shared writable mount, Gateway/runtime access, or unrelated-container mount exists. | |
| CA bundle | `secret-{env}/mosaic-stack/database/tls-ca` (`certificate`) → generation-pinned `mosaic-db-ca-v<version>` | Explicit DB-client consumers only: Gateway `/run/secrets/mosaic-db-ca.crt` `10001:10001` `0444`; migrator same path `10003:10003` `0444`; importer at the exact `DATABASE_TLS_CA_CERT_PATH=/run/secrets/mosaic-db-ca.crt`, `10002:10002` `0444`. PostgreSQL receives a distinct read-only CA copy only when client-cert validation is enabled; no unrelated container receives it. |
| Gateway leaf certificate | `secret-{env}/mosaic-stack/federation/gateway-server-tls` (`certificate`) → `mosaic-gateway-server-cert-v1` | Gateway only: `/run/secrets/mosaic-gateway-server.crt`, `0444`, `10001:10001`; renderer emits this exact Compose and Swarm target and validates it before start. |
| Gateway private key | same Vault record (`private_key`) → `mosaic-gateway-server-key-v1` | Gateway only: `/run/secrets/mosaic-gateway-server.key`, `0400`, `10001:10001`; not mounted to migrator or PostgreSQL and never world-readable. |
| PostgreSQL leaf certificate | `secret-{env}/mosaic-stack/database/postgres-server-tls` (`certificate`) → `mosaic-postgres-server-cert-v1` | PostgreSQL only: `/run/secrets/mosaic-postgres-server.crt`, `0444`, frozen verified postgres UID:GID. |
| PostgreSQL private key | same Vault record (`private_key`) → `mosaic-postgres-server-key-v1` | PostgreSQL only: `/run/secrets/mosaic-postgres-server.key`, `0400`, frozen verified postgres UID:GID; never mounted to Gateway or migrator. |
| Material | Vault target / deployment secret | Mount, injection, and authorized consumer |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- |
| Runtime URL | `secret-{env}/mosaic-stack/database/runtime` (`url`) → `mosaic-db-runtime-url-v1` | Gateway only: `/run/secrets/mosaic-db-runtime-url`, `0600`, `10001:10001`; entrypoint maps it to `DATABASE_URL` only at process exec. It is denied to every migrator, storage runtime, fleet, ordinary CLI, and test except an explicit runtime-negative fixture. |
| Runtime URL (User rung) | `secret-{env}/mosaic-stack/database/runtime-user` (`url`) → `mosaic-db-runtime-user-url-v1` **(PROVISIONAL-CONVENTION — the exact Vault subpath is inferred from the Runtime URL row's naming pattern and is TO-CONFIRM against the live Vault layout at KBN-101-03/-05 implementation; the secret NAME `mosaic-db-runtime-user-url` is envelope-fixed, the `.../database/runtime-user` subpath is not.)** | Gateway only: `/run/secrets/mosaic-db-runtime-user-url`, `0600`, `10001:10001`; entrypoint maps it to the User-rung pool DSN only at process exec. Denied to every migrator, storage runtime, fleet, ordinary CLI, and test except an explicit runtime-negative fixture. |
| Runtime URL (Orchestrator rung) | `secret-{env}/mosaic-stack/database/runtime-orchestrator` (`url`) → `mosaic-db-runtime-orchestrator-url-v1` **(PROVISIONAL-CONVENTION — the exact Vault subpath is inferred from the Runtime URL row's naming pattern and is TO-CONFIRM against the live Vault layout at KBN-101-03/-05 implementation; the secret NAME `mosaic-db-runtime-orchestrator-url` is envelope-fixed, the `.../database/runtime-orchestrator` subpath is not.)** | Gateway only: `/run/secrets/mosaic-db-runtime-orchestrator-url`, `0600`, `10001:10001`; entrypoint maps it to the Orchestrator-rung pool DSN only at process exec. Denied to every migrator, storage runtime, fleet, ordinary CLI, and test except an explicit runtime-negative fixture. |
| Migration URL | `secret-{env}/mosaic-stack/database/migrator` (`url`) → `mosaic-db-migrator-url-v1` | Each one-shot migrator only: `/run/secrets/mosaic-db-migrator-url`, `0600`, fixed migrator UID:GID asserted by the image/render test; entrypoint maps it only to `DATABASE_MIGRATION_URL`. It is denied to Gateway, storage runtime, fleet, and ordinary CLI. |
| Importer target URL + provider version | **Vault KV-v2** `secret-{env}/mosaic-stack/database/importer` (`url`) with the same successful response `data.metadata.version` → generation-pinned `mosaic-db-importer-url-v<version>` / `mosaic-db-importer-version-v<version>` | The renderer consumes value and authenticated provider version as one generation, never derives version from DSN bytes. It renders separate immutable copies: migrator-attestation producer only gets `/run/secrets/mosaic-migrate-target-url` and `/run/secrets/mosaic-migrate-target-version`, each `10003:10003` `0400`; importer only gets the same two fixed paths, each `10002:10002` `0400`. Each consumer receives its own read-only mount, never a shared writable file. Runtime, Gateway, ordinary CLI, fleet, and unrelated containers receive neither file nor version. |
| Attestation signing key | `secret-{env}/mosaic-stack/database/migrate-target-attestation` (`private_key`) → `mosaic-db-migrate-target-ed25519-v1` | Runner root-wrapper only: fixed `/run/secrets/mosaic-db-migrate-target-ed25519`, `root:root` `0400`, referenced only by `MOSAIC_DB_ATTESTATION_SIGNING_KEY_FILE`; it opens once then drops to migrator `10003:10003`. No importer/runtime/Gateway mount or log/export is permitted. |
| Attestation public key ring | versioned deployment public-key bundle → `mosaic-db-migrate-target-ed25519-public-v1` | Importer only: pinned `/run/mosaic-attestations/migrate-target.ed25519.pub`, `10002:10002` `0400`; active/overlap key IDs are explicit and revoked IDs fail closed. It contains no private key. |
| Handoff verifier inputs | root-owned non-secret generation descriptor (expected provider version, URL SHA-256, generation ID) plus pinned public verifier key | Privileged controller only: `0400`; no importer URL bytes or private key. It verifies artifact bindings before copy/start. |
| Target attestation artifact | runner-produced non-secret file | Migrator writes only `/run/mosaic-attestations-producer/migrate-target.v1.json`, `10003:10003` `0400`, on a producer-only mount. After runner success, privileged deployment handoff controller verifies and atomically copies the exact signed bytes to a distinct importer-only `/run/mosaic-attestations/migrate-target.v1.json` mount, `10002:10002` `0400`, seals it read-only, then creates importer. No importer write access, shared writable mount, Gateway/runtime access, or unrelated-container mount exists. | |
| CA bundle | `secret-{env}/mosaic-stack/database/tls-ca` (`certificate`) → generation-pinned `mosaic-db-ca-v<version>` | Explicit DB-client consumers only: Gateway `/run/secrets/mosaic-db-ca.crt` `10001:10001` `0444`; migrator same path `10003:10003` `0444`; importer at the exact `DATABASE_TLS_CA_CERT_PATH=/run/secrets/mosaic-db-ca.crt`, `10002:10002` `0444`. PostgreSQL receives a distinct read-only CA copy only when client-cert validation is enabled; no unrelated container receives it. |
| Gateway leaf certificate | `secret-{env}/mosaic-stack/federation/gateway-server-tls` (`certificate`) → `mosaic-gateway-server-cert-v1` | Gateway only: `/run/secrets/mosaic-gateway-server.crt`, `0444`, `10001:10001`; renderer emits this exact Compose and Swarm target and validates it before start. |
| Gateway private key | same Vault record (`private_key`) → `mosaic-gateway-server-key-v1` | Gateway only: `/run/secrets/mosaic-gateway-server.key`, `0400`, `10001:10001`; not mounted to migrator or PostgreSQL and never world-readable. |
| PostgreSQL leaf certificate | `secret-{env}/mosaic-stack/database/postgres-server-tls` (`certificate`) → `mosaic-postgres-server-cert-v1` | PostgreSQL only: `/run/secrets/mosaic-postgres-server.crt`, `0444`, frozen verified postgres UID:GID. |
| PostgreSQL private key | same Vault record (`private_key`) → `mosaic-postgres-server-key-v1` | PostgreSQL only: `/run/secrets/mosaic-postgres-server.key`, `0400`, frozen verified postgres UID:GID; never mounted to Gateway or migrator. |
Compose renders each value-plus-provider-version generation into a non-repository temporary generation directory, `fsync`s every file and directory, atomically renames the complete generation, and mounts immutable per-consumer copies only after the pair is complete. Swarm declares distinct versioned secret/config references for each migrator/importer consumer and generation. A deployment may not start, reload, or combine any URL/version/CA/attestation/public-key material across generations. The privileged handoff controller is the only bridge from migrator producer mount to importer mount: it verifies the signed producer artifact plus generation, copies atomically, seals importer read-only, and only then starts importer; it is tested for failed/partial copy, wrong generation, wrong owner/mode, and no importer start. KBN-101-05 rejects bind-mounted committed cert/key files, environment-encoded PEM, missing secrets, non-atomic renderer output, mixed generations, shared writable files, private-key access outside its named consumer (Gateway for Gateway key; PostgreSQL for PostgreSQL key), importer URL/version/attestation/key access outside their named consumer, CA outside explicit DB clients, and any world-readable URL/key. The existing target Vault names are planned canonical paths and must be verified/provisioned by the deployment-owner input; the planning card does not claim they exist.
@@ -229,20 +243,21 @@ Stable sanitized codes are `DATABASE_RUNTIME_URL_REQUIRED`, `DATABASE_MIGRATION_
Every KBN-101 card remains one PR with exclusive ownership. Cards `00``07` may merge only as **prepared, inactive capability**: no current owner-runtime deployment consumes their image/config, and no compatibility switch is exposed to a runtime operator. They must not retain `ALLOW_LEGACY_*`, runtime DDL, `DATABASE_URL` migration fallback, plaintext TLS, direct Drizzle, or test-only bypass flags. Current owner-runtime deployments remain on their known N-1 release until final activation.
| Card | Depends on | Complete, disjoint file/glob manifest and required test/evidence paths |
| ----------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `KBN-101-00` platform bootstrap / IaC | contract | **Only:** `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, `infra/pg-bootstrap/tests/**`. It creates the extension-owner role/schema/extension interface and proves fresh, approved-owner, legacy-shadow, catalog/default-privilege, and direct-denial bootstrap cases. No renderer, runner, Compose, CI, or deployment path. |
| `KBN-101-01` typed runtime config and verifier | 00 | **Only:** `packages/config/src/index.ts`, `packages/config/src/mosaic-config.ts`, `packages/config/src/mosaic-config.spec.ts`; `packages/db/src/client.ts`, `packages/db/src/defaults.ts`, `packages/db/src/connection-identity.ts`, `packages/db/src/client.spec.ts`, `packages/db/src/defaults.spec.ts`, `packages/db/src/connection-identity.spec.ts`; `apps/gateway/src/database/database.module.ts`, `apps/gateway/src/database/database.module.spec.ts`. It supplies runtime/migration/TLS DTO parsing plus runtime identity/search-path/readiness verification. No migrator, storage, installer, deploy, or CI path. |
| `KBN-101-03` sole runner, manifest, and schema foundation | 00,01 | **Only:** `.gitattributes`; `packages/db/package.json`; `packages/db/drizzle.config.ts`; `packages/db/src/cli.ts`, `packages/db/src/cli.spec.ts`, `packages/db/src/index.ts`, `packages/db/src/index.import-negative.spec.ts`, `packages/db/src/migrate.ts`, `packages/db/src/migrate.test.ts`, `packages/db/src/schema.ts`, `packages/db/src/schema.spec.ts`; `packages/db/src/migrator/**`; `packages/db/drizzle/*.sql`, `packages/db/drizzle/meta/*.json`; `docker/db-migrator.Dockerfile`, `docker/db-migrator.Dockerfile.spec.ts`; `packages/db/package-bin.spec.ts`. It alone publishes `"mosaic-db-migrator": "./dist/cli.js"`, verifies source/build/pack/discovery, and sets `ENTRYPOINT ["mosaic-db-migrator"]`; it exclusively owns `packages/db/src/migrator/target-attestation.dto.ts`, `target-attestation-signer.ts`, and their specs: fixed-key reference validation, JCS canonical payload, Ed25519 signing, producer TLS/identity/manifest binding, atomic artifact emission, and producer tests. It owns journal/manifest/ledger/lock/relocation/shadow tests. Shipped `0009` bytes stay unchanged. |
| `KBN-101-02` runtime DDL closure | 01,03 | **Only:** `docker/init-db.sql`, `infra/pg-init/01-extensions.sql`; `packages/storage/src/adapters/postgres.ts`, `packages/storage/src/adapters/postgres.spec.ts`, `packages/storage/src/factory.ts`, `packages/storage/src/factory.spec.ts`, `packages/storage/src/types.ts`, `packages/storage/src/tier-detection.ts`, `packages/storage/src/tier-detection.spec.ts`, `packages/storage/src/cli.ts`, `packages/storage/src/cli.spec.ts`, `packages/storage/src/migrate-tier.ts`, `packages/storage/src/migrate-tier.spec.ts`, `packages/storage/src/migrate-tier.integration.test.ts`; `apps/gateway/src/main.ts`, `apps/gateway/src/__tests__/integration/federated-boot.pg-unreachable.integration.test.ts`, `apps/gateway/src/__tests__/integration/federated-boot.success.integration.test.ts`, `apps/gateway/src/__tests__/integration/federated-pgvector.integration.test.ts`; `packages/db/src/federation.integration.test.ts`; `packages/mosaic/src/commands/fleet-backlog.ts`, `packages/mosaic/src/commands/fleet-backlog.spec.ts`. It consumes the -03 runner and exclusively owns importer verification/interface in `packages/storage/src/{cli,migrate-tier}.ts` and the named unit/integration specs: fixed URL/version/attestation/public-key safe-open fds (`O_RDONLY | O_CLOEXEC | O_NOFOLLOW`plus post-open regular-owner-mode-link-count validation), one-read protected-memory URL connection, fd zeroization/close, signature/key/expiry/replay/authenticated-provider-version/digest/generation/CA/binding validation, no forwarding/logging/oracle, post-TLS zero-DML comparison, consumer isolation, and DDL classifier. It closes runtime/retired-init DDL only; removes the current Gateway production`.env`/monorepo auto-load path in favor of the -05 renderer-backed process-exec or `LoadCredential` consumer boundary; and excludes every -03 signer/runner, index, migrate, and Drizzle-config asset, and every deployment/CI/doc path. |
| `KBN-101-04` installer/wizard | 01 | **Only:** `packages/mosaic/src/stages/gateway-config.ts`, `packages/mosaic/src/stages/gateway-config.spec.ts`, `packages/mosaic/src/stages/gateway-config-cors.spec.ts`, `packages/mosaic/src/stages/wizard-menu.spec.ts`, `packages/mosaic/src/wizard.ts`. It persists only non-secret references/injected-variable contracts; source inspection excludes `tools/install.sh`, which does not read/write the database DSN. |
| `KBN-101-05` renderer and deployment | 00,03 | **Only:** `tools/db/render-postgres-secrets.ts`, `tools/db/render-postgres-secrets.spec.ts`; `apps/gateway/Dockerfile`, `apps/gateway/Dockerfile.spec.ts`; `docker-compose.yml`, `docker-compose.spec.ts`; `docker-compose.federated.yml`, `docker-compose.federated.spec.ts`; `deploy/portainer/federated-test.stack.yml`, `deploy/portainer/federated-test.stack.spec.ts`; `tools/federation-harness/docker-compose.two-gateways.yml`, `tools/federation-harness/docker-compose.two-gateways.spec.ts`. It consumes the -00 bootstrap interface and -03 immutable runner image, and exclusively renders/tests fixed Gateway/importer/migrator UIDs; runner-only root-owned signing-key reference; KV-v2 importer URL plus same-response `data.metadata.version`; separate immutable generation-pinned URL/version mounts for `10003:10003` migrator and `10002:10002` importer; importer-only CA at `DATABASE_TLS_CA_CERT_PATH`, public-key/key-ring, and controlled producer-only-to-importer-only attestation handoff mount; privileged controller verification/copy/fsync/atomic-rename/seal-before-importer-start behavior; Compose generation-dir fsync/atomic-rename and Swarm versioned-secret/config no-mixed-generation behavior. It owns no bootstrap, runner, config, storage, or CI file. |
| `KBN-101-07` operator/runbook/docs | 02,03,04,05 | **Only:** `README.md`, `CLAUDE.md`, `docs/guides/admin-guide.md`, `docs/guides/dev-guide.md`, `docs/guides/deployment.md`, **`docs/guides/migrate-tier.md`**, `docs/guides/user-guide.md`, `docs/federation/MILESTONES.md`, `docs/federation/SETUP.md`, `docs/federation/TASKS.md`, `docs/fleet/backlog-conventions.md`, `docs/PERFORMANCE.md`, `docs/design/storage-abstraction-middleware.md`, `docs/plans/2026-03-15-agent-platform-architecture.md`, `docs/plans/2026-03-15-wave2-tui-layout-navigation.md`, `docs/runbooks/kbn-101-database-role-split.md`, `docs/reports/native-kanban-sot/kbn-101-operator-readiness-report.md`, `docs/native-kanban-sot/tests/kbn-101-operator-docs.spec.ts`. It exclusively owns the active migrate-tier operator route; local PGlite/non-PostgreSQL Compose disposition; the held PostgreSQL/federated activation order; and the non-operative production renderer/Vault generation-pinned process-exec or `LoadCredential` consumer-isolation schematic. It documents interfaces produced by -02/-03/-04/-05, including both file references, signing-key isolation, attestation bindings, rotation/replay, no-connection/no-DML errors, and the ban on current production `.env`, monorepo auto-load, environment-file, credential export/argv, or restart-as-secret-activation guidance; it owns no source, storage, CLI, runner, or CI file. |
| `KBN-101-06` CI classifier and command matrix | 02,03,05,07 | **Only:** `.woodpecker/ci.yml`, `tools/ci/kbn101-ddl-inventory.ts`, `tools/ci/kbn101-ddl-inventory.spec.ts`, `tools/ci/fixtures/kbn101-ddl-inventory.json`, `tools/ci/kbn101-entrypoint-matrix.ts`, `tools/ci/kbn101-entrypoint-matrix.spec.ts`. It invokes the already-produced bin/image/deployment/doc artifacts and edits no producer file. Its inventory test enforces manifest overlap, ownerless, duplicate-owner, path-existence, allowlist, active-route field completeness, finite operator-document inventory, normative/status-only non-masking, unknown-command, and historical/status-only masking failures. Before inventory, ownership, or status masking, its semantic suite fails the exact README commented code-fence generic-wrapper form and exact user-guide executable generic-wrapper form recorded in §2.2, and source-consistency opens `packages/storage/src/cli.ts` to prove its current direct-Drizzle `pnpm --filter @mosaicstack/db db:migrate` `execSync` implementation and absence of a `mosaic-db-migrator` bin make runner-delegation copy false. It also fails former `SETUP.md` automatic-first-boot/startup, the exact former `MILESTONES.md` wording, and the exact former README/dev/deployment Compose-first sequences before an inventory record can mask any path; it fails Compose-up-before-runner, extension/schema/migration automatic wording, init-script authority, production `.env`, `EnvironmentFile=`, credential shell export/argv, and restart-as-secret-activation. It passes the rc.16 held PGlite-data-layer/non-PostgreSQL-Compose disposition (and fails Gateway/Web local start while daemon/inherited/root/app-local DSN or non-local-tier input could select PostgreSQL), the one `Held future procedure` section with KBN-101-00/-03/-05 and external bootstrap → TLS/roles → `mosaic-db-migrator --run``mosaic-db-migrator --verify` → Gateway/Compose-readiness, and the README legacy-N-1 CI status assertion; every runner hit outside that section and every current-CI authority variant fails before masking. Its matrix invokes the -02 secure target route and verifies URL/version generation mismatch, provider rotation/revocation, consumer isolation, safe-open TOCTOU/link/mode/owner, absent/wrong importer CA, no log/oracle, every declared attestation refusal before target connection/DDL, and post-connect mismatch with zero DML. |
| `KBN-101-08` foundation certification and **atomic activation release** | 00…07 | **Only evidence:** `docs/reports/native-kanban-sot/kbn-101-foundation-activation-certificate.md`, `docs/reports/native-kanban-sot/kbn-101-foundation-activation-evidence.json`. It changes no implementation path. Independent review and terminal-green CI must verify prepared artifacts before Mosaic control plane/Jason authorizes backup → drain/scale-zero N-1 → TLS → roles → runner → verified readiness → rolling runtime; any red result aborts. |
| `KBN-101-09` post-KBN-100 certification | KBN-100,08 | **Only evidence:** `docs/reports/native-kanban-sot/kbn-101-immutable-role-certificate.md`, `docs/reports/native-kanban-sot/kbn-101-immutable-role-evidence.json`. It changes no implementation path and records real deployed runtime INSERT/SELECT plus UPDATE/DELETE-denial evidence and independent security/Ultron approval. |
| Card | Depends on | Complete, disjoint file/glob manifest and required test/evidence paths |
| --------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `KBN-101-00` platform bootstrap / IaC | contract | **Only:** `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/extensions.sql`, `infra/pg-bootstrap/README.md`, `infra/pg-bootstrap/tests/**`. It creates the extension-owner role/schema/extension interface and proves fresh, approved-owner, legacy-shadow, catalog/default-privilege, and direct-denial bootstrap cases. No renderer, runner, Compose, CI, or deployment path. |
| `KBN-101-01` typed runtime config and verifier | 00 | **Only:** `packages/config/src/index.ts`, `packages/config/src/mosaic-config.ts`, `packages/config/src/mosaic-config.spec.ts`; `packages/db/src/client.ts`, `packages/db/src/defaults.ts`, `packages/db/src/connection-identity.ts`, `packages/db/src/client.spec.ts`, `packages/db/src/defaults.spec.ts`, `packages/db/src/connection-identity.spec.ts`; `apps/gateway/src/database/database.module.ts`, `apps/gateway/src/database/database.module.spec.ts`. It supplies runtime/migration/TLS DTO parsing plus runtime identity/search-path/readiness verification. No migrator, storage, installer, deploy, or CI path. |
| `KBN-101-10` declarative sink-RBAC + per-role connection-selection + credential-handoff | 00,01 | **Only:** `packages/db/src/sink-rbac/**`, `packages/db/src/credential-handoff/**`. It homes: the source-controlled declarative RBAC policy artifact + schema + evaluator; the fixed **rung ladder** + federation-aware identity→rung resolver; the rung→DB-credential **connection-selection** module (consumes -01 pools; import direction `10→01`); the **status-preserving-UPSERT** contract + **invariant field-set** module + **no-status view/rule/trigger enumeration**; and the **SCM_RIGHTS** credential-handoff + **non-dumpable** assert/verify library. It creates no roles, no DDL, no Compose/CI/deploy path; it is consumed by -00 (role names), -01 (connection wiring + identity/attribute verify), -02/-03/-05 (write path + fd handoff), specified-against by **KBN-100** (sink DDL: grants/RLS/`USING`+`WITH CHECK` policies/override-table/FORCE/unique-key/trigger-view-rule-invariant on `tasks`), and certified deployed by KBN-101-09. |
| `KBN-101-03` sole runner, manifest, and schema foundation | 00,01,10 | **Only:** `.gitattributes`; `packages/db/package.json`; `packages/db/drizzle.config.ts`; `packages/db/src/cli.ts`, `packages/db/src/cli.spec.ts`, `packages/db/src/index.ts`, `packages/db/src/index.import-negative.spec.ts`, `packages/db/src/migrate.ts`, `packages/db/src/migrate.test.ts`, `packages/db/src/schema.ts`, `packages/db/src/schema.spec.ts`; `packages/db/src/migrator/**`; `packages/db/drizzle/*.sql`, `packages/db/drizzle/meta/*.json`; `docker/db-migrator.Dockerfile`, `docker/db-migrator.Dockerfile.spec.ts`; `packages/db/package-bin.spec.ts`. It alone publishes `"mosaic-db-migrator": "./dist/cli.js"`, verifies source/build/pack/discovery, and sets `ENTRYPOINT ["mosaic-db-migrator"]`; it exclusively owns `packages/db/src/migrator/target-attestation.dto.ts`, `target-attestation-signer.ts`, and their specs: fixed-key reference validation, JCS canonical payload, Ed25519 signing, producer TLS/identity/manifest binding, atomic artifact emission, and producer tests. It owns journal/manifest/ledger/lock/relocation/shadow tests. Shipped `0009` bytes stay unchanged. |
| `KBN-101-02` runtime DDL closure | 01,03,10 | **Only:** `docker/init-db.sql`, `infra/pg-init/01-extensions.sql`; `packages/storage/src/adapters/postgres.ts`, `packages/storage/src/adapters/postgres.spec.ts`, `packages/storage/src/factory.ts`, `packages/storage/src/factory.spec.ts`, `packages/storage/src/types.ts`, `packages/storage/src/tier-detection.ts`, `packages/storage/src/tier-detection.spec.ts`, `packages/storage/src/cli.ts`, `packages/storage/src/cli.spec.ts`, `packages/storage/src/migrate-tier.ts`, `packages/storage/src/migrate-tier.spec.ts`, `packages/storage/src/migrate-tier.integration.test.ts`; `apps/gateway/src/main.ts`, `apps/gateway/src/__tests__/integration/federated-boot.pg-unreachable.integration.test.ts`, `apps/gateway/src/__tests__/integration/federated-boot.success.integration.test.ts`, `apps/gateway/src/__tests__/integration/federated-pgvector.integration.test.ts`; `packages/db/src/federation.integration.test.ts`; `packages/mosaic/src/commands/fleet-backlog.ts`, `packages/mosaic/src/commands/fleet-backlog.spec.ts`. It consumes the -03 runner and exclusively owns importer verification/interface in `packages/storage/src/{cli,migrate-tier}.ts` and the named unit/integration specs: fixed URL/version/attestation/public-key safe-open fds (`O_RDONLY | O_CLOEXEC | O_NOFOLLOW`plus post-open regular-owner-mode-link-count validation), one-read protected-memory URL connection, fd zeroization/close, signature/key/expiry/replay/authenticated-provider-version/digest/generation/CA/binding validation, no forwarding/logging/oracle, post-TLS zero-DML comparison, consumer isolation, and DDL classifier. It closes runtime/retired-init DDL only; removes the current Gateway production`.env`/monorepo auto-load path in favor of the -05 renderer-backed process-exec or `LoadCredential` consumer boundary; and excludes every -03 signer/runner, index, migrate, and Drizzle-config asset, and every deployment/CI/doc path. |
| `KBN-101-04` installer/wizard | 01 | **Only:** `packages/mosaic/src/stages/gateway-config.ts`, `packages/mosaic/src/stages/gateway-config.spec.ts`, `packages/mosaic/src/stages/gateway-config-cors.spec.ts`, `packages/mosaic/src/stages/wizard-menu.spec.ts`, `packages/mosaic/src/wizard.ts`. It persists only non-secret references/injected-variable contracts; source inspection excludes `tools/install.sh`, which does not read/write the database DSN. |
| `KBN-101-05` renderer and deployment | 00,03,10 | **Only:** `tools/db/render-postgres-secrets.ts`, `tools/db/render-postgres-secrets.spec.ts`; `apps/gateway/Dockerfile`, `apps/gateway/Dockerfile.spec.ts`; `docker-compose.yml`, `docker-compose.spec.ts`; `docker-compose.federated.yml`, `docker-compose.federated.spec.ts`; `deploy/portainer/federated-test.stack.yml`, `deploy/portainer/federated-test.stack.spec.ts`; `tools/federation-harness/docker-compose.two-gateways.yml`, `tools/federation-harness/docker-compose.two-gateways.spec.ts`. It consumes the -00 bootstrap interface and -03 immutable runner image, and exclusively renders/tests fixed Gateway/importer/migrator UIDs; runner-only root-owned signing-key reference; KV-v2 importer URL plus same-response `data.metadata.version`; separate immutable generation-pinned URL/version mounts for `10003:10003` migrator and `10002:10002` importer; importer-only CA at `DATABASE_TLS_CA_CERT_PATH`, public-key/key-ring, and controlled producer-only-to-importer-only attestation handoff mount; privileged controller verification/copy/fsync/atomic-rename/seal-before-importer-start behavior; Compose generation-dir fsync/atomic-rename and Swarm versioned-secret/config no-mixed-generation behavior. It owns no bootstrap, runner, config, storage, or CI file. |
| `KBN-101-07` operator/runbook/docs | 02,03,04,05 | **Only:** `README.md`, `CLAUDE.md`, `docs/guides/admin-guide.md`, `docs/guides/dev-guide.md`, `docs/guides/deployment.md`, **`docs/guides/migrate-tier.md`**, `docs/guides/user-guide.md`, `docs/federation/MILESTONES.md`, `docs/federation/SETUP.md`, `docs/federation/TASKS.md`, `docs/fleet/backlog-conventions.md`, `docs/PERFORMANCE.md`, `docs/design/storage-abstraction-middleware.md`, `docs/plans/2026-03-15-agent-platform-architecture.md`, `docs/plans/2026-03-15-wave2-tui-layout-navigation.md`, `docs/runbooks/kbn-101-database-role-split.md`, `docs/reports/native-kanban-sot/kbn-101-operator-readiness-report.md`, `docs/native-kanban-sot/tests/kbn-101-operator-docs.spec.ts`. It exclusively owns the active migrate-tier operator route; local PGlite/non-PostgreSQL Compose disposition; the held PostgreSQL/federated activation order; and the non-operative production renderer/Vault generation-pinned process-exec or `LoadCredential` consumer-isolation schematic. It documents interfaces produced by -02/-03/-04/-05, including both file references, signing-key isolation, attestation bindings, rotation/replay, no-connection/no-DML errors, and the ban on current production `.env`, monorepo auto-load, environment-file, credential export/argv, or restart-as-secret-activation guidance; it owns no source, storage, CLI, runner, or CI file. |
| `KBN-101-06` CI classifier and command matrix | 02,03,05,07,10 | **Only:** `.woodpecker/ci.yml`, `tools/ci/kbn101-ddl-inventory.ts`, `tools/ci/kbn101-ddl-inventory.spec.ts`, `tools/ci/fixtures/kbn101-ddl-inventory.json`, `tools/ci/kbn101-entrypoint-matrix.ts`, `tools/ci/kbn101-entrypoint-matrix.spec.ts`. It invokes the already-produced bin/image/deployment/doc artifacts and edits no producer file. Its inventory test enforces manifest overlap, ownerless, duplicate-owner, path-existence, allowlist, active-route field completeness, finite operator-document inventory, normative/status-only non-masking, unknown-command, and historical/status-only masking failures. Before inventory, ownership, or status masking, its semantic suite fails the exact README commented code-fence generic-wrapper form and exact user-guide executable generic-wrapper form recorded in §2.2, and source-consistency opens `packages/storage/src/cli.ts` to prove its current direct-Drizzle `pnpm --filter @mosaicstack/db db:migrate` `execSync` implementation and absence of a `mosaic-db-migrator` bin make runner-delegation copy false. It also fails former `SETUP.md` automatic-first-boot/startup, the exact former `MILESTONES.md` wording, and the exact former README/dev/deployment Compose-first sequences before an inventory record can mask any path; it fails Compose-up-before-runner, extension/schema/migration automatic wording, init-script authority, production `.env`, `EnvironmentFile=`, credential shell export/argv, and restart-as-secret-activation. It passes the rc.16 held PGlite-data-layer/non-PostgreSQL-Compose disposition (and fails Gateway/Web local start while daemon/inherited/root/app-local DSN or non-local-tier input could select PostgreSQL), the one `Held future procedure` section with KBN-101-00/-03/-05 and external bootstrap → TLS/roles → `mosaic-db-migrator --run``mosaic-db-migrator --verify` → Gateway/Compose-readiness, and the README legacy-N-1 CI status assertion; every runner hit outside that section and every current-CI authority variant fails before masking. Its matrix invokes the -02 secure target route and verifies URL/version generation mismatch, provider rotation/revocation, consumer isolation, safe-open TOCTOU/link/mode/owner, absent/wrong importer CA, no log/oracle, every declared attestation refusal before target connection/DDL, and post-connect mismatch with zero DML. |
| `KBN-101-08` foundation certification and **atomic activation release** | 00…07,10 | **Only evidence:** `docs/reports/native-kanban-sot/kbn-101-foundation-activation-certificate.md`, `docs/reports/native-kanban-sot/kbn-101-foundation-activation-evidence.json`. It changes no implementation path. Independent review and terminal-green CI must verify prepared artifacts before Mosaic control plane/Jason authorizes backup → drain/scale-zero N-1 → TLS → roles → runner → verified readiness → rolling runtime; any red result aborts. |
| `KBN-101-09` post-KBN-100 certification | KBN-100,08 | **Only evidence:** `docs/reports/native-kanban-sot/kbn-101-immutable-role-certificate.md`, `docs/reports/native-kanban-sot/kbn-101-immutable-role-evidence.json`. It changes no implementation path and records real deployed runtime INSERT/SELECT plus UPDATE/DELETE-denial evidence and independent security/Ultron approval. |
The manifests above are the complete ownership universe for KBN-101 implementation paths; the KBN-101-06 inventory test fails on overlap, an ownerless in-scope path, or a nonexistent declared path. Cards `00``07` are prepared artifacts, not independently deployed releases: the immutable N-1 owner-runtime image stays live until KBN-101-08 control-plane atomic activation. No activation card edits a source-changing path, and no runtime bypass or broken deployed intermediate exists.
The manifests above are the complete ownership universe for KBN-101 implementation paths **including KBN-101-10**; the KBN-101-06 inventory test fails on overlap, an ownerless in-scope path, or a nonexistent declared path. Cards `00``07` are prepared artifacts, not independently deployed releases: the immutable N-1 owner-runtime image stays live until KBN-101-08 control-plane atomic activation. No activation card edits a source-changing path, and no runtime bypass or broken deployed intermediate exists.
**Authority:** Mosaic control plane/Jason is the sole activation and rollback authority. CI, Gateway, migrator, Coordinator, and Certifier cannot activate, waive a red result, or force release. Before an incompatible KBN-100 switch, the authority stops/scales runtime, uses the approved backup/restore or separately reviewed runner artifact, restores only a known TLS-compatible runtime with its runtime secret after `hostssl` enforcement, and verifies no plaintext sessions plus TLS/readiness. Migration URL is never injected into Gateway to enable rollback. KBN-100 starts only after KBN-101-08; KBN-105 starts only after KBN-101-09.
@@ -0,0 +1,377 @@
# KBN-101 — B1/B2 Envelope A (v6, FINAL) — Declarative Sink-RBAC + Per-Role Credential/Connection-Selection + RLS Write-Source (INSERT tenant-bound, single-compound-or-RESTRICTIVE composition) + Sink-Resident User-Override + Read/USING Enforcement
**Ratification status:** RATIFIED — part of the frozen SSOT as of this PR (KBN-101 Envelope A, landed **FORM A — apply-in-place**). This is the converged **v6** envelope, ratified as the authoritative record of the **rc.20** contract amendment now inlined into [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) §4/§10, [`SHARED-CONTRACT.md`](./SHARED-CONTRACT.md) (`### 1.0.0-rc.20`), and [`MISSION-MANIFEST.md`](./MISSION-MANIFEST.md) §10.
**Ratification lineage (authoritative):** Jason B1 ruling (2026-07-20T23:08Z) + Mos coordinator **OPTION A** (home the layer) + **Mos Q1** ruling (TIER-LEVEL) + **Mos Q2** ruling (RLS `WITH CHECK` AUTHORIZED, two mandatory conditions — `ENABLE`/`FORCE ROW LEVEL SECURITY` as the terminal migration step, and non-owner rung roles) + **Gate A** (`a11a1e2c`) + **Gate B** (`a6aac2cd`).
> **Ratification note.** The sections below are the converged v6 envelope text, retained **VERBATIM** for traceability. Their design-time framing — e.g. "**Scope:** DESIGN / CONTRACT-AMENDMENT only. No implementation code, no PRs, no SSOT edits." and "**Base:** … this doc modifies nothing there." — describes the envelope as originally authored against base `b0d78d86`. As of this PR that rc.20 amendment is **ratified and applied in-place (FORM A)** to the frozen SSOT; the design-time "no SSOT edits" wording is historical authoring context, not a live constraint on this ratified record. The Ratification lineage above is authoritative.
---
**Status:** v6 envelope (design / contract-amendment), FINAL. **v6 = v5 + N1/N2/F-NB4 non-blocking test/lint/cert hardening ONLY; NO design predicate/policy/grant/role/table change.** v5 CONVERGED — BOTH terminal re-gate-4 gates returned GO (Gate A-delta-4 `a4ce075a`; Gate B-delta-4 `a379e0de`), NO HALT; Gate B-delta-4 flagged 3 EXPLICITLY NON-BLOCKING hardening notes "for the trunk-commit record," and v6 folds exactly those 3 as additive test/lint/cert wording on an already-BOTH-GO design: **N1** (strengthen the `pg_policy.polpermissive` lint — group by effective role incl. `PUBLIC`/inheritance; reject an internally-disjunctive single `WITH CHECK`; behavioral positive-controls remain the PRIMARY proof), **N2** (state the option-(b) vacuous-deny guard invariant — ≥1 permissive policy per writable (rung, command)), and **F-NB4** (extend owner non-reachability to SET-ROLE assumability — conjoin the `MEMBER` variant of `pg_has_role`). No design predicate, policy, grant, role, or table is changed. v6 supersedes v5 (`a6e790fd`), which supersedes v4 (`a48ad69c`). Preserves EVERY verified v4 closure (all checks passed on v4 EXCEPT one bounded item) and closes the SINGLE remaining BLOCKING finding raised identically by BOTH re-gate-3 gates (Gate A-delta-3 `aa5cbf73` = CHANGES-NEEDED; Gate B-delta-3 `aa881074` = 1 blocking): the **RLS `WITH CHECK` composition semantics** — v4's construction is right but it justifies the composition with a FALSE PostgreSQL claim ("PostgreSQL requires ALL applicable `WITH CHECK` clauses to pass / they AND") and leaves the _realization_ unspecified, so an implementation could realize the status-pin, the workspace-bind, and the override as SEPARATE PERMISSIVE policies which combine with **OR** (not AND) → a foreign-workspace INSERT satisfying only the status-pin would be admitted (B2-INSERT / B-1 re-open). v5 (a) corrects the semantics, (b) **mandates** the composition be realized as a SINGLE compound `WITH CHECK` OR as `AS RESTRICTIVE` policies and **explicitly forbids** the multi-permissive-intended-to-AND realization, and (c) hardens the red-first negatives to an otherwise-valid row + adds a positive-control. It also folds Gate B-delta-3's NB-4-transitive nit (state owner non-reachability as TRANSITIVE membership). This is a PRECISE, LIGHT revision: every v4 closure (3-rung ladder / zero per-user; F1F7; GA-1/3/4; B-1/B-2/B-3; B2-INSERT workspace bind itself; N-1..N-5; AD-1/AD-2; NB-1..NB-5; continuous -06 scan + L174 GUC fold; rc.20) carries forward intact. Prior rc.18/rc.19 evidence does NOT carry.
**Authoring authority:** Jason B1 ruling (2026-07-20T23:08Z) + Mos coordinator **OPTION A** (home the layer) + **Mos Q1 ruling (TIER-LEVEL)** + **Mos Q2 ruling (RLS `WITH CHECK` AUTHORIZED, two mandatory conditions)**. No new coordinator authority is required for v5: the composition fix is a DDL-realization detail + corrected PostgreSQL-semantics statement + test-wording hardening entirely inside the already-authorized `WITH CHECK`/`FORCE RLS` mechanism (Q2); it introduces no new principal, key, or authority axis. **Both delta-3 gates independently confirmed the per-user/same-tier boundary HOLDS** (the crit-1 defect is a TENANT-ISOLATION correctness risk, NOT a per-user authority axis).
**Base:** frozen SSOT at `b0d78d86`, `/src/mosaic-stack/docs/native-kanban-sot/` (READ-ONLY; this doc modifies nothing there).
**Scope:** DESIGN / CONTRACT-AMENDMENT only. No implementation code, no PRs, no SSOT edits.
**HALT self-check result (re-run for v5): NO HALT.** No v5 change forces two SAME-TIER writers to hold DIFFERENT WRITE AUTHORITY. The v5 composition fix pins **how** the already-uniform predicates are physically composed (one compound `WITH CHECK`, or `AS RESTRICTIVE`) — it changes no predicate's _content_ and adds no writer-varying axis; both delta-3 gates confirmed this is a tenant-isolation correctness pin, not a per-user distinction. The B2-INSERT fix ANDs a **shared, per-request workspace predicate** (`workspace_id = current_setting('mosaic.workspace_id', true)::uuid`) into every rung's INSERT `WITH CHECK` — applied **uniformly** to every writer in a tier, exactly like the B-2 read/`USING` predicate; it is tenant isolation, never a per-writer distinction. The B-1 sink-resident User-override remains keyed on **(workspace_id, task_id, tier)** — task-identity + tier ONLY, **NO per-federated-writer key** — so every writer inside a given tier is subject to the _identical_ veto for a given task. The five NB fixes (override-relation no-status enumeration; two claim-scopings; owner-toggle-FORCE containment naming; baseline runtime grants for the two capability roles) introduce **zero** per-writer authority. Per-writer _attribution_ remains an AUDIT concern routed to metadata (`task_events.actor_id`), never a DB credential/role/RLS key. The design remains satisfiable with the **fixed 3-rung role ladder** (User / Orchestrator / others), federated identity → rung at authorization time. The Q1 hard re-open trigger (same-tier / different write-authority) is **not** hit. See §5.
---
## 0. What the "raw task-status sink" is (grounding — unchanged from v1/v2)
- The canonical task-status store is **`tasks.status`** (SHARED-CONTRACT §5.4 L214 "canonical authority"); legacy **`mission_tasks.status`** is frozen read-only / prohibited as a write source (SHARED-CONTRACT §5.4 L219; §5.1 phase 1 L165). `tasks` is a **mutable** relation created by **KBN-100**. Canonical statuses are `backlog | ready | in_progress | blocked | in_review | done | cancelled` (SHARED-CONTRACT §3 L134); initial creation state is `backlog` (§5.4 L214 not-started→backlog).
- The append-only event relations `task_events, artifacts, task_checkpoints, task_checkpoint_artifacts, approval_decision_artifacts` receive runtime-only `SELECT, INSERT` with `UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER` **denied** (frozen §4 L178). That producer/consumer precedent — grants applied **after** KBN-100 creates the relations — is the mechanism this envelope extends to the mutable `tasks.status` sink and to the new sink-resident override table (§2.9).
- Today there is exactly one runtime login, `mosaic_runtime` (LOGIN INHERIT), sole member of NOLOGIN `mosaic_runtime_capability` (§4 L149-150). The sink therefore has **no writer-authority discrimination** — the root of RC19-B1-01.
The three B1 findings are writes to this sink; the two B2 findings are the privileged-fd → UID-drop handoff used by the runner/importer (§5 L184/L191). This envelope homes one coherent layer over both, now with the User-override and the SELECT/tenant predicate **resident at the sink**.
**[NB-2 / NB-3 — precise compromise-resistance scope (Mos Q1 accepted co-resident-pool residual; NO HALT).]** The prior "a compromised Gateway cannot bypass a User deny or read cross-workspace" framing is **overstated** and is corrected here. All three rung credentials are co-resident in one Gateway process (DB authenticates the _credential_, never the federated end-user — the accepted Mos Q1 residual), and the workspace GUC is _app-set_ with no per-tenant DB check. Precisely:
- **B-1 sink-enforces the User veto against the _Orchestrator-rung path_** — an Orchestrator-pool write to a task carrying an active User deny ERRORS in PostgreSQL regardless of app-layer behavior (a _buggy_ or Orchestrator-path-compromised Gateway cannot skip it). It does **NOT** stop a Gateway compromised badly enough to hold the **User** pool: User = god and may itself clear any veto (User-write-only on the override table). That is the accepted co-resident-pool residual, not a new hole.
- **B-2 + B2-INSERT sink-enforce tenant isolation against a _buggy_ Gateway** — an omitted/wrong workspace predicate fails **closed** (reads return zero rows; INSERTs ERROR on the NULL/mismatched GUC). They do **NOT** defend a _compromised_ Gateway that forges `mosaic.workspace_id` to a victim tenant, because the GUC carries no per-tenant DB authentication (accepted Mos Q1 residual). The genuine, real closure B2-INSERT delivers is the **buggy / unbound-INSERT** hole: without it, _any_ rung could write a foreign `workspace_id` on INSERT even with a correct GUC set — that is now impossible.
Defending against a fully compromised Gateway forging identity/tenant requires per-federated-user DB credentials = the HALT boundary; Mos Q1 explicitly accepts this residual and rules NO HALT. The claims in §2.9 (B-1) and §2.2/§2.4 (B-2/INSERT) are scoped to match.
---
## 1. Contract amendment (exact ownership + text changes)
### 1.1 Homing decision — one NEW owner card + minimal responsibility-widenings
The layer is genuinely **new scope**. The **minimal** closed/disjoint expansion adds **one new owner card, `KBN-101-10`**, owning two brand-new, currently-unowned globs (disjoint from every existing manifest → KBN-101-06 overlap/ownerless/path-existence still pass):
- `packages/db/src/sink-rbac/**` — B1 layer (policy artifact + schema + evaluator + ladder + connection-selection + status-UPSERT + invariant-set + view/rule-forbid enumeration).
- `packages/db/src/credential-handoff/**` — B2 library (SCM_RIGHTS + non-dumpable assert/verify).
Plus **responsibility-widenings of already-owned files** (no manifest-glob change → no overlap):
| Existing card | Already-owned path(s) touched | Widened responsibility (v4; v3 items preserved) |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `KBN-101-00` | `infra/pg-bootstrap/roles.sql`, `infra/pg-bootstrap/tests/**` | **[GA-1 FIX]** Create the **fixed rung-ROLES** (3 LOGIN roles + their capability roles) with **deny-by-default base attributes** and **CONNECT** grants; bootstrap catalog tests that each rung role carries the safe attributes (F2) and is **non-owner** of the sink. **[AD-1 FIX]** Amend the frozen §4 **L176 CONNECT-allowlist certification** so the two new LOGIN rungs (`mosaic_runtime_user`, `mosaic_runtime_orchestrator`) are admitted to the `CONNECT` allowlist **and asserted `TEMPORARY`-denied**; otherwise the frozen L176 cert test REDS (it fails if an app login retains TEMPORARY, and it enumerates the CONNECT allowlist). **[NB-5 FIX]** Grant the two new capability roles (`mosaic_runtime_user_capability`, `mosaic_runtime_orchestrator_capability`) the **baseline frozen §4 L176 runtime grants** they need to function: `USAGE ON SCHEMA mosaic`, `USAGE ON SCHEMA drizzle` + `SELECT ON drizzle.__drizzle_migrations, drizzle.__mosaic_migration_manifest` (ledger read), and the relevant sequence `USAGE`/`SELECT` grants — mirroring what `mosaic_runtime_capability` already holds; without these the two new rungs cannot connect/operate and would FAIL the frozen §6 L222 runtime verify (missing inherited capability/sequence/ledger grants → fail-closed). **It owns NO DDL on `tasks` or the override table** — those relations do not exist at bootstrap time, so RLS/column-grant/policy/FORCE/unique-key/trigger DDL **cannot** live here. |
| `KBN-100` (producer; SPEC target of -10) | its own `packages/db/src/schema.ts` + generated `packages/db/drizzle/**` migration + migration tests | **[GA-1 + GA-2/Q2 + B-1 + B-2 + B-3 + B2-INSERT FIX]** Homes ALL `tasks`-specific sink DDL **and the new `task_status_write_override` table DDL** **after** it creates `tasks`, per the frozen §4 L178 producer/consumer precedent: the rung column-grant matrix (F1/F7/N-3), the **RLS `WITH CHECK` write-source policies** (Q2/F1/N-3), **[B2-INSERT] the workspace-predicate `workspace_id = current_setting('mosaic.workspace_id', true)::uuid` as a conjunct of EVERY rung's INSERT enforced check on `tasks` — realized as a single compound `WITH CHECK` OR `AS RESTRICTIVE`, NEVER as separate permissive policies (v5 composition mandate, §1.4 item 3-bis) — (and thus into the UPSERT INSERT arm), and the symmetric INSERT workspace binding on `task_status_write_override`** so tenant isolation is homed at the sink on the write path too, the **per-rung `SELECT`/`UPDATE` `USING` tenant policies** (B-2), the **override-table subquery** in every `tasks` write policy (B-1), the `tasks (workspace_id,id)` **UNIQUE** candidate key (F4), the `actor_id` attribution metadata column (Q1 audit channel), the **no-status-normalizing-trigger / no-status-view / no-status-rule** invariants (F3/N-1/N-2) — **[NB-1] extended to cover the `task_status_write_override` relation as well** (no status-writing trigger/RULE/SECURITY DEFINER function on the override relation may write `tasks.status`), and — as the **TERMINAL migration step**`ENABLE`/`FORCE ROW LEVEL SECURITY` on `tasks` and on the override table, run strictly **AFTER** all expand/backfill/repair DML (B-3). **KBN-100's DDL scope statement is explicitly amended** to include RLS + the override table on `tasks` under Mos Q2 coordinator co-authorization (§1.4). Certified deployed by KBN-101-09. |
| `KBN-101-01` | `packages/db/src/connection-identity.ts`(+spec), `apps/gateway/src/database/database.module.ts`(+spec) | Provision **only the three generic rung pools** at boot; set the per-request **workspace session predicate** (B-2) on each checked-out connection. **[F2 FIX]** Extend the frozen L222 unsafe-attribute checkout assertion to **each** rung connection: effective role == authorized rung **AND** fail closed on SUPERUSER / CREATEROLE / CREATEDB / REPLICATION / **BYPASSRLS** / ownership of the sink tables (`DATABASE_ROLE_UNSAFE`). **[AD-2/N-5 FIX]** -01 **provisions pools only**; it does **not** import -10. The rung→pool SELECTION lives in -10's `connection-selection.ts`, which **consumes** -01's provisioned pools (import direction `10→01`; §2.2). |
| `KBN-101-03` | `packages/db/src/migrator/**` (glob), **`docker/db-migrator.Dockerfile`** | Consume -10's credential-handoff: **SCM_RIGHTS-only** fd acquisition + **non-dumpable re-verify** after the drop to `10003:10003`. **[GA-4(ii) FIX]** The root-only **migrator launch wrapper** homes here (the migrator image). |
| `KBN-101-05` | `apps/gateway/Dockerfile`(+spec), renderer secret matrix | Renderer mounts **three** rung DSN secrets (`mosaic-db-runtime-url` + `mosaic-db-runtime-user-url` + `mosaic-db-runtime-orchestrator-url`), each `0600 10001:10001`, Gateway-only. Gateway rung-connection selection is a **DB-role choice, not a UID drop**, so -05 owns **no** SCM_RIGHTS/dumpable behavior (moved to -03 per GA-4(ii)). |
| `KBN-101-02` | `packages/storage/src/{cli,migrate-tier}.ts`, `packages/storage/src/adapters/postgres.ts` (already-owned) | Importer privileged-fd case uses SCM_RIGHTS (extends existing safe-open, drop to `10002:10002`); status writes go through the -10 status-preserving-UPSERT helper. |
| `KBN-101-06` | `tools/ci/fixtures/kbn101-ddl-inventory.json`, `.woodpecker/ci.yml` | Add `KBN-101-10` to the inventory fixture/command matrix; add the rung-selection + SCM_RIGHTS + **per-tier RLS negatives** + **override-veto negative** + **cross-workspace read negative** + **[B2-INSERT] cross-workspace INSERT/UPSERT negative** + **NULL-GUC INSERT negative** + **owner/backfill-ordering** cases to the matrix. **[Gate A-delta-2 obs #2 / N-2 continuous — ADOPTED]** Home the **continuous no-status catalog scan as a -06 CI gate that runs on EVERY migration** (promoted from the v3 one-shot -09 recommendation): the scan asserts no status-writing trigger/RULE/`SECURITY DEFINER` function/view on `tasks` **or on `task_status_write_override`** (NB-1), so a future migration that adds a status-write path fails CI, not only the one-time cert. |
### 1.2 §7 manifest expansion (KBN-101-06 must still pass)
Add exactly one row to the §7 card table (complete, disjoint ownership universe):
> | `KBN-101-10` declarative sink-RBAC + per-role connection-selection + credential-handoff | 00,01 | **Only:** `packages/db/src/sink-rbac/**`, `packages/db/src/credential-handoff/**`. It homes: the source-controlled declarative RBAC policy artifact + schema + evaluator; the fixed **rung ladder** + federation-aware identity→rung resolver; the rung→DB-credential **connection-selection** module (consumes -01 pools; import direction `10→01`); the **status-preserving-UPSERT** contract + **invariant field-set** module + **no-status view/rule/trigger enumeration**; and the **SCM_RIGHTS** credential-handoff + **non-dumpable** assert/verify library. It creates no roles, no DDL, no Compose/CI/deploy path; it is consumed by -00 (role names), -01 (connection wiring + identity/attribute verify), -02/-03/-05 (write path + fd handoff), specified-against by **KBN-100** (sink DDL: grants/RLS/`USING`+`WITH CHECK` policies/override-table/FORCE/unique-key/trigger-view-rule-invariant on `tasks`), and certified deployed by KBN-101-09. |
Amend the §7 L245 closing sentence (added clause **bold**): "The manifests above are the complete ownership universe for KBN-101 implementation paths **including KBN-101-10**; the KBN-101-06 inventory test fails on overlap, an ownerless in-scope path, or a nonexistent declared path."
**Dependency edges (v3, unchanged from v2 — acyclic; -10 depends only on 00,01):**
- `KBN-101-10 depends on 00,01`
- `KBN-101-02 depends on 01,03,10`
- `KBN-101-03 depends on 00,01,10`
- `KBN-101-05 depends on 00,03,10`
- `KBN-101-06 depends on 02,03,05,07,10`
- `KBN-101-08 depends on 00…07,10`
- `KBN-101-09 depends on KBN-100,08` (unchanged; evidence-only)
**Why still disjoint (KBN-101-06 green — UNCHANGED from v2):** the only NEW ownership is the two -10 globs. The B-1 override table, B-2 `USING` policies, and B-3 terminal-FORCE ordering are **all producer DDL homed in KBN-100's already-owned `schema.ts`/`drizzle/**`** (a *responsibility* widening on files KBN-100 already owns, exactly like the `tasks`sink DDL). The N-1/N-5 additions live inside -10's already-declared`sink-rbac/**`glob. AD-1 amends -00's already-owned`roles.sql`/tests. **No glob is split, narrowed, or shared → KBN-101-06 overlap/ownerless/path-existence stay green.\*\*
### 1.3 §4 identity-model amendment (per-role, closed additive set — F2 hardened; AD-1 CONNECT cert)
Add these rows to the §4 role table (§4 L141-151). **Per-ROLE, not per-user.** Implements Jason's B1 (User = god; main Orchestrator near-god, User-overridable; others deny-by-default):
| Role (added) | Attributes (F2-hardened) | Membership / session use |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mosaic_runtime_user_capability` | `NOLOGIN`, no ownership/admin attrs. | Holds the **User (god)** rung's named grants incl. `INSERT(status)`, `UPDATE(status)` + the status-transition RLS `WITH CHECK` policy on `tasks`; **plus the SOLE `INSERT/UPDATE/DELETE` grant on `task_status_write_override`** (B-1); never granted to Orchestrator/base. |
| `mosaic_runtime_orchestrator_capability` | `NOLOGIN`, no ownership/admin attrs. | Holds the **Orchestrator (near-god)** rung's grants; identical to User **except** (i) every `tasks` write policy **subqueries** the override table and is REJECTED at the sink when an active User deny row exists for that `(workspace_id, task_id, orchestrator)` (B-1); (ii) `INSERT(status)` is pinned by `WITH CHECK` to a **non-terminal initial status** (N-3) — only User may INSERT an arbitrary/terminal status; (iii) it has only `SELECT` (never write) on the override table. Scope is tier/task-identity only (F6). |
| `mosaic_runtime_user` | **`LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`**, no ownership; **non-owner of every sink table + the override table**. | Member of `mosaic_runtime_user_capability` only (`WITH INHERIT TRUE, SET FALSE, ADMIN FALSE`); the connection-selection **User-rung** credential. **[AD-1]** Requires `CONNECT`, denied `TEMPORARY`. |
| `mosaic_runtime_orchestrator` | **`LOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`**, no ownership; **non-owner of every sink table + the override table**. | Member of `mosaic_runtime_orchestrator_capability` only (same INHERIT/SET/ADMIN flags); the **Orchestrator-rung** credential. **[AD-1]** Requires `CONNECT`, denied `TEMPORARY`. |
The existing `mosaic_runtime` is confirmed already `LOGIN INHERIT` with no admin attrs (frozen L150) and non-owner; it becomes the **others / deny-by-default** base rung. On the sink it retains `SELECT` (workspace-scoped `USING`, B-2), `INSERT (explicit non-status column list)` (F1), and column-level `UPDATE` on **non-status, non-invariant, non-key, non-tenancy** columns only (F7), each `USING` workspace-scoped; it has **no** `INSERT(status)`, **no** `UPDATE(status)`, **no** `DELETE`, **no** `TRUNCATE` on `tasks`, **no** write on the override table, and RLS denies its status/override writes by default. The rung set is **fixed at three**.
**F2 note:** the three rung LOGIN roles' `NOBYPASSRLS` + `NOSUPERUSER` + **non-ownership** are load-bearing — `FORCE ROW LEVEL SECURITY` collapses for any SUPERUSER/BYPASSRLS role and RLS is silently bypassed by a table **owner**. These attributes are declared in -00 bootstrap, catalog-proven in -00 tests, and re-verified at every checkout by -01 (extended L222 assertion).
**[AD-1] CONNECT-allowlist certification amendment (frozen §4 L176):** L176 today grants `CONNECT` only to `mosaic_runtime`, `mosaic_migrator`, and the bootstrap actor, and its cert **fails if an unrelated login retains `CONNECT` or either application login retains `TEMPORARY`.** The two new LOGIN rungs are additional application logins that require `CONNECT` and must be `TEMPORARY`-denied. The -00 row + rc.20 text **explicitly amend the L176 allowlist** to `{mosaic_runtime, mosaic_runtime_user, mosaic_runtime_orchestrator, mosaic_migrator, bootstrap-actor}`, and the -00 cert asserts `TEMPORARY` denial for all three runtime rungs. Without this amendment the frozen -00 cert test REDS on the two new logins.
**rc.20 amendment text (v3 — extends v2)** — amend §4's closing note and SHARED-CONTRACT rc.5 L82/L88-90 ("Non-effect: role graph … unchanged / neither creates roles/secrets") with:
> **rc.20 (Envelope A) — authorized by Jason's declarative-RBAC B1 ruling + Mos OPTION A + Mos Q1 (TIER-LEVEL) + Mos Q2 (RLS):** adds (i) the fixed **User/Orchestrator/others** runtime rung-roles (per-ROLE, deny-by-default); each rung **LOGIN** role is `NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`, **NON-OWNER** of every sink/override table, **added to the §4 L176 `CONNECT` allowlist**, and **asserted `TEMPORARY`-denied**. **No per-federated-user role or credential is created** — federated identity maps to a rung **at authorization time only**, and per-writer **attribution lives in metadata columns** (app-supplied `actor_id` on `task_events`), **never** in DB credentials/roles. (ii) Two new fixed rung DSN secrets **`mosaic-db-runtime-user-url`** and **`mosaic-db-runtime-orchestrator-url`** (Gateway-only, `0600 10001:10001`), extending the frozen runtime-URL secret row. (iii) **RLS `WITH CHECK` write-source policies (including the B2-INSERT per-rung INSERT tenant binding `workspace_id = current_setting('mosaic.workspace_id', true)::uuid`, composed as a SINGLE compound `WITH CHECK` or `AS RESTRICTIVE` — never multi-permissive-intended-to-AND, per the v5 composition mandate §1.4 item 3-bis) + per-rung `SELECT`/`UPDATE` `USING` tenant policies + `FORCE ROW LEVEL SECURITY`** on the sink table `tasks` **and on the new sink-resident `task_status_write_override` table** (tighten-only, deny-by-default) — a §10 schema-v1 **mechanism addition** beyond the prior grant/revoke-only model, authorized by Mos Q2 as coordinator co-authorization with Jason's B1. **The two new rung capability roles additionally receive the baseline frozen §4 L176 runtime grants (`USAGE ON mosaic`, `drizzle` ledger `SELECT`, sequence grants) [NB-5] so they satisfy the frozen §6 L222 runtime verify.** (iv) The **`task_status_write_override` table** (keyed `(workspace_id, task_id, tier)`, no per-writer key), written only by the User rung, read by the Orchestrator/base rung `tasks` write-policy subquery, homes the User-override **at the sink**. The sink DDL (grants + RLS `USING`/`WITH CHECK` + override table + FORCE + unique key + trigger/view/rule invariants) is homed in the **KBN-100 producer** after it creates `tasks`, with `FORCE RLS` as the **terminal** migration step after all backfill/repair DML, per the §4 L178 producer/consumer precedent.
> **Frozen-invariant note (authorization basis):** touching the frozen role graph, the §4 L176 CONNECT allowlist, and adding RLS + the override table are §10 contract-change classes (MISSION-MANIFEST §10; SHARED-CONTRACT L82/L88-90; frozen §4 L176). They are authorized here by **Jason B1 + Mos OPTION A + Mos Q1 + Mos Q2**. The addition is strictly a **fixed per-ROLE** set + a **tighten-only** RLS mechanism + a **task/tier-scoped** override table. It stops exactly at the HALT boundary: **no** per-federated-user topology is introduced (§5 proof).
### 1.4 §10 mechanism amendment — RLS `WITH CHECK` (write-source + INSERT tenant-bound) + `USING` + sink-resident override at the sink (Q2), with the two mandatory conditions
The frozen write-authority model is grant/revoke-only; **RLS appears nowhere in the frozen contract.** Mos Q2 **AUTHORIZES** adding declarative, deny-by-default, **enforce-AT-THE-SINK, fail-closed** RLS policies, because grant/revoke alone cannot express (a) row-level _write-source_ enforcement (pin new-row status, F1/N-3), (b) a _sink-enforced_ User-override that the Orchestrator-rung path cannot skip (B-1), (c) _tenant-scoped reads/updates_ (B-2), or (d) **[B2-INSERT] _tenant-scoped INSERTs_ — binding `workspace_id` on the write/create path**, which neither column grants nor the `USING` read policy can do (PostgreSQL does not apply `USING` to INSERT). RLS is **tighten-only**: it can only further restrict beyond the column grants, never widen them.
**KBN-100 producer DDL scope is explicitly amended** to include, on `tasks` (and, where noted, the override table):
1. `ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;` — and the same on `task_status_write_override`.
2. **CONDITION (b) — EXPLICIT DECISION: `ALTER TABLE tasks FORCE ROW LEVEL SECURITY;`** (and on the override table) — DECIDED **YES/ON**, stated explicitly and not left defaulted, so that **even the table owner (`mosaic_schema_owner`) and any maintenance connection cannot silently bypass RLS**. (Directly closes the live U-Connect failure mode where an owner connection with `rls_forced=false` silently VOIDED RLS.)
3. A **deny-all default** posture (no permissive policy) plus per-rung policies:
- **Write (`WITH CHECK`):** base/others rung → new-row `status = <initial state 'backlog'>` only, no status UPDATE; **Orchestrator rung → status INSERT pinned to a non-terminal initial status (N-3), and every write (`INSERT`/`UPDATE`/transition) additionally `WITH CHECK`'d against `NOT EXISTS(<active User deny row for (workspace_id, id, 'orchestrator')>)` (B-1)**; User rung → status INSERT (any legal status) / transition. Every write policy is `WITH CHECK` (write-source), tighten-only.
- **[B2-INSERT — tenant isolation on the write path] Every rung's INSERT `WITH CHECK` on `tasks` MUST include the workspace predicate `workspace_id = current_setting('mosaic.workspace_id', true)::uuid` as a conjunct of the _same_ enforced check that carries the status-pin (and, for the Orchestrator rung, the B-1 override subquery) — see the COMPOSITION MANDATE (item 3-bis) for the exact realization.** This is REQUIRED because PostgreSQL does **NOT** apply `USING` to INSERT, and the explicit status-pinning `WITH CHECK` **suppresses** the `USING``WITH CHECK` substitution — so in v3 `workspace_id` was **unbound on the INSERT path** and any rung could `INSERT INTO tasks (workspace_id, …) VALUES (<foreign W2>, …)` (directly or via the sanctioned status-upsert INSERT arm) into a victim workspace, defeating B-2's "tenant isolation homed at the sink" goal and evading SHARED-CONTRACT §7.1 (body workspace forbidden). **CORRECTED SEMANTICS (v5 — the delta-3 fix):** multiple **PERMISSIVE** policies for the same command combine with **OR** — a row is admitted if it satisfies **ANY** one permissive policy's `WITH CHECK`, ANDed with all `AS RESTRICTIVE` policies' checks. "All applicable `WITH CHECK` clauses must pass / they AND" is **FALSE as a general rule** and is struck from this envelope; the AND-conjunction the B2-INSERT closure depends on holds ONLY (a) _within a single policy_ whose `WITH CHECK` is one AND-conjoined expression, or (b) _across `AS RESTRICTIVE` policies_. Realizing the status-pin, the workspace-bind, and the override as separate _permissive_ policies would let a foreign-workspace INSERT that satisfies only the status-pin be admitted by OR — re-opening B2-INSERT/B-1 — which item 3-bis forbids. **Fail-closed on NULL:** an absent GUC makes `current_setting(...,true)` return NULL, the equality yields NULL (not true), and — provided the predicate is a conjunct of the single enforced check (item 3-bis) — the INSERT **ERRORS at the sink**. The **same INSERT workspace binding is applied symmetrically to `task_status_write_override`**, under the identical composition mandate (its own single compound `WITH CHECK` or `AS RESTRICTIVE`).
- **Read/lock (`USING`) — B-2:** per-rung `SELECT` and `UPDATE` `USING` policies **workspace-scoped** by the session predicate `workspace_id = current_setting('mosaic.workspace_id', true)::uuid` (set per request by -01, §2.2). **`USING(true)` is explicitly FORBIDDEN** — tenant isolation rides on this predicate; a lazy `USING(true)` would expose every workspace's rows to every rung and is a fail-closed contract violation caught by a negative test.
- **3-bis. [v5 — LOAD-BEARING] COMPOSITION MANDATE (how the sink check MUST be realized; both delta-3 gates prescribed it).** Per rung, per command (INSERT / UPDATE / transition), the sink enforcement of the status-pin **AND** the workspace-bind **AND** (Orchestrator rung only) the User-override subquery MUST be realized as EITHER:
- **(a) a SINGLE policy** whose `WITH CHECK` is the _full conjunction_`<status-pin> AND workspace_id = current_setting('mosaic.workspace_id', true)::uuid` and, for the Orchestrator rung, `AND NOT EXISTS(<active User-override deny row for (workspace_id, id, 'orchestrator')>)`; **OR**
- **(b)** the status-pin as the **SOLE PERMISSIVE** policy for that command, **plus** the workspace-bind and (Orchestrator) the override subquery authored **`AS RESTRICTIVE`** (restrictive policies AND with everything, so a row must pass every restrictive check _and_ at least one permissive check).
- **EXPLICITLY FORBIDDEN:** realizing the status-pin, the workspace-bind, and the override as **multiple _permissive_ policies whose predicates are intended to AND.** Permissive policies combine with **OR**, so that realization admits a foreign-workspace INSERT that satisfies only the status-pin (cross-tenant injection) and equally bypasses the B-1 override — exactly the delta-3 defect. This mandate applies to the `tasks` INSERT/UPDATE/transition policies **AND** to the `task_status_write_override` table's own policies (same single-compound-`WITH CHECK`-or-`AS RESTRICTIVE` requirement; never multi-permissive-intended-to-AND). The producer DDL and the -09/-06 certification assert the realized policies are either single-compound or `AS RESTRICTIVE` (never separate permissive policies expected to AND) — a lint/catalog check over `pg_policy.polpermissive` backs it.
- **[v6 — N1: strengthened `pg_policy.polpermissive` lint (supplementary, NOT sufficient).]** The `pg_policy.polpermissive` lint (continuous -06 scan, §2.7) MUST (a) count permissive policies **grouped by EFFECTIVE role — including `PUBLIC` and role inheritance**, not merely by the policy's named `TO` role: a `TO PUBLIC` (or inherited-through-a-capability-role) permissive policy applicable to a rung's command is counted **alongside** that rung's status-pin, because at evaluation time it OR-widens exactly like a second same-role permissive policy — so a `TO PUBLIC` permissive next to a rung's status-pin is a **lint failure**; and (b) ideally **reject an internally-disjunctive single `WITH CHECK`** — a single policy whose expression is itself `status_pin OR workspace_bind` satisfies the naive one-permissive-policy count yet violates the composition mandate exactly as two permissive policies would, so the lint should detect a top-level `OR` between the status-pin and the workspace-bind/override conjuncts within one policy expression. **The lint is SUPPLEMENTARY, not sufficient:** the **behavioral positive-controls remain the PRIMARY proof** of correct composition (dropping the workspace conjunct — resp. the override subquery — alone flips the hardened negative to a PASS; §2.4 clause 5 / §2.9). The lint backstops the catalog shape; it does not replace the behavioral proof.
- **[v6 — N2: option-(b) vacuous-deny guard invariant — ≥1 permissive per writable (rung, command).]** Under option (b) (status-pin as the **sole PERMISSIVE** policy + workspace-bind/override `AS RESTRICTIVE`), PostgreSQL admits a row only if **≥1 PERMISSIVE `WITH CHECK` is TRUE AND all RESTRICTIVE checks pass** — so if a writable (rung, command) has **zero** permissive policies, every restrictive-only evaluation denies and the command **over-denies / fails closed** (nothing can ever be written). Therefore there **MUST be ≥1 PERMISSIVE policy per writable (rung, command)** — this "≥1 permissive per writable (rung, command)" is the **load-bearing option-(b) guard invariant** (the workspace-bind/override being restrictive is safe _only_ because the status-pin supplies the required permissive). It is verified by the mandated **"same-workspace valid-status write succeeds" positive tests** for each writable command — INSERT, transition/UPDATE, and SELECT — which would FAIL (spurious over-deny) if the permissive were dropped, and by the N1 lint confirming exactly one permissive status-pin (grouped by effective role) is present per writable (rung, command).
4. **B-3 migration ordering (TERMINAL FORCE):** all expand/backfill/repair DML on `tasks` (SHARED-CONTRACT §5.1 phase-3 L165-171, §5.3 L188) runs **FIRST**, while `tasks` has RLS **disabled**; the `ENABLE`/`FORCE ROW LEVEL SECURITY` statements are the **TERMINAL** DDL of the activation migration. **No standing `mosaic_schema_owner` ALLOW write policy exists after activation** (that would recreate the unconstrained owner write path = U-Connect mode). Any schema_owner maintenance policy, if ever needed, is **migration-only** and must be proven **unreachable by the 3 runtime LOGIN roles** + a negative test.
**CONDITION (a):** every runtime per-role LOGIN role (`mosaic_runtime`, `mosaic_runtime_user`, `mosaic_runtime_orchestrator`) **MUST remain NON-OWNER** of every sink table **and the override table** (owner stays `mosaic_schema_owner`). Enforced by -00 bootstrap catalog assertion + re-verified at each checkout by -01 (F2). A rung role that owns a sink/override table is a fail-closed contract violation.
**Break-glass boundary (B-3, named explicitly in KBN-101-09 cert):** the ONLY identities that legitimately bypass `FORCE RLS` are the sanctioned break-glass actors named in the frozen contract — `mosaic_extension_owner` (`NOLOGIN SUPERUSER`, §4.1 L146) and the external platform bootstrap actor (superuser, §4/§7; §9 L266 residual authority). Both are `NOLOGIN`/external, carry no runtime credential, and are outside application containment. The -09 cert **names these two as the accepted, audited boundary** — not a new hole — and asserts the three runtime LOGIN rungs are NOT superuser/NOT BYPASSRLS and cannot assume either.
**[NB-4 — owner-toggle-FORCE containment is NON-REACHABILITY, not incapability.]** The -09 break-glass enumeration must **additionally name `mosaic_schema_owner`** (frozen §4 L145, `NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS`) as a **contained-but-CAPABLE** path. A table **owner** — regardless of `NOSUPERUSER`/`NOBYPASSRLS` — can `ALTER TABLE tasks DISABLE ROW LEVEL SECURITY` or `ALTER TABLE tasks NO FORCE ROW LEVEL SECURITY` and then write freely; `FORCE`/`NOBYPASSRLS` do **not** make an owner incapable. So the containment claim is **NOT** "the owner cannot bypass RLS" — it is **"the owner role is NOT REACHABLE from the three runtime LOGIN rungs."** The -09 cert asserts this non-reachability **explicitly**: (i) `mosaic_schema_owner` is `NOLOGIN` (no direct credential); (ii) it is reachable only via `mosaic_migrator`'s `SET ROLE` (frozen §4 L147, migrator runner-before-Gateway, `max:1` locked migration session), which is **not** one of the three runtime rungs and holds no runtime pool; (iii) none of `mosaic_runtime` / `mosaic_runtime_user` / `mosaic_runtime_orchestrator` is a member of, or can `SET ROLE` to, `mosaic_schema_owner` or `mosaic_migrator` (catalog-proven at -00, re-verified at every checkout by -01's L222 non-owner/assumability assertion). **[NB-4-transitive — Gate B-delta-3 nit, folded]** the non-reachability assertion in the -09 cert MUST be stated as **TRANSITIVE membership**, not merely the absence of a **direct** `pg_auth_members` row: PostgreSQL's ownership/privilege check returns true for an **inherited** membership reached _through a capability role_, which would confer the owner's `ALTER TABLE … NO FORCE` / `DISABLE ROW LEVEL SECURITY` right. The assertion therefore uses the recursive predicates — `NOT pg_has_role('mosaic_runtime_user', 'mosaic_schema_owner', 'USAGE')` (and the same for each runtime rung against `mosaic_schema_owner` and `mosaic_migrator`), equivalently `NOT has_privs_of_role(<rung>, 'mosaic_schema_owner')` — which fold in transitive/inherited membership through any intermediate (capability) role. The frozen §6 L222 fail-closed-on "owner/migrator identity **or assumability**" check plausibly already covers this transitive case; v5 states "**transitive membership**" **explicitly** in the -09 cert assertion so no implementation reads "non-member" as only the direct-edge check. **[v6 — F-NB4: cover SET-ROLE assumability, not only inherited privilege.]** The v5 predicates `NOT pg_has_role(<rung>, 'mosaic_schema_owner', 'USAGE')` / `NOT has_privs_of_role(<rung>, 'mosaic_schema_owner')` capture the **inherited-privilege** axis, but a membership granted `WITH INHERIT FALSE, SET TRUE` yields `has_privs_of_role = FALSE` **yet still allows `SET ROLE mosaic_schema_owner`** followed by `ALTER TABLE … NO FORCE ROW LEVEL SECURITY` — an assumability path the `USAGE`/`has_privs_of_role` predicate alone does **not** close. The -09 cert assertion therefore **conjoins the `MEMBER` variant**`NOT pg_has_role(<rung>, 'mosaic_schema_owner', 'MEMBER')` **AND** `NOT pg_has_role(<rung>, 'mosaic_migrator', 'MEMBER')` — for **each of the three runtime rungs** (`mosaic_runtime`, `mosaic_runtime_user`, `mosaic_runtime_orchestrator`); the `MEMBER` privilege captures SET-ROLE reachability **regardless of `INHERIT`**, so `pg_has_role(rung, owner, 'USAGE')` **AND** `pg_has_role(rung, owner, 'MEMBER')` are asserted false together. This is stated explicitly rather than leaning on "frozen §6 L222 assumability plausibly covers it." (The intended role graph is already safe — per envelope §1.3 + frozen §4 L150, each rung is a member of **only** its own capability role, `WITH INHERIT TRUE, SET FALSE, ADMIN FALSE`, and of no owner/migrator role — so no live `SET TRUE` edge to the owner/migrator exists; this fold is **assertion-completeness hardening, not a live hole.**) The owner's toggle-then-write capability is therefore real but confined to the migrator/owner phase (frozen §5, runner-before-Gateway), never to a live runtime rung — that is the containment, stated as **transitive non-reachability** rather than incapability.
**Red-team requirement (baked into §3/§4; v5-HARDENED so the negatives cannot false-green under the OR-trap):** each per-rung policy is proven **deny-by-default via NEGATIVE tests PER TIER** in real PostgreSQL:
- an unauthorized write-source (base terminal-status INSERT/transition) that **ERRORS**;
- **[B2-INSERT — hardened] a cross-workspace INSERT/UPSERT using a row OTHERWISE VALID for the rung** — a status the rung's pin **ACCEPTS** (e.g. **base rung + `status='backlog'`**, and no active override), differing from a passing insert ONLY in `workspace_id = <foreign W2>` — so the rejection **isolates the workspace bind** (not a status/override rejection). Tested both directly and via the status-upsert INSERT arm; it **ERRORS at the sink**. **Positive-control:** removing the workspace-bind conjunct **alone** (leaving the status-pin) flips this negative to a **PASS**, proving the _workspace bind_, not the status-pin, is doing the rejecting (and proving the composition is a single enforced conjunction / RESTRICTIVE, not an OR of separate permissive policies).
- **[B-1 — hardened] an Orchestrator write vetoed by an active User-override deny row, using a transition the Orchestrator pin OTHERWISE ACCEPTS** (a legal non-terminal transition on `(W, T)` that would succeed absent the deny row) — so the rejection **isolates the override subquery**. It **ERRORS at the sink**. **Positive-control:** removing the override subquery **alone** flips this negative to a **PASS**, proving the _override_, not the status-pin/workspace-bind, is doing the rejecting.
- a **cross-workspace `SELECT` under the wrong/absent session predicate** → zero rows;
- **[B2-INSERT] a NULL-GUC INSERT** (absent `mosaic.workspace_id`) that **ERRORS** (fail-closed on NULL);
- a **same-workspace INSERT with a valid status** that **succeeds** (proving the predicate composes with F1/N-3 and does not over-deny);
- an **owner/maintenance connection attempting a post-activation `tasks` write** that must **fail under `FORCE`**.
All rejections **ERROR at the sink**. The two positive-controls are load-bearing: they are the direct proof that the composition is single-compound-`WITH CHECK`-or-`AS RESTRICTIVE` (delta-3), because under the forbidden multi-permissive-OR realization the hardened cross-workspace / override negatives would already PASS (false-green) without removing anything.
---
## 2. Design
### 2.1 Declarative RBAC policy (source-controlled artifact + schema)
- **Artifact:** `packages/db/src/sink-rbac/policy.task-status.v1.ts` — a source-controlled, versioned, deny-by-default policy object; the single source of truth for "who may write the sink." No policy lives in the database except the _derived_ GRANT/RLS state the KBN-100 producer emits from it.
- **Schema:** `packages/db/src/sink-rbac/policy.schema.ts` — a typed schema (zod/TS) validating: `rungs` (exactly the fixed ladder), per-rung `allow`/`deny` verbs (`insert`, `insert:status`, `update:<column-set>`, `transition:status`, `delete` — always deny), `federationMap` (federated-identity-claim → **rung**, never a role/credential per user), and `userOverride`.
- **[F6 FIX] `userOverride` scope guardrail:** the schema **constrains** override predicates to **tier- + task-identity (row) scope `(workspace_id, task_id, tier)`** and **explicitly FORBIDS** a per-federated-writer identity as an override or authority key (the Q1 re-open trigger). A policy that names a per-federated-writer identity as an override/authority key **fails schema validation** (fail-closed at load). The override is realized as the sink-resident `task_status_write_override` table (§2.9), NOT as app-resolved state. Per-writer attribution is not an authority input; it flows only to the audit metadata column (§2.6).
- **Evaluator:** `packages/db/src/sink-rbac/evaluate.ts` — pure `(identityClaims, requestedWrite) → Decision ∈ {allow(rung), deny(reason)}`, **deny-by-default**, tier resolution only. It **only chooses a rung**; it never mints credentials and — critically (B-1) — **it is NOT the enforcement point for the User-override**: the override is enforced in PostgreSQL by the write-policy subquery, so a compromised Gateway that skips the evaluator still cannot bypass a User deny.
- **Ladder / federation-awareness:** `packages/db/src/sink-rbac/ladder.ts` — fixed order `User > Orchestrator > others` and `resolveRung(federatedIdentity) → rung` at authorization time (Q1: federation-awareness lives in the resolver; the sink authorizes by the writer's declared ROLE/tier).
- **Enforcement is AT THE SINK, fail-closed:** the evaluator's rung choice only selects _which pre-provisioned connection_ is used; PostgreSQL then enforces via (a) revoked base privileges, (b) column-level `INSERT(status)`/`UPDATE(status)` granted only to the User/Orchestrator capabilities, (c) `FORCE ROW LEVEL SECURITY` + deny-all default + per-rung `WITH CHECK` policies **including the override subquery** (B-1), (d) per-rung `SELECT`/`UPDATE` `USING` **workspace predicate** (B-2, tenant isolation at the sink), (e) `DELETE/TRUNCATE` revoked from all runtime rungs, and (f) `UPDATE` on key/tenancy/invariant columns revoked from **all** rungs (F7). A mis-authorized OR override-vetoed OR cross-workspace write **fails closed in PostgreSQL** because the connection's effective DB role lacks the privilege or the policy predicate rejects the row — caller discipline is not trusted.
### 2.2 Per-role credential + connection-selection model (NO per-user roles; import direction pinned)
```
federated writer ──(verified identity claim)──► evaluate.ts ──► rung (one of 3, fixed)
connection-selection.ts ───┤ selects the PRE-PROVISIONED
(in -10; consumes -01) │ pool for that rung; sets
│ SET LOCAL mosaic.workspace_id
mosaic_runtime_user | mosaic_runtime_orchestrator | mosaic_runtime
└── PostgreSQL enforces grants + FORCE RLS WITH CHECK(+override) + USING(workspace) ──┘
```
- **`packages/db/src/sink-rbac/connection-selection.ts`** maps `rung → pooled connection`. **Exactly three** connection pools, one per rung, each opened with that rung's **fixed** mounted-secret DSN. Credentials = rungs = **3**, constant.
- **[AD-2/N-5 — import direction pinned]** `-01` (`database.module.ts`) **provisions ONLY the three generic rung pools** at boot and knows nothing of rung _selection_. The rung→pool SELECTION lives entirely in **-10's `connection-selection.ts`**, which is **consumed by the write-path cards (`-02`/`-03`/`-05`, which already `depends on 10`)** and is **NEVER imported by `-01`**. Direction is therefore `10 → 01` (10's selection consumes 01's pools) — **acyclic; there is no `01 → 10` cycle.** An impl lane must not read "01 wires 10" as `01 → 10`.
- **[B-2 — workspace session predicate]** on checkout, before the first query, -01 sets `SET LOCAL mosaic.workspace_id = <verified request workspace>` inside the request transaction; the per-rung `USING` policies **and the B2-INSERT `WITH CHECK`** read `current_setting('mosaic.workspace_id', true)::uuid`. The predicate is set from the **server-verified** tenant context, never from a body field (SHARED-CONTRACT §7.1 "Body workspace fields are forbidden"). A missing/invalid setting → `USING` yields no rows (fail-closed read) and INSERT `WITH CHECK` yields NULL→ERROR (fail-closed write), never cross-workspace exposure.
- **[NB-3 — compromise scope]** the GUC is **app-set with no per-tenant DB authentication**, so B-2 (reads/UPDATE `USING`) and B2-INSERT protect a **buggy** Gateway (omitted/wrong predicate → fail-closed), NOT a **compromised** Gateway that forges `mosaic.workspace_id` to a victim tenant (accepted Mos Q1 co-resident-pool residual; defending it requires per-user DB creds = the HALT boundary). The genuine closure B2-INSERT delivers is the **buggy / unbound-INSERT** hole: even with a correct GUC, v3 left `workspace_id` unbound on INSERT so any rung could stamp a foreign workspace — now impossible.
- **[Gate B 2(a) — poisoned-pool / SET LOCAL negative]** the `mosaic.workspace_id` GUC is folded into the frozen L174 **poisoned-pooled-session-reset + transaction `SET LOCAL` restoration** negative test (which already covers `search_path`): a negative proves `mosaic.workspace_id` is **transaction-scoped via `SET LOCAL`** (mirroring the frozen `SET LOCAL search_path` discipline, §4 L152) so a pooled connection **cannot leak a stale workspace** across requests — an un-reset/poisoned session fails closed, and a committed/rolled-back transaction does not carry the prior request's workspace.
- **[F2 FIX] Identity + attribute verification at checkout** (`connection-identity.ts`): verify effective role == the rung's expected DB role **AND** assert the safe attributes — fail closed (`DATABASE_ROLE_UNSAFE`) on SUPERUSER, CREATEROLE, CREATEDB, REPLICATION, **BYPASSRLS**, or ownership of any sink/override table. Extends the frozen L222 check to **each** rung connection.
- **No per-federated-user role/credential:** a new federated user needs **zero** new DB roles/credentials/secrets/pools — it resolves to an existing rung. Federation growth is O(1) in DB-role count.
### 2.3 Status-preserving UPSERT on a stable natural key (F4 bound)
- **`packages/db/src/sink-rbac/status-upsert.ts`** — the single sanctioned refresh helper: `INSERT INTO tasks (natural_key…, <mutable non-status cols…>) VALUES (…) ON CONFLICT (<natural key>) DO UPDATE SET <mutable non-status cols…>`**`status` is never in the `DO UPDATE SET` list**, so a refresh preserves existing `status` byte-for-byte.
- **[B-2 dependency]** the `ON CONFLICT` conflict-probe/refresh reads the existing row; under `FORCE RLS` this requires the per-rung `SELECT`/`UPDATE` `USING` policy to admit the row (workspace-scoped). Without B-2's `USING` policies the probe would see zero rows and the UPSERT would spuriously INSERT-conflict or mis-refresh — B-2 is what makes the sanctioned UPSERT actually work post-activation.
- **[B2-INSERT dependency]** the UPSERT's **INSERT arm** is a `tasks` INSERT and is therefore subject to the B2-INSERT workspace `WITH CHECK` (§1.4/§2.4): a status-upsert that supplies a foreign `workspace_id` (or runs under an absent GUC) **ERRORS at the sink**, closing the sanctioned-UPSERT INSERT path that v3 left tenant-unbound. The helper sets `workspace_id` from the server-verified tenant context (never a body field, §7.1); it matches the session GUC by construction.
- **[F4 FIX] Natural key:** the tenant-scoped stable identity **`(workspace_id, id)`**. No frozen `tasks(workspace_id,id)` unique key exists (only `missions_workspace_id_uidx`, rc.4 L94) — `ON CONFLICT` against a nonexistent unique index is a **HARD planning error** that fails loud. Therefore **KBN-100 MUST create `tasks (workspace_id,id)` UNIQUE** (aligned to the SI-001 pattern, SHARED-CONTRACT §5.2). **Fallback:** if KBN-100 keys `tasks` on global `id` only, the UPSERT natural key **re-binds to `(id)`** (spec-level, no HALT).
- **Status changes only via an authorized transition:** `transitionStatus()` guarded by (a) the rung's `transition:status` policy verb, (b) `UPDATE(status)` present only on User/Orchestrator capabilities, (c) the RLS `WITH CHECK` transition policy (admits the update only as an explicit transition, never as a refresh/grant side effect) **plus the override subquery for the Orchestrator rung (B-1)**, and (d) the `USING` workspace predicate to see/lock the row (B-2). `DELETE` is denied to all runtime rungs → **DELETE + default-INSERT status reset is impossible at the sink** (fails closed on the DELETE).
### 2.4 INSERT-time status constraint (F1 BLOCKING + N-3 hardening)
Base `mosaic_runtime` must not stamp an arbitrary terminal status at creation, **and the Orchestrator rung must not create a task directly at a terminal status** (N-3). Layered fail-closed controls (homed in the KBN-100 producer DDL):
1. **Column-privilege:** `GRANT INSERT (<explicit non-status column list>) ON tasks TO mosaic_runtime_capability;` — the base rung **cannot name `status` on INSERT**, so `status` takes its column **DEFAULT** (`backlog`). `INSERT(status)` is granted **only** to the User/Orchestrator capabilities.
2. **RLS `INSERT … WITH CHECK` (base):** pins **new-row `status = 'backlog'`** for the base rung.
3. **[N-3 FIX] RLS `INSERT … WITH CHECK` (Orchestrator):** pins the Orchestrator rung's new-row status to a **non-terminal initial status** (`status IN {backlog, ready}`; never `done`/`cancelled`/`in_review`). **Only the User (god) rung may INSERT an arbitrary/terminal status.** This prevents an Orchestrator creating a task directly at `done`/`cancelled`, skipping the transition/lease/review trail (SHARED-CONTRACT §7.2 `POST /tasks` L272 vs `POST /tasks/:taskId/transition` L275). The exact terminal/non-terminal set is bound by KBN-100 to the §3 L134 vocabulary.
4. **[B2-INSERT FIX] RLS `INSERT … WITH CHECK` (EVERY rung, tenant binding):** every rung's INSERT sink-check MUST include `workspace_id = current_setting('mosaic.workspace_id', true)::uuid` as a conjunct of the **same enforced check** that carries the status-pin (base=`backlog`, Orch=non-terminal, User=any) **and** the B-1 override subquery on the Orchestrator rung. `status` is bound by clause 2/3; `workspace_id` is bound by this conjunct; they are orthogonal columns so there is no conflict. **[v5 — corrected PostgreSQL semantics; the delta-3 fix]** the earlier justification "because PostgreSQL requires all applicable `WITH CHECK` clauses to pass, they AND" is **FALSE and is struck**: multiple **PERMISSIVE** policies for a command combine with **OR** (a row is admitted if it satisfies **ANY** one permissive `WITH CHECK`, ANDed with all `AS RESTRICTIVE` checks). The AND this closure needs holds ONLY (a) within a _single_ policy whose `WITH CHECK` is one AND-conjoined expression, or (b) across `AS RESTRICTIVE` policies. **Fail-closed on NULL:** an absent GUC → NULL equality → INSERT ERRORS — _provided_ the workspace conjunct sits in the single enforced check (clause 5). This closes the v3 gap where the explicit status `WITH CHECK` suppressed the `USING``WITH CHECK` substitution, leaving `workspace_id` unbound on INSERT and allowing any rung to INSERT into a foreign workspace.
5. **[v5 COMPOSITION MANDATE — LOAD-BEARING] Single-compound-`WITH CHECK`-or-`AS RESTRICTIVE`, never multi-permissive-intended-to-AND.** The status-pin (clause 2/3), the workspace-bind (clause 4), and (Orchestrator) the B-1 override subquery MUST be realized per rung, per command as EITHER **(a)** a **SINGLE policy** whose `WITH CHECK` is the full conjunction `<status-pin> AND workspace_id = current_setting('mosaic.workspace_id', true)::uuid [AND NOT EXISTS(<active override deny row>)]`; **OR (b)** the status-pin as the **sole PERMISSIVE** policy **plus** the workspace-bind and (Orchestrator) the override authored **`AS RESTRICTIVE`**. Realizing them as **multiple _permissive_ policies intended to AND is EXPLICITLY FORBIDDEN** — permissive policies OR, so a foreign-workspace INSERT satisfying only the status-pin (e.g. base + `backlog` + foreign `workspace_id`) would be OR-admitted, re-opening B2-INSERT, and an override-vetoed Orchestrator write satisfying the status-pin would likewise slip the B-1 veto. The **identical mandate applies to `task_status_write_override`'s own policies** (single compound `WITH CHECK` or `AS RESTRICTIVE`; never multi-permissive-AND). A `pg_policy.polpermissive` catalog check + the hardened positive-control negatives (below) prove the realization. **[v6 — N1]** the `pg_policy.polpermissive` check is strengthened to count permissive policies **grouped by EFFECTIVE role (including `PUBLIC` and role inheritance)** — a `TO PUBLIC`/inherited permissive applicable to a rung's command counts alongside its status-pin — and to **reject an internally-disjunctive single `WITH CHECK`** (a lone policy whose expression is `status_pin OR workspace_bind` OR-widens exactly like two permissive policies); the lint is **supplementary, and the behavioral positive-controls below remain the PRIMARY proof** (§1.4 item 3-bis). **[v6 — N2]** because option (b) admits a row only when **≥1 permissive `WITH CHECK` is TRUE AND all restrictive checks pass**, there **MUST be ≥1 PERMISSIVE policy per writable (rung, command)** — the load-bearing option-(b) vacuous-deny guard — else the command over-denies (fails closed); this invariant is proven by the mandated "same-workspace valid-status write succeeds" positive tests (INSERT, transition/UPDATE, SELECT).
**Red-first tests (v5-hardened):** base-rung `INSERT … status='done'` **ERRORS** (column-privilege and/or RLS); base-rung INSERT omitting status succeeds at DEFAULT; **Orchestrator `INSERT … status='done'` ERRORS (N-3)**, Orchestrator `INSERT … status='backlog'` succeeds; User-rung `INSERT … status='done'` succeeds. **[B2-INSERT — hardened valid-status form]** the cross-workspace negative uses a row **otherwise valid for the rung****base rung + `status='backlog'` + `workspace_id=<foreign W2>`, no active override** — differing from a passing insert ONLY in `workspace_id`, so the **ERROR** isolates the _workspace bind_ (not a status/override rejection); tested direct and via the status-upsert INSERT arm. **Positive-control:** removing the workspace-bind conjunct **alone** flips this to a **PASS** (proving the bind — not the status-pin — rejects, hence a single enforced conjunction / RESTRICTIVE, not a permissive OR). An INSERT under an absent `mosaic.workspace_id` GUC **ERRORS** (fail-closed on NULL); a same-workspace INSERT with a valid status **succeeds** (predicate composes with F1/N-3, does not over-deny).
### 2.5 Invariant / key / tenancy field-set (F7 — closure)
- **`packages/db/src/sink-rbac/invariant-set.ts`** defines the invariant field-set = every sink column NOT in the explicitly-mutable set: `{ natural-key columns (workspace_id, id), status, creation/tenancy columns, immutable metadata }`. The complement (title, tags-normalized, due_at, rank, …) is the mutable set the UPSERT `DO UPDATE` touches.
- **[F7 FIX] `UPDATE` on key/tenancy/invariant columns (`workspace_id`, `id`, tenancy columns) is REVOKED from ALL rungs — including User and Orchestrator.** A re-key would otherwise **launder status** (relocate a row into a new identity to escape the status invariant). Column-level `UPDATE` grants for every rung exclude these columns.
- **Grant/data-plane disjointness (RC19-B1-03):** grants are declarative + physically separate from the data plane, so a grant/policy mutation writes **zero** `tasks` rows. A test applies an arbitrary policy/grant delta → asserts **zero byte delta** on any `tasks` row (closes RC19-B1-03 by construction).
### 2.6 Per-writer attribution → metadata column (Q1 audit channel)
- Per-writer attribution (which federated writer inside a tier acted) is an **AUDIT** concern, not an authority concern. It is captured as an **app-supplied `actor_id` metadata column on `task_events`** (append-only, already runtime `INSERT/SELECT`-only per frozen §4 L178). KBN-100 owns the column; the application supplies the verified federated-writer identity as **data**.
- It is **never** a DB credential, role, or RLS predicate key, and (per F6) never a `userOverride`/authority key (the override is keyed on task+tier, not writer). This keeps "which writer acted" auditable **without** a per-writer DB topology — i.e. without hitting the Q1 HALT trigger.
### 2.7 No-status-write via trigger / view / rule (F3 BLOCKING + N-1 + N-2)
Column-level `UPDATE(status)` alone does not close every indirect status-write path. Three complementary invariants (homed in KBN-100 producer; enumerated by -10's `invariant-set.ts`/no-status enumeration; certified deployed by KBN-101-09):
- **F3 — no status-normalizing trigger on `tasks`:** no trigger on `tasks` writes/normalizes `status` outside the sanctioned `transitionStatus()` path. The transition path is the **only** status writer.
- **[N-2 FIX + NB-1 + continuous-scan ADOPTED] Complete no-status-write enumeration (F3 completeness):** the cert asserts, in addition to F3: **no AFTER trigger** on `tasks` executing `UPDATE tasks SET status`; **no `SECURITY DEFINER` function** that writes `tasks.status` (frozen §4 L176 already forbids `SECURITY DEFINER` unless a separately reviewed exception — cited as the backstop); **no trigger on a RELATED runtime-writable table** (`task_events` and the other §4 L178 relations) that writes `tasks.status`; and **no `CREATE RULE`** on `tasks`. **[NB-1]** The enumeration is **extended to the `task_status_write_override` relation**: no status-writing trigger / RULE / `SECURITY DEFINER` function on the override relation may write `tasks.status` (the override table is runtime-writable by the User rung, so it is exactly the class §2.7's related-table clause must cover). The enumeration **scans function BODIES** (`pg_proc.prosrc` / dependency graph) for `tasks.status` writes, not merely `pg_trigger` rows. **[Gate A-delta-2 obs #2 — ADOPTED, not just recommended]** this enumeration is now a **continuous -06 CI catalog scan run on EVERY migration** (promoted from the v3 one-shot -09 recommendation), so a _later_ migration that adds a status-writing trigger/function/rule/view on `tasks` **or** on `task_status_write_override` **fails CI**, not only the one-time -09 cert. The -09 deployed cert still records the final deployed proof; the -06 scan is the standing gate. **[v6 — N1]** the same continuous -06 scan family carries the **strengthened `pg_policy.polpermissive` composition lint** (§1.4 item 3-bis / §2.4 clause 5): on every migration it counts permissive policies **grouped by effective role — including `PUBLIC` and role inheritance** (a `TO PUBLIC`/inherited permissive applicable to a writable command counts alongside that rung's status-pin) and flags an **internally-disjunctive single `WITH CHECK`** (`status_pin OR workspace_bind` within one policy). This lint is **supplementary**: it backstops the catalog shape but is **not sufficient** — the behavioral positive-controls (§2.4 clause 5 / §2.9) remain the PRIMARY proof that the composition is single-compound/`AS RESTRICTIVE` rather than an OR of permissive policies.
- **[N-1 FIX] no status-write via VIEW or RULE:** forbid any `VIEW` or `RULE` on `tasks` that yields a status-write path. An owner-owned view runs with the owner's privileges (`security_invoker = false` by default), side-stepping rung grants; an `ON INSERT/UPDATE DO INSTEAD` rule rewrites a write onto `tasks` similarly. The `invariant-set.ts` enumeration **forbids such views/rules OR mandates `security_invoker = true` + ZERO runtime grants** on any view over `tasks`. A negative test proves a rung cannot mutate `tasks.status` through any view/rule.
### 2.8 B2 — SCM_RIGHTS credential-acquisition + non-dumpable re-verify (F5 hardened — unchanged from v2)
- **`packages/db/src/credential-handoff/scm-rights.ts`** — the privileged process (the **-03 migrator launch wrapper**) opens the privileged fd (attestation signing key, §5 L191) **before** the UID drop and passes the **descriptor itself** over a Unix-domain socket via an `SCM_RIGHTS` ancillary message. The receiver **never** re-opens `/proc/self/fd/N` (the kernel re-checks permission and returns EACCES after the drop — the FD5 failure). `O_CLOEXEC` managed explicitly on both ends.
- **[F5 FIX] Socket authentication:** the transfer socket **MUST** be a `socketpair()` created **pre-fork** (no filesystem socket) **OR** a filesystem socket in a `0700` directory verified with **`SO_PEERCRED`** (assert peer UID/GID/PID). The receiver sets **`MSG_CMSG_CLOEXEC`** on `recvmsg`. An unauthenticated/anonymous peer is rejected fail-closed.
- **`packages/db/src/credential-handoff/process-hardening.ts`** — after **every** credential/UID transition, re-assert `prctl(PR_SET_DUMPABLE, 0)` and **verify** `prctl(PR_GET_DUMPABLE) == 0`; treat `dumpable != 0` as a fail-closed abort.
- **Consumers:** KBN-101-03 migrator wrapper (drop to `10003:10003`), KBN-101-02 importer (privileged-fd case, drop to `10002:10002`). The runtime rung-connection selection is a **DB-role choice, NOT an OS-UID transition** — no new setuid surface, no dumpability handling, reinforcing the no-per-user / no-new-privilege-drop property.
### 2.9 [B-1 FIX] Sink-resident, task/tier-scoped User-override table (the User deny is now enforced IN PostgreSQL)
**Problem (Gate B-delta B-1):** in v2 the evaluator resolved the User-override in-app _before_ selecting a connection, so a compromised/buggy Gateway on the Orchestrator pool could bypass a User deny — contradicting v2 §1.4/Q2, which mandates that "an Orchestrator write vetoed by a User-override deny must ERROR at the sink." App-resolution is not sink-enforcement.
**Fix (option a — DB-resident override, RLS-forced):**
- **New relation `mosaic.task_status_write_override`** (owned/created by the KBN-100 producer, exactly like `tasks`), columns: `workspace_id`, `task_id`, `tier` (enum `{orchestrator}` for v1 — the only vetoable sub-god tier; extensible to a future 4th rung), `active boolean`, `created_by_actor_id` (audit metadata, NOT an authority key), timestamps. **Primary/unique key `(workspace_id, task_id, tier)`****task-identity + tier ONLY; NO per-federated-writer column is part of the key or an authority input** (preserves F6 + the Q1 HALT boundary: every Orchestrator-tier writer is vetoed identically for a given task).
- **Who may WRITE it (sink-enforced):** `INSERT/UPDATE/DELETE` on the override table is granted **ONLY** to `mosaic_runtime_user_capability` (the User god rung), with the table under `FORCE RLS` and a `WITH CHECK` policy binding the row's `workspace_id` to the session workspace predicate. **[B2-INSERT parity]** that `WITH CHECK` workspace binding applies to the override table's **INSERT** path too (`workspace_id = current_setting('mosaic.workspace_id', true)::uuid`, fail-closed on NULL), so the override table's write path carries the identical symmetric tenant isolation as `tasks` — a User-rung INSERT of a veto row for a foreign workspace, or under an absent GUC, **ERRORS at the sink**. **[v5 COMPOSITION MANDATE parity]** the override table's write policies are subject to the SAME composition rule as `tasks` (§1.4 item 3-bis / §2.4 clause 5): the workspace-bind (and any status/authority conjunct) MUST be realized as a **single compound `WITH CHECK`** OR **`AS RESTRICTIVE`** — **never as multiple _permissive_ policies intended to AND** (which would OR-admit a foreign-workspace veto row). The Orchestrator and base rungs have **no** write privilege — a non-User attempt to author/clear a veto **ERRORS at the sink**. Thus only the User rung can raise or lift a veto.
- **Who READS it:** all three rung capabilities get **`SELECT`** on the override table (workspace-scoped `USING`), because the `tasks` write policy subquery evaluates as the current (querying) rung role. The read is confined to the querying connection's workspace by the override table's own `USING` predicate.
- **How it vetoes (the subquery):** every `tasks` **write policy** for the **Orchestrator rung** (`INSERT … WITH CHECK`, `UPDATE … WITH CHECK`, and the transition `WITH CHECK`) is extended with:
```sql
AND NOT EXISTS (
SELECT 1 FROM mosaic.task_status_write_override o
WHERE o.workspace_id = tasks.workspace_id
AND o.task_id = tasks.id
AND o.tier = 'orchestrator'
AND o.active
)
```
So an Orchestrator-tier write to a task carrying an active User deny is **REJECTED in PostgreSQL**, regardless of app-layer behavior. **[v5 COMPOSITION MANDATE]** this `NOT EXISTS(<override>)` subquery MUST be a **conjunct of the Orchestrator rung's single compound `WITH CHECK`** (alongside the status-pin and the workspace-bind), OR authored **`AS RESTRICTIVE`** — it may **NOT** be a separate _permissive_ policy intended to AND, because permissive policies OR and an Orchestrator write satisfying the status-pin permissive policy would then be OR-admitted despite an active deny (bypassing B-1). The User rung's own `tasks` policies do **not** subquery the override (User = god; the User authored the deny and overrides it). The base rung already cannot write status.
- **Per-tier NEGATIVE test (real PostgreSQL, red-first; v5-HARDENED):** insert an active override row for `(W, T, orchestrator)` via the User rung; then an Orchestrator-rung write on `(W, T)` **using a transition the Orchestrator pin OTHERWISE ACCEPTS** (a legal non-terminal `UPDATE`/transition that would succeed absent the deny row, and under the correct workspace GUC) **must ERROR at the sink** — so the rejection **isolates the override subquery** (not a status/workspace rejection). **Positive-control:** removing the override subquery **alone** flips this negative to a **PASS**, proving the _override_ — not the status-pin or workspace-bind — is doing the rejecting (and proving the subquery is a single-enforced conjunct / RESTRICTIVE, not an OR of separate permissive policies). Clearing the row (`active=false`, User rung only) re-permits the Orchestrator write; a non-User attempt to write the override table **ERRORS**. This proves the User-override is **sink-enforced** (satisfies v2 Q2) and stays within Mos Q1 tier-level (task/tier-scoped, not per-writer).
**[NB-2 — compromise-resistance scope of B-1.]** B-1 sink-enforces the veto against the **Orchestrator-rung path** — an Orchestrator-pool write to a vetoed task ERRORS in PostgreSQL regardless of app behavior. It does **NOT** claim to stop a Gateway compromised badly enough to hold the **User** pool: User = god and holds the sole write on the override table, so a compromised-User-pool Gateway can itself clear the veto. That is the **accepted co-resident-pool residual** (Mos Q1, NO HALT), not a defended boundary. The v3 §0 framing "a compromised Gateway cannot bypass a User deny" is corrected to "an Orchestrator-**path** write cannot bypass a User deny at the sink." Defending against a compromised User-pool Gateway would require per-federated-user DB credentials = the HALT boundary, which Mos Q1 explicitly declines.
**HALT note:** because the key is `(workspace_id, task_id, tier)` and never `writer`, this fix does **not** give two same-tier writers different authority — it gives the _whole_ Orchestrator tier the _same_ per-task veto. The B2-INSERT workspace predicate is likewise applied uniformly to every writer in a tier. NO HALT.
---
## 3. Findings-closure map (each → fail-closed, red-first testable)
| Finding | Closure | Fail-closed enforcement point | Test (red-first, real PostgreSQL / real UID drop) |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **RC19-B1-01** raw sink has no enforceable authority boundary | Declarative deny-by-default RBAC (§2.1) → fixed rung → per-rung DB credential; PG enforces via revoked base privileges + `FORCE RLS` deny-all + per-rung `WITH CHECK`. | At the sink: effective DB role lacks the privilege; mis-authorized write errors in PG. | -00: rung roles created, safe attrs, non-owner. KBN-100: base `UPDATE(status)`/`DELETE` denied, User/Orch allowed. -01: checkout identity==rung + safe-attr. |
| **RC19-B1-02** DELETE+default-INSERT resets status | Status-preserving UPSERT on `(workspace_id,id)`; `status` excluded from `DO UPDATE`; `DELETE` revoked all rungs; status only via authorized transition. | At the sink: DELETE fails closed; refresh cannot touch `status`. | KBN-100 + -09: refresh preserves `status` byte-stable; DELETE denied; transition-only status change. |
| **RC19-B1-03** grant change breaks non-status compatibility | Invariant field-set (§2.5); grants declarative + physically separate → grant mutation writes zero `tasks` rows. | Structural: data plane and grant plane disjoint. | Apply arbitrary policy/grant delta → **zero byte delta** on all `tasks` rows + invariant-set membership test. |
| **RC19-B2-01** FD5 `/proc/self/fd` EACCES after UID drop | SCM_RIGHTS descriptor-passing (§2.8); never re-open `/proc/self/fd/N` post-drop; explicit `O_CLOEXEC`; authenticated socket (F5). | Post-drop process receives a live fd; no privileged re-open. | Drop UID → `/proc/self/fd` re-open EACCES/absent vs SCM_RIGHTS receive succeeds; unauthenticated peer rejected. |
| **RC19-B2-02** dumpability resets after credential transition | Re-assert + **verify** `PR_SET_DUMPABLE=0` after every transition; fail closed if `PR_GET_DUMPABLE != 0`. | Process aborts if dumpable ≠ 0 post-transition. | After simulated setuid: assert `PR_GET_DUMPABLE == 0`; inject reset → fail-closed abort. |
| **F1 (BLOCKING)** INSERT-time status unconstrained for base rung | `GRANT INSERT (non-status list)` → status DEFAULT for base; RLS `INSERT … WITH CHECK` pins new-row status to initial; `INSERT(status)` only User/Orch (§2.4). | At the sink: base status-on-insert denied by column-privilege and/or RLS `WITH CHECK`. | **Red-first:** base `INSERT … status='done'` ERRORS; base INSERT omitting status → DEFAULT; User/Orch initial-status INSERT succeeds. |
| **F2 (BLOCKING)** rung LOGIN roles lack declared/verified safe attributes | Declare both rung roles `NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS` + non-owner (§1.3); extend L222 checkout assertion (§2.2). | Bootstrap catalog + every checkout: fail closed on SUPERUSER/BYPASSRLS/ownership. | -00 catalog asserts attrs + non-ownership; -01 injects unsafe attr/ownership → `DATABASE_ROLE_UNSAFE`. |
| **F3 (BLOCKING)** BEFORE-trigger status bypass | Invariant: no trigger on `tasks` writes/normalizes `status` outside `transitionStatus()` (§2.7); homed in KBN-100 producer. | Producer DDL admits no status-normalizing trigger; transition path is only status writer. | KBN-100 static: enumerate triggers on `tasks`, none assign status. **-09 deployed:** base UPDATE of a permitted col cannot change status via any trigger. |
| **F4 (non-blocking)** no frozen `tasks(workspace_id,id)` unique key | Bind `tasks (workspace_id,id)` UNIQUE as explicit KBN-100 req; else re-bind UPSERT to `(id)` (§2.3). | `ON CONFLICT` resolves to a real unique index; else hard planning error (fails loud). | KBN-100 migration: unique key exists; UPSERT plans/executes; missing-key variant fails loud. |
| **F5 (non-blocking)** SCM_RIGHTS socket unauthenticated | `socketpair()` pre-fork **or** `SO_PEERCRED` + `0700` dir; `MSG_CMSG_CLOEXEC` on `recvmsg` (§2.8). | Fd transfer only over an authenticated peer; received fd close-on-exec. | Unauthenticated/wrong-peer rejected; `SO_PEERCRED` mismatch fails closed; `MSG_CMSG_CLOEXEC` asserted. |
| **F6 (non-blocking)** `userOverride` scope | Schema constrains override to `(workspace_id,task_id,tier)`; **forbids** per-writer key (§2.1); realized as the sink-resident override table (§2.9). Attribution → metadata (§2.6). | Fail-closed at policy load: a per-writer authority key **fails schema validation**. | Schema test: per-writer override/authority key **rejected**; tier/task-scoped accepted. |
| **F7 (non-blocking)** re-key launders status | Revoke `UPDATE(workspace_id,id,tenancy)` from **all** rungs incl. User/Orch (§2.5). | At the sink: no rung may UPDATE key/tenancy columns. | Each rung (incl. User) `UPDATE workspace_id`/`id` **ERRORS**. |
| **B-1 (BLOCKING; composition PINNED in v5)** User-override was app-enforced, not sink-enforced | Sink-resident `task_status_write_override` table `(workspace_id,task_id,tier)`, User-rung-write-only; the `NOT EXISTS(<active deny>)` subquery is a **conjunct of the Orchestrator rung's single compound `WITH CHECK`** (or authored **`AS RESTRICTIVE`**), **never a separate permissive policy** — else an Orchestrator write satisfying the status-pin permissive policy is OR-admitted despite the deny (§2.9 / §1.4 item 3-bis). | At the sink: an active User deny → Orchestrator write REJECTED in PG (single enforced check, not OR-bypassable); non-User cannot author the veto. | **Per-tier NEGATIVE (real PG; v5-hardened):** User inserts deny row → Orchestrator write **using a transition the pin OTHERWISE ACCEPTS** **ERRORS** (isolates the override). **Positive-control:** dropping the override subquery alone flips it to PASS (proves single-conjunction/RESTRICTIVE, not permissive-OR). Clear row → re-permitted; Orch/base write to override table **ERRORS**. |
| **B-2 (BLOCKING)** FORCE + `WITH CHECK`-only bricks all reads (SELECT returns 0 rows for every rung and owner) | Add per-rung `SELECT`/`UPDATE` `USING` policies, **workspace-scoped** via `current_setting('mosaic.workspace_id')`; forbid `USING(true)` (§1.4/§2.2). Homes tenant isolation at the sink. | At the sink: reads succeed only for the session's workspace; wrong/absent predicate → zero rows (no cross-tenant leak). | Post-activation: each rung `SELECT`/UPSERT-probe within its workspace **succeeds**; a `SELECT` under a foreign/absent `mosaic.workspace_id` returns **zero** rows; a `USING(true)` variant **fails the isolation negative**. |
| **B-3 (BLOCKING)** owner/backfill write path under FORCE RLS | Backfill/repair DML runs FIRST (RLS disabled); `ENABLE`/`FORCE RLS` is the **TERMINAL** migration step; **no standing schema_owner ALLOW write policy** after activation; break-glass (`mosaic_extension_owner` §4.1 L146 + external bootstrap actor §9 L266) named in -09 cert (§1.4). | Migration ordering: backfill completes before FORCE binds; post-activation owner write has no policy → fails closed. | KBN-100 migration test: backfill INSERTs succeed pre-FORCE; **post-activation `mosaic_schema_owner` `tasks` write ERRORS under FORCE**; no runtime rung is superuser/BYPASSRLS; -09 names the two break-glass actors as the accepted boundary. |
| **N-1 (non-blocking)** VIEW/RULE status-write path | Forbid views/rules on `tasks` yielding a status write, OR mandate `security_invoker=true` + zero runtime grants; enumerated by `invariant-set.ts` (§2.7). | Enumeration admits no status-writable view/rule; any view over `tasks` is invoker-rights + ungranted. | Negative: a rung cannot mutate `tasks.status` through any view/rule; enumeration fails an owner-rights view with runtime grants. |
| **N-2 (non-blocking)** F3 enumeration incomplete | Extend enumeration: no AFTER trigger `UPDATE tasks SET status`; no `SECURITY DEFINER` fn writing `tasks.status` (L176 backstop); no trigger on related runtime-writable table (`task_events`, L178) writing `tasks.status`; no `CREATE RULE`; scan function **BODIES** (§2.7). One-shot -09 cert; recommend continuous -06 CI scan. | Producer/-09 cert admits no indirect status-write path. | Catalog scan of `pg_proc.prosrc`/triggers/rules asserts none write `tasks.status`; deployed behavioral proof; (recommended) -06 CI re-scan per migration. |
| **N-3 (non-blocking)** Orchestrator can INSERT terminal status | Orchestrator `INSERT(status)` `WITH CHECK` pinned to non-terminal initial status; only User may INSERT arbitrary/terminal (§2.4). | At the sink: Orchestrator terminal-status INSERT rejected by `WITH CHECK`. | Orch `INSERT … status='done'` **ERRORS**; Orch `status='backlog'` succeeds; User `status='done'` succeeds. |
| **N-4 (non-blocking)** transition-graph legality not DB-enforced (`done→backlog` reset by another name) | **DECISION (documented, §2.10):** edge-legality is **app-enforced** within near-god/god authority; the sink closes RC19-B1-02 **for base only** (DELETE-denial + no base status write). No status-writing trigger is added (would collide with F3/N-2); a validation-only guard is deferred. | Documented boundary: User/Orch hold near-god/god authority; illegal edges are an app-layer concern, not a sink guarantee. | -09 records the decision + asserts base cannot reset status at the sink; app-layer transition-graph tests are owned by the Gateway command lane (out of KBN-101 scope). |
| **N-5 / AD-2 (non-blocking)** import direction ambiguity | `-01` provisions generic pools only; rung→pool SELECTION lives in `-10`'s `connection-selection.ts`, consumed by `-02/-03/-05`; **never imported by `-01`**`10→01`, acyclic (§2.2). | Structural: no `01→10` edge exists. | Static import test: `-01` has no import of `sink-rbac/connection-selection`; `-02/-03/-05` do; graph acyclic. |
| **AD-1 (completeness)** frozen §4 L176 CONNECT cert reds on the 2 new logins | Amend the L176 allowlist to admit `mosaic_runtime_user`/`mosaic_runtime_orchestrator` + assert their `TEMPORARY` denial; named in -00 row/rc.20 (§1.3). | -00 cert: CONNECT allowlist includes the 2 new logins; all 3 runtime rungs `TEMPORARY`-denied. | -00 cert asserts CONNECT granted to the 2 new logins and no runtime rung retains `TEMPORARY`; an un-amended allowlist REDS. |
| **B2-INSERT (BLOCKING; composition PINNED in v5)** tenant isolation not enforced on the INSERT write path (v3 bound workspace only in `SELECT`/`UPDATE` `USING`; PostgreSQL does not apply `USING` to INSERT, and the explicit status `WITH CHECK` suppresses `USING` substitution → `workspace_id` unbound on INSERT → any rung can INSERT/UPSERT into a foreign workspace). **[v5 delta-3]** the v4 justification ("all `WITH CHECK` AND") was FALSE — multiple permissive policies OR — so the workspace conjunct must be pinned into a _single compound check or `AS RESTRICTIVE`_, else the bind is bypassable. | Include `workspace_id = current_setting('mosaic.workspace_id', true)::uuid` as a **conjunct of the single compound INSERT `WITH CHECK`** (with the F1/N-3 status-pin and, Orchestrator, the B-1 override subquery) **OR** author it **`AS RESTRICTIVE`**, on `tasks` (and the UPSERT INSERT arm) + symmetric on `task_status_write_override`. **Multiple _permissive_ policies intended to AND are EXPLICITLY FORBIDDEN** (they OR). Corrected semantics + realization mandate in §1.4 item 3-bis / §2.4 clauses 45 / §2.9. | At the sink: an INSERT/UPSERT with a foreign `workspace_id` fails the single enforced check; a NULL GUC → NULL predicate → INSERT ERRORS (fail-closed). Write-side tenant isolation homed at the sink and no longer OR-bypassable. | **Red-first (real PG; v5-hardened):** the cross-workspace negative uses an **otherwise-valid row** (base + `status='backlog'` + no override, differing only in `workspace_id=<foreign W2>`) so the **ERROR** isolates the workspace bind; direct and via status-upsert INSERT arm. **Positive-control:** dropping the workspace conjunct alone flips it to PASS (proves single-conjunction/RESTRICTIVE, not permissive-OR). NULL-`mosaic.workspace_id` INSERT **ERRORS**; same-workspace valid-status INSERT **succeeds** (no over-deny); override-table foreign-workspace INSERT **ERRORS**. |
| **NB-1 (non-blocking)** override relation absent from the no-status enumeration | Extend the F3/N-1/N-2 no-status-write enumeration **and the -09 cert / continuous -06 scan** to `task_status_write_override`: no status-writing trigger / RULE / `SECURITY DEFINER` function on the override relation writes `tasks.status` (§2.7). | Enumeration/CI admits no indirect status-write path via the override relation. | Catalog + fn-body scan asserts no trigger/rule/SECURITY-DEFINER on `task_status_write_override` writes `tasks.status`; a planted one REDS the -06 scan. |
| **NB-2 (non-blocking)** overstated B-1 compromise claim | Scope §0/§2.9 wording: B-1 sink-enforces the veto against the **Orchestrator-rung path**; a compromised **User**-pool Gateway (User=god) can clear a veto — accepted Mos Q1 co-resident residual, NO HALT (§0/§2.9). | Documented boundary: sink-enforcement is Orchestrator-path-scoped; the User-pool residual is accepted, not defended. | Doc/claim assertion in -09 cert notes: the override-veto negative proves the **Orchestrator-path** ERROR; the framing no longer claims User-pool compromise resistance. |
| **NB-3 (non-blocking)** overstated B-2 compromise claim | Scope §0/§2.2 wording: the workspace GUC is app-set with no per-tenant DB check → B-2 + B2-INSERT protect a **buggy** Gateway (omitted predicate → fail-closed), NOT a **compromised** one forging the GUC (accepted residual). The B2-INSERT unbound-INSERT closure remains a real fail-closed win (§0/§2.2). | Documented boundary: fail-closed on omission/NULL; forged-GUC cross-tenant is the accepted per-user residual. | Cross-workspace read → zero rows; NULL/foreign-GUC INSERT ERRORS (buggy-Gateway closure proven); doc states forged-GUC is out of scope (HALT boundary). |
| **NB-4 (non-blocking; v5 states transitive)** owner-toggle-FORCE containment mis-stated as incapability | Name `mosaic_schema_owner` (reachable via `mosaic_migrator` `SET ROLE`) in the -09 break-glass enum as **contained-but-CAPABLE**: an owner can `ALTER TABLE tasks DISABLE/NO FORCE RLS` then write regardless of NOSUPERUSER/NOBYPASSRLS → containment = **transitive non-reachability from the 3 runtime LOGIN rungs**, asserted explicitly (§1.4). | -09 cert: owner toggle-then-write is real but confined to the migrator/owner phase; runtime rungs cannot reach `mosaic_schema_owner`/`mosaic_migrator` through any inherited (capability-role) path. | **[NB-4-transitive]** -09 asserts non-reachability as **TRANSITIVE membership**`NOT pg_has_role(<rung>, 'mosaic_schema_owner', 'USAGE')` / `NOT has_privs_of_role(...)` recursion, NOT merely the absence of a direct `pg_auth_members` row (ownership check returns true for an _inherited_ membership through a capability role). Frozen §6 L222 "assumability" plausibly already covers this; "transitive membership" is stated explicitly. **[v6 — F-NB4]** the assertion additionally **conjoins the `MEMBER` variant**`NOT pg_has_role(<rung>, 'mosaic_schema_owner', 'MEMBER')` **AND** `NOT pg_has_role(<rung>, 'mosaic_migrator', 'MEMBER')` for each of the 3 runtime rungs — because a `WITH INHERIT FALSE, SET TRUE` membership yields `has_privs_of_role = FALSE` yet still permits `SET ROLE … ; ALTER TABLE … NO FORCE`; the `MEMBER` privilege captures SET-ROLE reachability regardless of `INHERIT` (the intended graph has each rung `SET FALSE` to only its capability role, so this is assertion-completeness, not a live hole). `mosaic_schema_owner` is `NOLOGIN`, reachable only via `mosaic_migrator` `SET ROLE`; the 3 runtime rungs are transitive non-members and non-assumers (catalog + L222 checkout). |
| **NB-5 (non-blocking)** 2 new capability roles lack baseline runtime grants → fail frozen §6 L222 verify | Grant `mosaic_runtime_user_capability` / `mosaic_runtime_orchestrator_capability` the frozen §4 L176 baseline runtime grants (`USAGE ON SCHEMA mosaic`; `USAGE ON SCHEMA drizzle` + `SELECT` on the two ledger relations; relevant sequence `USAGE`/`SELECT`) at -00 (§1.1/§1.3). | Bootstrap: the 2 new rungs hold the baseline grants → they connect/operate and pass the L222 runtime verify. | -00 catalog asserts each new capability role holds `USAGE ON mosaic`, ledger `SELECT`, sequence grants; an omission → L222 runtime verify fails closed (missing inherited grant). |
| **Q2 per-tier deny-by-default (red-team requirement; v5 composition-pinned)** | Per-rung **single compound `WITH CHECK`** (status-pin + B2-INSERT workspace bind + Orchestrator override) **or `AS RESTRICTIVE`****never multi-permissive-intended-to-AND** — plus `USING` + `FORCE RLS`; deny-all default (§1.4 item 3-bis). | At the sink, every tier: unauthorized write-source rejected even for owner/maintenance (`FORCE`); tenant/override binds not OR-bypassable. | **NEGATIVE per tier (v5-hardened):** base terminal-status INSERT/transition ERROR; **Orchestrator write under User-override deny, using an otherwise-accepted transition, ERROR (+ positive-control: drop override subquery → PASS)**; **cross-workspace INSERT/UPSERT with an otherwise-valid status ERROR (+ positive-control: drop workspace conjunct → PASS)**; NULL-GUC INSERT ERROR; owner/maintenance post-activation write ERROR (`FORCE` proven); cross-workspace read → zero rows; **same-workspace valid-status write SUCCEEDS per writable command (INSERT, transition/UPDATE, SELECT) — the [v6 — N2] ≥1-permissive-per-writable(rung,command) option-(b) vacuous-deny guard**; `pg_policy.polpermissive` check confirms single-compound-or-RESTRICTIVE, **[v6 — N1] grouped by effective role incl. `PUBLIC`/inheritance and rejecting an internally-disjunctive single `WITH CHECK` — supplementary to, never a substitute for, the behavioral positive-controls (PRIMARY proof)**. |
---
## 4. Implementation card DAG (owner + disjoint manifest, red-first at Gate A/B)
```
contract(amended) ─► KBN-101-00 (rung-ROLES + capability roles, safe attrs [F2],
│ deny-default base, CONNECT + [AD-1] L176 allowlist
│ amend (2 new logins CONNECT, TEMPORARY-denied);
│ bootstrap catalog tests. NO tasks/override DDL [GA-1])
KBN-101-01 ──┤ (provisions 3 generic rung pools; sets mosaic.workspace_id
│ session predicate [B-2]; checkout identity==rung AND
│ safe-attribute/non-owner verify [F2, extended L222].
│ Does NOT import -10 [AD-2/N-5])
KBN-101-10 ──┘ depends 00,01:
packages/db/src/sink-rbac/** (policy+schema[F6]+evaluator+ladder+
connection-selection[consumes 01; 10→01, N-5]+
status-upsert[F4]+invariant-set[F7]+
no-status trigger/view/rule enum[N-1,N-2])
packages/db/src/credential-handoff/** (scm-rights[F5] + process-hardening +specs)
┌─────────────┬────────┼──────────────────────┬──────────────────────────────┐
KBN-101-03 KBN-101-05 KBN-101-02 KBN-100 (producer lane) KBN-101-09 (evidence-only)
(dep 00,01,10) (dep 00,03, (dep 01,03,10) SPEC target of -10: (dep KBN-100,08)
migrator 10) importer fd + homes tasks + override DDL DEPLOYED cert:
wrapper: renderer: sink adapter uses AFTER create: real rung INSERT/
SCM_RIGHTS 3 rung DSN status-upsert - rung column-grant matrix transition success;
SEND + dumpable secret (drop 10002) [F1,F7,N-3] override-veto [B-1];
re-assert mounts; - RLS WITH CHECK(+override) cross-workspace read
(drop 10003) Gateway + USING workspace pred zero-rows [B-2];
[GA-4(ii): Dockerfile; [Q2,B-1,B-2, conds a&b] backfill-then-FORCE
docker/db- NO SCM_RIGHTS - task_status_write_override ordering + owner-write
migrator. (DB-role, no table [B-1] denial [B-3] + break-
Dockerfile] UID drop) - tasks(workspace_id,id) UNIQUE glass named;
│ [F4] base status
KBN-101-06 (dep 02,03,05,07,10): inventory += KBN-101-10; INSERT/UPDATE/DELETE
overlap/ownerless/path-existence green; denial; refresh
matrix += rung selection + SCM_RIGHTS[F5] + status-preservation;
per-tier RLS negatives + override-veto[B-1] + no-status trigger/
cross-workspace read[B-2] + backfill-order[B-3] view/rule [F3,N-1,N-2];
+ ADOPTED continuous no-status scan[N-2,NB-1] grant-delta zero-byte;
+ cross-workspace/NULL-GUC INSERT neg[B2-INSERT]
│ dumpable=0 evidence
KBN-101-08 (dep 00…07,10): foundation + atomic activation cert
```
- **Gate A (re-review):** every element traces to a manifest owner; disjointness/overlap/ownerless proven against amended §7; the SPEC-vs-implement split for `tasks`+override sink DDL matches the frozen §4 L178 precedent (producer = KBN-100, applied **after** table creation — GA-1); dep edges acyclic with `10→01` import direction (AD-2/N-5); the L176 CONNECT amendment named (AD-1).
- **Gate B (red-team):** each B1/B2 finding **and** F1F7 **and** B-1/B-2/B-3 **and** B2-INSERT **and** N-1..N-4 **and** NB-1..NB-5 **and** the Q2 per-tier deny-by-default has a **red-first** test that fails on the current design and passes only with the enforcement point; sink privilege/RLS/override/tenant denials — **including cross-workspace and NULL-GUC INSERT/UPSERT [B2-INSERT]** — proven **in PostgreSQL** (not PGlite — §5 L218); SCM_RIGHTS/dumpable against a real UID drop with an authenticated socket.
---
## 5. Executability self-audit (re-run for v5)
**Every element has an owner in the amended manifest — nothing ownerless:**
| Element | Owner |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Declarative RBAC policy artifact + schema [F6] + evaluator + ladder + no-status trigger/view/rule enum [N-1,N-2] | KBN-101-10 (`packages/db/src/sink-rbac/**`) |
| Rung→credential connection-selection module (consumes -01 pools; `10→01` [N-5]) | KBN-101-10; pools provisioned in KBN-101-01 (`database.module.ts`) |
| Status-preserving-UPSERT [F4] + invariant-set [F7] module | KBN-101-10 (`packages/db/src/sink-rbac/**`) |
| SCM_RIGHTS handoff [F5] + non-dumpable assert/verify | KBN-101-10 (`packages/db/src/credential-handoff/**`) |
| Fixed rung DB roles + safe attributes [F2] + deny-default base + CONNECT + **[AD-1] L176 allowlist amend + TEMPORARY denial** + **[NB-5] baseline L176 runtime grants (`USAGE mosaic`, ledger `SELECT`, sequence grants) on the 2 new capability roles** | KBN-101-00 (`infra/pg-bootstrap/roles.sql` + tests) — **no tasks/override DDL [GA-1]** |
| `tasks` sink DDL: rung grants [F1,F7,N-3] + RLS `WITH CHECK`(+override) [Q2,B-1] + **INSERT `WITH CHECK` workspace bind on every rung [B2-INSERT]** + `SELECT`/`UPDATE` `USING` workspace predicate [B-2] + `FORCE RLS` terminal-after-backfill [B-3, conds a&b] + `(workspace_id,id)` UNIQUE [F4] + `actor_id` col [Q1] + no-status trigger/view/rule invariant on `tasks` **and override relation** [F3,N-1,N-2,NB-1] | **KBN-100 producer** (SPEC by -10; §4 L178 precedent; scope amended per Q2) |
| **`task_status_write_override` table [B-1]** (create + grants + FORCE RLS + policies; User-write-only incl. **INSERT `WITH CHECK` workspace bind [B2-INSERT parity]**, all-rung-SELECT) | **KBN-100 producer** (SPEC by -10; same L178 precedent) |
| `mosaic.workspace_id` session predicate set at checkout [B-2] | KBN-101-01 (`database.module.ts` / `connection-identity.ts`) |
| Rung DSN secret mounts (3 fixed) | KBN-101-05 (renderer, extends runtime-URL row) |
| Checkout identity==rung + safe-attribute/non-owner verify [F2] | KBN-101-01 (`connection-identity.ts`, extended L222) |
| Migrator launch wrapper SCM_RIGHTS send + dumpable re-assert | KBN-101-03 (`docker/db-migrator.Dockerfile` [GA-4(ii)]) |
| Importer privileged-fd case | KBN-101-02 |
| Deployed enforcement certification (F3/N-1/N-2 **+ NB-1 override-relation scan**, per-tier RLS negatives, B-1 override-veto, B-2 cross-workspace read, **B2-INSERT cross-workspace/NULL-GUC INSERT negatives**, B-3 ordering + break-glass naming **incl. [NB-4] `mosaic_schema_owner` contained-but-capable / non-reachability assertion**, N-4 decision) | KBN-101-09 (evidence-only) |
| Inventory/overlap/path-existence + command matrix (rung sel + SCM_RIGHTS + per-tier RLS + override-veto + cross-workspace read + **[B2-INSERT] cross-workspace/NULL-GUC INSERT negatives** + backfill-order + **[ADOPTED] continuous no-status scan on `tasks` + override relation**) | KBN-101-06 (fixture += -10) |
**Manifest closure (KBN-101-06 green):** the only NEW ownership is the two disjoint -10 globs. The B-1 override table, B-2 `USING` policies, B-3 terminal-FORCE ordering, **and the v4 B2-INSERT INSERT `WITH CHECK` workspace binding (+ its override-table parity) and the NB-1 override-relation no-status enumeration** are ALL producer DDL homed in KBN-100's already-owned `schema.ts`/`drizzle/**` (a _responsibility_ widening, not a glob change — identical treatment to the v3 `tasks` sink DDL). The **NB-5 baseline capability-role grants** amend -00's already-owned `roles.sql`/tests (like AD-1); the **continuous -06 no-status scan** lives in -06's already-owned `tools/ci/fixtures/kbn101-ddl-inventory.json` + `.woodpecker/ci.yml`; the NB-2/NB-3/NB-4 claim-scopings/enumeration are doc + -09 cert wording on already-owned paths. N-1/N-2/N-5 additions live in -10's already-declared `sink-rbac/**`. **No glob is split/shared/narrowed → overlap/ownerless/path-existence stay green (KBN-101-06 unchanged-green vs v3).**
**NO per-federated-user topology (HALT boundary respected — re-verified for v5):** DB-role/credential cardinality = **3** (User / Orchestrator / others), fixed and independent of the federated-user count. Federated identity → rung happens in the evaluator at authorization time. A new federated user creates **zero** roles/credentials/secrets/pools. The **B-1 override table is keyed `(workspace_id, task_id, tier)` — task+tier, never writer** — so it homes a tier-level veto, not a per-user distinction. The **B-2 read `USING` predicate AND the B2-INSERT INSERT check predicate are the SAME shared per-request session var** (`mosaic.workspace_id`), applied uniformly to whichever rung role holds the connection — tenant isolation, not writer discrimination (and, per v5, composed into a single compound check or `AS RESTRICTIVE` rather than an OR of separate permissive policies). Per-writer **attribution** is a metadata column (`task_events.actor_id`, §2.6), not a DB principal. `userOverride` forbids a per-writer authority key (F6, fail-closed at load). The NB-1..NB-5 fixes add no principal, key, or predicate that varies by writer. Enforce-at-sink is satisfied by the fixed rung roles' grants + `FORCE RLS WITH CHECK`(+override, +workspace-bound INSERT) + `USING` — it does **not** require, and this design does **not** use, a DB role/credential per federated user.
**Q1 same-tier / different-authority check (the ONLY HALT trigger):** no v5 change gives two SAME-TIER writers different WRITE authority. The v5 composition pin fixes only **how** the already-uniform predicates are physically composed (single compound `WITH CHECK` or `AS RESTRICTIVE`, never multi-permissive-OR) — it changes no predicate's content and introduces no writer-varying axis. F1/F2/F3/F7/N-3 tighten authority uniformly per tier; **B2-INSERT binds the same workspace predicate uniformly into every rung's INSERT check** (tenant isolation, not writer discrimination); B-1 vetoes the _entire_ Orchestrator tier per task (not a subset of Orchestrator writers); B-2 isolates by workspace uniformly; F6 forbids per-writer authority keys; attribution is audit-only. The NB-4-transitive nit only _strengthens_ the owner non-reachability assertion (transitive membership) — no authority axis. **No same-tier-different-authority requirement exists → NO HALT — an executable envelope is delivered.**
---
## 6. Flagged ambiguities in the frozen contract (for reviewer awareness)
1. **Sink identity of the status store.** The RBAC-guarded sink is `tasks.status`; legacy `mission_tasks.status` stays frozen read-only. Confirm no reviewer reads "raw sink" as the legacy column.
2. **Frozen "role graph unchanged" + "no RLS" + L176 CONNECT allowlist vs. the rulings.** SHARED-CONTRACT rc.5 L82/L88-90 froze the role graph and grant/revoke-only (no RLS); frozen §4 L176 froze the CONNECT allowlist. Jason B1 + Mos OPTION A add fixed rung-roles; **Mos Q2** authorizes RLS `WITH CHECK`/`USING` + `FORCE RLS` + the override table; **AD-1** amends the L176 allowlist for the 2 new logins. The rc.20 amendment text (§1.3/§1.4) records all three as deliberate, human-authorized departures. Reviewer should confirm rc.20 is **adopted** (not treated as a violation), that KBN-100's DDL scope is amended to include RLS + the override table on `tasks`, and that the -00 row amends the L176 allowlist.
3. **Natural-key column list for `tasks` (F4).** `tasks (workspace_id, id)` UNIQUE is asserted by the SI-001 pattern but must be **bound + created by KBN-100**. If KBN-100's `tasks` identity differs, the UPSERT natural key re-binds to `(id)` (spec-level, no HALT).
4. **Initial/terminal status values (F1/N-3).** F1 pins base new-row status to `backlog` (§5.4 L214); N-3 pins the Orchestrator's INSERT set to non-terminal (`{backlog, ready}`) and reserves terminal (`{done, cancelled}`; `in_review` treated as non-initial) to the User rung. KBN-100 binds the exact column DEFAULT and the RLS `WITH CHECK` literals against the §3 L134 vocabulary so they agree.
5. **Workspace session-var name + set-site (B-2 + B2-INSERT).** The `USING` predicate **and the v4 INSERT `WITH CHECK` predicate** both use `current_setting('mosaic.workspace_id', true)`. -01 sets it per request from the server-verified tenant (never a body field, §7.1). Reviewer should confirm the exact GUC name and that a transaction-local `SET LOCAL` is used so a pooled connection cannot leak a stale workspace across requests (mirrors the frozen `SET LOCAL search_path` discipline, §4 L152) — now **explicitly folded into the frozen L174 poisoned-pooled-session-reset + `SET LOCAL` restoration negative test** (Gate B 2(a), §2.2). Reviewer should also confirm the accepted scope (NB-3): fail-closed on an omitted/NULL GUC (buggy Gateway), but a _forged_ GUC to a victim tenant is the accepted per-user residual (Mos Q1), not defended.
6. **N-4 transition-graph decision.** Edge-legality (illegal/backward transitions such as `done→backlog`) is **app-enforced** within near-god/god authority; the sink closes RC19-B1-02 for **base only** (DELETE-denial + no base status write). A validation-only sink guard is deferred to avoid colliding with the F3/N-2 no-status-trigger invariant. Reviewer should confirm this decision is acceptable (it is stated, not silently dropped).
7. **N-2 continuous scan — now ADOPTED.** The complete no-status-write enumeration (trigger/view/rule/SECURITY DEFINER/function-body), covering `tasks` **and `task_status_write_override` (NB-1)**, is now a **continuous -06 CI catalog scan run on every migration** (promoted from the v3 one-shot -09 recommendation, per Gate A-delta-2 obs #2); the -09 deployed cert still records the final proof. Reviewer should confirm the -06 scan is owned/wired in `tools/ci/fixtures/kbn101-ddl-inventory.json` + `.woodpecker/ci.yml` (already-owned -06 paths).
8. **KBN-101-09 is evidence-only.** B-1/B-2/B-3/F3/N-1/N-2 negative tests are _authored/owned_ by the producing implementation cards (KBN-100 producer for `tasks`/override triggers/RLS; -10/-01 for the rung path); KBN-101-09 records the **deployed** certification evidence and changes no implementation path. Confirm the test-ownership split is acceptable (mirrors the frozen -09 "evidence-only" role).
9. **B2-INSERT / B-1 `WITH CHECK` composition — SEMANTICS CORRECTED + REALIZATION MANDATED (v5; the delta-3 fix).** v4 justified the composition with a **FALSE** PostgreSQL claim — "multiple applicable `WITH CHECK` clauses must all pass (they AND)". **That is struck.** The **correct** semantics: multiple **PERMISSIVE** policies for a command combine with **OR** (a row is admitted if it satisfies **ANY** one permissive `WITH CHECK`, ANDed with all `AS RESTRICTIVE` checks); the AND this closure needs holds **ONLY** (a) within a _single_ policy whose `WITH CHECK` is one AND-conjoined expression, or (b) across `AS RESTRICTIVE` policies. Accordingly v5 **MANDATES** (§1.4 item 3-bis / §2.4 clause 5 / §2.9) that, per rung per command, the status-pin **AND** the workspace-bind **AND** (Orchestrator) the override subquery be realized as EITHER a **single compound `WITH CHECK`** OR the status-pin-as-sole-permissive **plus** the workspace-bind/override **`AS RESTRICTIVE`**, and **EXPLICITLY FORBIDS** realizing them as multiple _permissive_ policies intended to AND (which would OR-admit a foreign-workspace INSERT satisfying only the status-pin — cross-tenant injection — and equally bypass the B-1 veto). Reviewer should confirm: (i) the corrected OR-semantics statement replaces every "all `WITH CHECK` AND" assertion; (ii) the realized policies are single-compound or `AS RESTRICTIVE` (a `pg_policy.polpermissive` catalog check backs it); (iii) the hardened red-first negatives use an _otherwise-valid_ row (cross-workspace: base + `backlog` + foreign ws; override: an otherwise-accepted Orchestrator transition) so each rejection isolates the bind/override under test, **and each carries a positive-control** (dropping the workspace conjunct — resp. the override subquery — alone flips the negative to a PASS, proving the bind/override, not the status-pin, is rejecting). Confirm the accepted scope (NB-2/NB-3) is unchanged: write-side sink tenant isolation is complete against a _buggy_ Gateway; a _compromised_ Gateway forging the GUC or holding the User pool is the accepted Mos Q1 residual.
---
_Envelope A **v6** authored from base `b0d78d86`. **v6 = v5 + N1/N2/F-NB4 non-blocking test/lint/cert hardening ONLY — NO design predicate, policy, grant, role, or table is changed** (only test/lint/cert wording). v5 CONVERGED with BOTH terminal re-gate-4 gates GO (Gate A-delta-4 `a4ce075a`; Gate B-delta-4 `a379e0de`), NO HALT; Gate B-delta-4 flagged 3 EXPLICITLY NON-BLOCKING hardening notes "for the trunk-commit record," folded here additively: **N1** — the `pg_policy.polpermissive` lint is strengthened to count permissive policies grouped by EFFECTIVE role (incl. `PUBLIC`/inheritance) and to reject an internally-disjunctive single `WITH CHECK`, stated as SUPPLEMENTARY with the behavioral positive-controls remaining the PRIMARY proof (§1.4 item 3-bis / §2.4 clause 5 / §2.7 / §3 Q2 row); **N2** — the option-(b) vacuous-deny guard is stated explicitly as the load-bearing "≥1 PERMISSIVE policy per writable (rung, command)" invariant, verified by the "same-workspace valid-status write succeeds" positive tests for INSERT / transition-UPDATE / SELECT (§1.4 item 3-bis / §2.4 clause 5 / §3 Q2 row); **F-NB4** — owner non-reachability is extended to SET-ROLE assumability by conjoining the `MEMBER` variant `NOT pg_has_role(<rung>, 'mosaic_schema_owner', 'MEMBER')` AND `NOT pg_has_role(<rung>, 'mosaic_migrator', 'MEMBER')` for each of the 3 runtime rungs in the -09 cert (§1.4 NB-4 para / §3 NB-4 row). All three are assertion/test/lint completeness on an already-BOTH-GO design; the intended role graph is already safe. The v6 body otherwise preserves the v5 text verbatim, which in turn preserved EVERY v4 closure (all v4 checks passed except one bounded item) and closed the SINGLE remaining BLOCKING finding raised identically by BOTH re-gate-3 gates (Gate A-delta-3 `aa5cbf73` CHANGES-NEEDED; Gate B-delta-3 `aa881074` 1 blocking) — the **RLS `WITH CHECK` composition semantics**: (1) **corrected semantics** — struck the FALSE "PostgreSQL requires ALL applicable `WITH CHECK` to pass / they AND" from §1.4/§2.4/§6.9 and stated the true rule (multiple PERMISSIVE policies OR; AND holds only within a single compound `WITH CHECK` or across `AS RESTRICTIVE`); (2) **mandated the realization** — per rung per command, status-pin + workspace-bind + (Orchestrator) override subquery MUST be a **single compound `WITH CHECK`** OR the status-pin-as-sole-permissive **plus** the bind/override **`AS RESTRICTIVE`**, with **multi-permissive-intended-to-AND EXPLICITLY FORBIDDEN**, applied to `tasks` **and** `task_status_write_override` (§1.4 item 3-bis, §2.4 clauses 45, §2.9); (3) **hardened the red-first negatives** — cross-workspace INSERT uses an otherwise-valid row (base + `backlog` + foreign ws) and the B-1 override negative uses an otherwise-accepted Orchestrator transition, so each rejection isolates the bind/override, **each with a positive-control** (dropping the workspace conjunct — resp. override subquery — alone flips the negative to PASS); plus **NB-4-transitive** (owner non-reachability stated as TRANSITIVE membership — `NOT pg_has_role(...)`/`has_privs_of_role(...)` recursion, not a direct `pg_auth_members` edge; frozen L222 "assumability" noted as plausibly already covering it). Every other v4 closure carries forward intact (3-rung ladder / 0 per-user; F1F7; GA-1/3/4; B-1/B-2/B-3; the B2-INSERT workspace bind itself; N-1..N-5; AD-1/AD-2; NB-1..NB-5; continuous -06 no-status scan; L174 poisoned-pool `SET LOCAL` GUC fold; rc.20). Manifest disjoint (KBN-101-06 green) — all v5 changes land on already-owned KBN-100-producer / -09 / -06 paths (DDL realization detail + test wording; no glob change). Design/contract only; no SSOT file modified, no code, no PR. HALT self-check: NO HALT (fixed 3-rung ladder; override keyed task+tier not writer; workspace predicate shared per-request and uniform per tier, now composed as single-compound/`RESTRICTIVE`; attribution → metadata; no per-federated-user DB role/credential; no same-tier different-authority requirement — both delta-3 gates independently confirmed the boundary HOLDS). **v6 changes NOTHING in this HALT calculus: N1/N2/F-NB4 add only lint-scope, a vacuous-deny test invariant, and a `MEMBER` assumability assertion — zero new principal, key, predicate, grant, role, or table, and no writer-varying axis — so NO HALT is re-confirmed for v6.** Manifest remains disjoint (KBN-101-06 green): every v6 fold lands on already-owned paths — the -06 continuous scan / `pg_policy.polpermissive` lint in `tools/ci/fixtures/kbn101-ddl-inventory.json` + `.woodpecker/ci.yml`; the option-(b) positive tests in the KBN-100-producer / -09 evidence paths; the `MEMBER` assertion in the -09 cert — no glob split/shared/narrowed. NO code, NO PR, NO SSOT edit._
@@ -192,4 +192,6 @@ No role can transform a missing gate into a warning by changing status, editing
This manifest is derived from the ratified source plan. Any change to SOT authority, workspace tenancy, fixed statuses, Coordinator/Certifier authority, health-state semantics, schema v1, migration direction, or recovery-tier field set is a contract change. Contract changes require Jason/Mos authorization and cannot be inferred by an implementation lane.
> KBN-101 Envelope A (rc.20, `KBN-101-DB-ROLE-SPLIT.md` §4/§10) is the specific authorized instance of a schema-v1 contract change under this clause, ruled by Jason B1 + Mos OPTION A/Q1/Q2; see `KBN-101-DB-ROLE-SPLIT.md` rc.20.
No coder lane may start while the build hold is active. KBN-010 must complete before KBN-100; KBN-105 exact endpoint/DTO freeze must complete before any API consumer implementation.
+10 -2
View File
@@ -8,6 +8,14 @@
## Amendment record
### 1.0.0-rc.20 — KBN-101 Envelope A: declarative sink-RBAC + per-role connection-selection + RLS write-source + sink-resident User-override
- **Choice:** adds the fixed User/Orchestrator/others runtime rung-roles (per-ROLE, deny-by-default; `mosaic_runtime_user`, `mosaic_runtime_orchestrator` + their capability roles), amends the frozen §4 L176 CONNECT allowlist for the two new logins (TEMPORARY-denied), and authorizes declarative RLS `WITH CHECK`/`USING` + `FORCE ROW LEVEL SECURITY` + a sink-resident `task_status_write_override` table on `tasks` — a schema-v1 mechanism addition beyond the prior grant/revoke-only model.
- **No per-federated-user topology:** DB-role/credential cardinality remains fixed at 3, independent of federated-user count; the override table is keyed `(workspace_id, task_id, tier)`, never per-writer; per-writer attribution is a metadata column (`task_events.actor_id`), never a DB principal.
- **Non-effect:** rc.4 SI-001, all KCR-001016 decisions, and every prior rc (rc.5rc.16) invariant not explicitly named above remain unchanged. Introduces no new principal, key, or per-user authority axis (Q1 HALT boundary not hit).
- **Authority:** Jason B1 ruling + Mos OPTION A (home the layer) + Mos Q1 (TIER-LEVEL) + Mos Q2 (RLS `WITH CHECK` authorized, two mandatory conditions — `ENABLE`/`FORCE ROW LEVEL SECURITY` as terminal migration step, non-owner rung roles). Exact implementation detail is normative in [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md) §4/§10 and the KBN-101 Envelope A v6 record.
- **Gate:** lands as `KBN-101-10` (new owner card) + responsibility-widenings on `KBN-101-00/01/02/03/05/06`; the `tasks`/override-table DDL itself is homed in the **KBN-100** producer (starts after KBN-101-08) per the existing §4 L178 producer/consumer precedent — a SPEC note today, not an immediate implementation.
### 1.0.0-rc.16 — Current generic storage-wrapper authority closure
- **Current-source truth:** `packages/storage/src/cli.ts` currently shells `storage migrate --run` directly to `pnpm --filter @mosaicstack/db db:migrate` through `execSync`; no `mosaic-db-migrator` executable exists. README and user-guide command guidance therefore remove that command and any runner-delegation claim. The current wrapper is legacy N-1, uncertified, non-operative, and MUST NOT be invoked pending KBN-101-02/-03/-06/-08 activation.
@@ -79,14 +87,14 @@
- **Exact ledger:** manifest v1 canonically serializes journal logical index/tag and SHA-256 of exact shipped migration bytes. It maps each observed ledger hash to one tuple; physical insertion order is non-normative, while missing/unknown/duplicate/ambiguous/corrupt/stale states fail closed. Shipped `0009` bytes remain unchanged; a missing/effects-absent `0009` runs normally, an applied-late hash maps normally, and partial/full effects with missing hash require backup restoration or separately reviewed repair—not manual adoption.
- **TLS/search path:** operator/IaC owns CA and server leaf lifecycle, exact compose/Swarm secret mounts, server TLS activation, service-DNS SANs, verified-TLS readiness, transition, CA overlap rotation, and rollback. Runtime/migrator use `verify-full`; PGlite is not PostgreSQL TLS evidence. Application sessions use only `pg_catalog,mosaic`; no URL/config-derived identifier reaches SQL.
- **Safe release:** cards 0007 land prepared but inactive; owner-runtime deployments remain N-1. Mosaic control plane/Jason alone authorizes one atomic TLS/roles → runner → readiness → runtime activation or rollback. No runtime-operator compatibility switch, bypass, plaintext interval, or force-on-red exists; all temporary support is removed before KBN-101-08.
- **Non-effect:** role graph, immutable certification after KBN-100, KBN-105 gate, rc.5s preserved rc.4 SI-001 invariants, and all KCR-001016 decisions remain unchanged. Exact detail is normative in [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
- **Non-effect:** role graph (**except as amended by rc.20 — see below**), immutable certification after KBN-100, KBN-105 gate, rc.5s preserved rc.4 SI-001 invariants, and all KCR-001016 decisions remain unchanged. Exact detail is normative in [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
### 1.0.0-rc.5 — KBN-101 role/connection split
- **Choice:** PostgreSQL `standalone` and `federated` runtime uses `DATABASE_URL` only as a non-owner `mosaic_runtime` login; an explicit migration phase uses `DATABASE_MIGRATION_URL` only as `mosaic_migrator`, which `SET ROLE`s to non-login `mosaic_schema_owner` for DDL. Local PGlite remains an explicit embedded exception.
- **No fallback / no startup DDL:** missing migration URL fails the migration phase; it never falls back to runtime URL/default/config. Gateway replicas do not run migrations. An advisory-locked migration phase verifies the exact ordered Drizzle ledger fingerprint before replicas may become ready.
- **Privilege model:** non-login `mosaic_platform_database_owner` is outside application paths; `mosaic_schema_owner` owns only application/ledger schemas. `mosaic_runtime` has only `mosaic_runtime_capability`, owns no object/schema, cannot assume owner/migrator, has no TEMPORARY privilege, has only read access to the Drizzle ledger, and must fail startup if effective identity, unsafe attributes, authenticated TLS, search path, schema version, grants, or immutable relation privileges differ from the frozen contract. `task_events`, `artifacts`, `task_checkpoints`, `task_checkpoint_artifacts`, and `approval_decision_artifacts` grant runtime only INSERT/SELECT; KBN-100 retains RESTRICT/no-cascade semantics.
- **Non-effect:** rc.4 SI-001 candidate-key/FK order and all KCR-001016 tenancy, SOT, proposal-audit, approval, fence, recovery, no-cascade, endpoint, and wire invariants are unchanged. This amendment neither creates roles/secrets nor changes production deployment.
- **Non-effect:** rc.4 SI-001 candidate-key/FK order and all KCR-001016 tenancy, SOT, proposal-audit, approval, fence, recovery, no-cascade, endpoint, and wire invariants are unchanged. This amendment neither creates roles/secrets nor changes production deployment (**except as amended by rc.20 — see below**).
- **Gate:** KBN-101s role/schema-boundary foundation certificate, Vault/redaction/rotation, N-1/rollback, and independent security GO are mandatory before KBN-100. After KBN-100 creates the immutable relations, KBN-101 real deployed-role immutable-operation certification plus Ultron GO is mandatory before KBN-105; synthetic test-role success alone is insufficient. Exact implementation detail is normative in [`KBN-101-DB-ROLE-SPLIT.md`](./KBN-101-DB-ROLE-SPLIT.md).
### 1.0.0-rc.4 — KBN010-SI-001 (preserved)
+25
View File
@@ -0,0 +1,25 @@
<!-- board-roll: 1 entry rolled from BOARD.md -->
### **D-1 / P-ACTIVATION + hygiene — committed `.npmrc` hard-pins `store-dir=/root/.local/share/pnpm/store`.**
Correct for the CI container (runs as root), fatal for EVERY non-root local checkout: `EACCES` on `/root/.local/share/pnpm/store/v10/server/server.json`. A committed config that only works on one runtime is exactly the activation-skew class. Fix candidate: make store-dir env-overridable, not hardcoded.
<!-- board-roll: 2 entries rolled from BOARD.md -->
### **D-3 / P-FLEET-001 — the seats running this mission are UNMANAGED.** `mos-remediation`, `rev-974`,
`planner-opus`, `planner-sol` appear in NO roster (`~/.config/mosaic/fleet/roster.yaml`, `agents/`). Planners run on socket `default`; the roster declares `mosaic-fleet`. This is the exact "one roster-owned socket/host + quarantine unmanaged + stale GC" failure P-FLEET-001 indicts — observed on the remediation mission's own fleet. Prerequisite for INBOX identity-addressing.
### **D-2 / hygiene — husky `prepare` fails `EPERM` copying into root-owned `.husky/_/`.** Repo working
tree has root-owned dirs (`.husky/`, repo root) under a non-root agent. Worked around with the intended `HUSKY=0` escape hatch (does NOT disable the existing pre-commit/pre-push hooks).
<!-- board-roll: 2 entries rolled from BOARD.md -->
### **D-5 / P-QUEUE-001 + P-CONFORMANCE-001 — KEYSTONE: an inert gate that erased its own evidence.**
Merged PR #868 (`b79336a8`) shipped a file that FAILS `pnpm format:check` ⇒ the CI format gate did not block. An unrelated later PR (#872) then reformatted that file via its own `lint-staged`, so `main` went green again and nobody learned the gate had failed to fire. Verified blob-level under the repo's own config. **Detection must be per-merge-commit against that commit's own tree** — a "is main green today" check reports all-clear on this exact defect. Binding on RM-02/RM-55. Full chain in `TASKS.md` §1a. NOT quiet-patched, by Mos's ruling: patching the symptom destroys the signal.
### **D-4 / P-LIFECYCLE + hygiene — a dispatched agent silently IGNORED an in-message context reset.**
planner-sol was at 64.3%/372k; the brief asked it to reset first; it began work on dirty context anyway. Only an out-of-band `/new` driven by the orchestrator guaranteed clean state. Confirms the postmortem thesis: **instructions are not enforcement.** Reset must be a mechanical pre-dispatch step, not a request.
+93
View File
@@ -0,0 +1,93 @@
# mos-remediation — LIVE BOARD (keep < 8 KB)
**Phase:** EXECUTING — P0 open. RM-01 MERGED; RM-02 (keystone gate registry) is next.
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
## Head
- Mission charter + 15 decisions + 4-build plan: PERSISTED (`docs/remediation/MISSION.md`).
- HOLD lifted for this workstream (Jason 2026-07-31). Nothing implemented yet — planning first.
- Orchestrator seat `mos-remediation` is LIVE and owns the mission. Residency attestation: PASS.
- **TASK-0 DONE** — checkout repaired, all three gates green HONESTLY (no `--no-verify`), branch pushed.
- **TASK-1 DONE** — both planners delivered independently on clean context; reconciled into `TASKS.md`
(58 tasks across P0P5, 7 convergences, 7 adjudicated disagreements, 3 escalated decisions).
- **NEXT ACTION IS NOT MINE:** DECISION-1/2/3 (`TASKS.md` §5) must be ruled before P0 dispatch.
RM-01 is dispatchable immediately regardless — it depends on nothing and blocks everything.
## In-flight
| Task | Owner | State |
| ----------------------------------- | --------------- | ------------------------------------------------------------------------- |
| RM-01 reproducible checkout | — | **MERGED** `f58b3699` (PR #1027) — rev-974 APPROVE + CI #2172 8/8 green |
| RM-02 gate registry ★keystone | unassigned | **READY** — depends only on RM-01; not held by RM-03 |
| RM-03 queue guard (3 defects) | — | HOLD — #1023 SUPERSEDED-PENDING-JASON |
| RM-59 close D-19 residual risk | — | BLOCKED by RM-12/RM-21/RM-25 (spine + executor) — tracked edge, not prose |
| `remediation/state` snapshot → main | mos-remediation | opening at this mission seam |
## Fleet seats
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — DELIVERED, idle
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — DELIVERED, idle
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
## Gate status
- Delivery gates active: author≠reviewer, diff-blind pre-registered checks, CI-green, merged-PR completion.
- Freeze: LIFTED for this workstream only.
- Git identity: `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
gate-16 protects; rev-974 reviews, mos-dt-0 never self-reviews). Dedicated identity TRACKED, Mos provisions.
- Capability check (D-11b): before dispatching seat X to provider Y, verify
`~/.config/mosaic/secrets/gitea-tokens/gitea-<Y>-<X>.token` exists. Token-file set = authoritative
capability registry. Mos owns provisioning; escalate missing pairs to him.
- Seat identity (D-11a): token identity AND `git config user.name`/`user.email` must BOTH be set and
agree. Exporting `MOSAIC_GIT_IDENTITY` alone does NOT fix commit authorship.
- Standing worker-brief doctrine (accreted, mandatory in every brief): don't weaken a RED test to make
it pass; if a check is unrunnable as written SAY SO, never silently substitute; `agent-send -f` never
`-m`; heavy artifacts off shared `/tmp`.
- Remote control: native `/remote-control` NOT wired in this runtime. Path is **Mos-relay**
(Jason ↔ mos-claude via Discord ↔ mos-remediation via agent-send). Not a blocker.
## Sequencing (from MISSION.md)
1. Spine + choke-point service (MACP wiring @ mosaic_orchestrator.py::run_single_task) + PG/Redis
**CONTESTED — see DECISION-1.** Both planners independently reject this wire-in point: that
controller is `"enabled": false` and references a dispatcher that does not exist here. Charter text
left UNCHANGED pending Mos/Jason ruling; do not treat it as settled.
2. Rotation daemon (finish Mission Control Plane, reuse packages/coord)
3. Comms service (envelope→service→PG/Redis→adapters)
4. Hygiene + conformance harness
Cross-cutting retirements: flat-file tracking, 3 MACP islands, silent MOSAIC BYPASS.
## Dogfood evidence — live failure classes, not hypotheticals
> Newest first. Oldest entries roll to `BOARD-LEDGER.md` via `board-roll.sh` when this file
> exceeds its 8 KB cap. Keystone detail is duplicated in `TASKS.md` §1a, so rolling loses nothing.
<!-- BOARD-ROLL:START -->
### **D-8 / P-CONFORMANCE-001 — a PRE-REGISTERED acceptance check that was not runnable as written.**
PR #1025 AC2's fixture `mkdir -p apps/*/venv/lib` creates a literal `apps/*/venv/lib` dir when the glob is unmatched — it did not test what it claimed. rev-974 ran it exactly as written, caught it, re-ran the intended assertion at an explicit path, and **disclosed** rather than silently substituting a working fixture and reporting PASS. **Pre-registration protects a check from being retrofitted to the implementation; it does not make the check correct.** An unverified gate appeared inside the mechanism built to catch unverified gates. Hard requirement on RM-02: the registry must self-verify that every registered case runs AND can fail — presence is not evidence.
### **D-7 / P-FLEET-001 — stale-GC-on-disk: shared 30G /tmp hit 100% ENOSPC, degrading two seats.**
~5.2G was session scratch dead 8-9 days (this session's own footprint: 88K). Same missing capability as orphaned-tmux-session GC, applied to disk — not a quota or discipline problem. Resolved manually by Mos (lead coordinator) after independent verification; `/tmp` now 79%. **The gap IS the finding:** the authority to reap exists, the deterministic reaper does not. Folded into RM-50 with explicit requirements (mechanical liveness, age threshold, dry-run, audit event per reap — never a heuristic sweep). Refusing to unilaterally delete another session's scratch was correct doctrine; the fix is a reaper, not braver agents.
### **D-6 / P-QUEUE-001 — the mandated queue guard returned PASS on an UNKNOWN state, live, today.**
Running the required `ci-queue-wait.sh --purpose push` before pushing produced `state=unknown ... exit 0` — the exact defect at `ci-queue-wait.sh:282-288` that PR #1023 is parked on. It also evaluated `branch=main` rather than the branch being pushed. The mission's own required pre-push gate passed me on an indeterminate result. Third independent live instance of the class.
<!-- BOARD-ROLL:END -->
## Decisions log
- 2026-07-31 — Mission set up by Mos post-postmortem (15/15 decided). Dogfood posture active.
- 2026-07-31 — Mos: stale `.mosaic/orchestrator/mission.json` is RESIDUE of the disabled Python
orchestrator rail that this plan RETIRES. Do NOT invest in it; do NOT build on that rail. The 0/0
milestone banner is cosmetic. (Supersedes any plan to repair it.)
- 2026-07-31 — Mos: planners must be dispatched with GUARANTEED clean context, not requested-clean.
Prior default-socket planner sessions predate this mission; dirty context is the indicted hygiene.
- 2026-07-31 — mos-remediation: worker briefs forbid all git ops and restrict each worker to a single
named output file, so two planners can share one checkout without a branch race (M2-era incident doctrine).
File diff suppressed because it is too large Load Diff
+424
View File
@@ -0,0 +1,424 @@
# Adversarial Decomposition — Pragmatic / Shortest-Path Side
**Planner:** `planner-sol`
**Bias:** make one real fleet task pass through one enforced path as early as possible; reuse before building.
**Scope source:** `MISSION.md`, `MACP-WIRING-SCOUT.md`, `BOARD.md`, existing `@mosaicstack/macp`, `packages/coord`, PG/Valkey, Tess durable inbox/outbox, and the Mission Control PRD.
## Executive position
The first useful milestone is **not** “complete Builds 1 and 2.” It is this narrow vertical slice:
> A DB-backed mission task is atomically claimed by `packages/coord`, executed by one Node `@mosaicstack/macp` TaskExecutor, gated, and terminally recorded with identity-bound events and a tri-state mutation result. No `docs/TASKS.md`, `mission.json`, `tasks.json`, Python gate loop, or NDJSON ledger participates.
That slice is **SOL-03 → SOL-04 → SOL-05 → SOL-06 → SOL-07 → SOL-08**, estimated at **72K tokens**, mostly Codex. PG polling is acceptable for this first proof. Redis acceleration follows only after correctness is observable. This is the shortest path that is both dogfoodable and not throwaway work.
### Cost posture
- **25 PR tasks, ~294K tokens total:** ~164K Codex, ~130K Sonnet, **0K Opus**.
- First live choke-point dogfood: ~72K on the hard path; SOL-01 and SOL-02 can run beside it.
- Opus is not justified for planned implementation. Escalate only if an independent security review finds an unresolved architecture-level authority flaw.
- Every row is one PR. Estimates include implementation, focused tests, docs affected by that PR, and one remediation pass—not orchestration/reviewer overhead.
## Gates and critical path
| gate | opens when | proof required before downstream work |
| ------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------- |
| **G0 — trustworthy launch gates** | SOL-01, SOL-02 | non-root checkout works; queue status cannot become false-green |
| **G1 — first dogfood / minimum viable cut** | SOL-08 | one live fleet task completes DB → MACP executor → gates → DB with no flat-file state |
| **G2 — Builds 1+2 closed** | SOL-09..SOL-12 | all producers use the executor; duplicate islands retired; Redis loss is recoverable from PG |
| **G3 — rotation real** | SOL-13..SOL-16 | stale generation cannot mutate; fresh session resumes typed state; Pi-brick recovery works without broker |
| **G4 — sole-path comms real** | SOL-17..SOL-22 | roster identity is stable; bounced/stale messages converge through PG/Redis and adapters |
| **G5 — mission proof** | SOL-23..SOL-25 | workflow sweep cannot capture unknown files; fault bank passes, including 100 rotations |
**Critical path:** `03 → 04 → 05 → 06 → 08 → 10 → 12 → 13 → 14 → 15 → 16 → 17 → 18 → 19 → 20 → 21 → 22 → 24 → 25`.
## Ordered task list
### SOL-01 — Repair activation coherence and non-root checkout hygiene
- **build:** 5 (hygiene; pulled forward)
- **depends_on:**
- **acceptance criteria (diff-blind testable):**
1. A clean non-root checkout can run dependency/bootstrap preparation without accessing `/root`.
2. A root CI checkout still uses an isolated writable pnpm store.
3. Activation installs CLI, hooks, broker/runtime assets, and version manifest transactionally: induced failure leaves the prior complete generation active.
4. Launch with a deliberately skewed component version is rejected with the exact repair command; diagnostics remain usable.
5. No test uses `--no-verify` or suppresses hooks.
- **dogfood seed:** BOARD D-1 root-pinned `.npmrc` and D-2 root-owned Husky path.
- **est. tokens:** 6K
- **suggested runtime tier:** codex
### SOL-02 — Make queue guard fail-safe with exit-asserting tests
- **build:** 1
- **depends_on:**
- **acceptance criteria (diff-blind testable):**
1. Fixture responses `pending`, `success`, `failure`, no-status, malformed JSON, provider error, and unknown status produce explicitly asserted process exits.
2. Only terminal success/no-active-queue returns 0; unknown, malformed, and transport failure return non-zero with actionable output.
3. A payload larger than 150 KiB is consumed without argv expansion or truncation.
4. At least one mutant changes unknown→success and is killed by the test suite.
- **dogfood seed:** inert gate-6 and recursive #1019 failure (unknown→exit 0; ARG_MAX).
- **est. tokens:** 8K
- **suggested runtime tier:** codex
### SOL-03 — Complete the canonical MACP contract, not another protocol
- **build:** 1
- **depends_on:**
- **acceptance criteria (diff-blind testable):**
1. `@mosaicstack/macp` validates typed Task, TaskResult, lifecycle Event, state Claim, and `verified | written-unverified | failed` mutation outcome records.
2. Claims require source, confidence, issued-at, TTL/expiry, refresh instruction, and HMAC integrity; tamper/expiry returns a typed refusal, never partial data.
3. Lifecycle events include launch, mission generation, checkpoint, rotation, recovery, inbox receipt, and terminal disposition while preserving existing task events.
4. `MOSAIC_AGENT_NAME` is required for mutating execution and appears in credential/actor binding; missing identity fails closed.
5. Target metadata requires repository identity, task/record ID, and head or generation where applicable.
- **dogfood seed:** rev-974 identity drift plus the three observed write outcomes.
- **est. tokens:** 8K
- **suggested runtime tier:** codex
### SOL-04 — Add the narrow PG orchestration spine schema
- **build:** 2
- **depends_on:** SOL-03
- **acceptance criteria (diff-blind testable):**
1. Migration up creates mission, task, dependency, task-claim, MACP event, typed state-claim, session-generation, and dispatch-outbox records with tenant/mission keys and uniqueness constraints.
2. The database rejects a task without a mission, a dependency outside its mission, duplicate idempotency keys, and terminal→running regression.
3. Event and claim records reference canonical mission/task/generation identities; claims store integrity metadata.
4. Migration rollback on an empty test DB succeeds; rerunning migration is safe.
5. No comms-specific “universal message” schema is invented here; Build 4 reuses/extends existing interaction inbox/outbox tables.
- **dogfood seed:** mission convention existed but a lane could act with no mechanically valid mission/task.
- **est. tokens:** 12K
- **suggested runtime tier:** codex
### SOL-05 — Implement atomic PG task claims, transitions, ledger, and outbox
- **build:** 2
- **depends_on:** SOL-04
- **acceptance criteria (diff-blind testable):**
1. Two concurrent claimers for one runnable task yield exactly one lease owner.
2. Dependencies are evaluated transactionally; an unmet dependency can never be claimed.
3. Claim, state transition, MACP event, and dispatch-outbox append commit atomically or all roll back.
4. Expired leases are reclaimable with a higher fencing generation; stale owners cannot complete or mutate.
5. Querying mission status is derived solely from PG and returns the next runnable task deterministically.
- **dogfood seed:** model-maintained live board and stale claims surviving session changes.
- **est. tokens:** 12K
- **suggested runtime tier:** codex
### SOL-06 — Build the one production Node MACP TaskExecutor
- **build:** 1
- **depends_on:** SOL-03, SOL-05
- **acceptance criteria (diff-blind testable):**
1. A public Node executor accepts only a claimed canonical MACP Task, resolves credentials/identity, runs the worker, runs structured gates, and persists terminal result/events through SOL-05.
2. Worker exit 0 plus a failed gate cannot produce `completed`; worker failure cannot skip terminal ledger emission.
3. Claude, Codex, and Pi fixture backends emit the same runtime-neutral lifecycle sequence.
4. Every mutation returns one mandatory tri-state outcome; callers cannot compile while discarding it.
5. Crash after worker success but before terminal commit leaves a recoverable fenced claim and no false completion.
- **dogfood seed:** stranded MACP, gate-6 inert completion, and `written-unverified` being treated as success.
- **est. tokens:** 16K
- **suggested runtime tier:** sonnet
### SOL-07 — Provide one-shot flat-file import and cutover readiness audit
- **build:** 2
- **depends_on:** SOL-05
- **acceptance criteria (diff-blind testable):**
1. A dry-run parses existing mission/TASKS artifacts, reports unsupported/ambiguous rows, and performs zero writes.
2. Apply is idempotent and records source digests; repeated apply creates no duplicates.
3. Unknown status, dangling dependency, duplicate task ID, and malformed table block import with row-level diagnostics.
4. Readiness reports “cutover-ready” only when imported PG projections exactly match source counts/dependencies/statuses.
5. This command is migration-only; it exposes no dual-write or ongoing sync mode.
- **dogfood seed:** current remediation board/TASKS state needs a clean DB landing without silently losing tasks.
- **est. tokens:** 8K
- **suggested runtime tier:** codex
### SOL-08 — Hard-cut `packages/coord` to PG and dogfood one live task
- **build:** 1
- **depends_on:** SOL-06, SOL-07
- **acceptance criteria (diff-blind testable):**
1. `mosaic coord run/status/continue` reads and mutates PG only; absent/unmigrated DB state fails with the SOL-07 repair path.
2. No fallback reads/writes `docs/TASKS.md`, mission JSON, task JSON, state JSON, results JSON, or events NDJSON.
3. A live canary task assigned to a fleet seat travels PG claim → TaskExecutor → worker → gate → terminal PG result/event and closes only after gate success.
4. Killing the coordinator after claim and restarting it neither duplicates execution nor allows the stale lease to close the task.
5. Evidence query shows actor seat, mission/task, target metadata, gate results, and tri-state outcome.
- **dogfood seed:** this remediation mission itself; reproduce a gate-6-style non-null task and identity-bound write.
- **est. tokens:** 16K
- **suggested runtime tier:** sonnet
> **G1 FIRST-DOGFOOD:** stop and validate here before broadening. If SOL-08 cannot carry a real task, do not build Redis, rotation, comms, or UI.
### SOL-09 — Route Forge and OpenClaw/MACP producers through TaskExecutor
- **build:** 1
- **depends_on:** SOL-08
- **acceptance criteria (diff-blind testable):**
1. Forge and the OpenClaw MACP runtime submit the canonical Task type to SOL-06; neither executes a worker or gate itself.
2. Their success callbacks are derived from canonical terminal results, not local/stub completion.
3. A failed canonical gate is observed identically from Coord, Forge, and OpenClaw fixtures.
4. Repository search plus an executable import boundary test finds no production-local redefinition of Task/TaskResult/GateResult on these paths.
- **dogfood seed:** Forges immediate empty-gate completion and the plugins redefined MACP-shaped result.
- **est. tokens:** 10K
- **suggested runtime tier:** codex
### SOL-10 — Retire flat-file orchestration and the disabled duplicate rail
- **build:** 1
- **depends_on:** SOL-09
- **acceptance criteria (diff-blind testable):**
1. The Python controller execution/gate/event path, `tasks_md_sync`, plugin-local protocol types, orphaned context loader, and production flat-file orchestration writers/readers are absent from shipped assets.
2. Framework guides/templates/startup context point to DB mission commands, not `docs/TASKS.md` as orchestration SoR.
3. A regression scan fails CI if production code reintroduces `events.ndjson`, `tasks.json`, `mission.json`, or `docs/TASKS.md` orchestration mutation.
4. jarvis-brain PDA flat files and unrelated project docs remain untouched.
5. Upgrade removes/quarantines obsolete generated rail files without deleting user source/docs.
- **dogfood seed:** three parallel islands and stale `.mosaic/orchestrator/mission.json` 0/0 residue.
- **est. tokens:** 14K
- **suggested runtime tier:** sonnet
### SOL-11 — Add Redis hot dispatch as a derived outbox consumer
- **build:** 2
- **depends_on:** SOL-08
- **acceptance criteria (diff-blind testable):**
1. Task creation commits mission/task/outbox in PG before any Redis enqueue.
2. Induced Redis failure leaves the task durable and pending; a sweeper later enqueues it exactly once logically.
3. Deleting the Redis queue and rebuilding from PG restores all non-terminal dispatches without reviving terminal tasks.
4. Duplicate delivery is neutralized by PG claim fencing/idempotency.
5. Existing `packages/queue` adapter/config is reused; no second broker API is introduced.
- **dogfood seed:** inert/unknown queue transport and broker outage during task dispatch.
- **est. tokens:** 12K
- **suggested runtime tier:** codex
### SOL-12 — Lock Builds 1+2 with black-box failure cases
- **build:** 2
- **depends_on:** SOL-02, SOL-10, SOL-11
- **acceptance criteria (diff-blind testable):**
1. A black-box suite proves: unknown queue status blocks; malformed task blocks; gate failure blocks completion; dropped identity blocks mutation; HMAC corruption forces refresh; Redis loss recovers from PG; stale lease cannot close.
2. The suite invokes shipped CLI/service boundaries, not internal mocks.
3. Every asserted failure checks process/result status and durable terminal/non-terminal state.
4. The same canary task succeeds under Claude, Codex, and Pi adapters or a documented unavailable-runtime fixture fails explicitly.
- **dogfood seed:** gate-6/#1019, identity drift, and built-but-unwired MACP.
- **est. tokens:** 8K
- **suggested runtime tier:** sonnet
### SOL-13 — Bind session authority to contract hash and generation
- **build:** 3
- **depends_on:** SOL-12
- **acceptance criteria (diff-blind testable):**
1. Launch computes a stable hash over the effective Constitution/AGENTS/runtime/skills set and stores it with session generation.
2. Policy change or compaction detection marks the generation stale before any subsequent mutation.
3. A stale/mismatched generation can read diagnostics but cannot claim, write task state, acknowledge comms, merge, or close.
4. Re-attestation creates a new generation; old credentials/leases remain fenced.
5. Hash input order/path normalization is deterministic across two clean launches.
- **dogfood seed:** compacted orchestrator losing directives and D-4 ignoring an in-message reset.
- **est. tokens:** 12K
- **suggested runtime tier:** sonnet
### SOL-14 — Persist compact typed rotation checkpoints using Coord primitives
- **build:** 3
- **depends_on:** SOL-13
- **acceptance criteria (diff-blind testable):**
1. Checkpoint contains mission/task, completed/blocked state, next three actions, constraints, claims, contract hash/generation, and cursors—never transcript text.
2. Checkpoint is HMAC-verified before rehydration; corrupt/expired/missing required claims refuse resume and request deterministic refresh.
3. Writing checkpoint and rotation-intent event is atomic in PG.
4. Existing Coord continuation capsule semantics are reused; no competing handoff schema/file is created.
- **dogfood seed:** manual MOS-ORCHESTRATION-BOARD checkpoint and incomplete-rehydration risk.
- **est. tokens:** 12K
- **suggested runtime tier:** codex
### SOL-15 — Finish the deterministic coordinator rotation daemon
- **build:** 3
- **depends_on:** SOL-14
- **acceptance criteria (diff-blind testable):**
1. Configured token threshold triggers checkpoint → revoke old authority → terminate → launch fresh → verify rehydration in that order.
2. Compaction-detected is a backstop that forces the same rotation path; it never requests recursive compaction.
3. A launch failure leaves the mission recoverable and visibly paused, not assigned to two active generations.
4. Ephemeral seats die/respawn without mission checkpoint; persistent/orchestrator seats rotate.
5. The implementation extends `packages/coord`; untracked `apps/coordinator` residue is not revived.
- **dogfood seed:** planner-sol dirty-context dispatch and the old coordinators log-only `_check_context()` behavior.
- **est. tokens:** 16K
- **suggested runtime tier:** sonnet
### SOL-16 — Add broker-independent recovery and remove silent MOSAIC BYPASS
- **build:** 3
- **depends_on:** SOL-15
- **acceptance criteria (diff-blind testable):**
1. With Redis/broker unavailable, a diagnostic/bootstrap command can inspect PG mission state, repair broker configuration, and resume without traversing the broker gate.
2. Normal recovery remains broker-gated and is labeled as such.
3. Break-glass requires explicit scope and expiry, emits a durable event, displays a loud banner, and auto-expires; permanent/silent bypass text or behavior is absent.
4. The Pi-brick fixture recovers the broker, then returns to normal gated operation without editing source/config by hand.
5. Orchestrator guidance removes “/compact and continue” only after the rotation command is available; ephemeral guidance remains explicit.
- **dogfood seed:** Pi brick and silent `MOSAIC BYPASS 2026-07-22`.
- **est. tokens:** 12K
- **suggested runtime tier:** sonnet
### SOL-17 — Converge each host on one roster-owned lifecycle domain
- **build:** 5 (hygiene; hard prerequisite for addressed comms)
- **depends_on:** SOL-16
- **acceptance criteria (diff-blind testable):**
1. Reconcile establishes exactly one roster-declared tmux socket/lifecycle domain per host.
2. Unknown sessions are reported and quarantined; they are never killed without positive unmanaged classification.
3. Max-age/max-context stale sessions invoke SOL-15 rotation for persistent seats or reap for ephemerals.
4. Seat identity survives respawn and equals the roster/MOSAIC_AGENT_NAME binding.
5. A fixture matching the current four unmanaged remediation seats converges them or produces explicit quarantine actions.
- **dogfood seed:** scout-bounce and BOARD D-3 seats split between default and `mosaic-fleet` sockets.
- **est. tokens:** 14K
- **suggested runtime tier:** codex
### SOL-18 — Publish authenticated `comms/v1` envelope and compatibility rules
- **build:** 4
- **depends_on:** SOL-13, SOL-17
- **acceptance criteria (diff-blind testable):**
1. Envelope validates protocol version, message/idempotency ID, sender/recipient seat identity, class, ordering/coalesce key, creation/expiry, correlation, payload digest, and authentication.
2. Current and immediately previous supported protocol versions are accepted; unsupported versions are rejected loudly with supported range.
3. Framework/runtime version is diagnostic metadata and never the compatibility key.
4. Forged sender, changed recipient/payload, expired envelope, and replay with conflicting content fail closed.
- **dogfood seed:** wrong-socket bare tmux message with no authoritative sender/recipient receipt.
- **est. tokens:** 8K
- **suggested runtime tier:** codex
### SOL-19 — Build the logical PG-first comms service with tmux adapter
- **build:** 4
- **depends_on:** SOL-18
- **acceptance criteria (diff-blind testable):**
1. Sending commits envelope/payload and PENDING state in PG before adapter delivery.
2. State machine enforces PENDING → RECEIVED → CONSUMED or DEAD-LETTER; illegal regressions are rejected.
3. Recipient-filtered claims and append/coalesce policy are deterministic by message class.
4. tmux is a dumb adapter: delivery failure changes no PG authority state and is retryable.
5. Existing Tess durable repository/state-machine patterns are extended or generalized; no new deployable microservice or second inbox framework appears.
- **dogfood seed:** MACP scout bounce that was discovered only by manual liveness check.
- **est. tokens:** 16K
- **suggested runtime tier:** sonnet
### SOL-20 — Make `agent-send` use the sole path and prove stale-message handling
- **build:** 4
- **depends_on:** SOL-19
- **acceptance criteria (diff-blind testable):**
1. Normal `agent-send` creates a comms/v1 record and observes RECEIVED/CONSUMED; it cannot directly invoke tmux.
2. A wrong/missing socket leaves PENDING with retry diagnostics, then reaches RECEIVED after roster repair without resending.
3. A stale coalescible message arriving after a newer terminal message is marked superseded/consumed and is not surfaced as live work.
4. Duplicate identical send is idempotent; same ID with changed content is rejected.
5. Inbox receipt/terminal disposition emits canonical MACP lifecycle events.
- **dogfood seed:** scout-bounce and #1018 stale-consumed message arriving after merge.
- **est. tokens:** 12K
- **suggested runtime tier:** codex
### SOL-21 — Add Redis Streams hot delivery and PG reconciliation
- **build:** 4
- **depends_on:** SOL-11, SOL-20
- **acceptance criteria (diff-blind testable):**
1. PG commit precedes XADD; induced XADD failure is repaired by sweeper.
2. Consumer uses a PEL; ack sequence is PG CONSUMED commit before XACK.
3. Redis flush/restart rebuilds pending delivery from PG without duplicating consumed messages.
4. Pending, abandoned, and dead-letter transitions are observable with bounded retry/backoff.
5. Existing Redis/queue connection/configuration is reused.
- **dogfood seed:** delivery bounce plus broker loss between durable write and hot enqueue.
- **est. tokens:** 12K
- **suggested runtime tier:** codex
### SOL-22 — Prove adapter pluggability with the existing Matrix connector
- **build:** 4
- **depends_on:** SOL-21
- **acceptance criteria (diff-blind testable):**
1. Existing Matrix connector consumes/produces comms/v1 through SOL-19 without owning authority state.
2. The same envelope can fail tmux and later deliver through Matrix while producing one logical message lifecycle.
3. Matrix retry/reconnect cannot regress PG state or duplicate CONSUMED work.
4. Removing Matrix availability leaves PG/Redis/tmux behavior intact.
- **dogfood seed:** cross-socket scout notification bounce; alternate reach must not become alternate authority.
- **est. tokens:** 8K
- **suggested runtime tier:** codex
### SOL-23 — Constrain auto-sync and agent writes by allowlist and lease
- **build:** 5
- **depends_on:** SOL-10
- **acceptance criteria (diff-blind testable):**
1. Auto-sync stages only an explicit allowlist; an unknown modified/untracked docs/source file remains unstaged and is reported.
2. Agent source/docs writes require the correct worktree/lease; two seats cannot acquire the same mutable target concurrently.
3. Generated files are positively identified, not inferred by denylist.
4. The measured annotation/index mid-write fixture cannot be swept into an unrelated commit.
5. DB orchestration state is absent from repository staging concerns.
- **dogfood seed:** auto-sync sweep commit `517bd5c26` capturing agent-authored docs mid-write.
- **est. tokens:** 8K
- **suggested runtime tier:** codex
### SOL-24 — Build the real-artifact lifecycle conformance harness
- **build:** 5
- **depends_on:** SOL-16, SOL-17, SOL-22, SOL-23
- **acceptance criteria (diff-blind testable):**
1. Harness launches shipped CLI/runtime artifacts and fault-injects compaction, broker outage, delivery bounce, identity drop, stale contract hash, queue unknown/malformed, Redis loss, and auto-sync collision.
2. One deterministic test executes 100 sequential rotations with no lost/duplicated task, claim, receipt, or terminal disposition.
3. Tests assert DB state/event order and process exits, not log substrings alone.
4. Harness runs against isolated PG/Redis namespaces and cleans only resources it created.
5. Every banked dogfood seed has a named case and evidence output suitable for CI/release attachment.
- **dogfood seed:** the complete failure bank: Pi brick, scout-bounce, gate-6/#1019, identity drift, auto-sync, #1018 stale-consumed, D-4 dirty context.
- **est. tokens:** 18K
- **suggested runtime tier:** sonnet
### SOL-25 — Complete operator cutover docs and activation proof
- **build:** 5
- **depends_on:** SOL-01, SOL-02, SOL-10, SOL-16, SOL-22, SOL-24
- **acceptance criteria (diff-blind testable):**
1. Operator docs give exact DB import/cutover, rollback-before-cutover, recovery, break-glass expiry, rotation, comms, quarantine, and conformance commands.
2. Link/command checks find no orchestrator instruction to mutate flat-file mission/tasks, use silent bypass, direct-tmux normal comms, or “compact and continue” a persistent seat.
3. A clean non-root install activates one coherent version and runs the conformance smoke subset.
4. Release evidence maps all 15 decisions and every live seed to a passing check or an explicit deferred item below.
- **dogfood seed:** activation skew plus the tendency to leave built fixes unwired or undocumented.
- **est. tokens:** 6K
- **suggested runtime tier:** codex
## Explicit DEFER list (10)
These are not rejected; they are **past first dogfood** and should not delay G1/G2. Each is gold-plating unless a live failure makes it necessary.
1. **DEFER — Mission dashboard/TUI views.** CLI/DB queries are enough to operate and prove the spine.
2. **DEFER — PRD-to-board automatic decomposition.** This is LLM/judgment-heavy and unrelated to enforcing already-decided tasks.
3. **DEFER — General heuristic churn scoring.** Implement token threshold + compaction sensor first; repeated-tool-loop inference can follow measured need.
4. **DEFER — Discord comms adapter.** Existing plugin reach remains; migrate only after tmux+Matrix prove the service contract.
5. **DEFER — Slack comms adapter.** No current dogfood dependency.
6. **DEFER — Telegram comms adapter.** No current dogfood dependency.
7. **DEFER — Public MCP comms surface.** `agent-send` and service API are sufficient for the mission proof.
8. **DEFER — Protocol-v2 features/general negotiation framework.** Ship v1 with a bounded current/previous acceptance window; do not predict v2.
9. **DEFER — Multi-region/HA PG or Redis.** Existing in-stack PG+Redis and rebuildability satisfy current failure classes.
10. **DEFER — Event analytics/search UI and long-term warehouse.** Indexed PG evidence plus CLI queries is enough for audit/conformance.
## Suspect abstractions register
| proposed thing | verdict |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Canonical Node `TaskExecutor` | **JUSTIFIED:** explicitly required single choke point; wraps existing MACP functions rather than replacing them. |
| PG repository methods | **JUSTIFIED but narrow:** ordinary adapters around existing Drizzle/DB patterns, not a new “state platform.” |
| Rotation daemon | **JUSTIFIED:** finishes `packages/coord`; do not revive `apps/coordinator` or create another service. |
| Comms service | **JUSTIFIED only as a logical in-process boundary:** reuse Tess durable inbox/outbox and existing connectors; no new deployable microservice this cycle. |
| Universal queue/broker abstraction | **SUSPECT / DO NOT BUILD:** reuse `packages/queue`, PG outbox, and Redis Streams/BullMQ configuration already present. |
| Universal envelope/state framework | **SUSPECT / DO NOT BUILD:** MACP Task/Claim/Event and comms/v1 have different bounded purposes. |
| Generic compatibility-negotiation engine | **SUSPECT / DEFER:** a small supported-version check meets v1 needs. |
## Dissent (7)
1. **Do not wire new production behavior into `mosaic_orchestrator.py::run_single_task`.** The scout correctly identified the duplicated block, but BOARDs later ruling says the disabled Python rail is residue and must be retired. Building a Node bridge only to delete it is throwaway. Put the Node TaskExecutor in `@mosaicstack/macp`, route the live Coord path to it at SOL-08, migrate remaining producers at SOL-09, then delete the Python block at SOL-10.
2. **Redis is not on the first-dogfood critical path.** PG claim/polling is sufficient for one real task and exposes correctness earlier. Add Redis only after G1; otherwise queue debugging obscures whether the choke point works.
3. **“One choke-point service” does not justify a new deployable service.** An exported executor plus Coord daemon is enough. A new Nest app, RPC protocol, deployment, auth layer, and health plane would be greenfield.
4. **The Mission Control PRDs file-first and board-regeneration assumptions are superseded.** Keep its mission/rotation semantics, but obey the accepted hard DB cutover; do not implement its file-first milestones or PRD-to-board generator now.
5. **Do not gate every interactive runtime launch as if it were a mission task.** Enforce every tracked task/data mutation at the executor/DB authority boundary. Ephemeral interactive shells may launch, but receive no task mutation authority unless attached to a valid claim.
6. **Do not implement broad “churn intelligence.”** Token threshold and compaction-detected are deterministic sensors. Repeated-loop semantic detection is expensive, noisy, and premature until telemetry demonstrates a gap.
7. **The 100-rotation test is a final conformance bar, not an early unit-test tax.** First prove one rotation, then fault cases, then 100 repetitions in SOL-24. Requiring 100 before G3 would delay feedback without changing the design.
## Orchestrator reconciliation notes
- Pre-register each tasks acceptance checks from this document before showing implementation diffs to its reviewer. Author and reviewer remain different seats.
- SOL-07 permits a one-time import, **not** an interim store: no shadow writes, dual reads, or sync daemon.
- G1 is the budget escape hatch. If the 72K hard-path slice does not work, stop and remediate instead of spending the remaining ~222K.
- Docs belong in each behavior-changing PR where required; SOL-25 is cross-link/cutover validation, not permission to postpone essential docs.
+49
View File
@@ -0,0 +1,49 @@
# mos-remediation — Orchestrator Kickstart / Compaction-Survival Resume
**You are `mos-remediation`, the project orchestrator for the Mosaic Stack remediation, launched in `/src/mosaic-stack`.**
This file is your fail-closed resume procedure. Read it on EVERY fresh/cleared session and on the FIRST turn
after any compaction. This mission's whole point is that manual compaction-survival is fragile — so follow this
mechanically until Build 3 (rotation) makes it automatic.
## On resume (do in order, before any orchestration action)
1. `cd /src/mosaic-stack`, then **`git fetch origin remediation/state`**.
**The live board is on the rolling branch `remediation/state`, NOT on `main`.** `main` carries only
periodic snapshots, so reading the board from `main` will silently give you a STALE tick. Read the
live files at `origin/remediation/state` (e.g. `git show origin/remediation/state:docs/remediation/BOARD.md`),
or check that branch out. Every tick is pushed there immediately, so its HEAD is always the newest state.
2. Read `docs/remediation/MISSION.md` — the charter (goal, 4 builds, 15 decisions, sequencing, directives).
3. Read `docs/remediation/BOARD.md` **at `origin/remediation/state`** — the LIVE state: current phase,
in-flight tasks, fleet seat assignments, gate status. Single source of in-flight truth (kept < 8 KB;
older entries roll to `BOARD-LEDGER.md` via `board-roll.sh`).
4. Read the discussion checkpoint for full rationale if needed:
`../jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md` (or the jarvis-brain repo path).
5. **Residency attestation (fail-closed):** restate from the reloaded files — (a) the goal in one line, (b) the
current build/phase, (c) the BOARD head (in-flight tasks + who owns them). If you cannot, HALT and re-read.
Do NOT act on memory alone; a compaction may have dropped context silently.
## 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.
- **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.
## After every significant event
Overwrite stale lines in `BOARD.md`, keep it < 8 KB, commit + push. The board IS your checkpoint until the
DB-backed rotation daemon (Build 3) exists. Persist typed state (phase, tasks, owners, gates) — never the transcript.
## Fleet
- Adversarial planners: `planner-opus` (robustness), `planner-sol` (pragmatic) — dispatch for task decomposition; reconcile their oppositional decomps.
- Coders/reviewers: dispatch per roster + delivery gates. Comms: `~/.config/mosaic/tools/tmux/agent-send.sh`
(`-L <socket> -s <dst> -S <yourhost>:<yourseat> --class <class>`); always pass `-S`.
- Lead coordinator: Mos (`mos-claude`). Escalate only on the Constitution's escalation triggers.
## Remote control
On first startup, activate remote control for this session (`/remote-control`) so Jason can reach/drive you while
away. If the command is unavailable in this runtime, report it to Mos and continue — it is not a blocker.
+98
View File
@@ -0,0 +1,98 @@
# MACP wiring investigation
**Scope:** `/src/mosaic-stack` inspected at HEAD `b79336a8c11e2a4646a47ff8d295a226e0c71404`; read-only. Existing dirty/untracked state was not touched.
## Verdict
**(c) STRANDED.** `packages/macp` is exported, unit-tested, and registered as a CLI command group, but no production dispatch/execution code invokes its credential resolver, gate runner, or event emitter.
A separate MACP-named OpenClaw/orchestrator rail exists, but it redefines task/result types and gate/event logic instead of importing `@mosaicstack/macp`; direct `mosaic yolo|claude|codex|opencode|pi` also bypasses it.
## 1. Production call sites vs tests
### Production references to `@mosaicstack/macp`
| Surface | Evidence | Actual use |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Unified CLI | `packages/mosaic/src/cli.ts:8,385` | Imports and registers `registerMacpCommand`; no task/gate/event execution. |
| Forge | `packages/forge/src/types.ts:1,17,68,79` | **Type-only** imports of `GateEntry` and `TaskResult`. Pipeline calls an injected abstract executor at `packages/forge/src/pipeline-runner.ts:189-190,299-300`, not MACP. |
| Mosaic package metadata | `packages/mosaic/package.json:36`; `packages/mosaic/src/runtime/update-checker.ts:172` | Dependency/update inventory only. |
| Agent | No match under production `packages/agent/src/**` | No MACP import/call. |
| Coord | No match under production `packages/coord/src/**`; dependency list is only `@mosaicstack/types` at `packages/coord/package.json:25-27` | No MACP import/call. |
| Plugins | No `@mosaicstack/macp` import under `plugins/**` | No package use; the MACP-named plugin is an independent implementation (below). |
**Repository-wide production call-site search result:** excluding `packages/macp/**`, tests, worktrees, and build output, there are **zero** calls to `runGate`, `runGates`, `emitEvent`, `appendEvent`, or `resolveCredentials`.
### `packages/macp` implementation is internally connected only
- Public exports: `packages/macp/src/index.ts:1-48` exports Task/GateEntry/MACPEvent/TaskResult, credential resolution, `runGate(s)`, risk-floor, and event emission.
- Gate runner calls its own event emitter: `packages/macp/src/gate-runner.ts:187-236`.
- Event persistence implementation appends NDJSON to a caller-supplied path: `packages/macp/src/event-emitter.ts:11-27`.
- There is **no exported programmatic `submit` implementation** in `packages/macp/src/index.ts:1-48`; only the CLI placeholder named `submit`.
### Test-only invocations
- Gate runner: `packages/macp/__tests__/gate-runner.test.ts:96-242` invokes `runGate/runGates`.
- Event ledger: `packages/macp/__tests__/event-emitter.test.ts:46-133` invokes `appendEvent/emitEvent` against temporary `events.ndjson` files.
- Credential resolver: `packages/macp/__tests__/credential-resolver.test.ts` exercises resolver behavior.
- CLI tests only verify command registration: `packages/macp/src/cli.spec.ts:37-73`; `packages/mosaic/src/cli-smoke.spec.ts:8` imports registration.
## 2. Gate on the live dispatch path
### Direct Mosaic runtime launch bypasses MACP
- Runtime commands dispatch directly to harness launch: `packages/mosaic/src/commands/launch.ts:730-801`.
- Claude/Pi go through the lease broker, then spawn the runtime: `packages/mosaic/src/commands/launch.ts:817-843`.
- Commander wiring sends `mosaic yolo <runtime>` and direct runtime commands to `launchRuntime`: `packages/mosaic/src/commands/launch.ts:1102-1157,1165-1167`.
- None of those ranges imports/calls `@mosaicstack/macp`, `runGates`, or `emitEvent`.
**Result:** a direct `mosaic yolo`, `mosaic claude/codex/opencode/pi`, or underlying exec does not create a typed MACP Task, run the package gate-runner, or append a package MACPEvent.
### Coord bypasses MACP
- Coord reads/updates `docs/TASKS.md`: `packages/coord/src/runner.ts:6,306-386`; parser/writer is `packages/coord/src/tasks-file.ts:326-377`.
- Coord launches a child process directly: `packages/coord/src/runner.ts:397-427`.
- Mission state is its own `.mosaic/orchestrator/mission.json`/`next-task.json`: `packages/coord/src/mission.ts:8-12`; `packages/coord/src/runner.ts:15-16,355-384`.
**Result:** Coord task execution has no MACP Task validation, package gate runner, or event append.
### Forge bypasses MACP execution
- Forge defines its own `ForgeTask` and abstract `TaskExecutor`: `packages/forge/src/types.ts:48-80`.
- The production CLI injects a **stub executor** that immediately reports completion with empty gates: `packages/forge/src/cli.ts:13-31,167,185`.
**Result:** even `mosaic forge run` does not execute MACP gates or persist MACP events.
### Separate MACP-named rail is not `packages/macp`
- OpenClaw plugin registers an ACP backend named `macp`: `plugins/macp/src/index.ts:1-18,72-102`.
- It locally redefines `OrchestratorTask`, `TaskResult`, and gate-result shapes instead of importing package types: `plugins/macp/src/macp-runtime.ts:43-77`.
- It appends directly to `.mosaic/orchestrator/tasks.json`, triggers an external controller, and polls `results/<task>.json`: `plugins/macp/src/macp-runtime.ts:290-329,437-483`.
- The controller independently implements `append_event`, `emit_event`, shell execution, gate execution, and results: `packages/mosaic/framework/tools/orchestrator-matrix/controller/mosaic_orchestrator.py:29-91,126-276`.
- Its gate loop runs raw string gates after worker success: `mosaic_orchestrator.py:213-235`; it does not support the package's structured `GateEntry`/AI-review behavior.
- Current checkout disables this controller: `.mosaic/orchestrator/config.json:2` (`"enabled": false`).
- Plugin references `tools/macp/dispatcher/pi_runner.ts` at `plugins/macp/src/macp-runtime.ts:85-91`, but `tools/macp/` does not exist in this checkout.
**Result:** there is a parallel, optionally enabled MACP-shaped rail, not package integration. It cannot make `packages/macp` the enforced path.
## 3. Event ledger status
- Package persistence exists only as a library primitive: `packages/macp/src/event-emitter.ts:11-27` appends JSON lines to an arbitrary `eventsPath`.
- Package event emission is reached only from package `runGates`: `packages/macp/src/gate-runner.ts:204-236`.
- No production caller invokes package `runGates/emitEvent/appendEvent`; therefore no runtime destination path is configured for the package ledger.
- Test-only ledgers use temp paths: `packages/macp/__tests__/event-emitter.test.ts:35-133`; gate tests use temp `events.ndjson`: `packages/macp/__tests__/gate-runner.test.ts:171-242`.
- The separate Python controller writes `.mosaic/orchestrator/events.ndjson`: `mosaic_orchestrator.py:129-133,159-161,219-235`; the Mosaic Framework plugin only **reads** that file for context at `plugins/mosaic-framework/src/index.ts:279-316,430-438`.
- In this checkout, `.mosaic/orchestrator/events.ndjson` is absent and the controller is disabled (`.mosaic/orchestrator/config.json:2`).
**Conclusion:** `MACPEvent` from `packages/macp` is defined/tested but not emitted or persisted by live production call sites. The similarly shaped Python ledger is a duplicate island.
## 4. Coord link
- `packages/coord` has no `@mosaicstack/macp` dependency/import: `packages/coord/package.json:25-27`; no matches in `packages/coord/src/**`.
- Coord's task model is Markdown `docs/TASKS.md` plus mission/session JSON: `packages/coord/src/tasks-file.ts:1-10,257-377`; `packages/coord/src/mission.ts:8-12`; `packages/coord/src/runner.ts:306-427`.
- It does not consume `.mosaic/orchestrator/events.ndjson`, MACP Task, MACPEvent, GateEntry, or TaskResult.
**Conclusion:** Coord and `packages/macp` are disconnected islands.
## Shortest wiring gap
**Single integration point:** replace the duplicated execution/gate/event block in `mosaic_orchestrator.py::run_single_task` (`:126-276`) with one production Node `TaskExecutor` backed by `@mosaicstack/macp` (typed Task validation + `resolveCredentials` + `runGates` + `emitEvent`), and make Coord/Forge/OpenClaw submit through that executor. This queue/controller choke point is where `yolo|acp|exec` worker outcomes can be gated and journaled before completion is recorded.
+166
View File
@@ -0,0 +1,166 @@
# Mosaic Stack Remediation — Mission Charter
**Owner:** project orchestrator `mos-remediation` (Claude, launched in `/src/mosaic-stack`).
**Origin:** 2026-07-16..31 fleet lifecycle postmortem. **Status:** EXECUTING (planning complete; RM-01 in flight).
**HOLD lifted** for this workstream by Jason, 2026-07-31 — "begin full mosaic fleet operation on this."
## Goal
Convert the 15 accepted postmortem remediation proposals into a working, **dogfooded** implementation.
**North star:** anything with a deterministic right answer moves OUT of the LLM into a deterministic
gate/program; the LLM handles only genuine judgment.
### First-class principle — observe the property, not the exit code
> **No write is done until the requested PROPERTY is observed. A success exit code is not evidence.**
>
> **Success output is designed to be believed.** That is the whole reason the inert-gate class exists
> and why P-WRAPPER-001's tri-state (`verified` / `written-unverified` / `failed`) is not optional. The
> failure is not carelessness — a green is _engineered_ to be trusted, so trusting it is the default
> behaviour of a competent operator, not a lapse.
>
> Promoted to the charter by Mos (2026-07-31) after the orchestrator committed this exact error: a
> `--draft` flag was silently dropped by a wrapper fallback that still exited 0, and the PR was reported
> as a draft on the strength of the exit code rather than an observed `draft: true` (D-12). Twelve
> failure instances were banked in that session; **three of them were the orchestrator's own.** That
> ratio is the point — the mechanism must catch the mechanic too, or it is not a mechanism.
>
> Operationally: after any write, read back the property you required. Applies to gates, wrappers, PR
> flags, commit authorship, file installs, and message delivery alike.
### First-class principle — pre-registration prevents retrofitting, and nothing else
> **A pre-registered check set can fail in three distinct ways:**
>
> | mode | the set is… | found as |
> | --------------------------- | ------------------------------------------------- | -------- |
> | **WRONG** | a check does not test what it claims | D-8 |
> | **INCOMPLETE** | green while a criterion's requirement is untested | D-17 |
> | **INTERNALLY INCONSISTENT** | two criteria cannot both hold | D-18 |
>
> **Pre-registration protects against exactly one thing: retrofitting a check to fit the implementation
> it is supposed to judge.** It confers neither correctness, nor coverage, nor consistency. "We
> pre-registered the checks" has been treated as though it settled the question — it settles one of
> three.
>
> Promoted to the charter by Mos (2026-07-31). All three modes were found on this mission's own **first
> delivery**, by the machinery applied to its own work — not by inspection, and not by looking for them.
>
> **Enforceable form — RM-02's four clauses.** The registry must establish that: (1) each check is
> **right** — proven red for its own stated reason before its green counts; (2) the set **covers**
> every criterion bound to a case that actually exercises it; (3) no two criteria **conflict**
> mutual unsatisfiability is a registry defect discoverable by construction; (4) when a criterion's
> meaning changes, the registry **retains original text, restatement, and reason**, so evolution stays
> auditable. A criterion with no case that can fail for its own reason is unregistered in substance,
> however it reads in the manifest.
### Corollary — never ship an integrity claim dressed as a property
> A verification artifact that can be forged by whoever it is meant to catch verifies nothing. If a
> manifest, marker, ledger, or receipt is writable by the same actor whose behaviour it certifies, it
> **certifies the attack.** Such an artifact must sit inside the integrity envelope it belongs to,
> publish atomically, and carry a **tamper negative-control observed red** — otherwise its integrity is
> a _claim_, not a _property_.
>
> **If it cannot be made tamper-evident, say so and reconsider the approach.** Laundering foreign
> content as certified is the only unacceptable outcome; an honest "this cannot be verified" is always
> available and always preferable.
### First-class principle — when a property cannot exist at the layer it was specified
> Some required properties are **impossible at the layer that asked for them** — not hard, impossible.
> A local check cannot defend against an actor who can rewrite the check itself. When that happens,
> there are exactly three honest moves, and all three are mandatory:
>
> 1. **Implement what the layer _can_ guarantee.** Partial protection against the class it was actually
> born from is worth having.
> 2. **State the boundary precisely, in BOTH directions.** What it does _not_ defend, **and** beside it
> what it _does_. A reader who sees only the negative dismisses the check as worthless; one who sees
> only the positive over-trusts it. **Both together is the honest artifact** — either alone misleads.
> 3. **Record where the real guarantee will come from — as a TRACKED DEPENDENCY, not prose.** It must
> name a task that someone must close. _A documented gap with no owner becomes a permanent gap that
> reads as intentional._
>
> **A written-down gap is acceptable engineering. An implied-fixed gap is this mission's core failure in
> a new costume** — a verification artifact that verifies nothing, with a green to prove it.
>
> Promoted to the charter by Mos (2026-07-31) from D-19. Origin: the RM-01 symlink manifest could not be
> made tamper-evident against a same-UID actor (CWE-345), because the manifest and its marker share one
> writable tree. The implementing seat **escalated rather than relabelling self-authentication as
> tamper-resistance** — the corollary above firing on its first real adversarial test, on the cheapest
> seat in the loop. Residual risk bound to **RM-59** (`depends_on: RM-12, RM-21, RM-25`), where the
> choke-point executor and spine verify from _outside_ the worktree's authority.
## Decision record (authoritative, immutable)
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
- Site + `annotations.json`: `jarvis-brain/docs/postmortem-spec/site/` (committed, origin/main).
- Discussion checkpoint (rich rationale per proposal): `jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md`.
- Postmortem report: mosaicstack/stack PR #107 (merged 88f4ee04).
- MACP wiring scout (verdict c=STRANDED): [`MACP-WIRING-SCOUT.md`](./MACP-WIRING-SCOUT.md) (copied into this dir; TODO discharged). Its findings are sound; its _recommended wire-in point_ is superseded by DECISION-1.
## The plan — 15 proposals collapse to 4 builds + hygiene
| Build | Absorbs | What it is |
| ------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **1. One choke-point service** (mechanical enforcer) | MISSION, STATE, AUDIT, WRAPPER, QUEUE | Deterministic program every task/data mutation flows through. **Wire the stranded `@mosaicstack/macp`** — typed tasks, gate-runner, event ledger, credential binding, tri-state write outcomes. ⚠ **Target CORRECTED 2026-07-31 (DECISION-1, Mos):** a new production Node `TaskExecutor` on the **live** dispatch path (`packages/mosaic` launch + `packages/coord`), which Coord/Forge/live-dispatch submit through. **NOT** `mosaic_orchestrator.py::run_single_task` — that controller is `"enabled": false` and references a dispatcher absent from this checkout; wiring it would strand the executor, reproducing this mission's own disease. The Python rail is **deleted**, not ported. Both planners reached this independently. |
| **2. One durable spine + hot path** | (storage under everything) | **PG system-of-record + Redis hot queue** (transactional-outbox). Mission/tasks/state-claims/audit-ledger/comms-inbox all land here. |
| **3. Rotation lifecycle** (finish the Mission Control Plane) | LIFECYCLE, CONTRACT, GUIDE, RECOVERY | Coordinator daemon: contract-hash binding, compaction-detected → rotate-not-compact, checkpoint→fresh-session→rehydrate, broker-independent recovery. Deterministic, not an LLM. Reuse `packages/coord`; existing PRD at `docs/mission-control/`. |
| **4. Comms service** | AUTHORITY, INBOX (+ versioning roadmap) | Envelope (comms/v1) → sole-path service → PG/Redis → pluggable adapters (tmux→Matrix/Discord/Slack/Telegram). Version the protocol, not participants. |
| **+ Hygiene & proof** | FLEET, WORKFLOW, CONFORMANCE | One roster-owned socket/host + stale GC; allowlist auto-sync; the conformance harness that fault-injects the failure classes and proves builds 14 hold. |
## The finding that sets the cost
**Built-but-unwired disease.** `@mosaicstack/macp` is stranded (nothing calls it); `packages/coord` primitives
exist; the Mission Control PRD exists; PG + Redis already run in-stack. Three duplicate MACP islands, an
orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retire, NOT greenfield. "Finish, don't re-spec."**
## Sequencing (skeleton — adversarial decomposition refines this)
1. **Spine + choke-point service** (builds 1+2) — foundation; unlocks MISSION/STATE/AUDIT/WRAPPER/QUEUE at one integration point.
(Per DECISION-1, a P0 phase of provable-gate + activation work precedes this; see `TASKS.md` §3.)
2. **Rotation daemon** (build 3) on that spine — the drift fix proper.
3. **Comms service** (build 4) — envelope → service → PG/Redis → adapters; retire direct-tmux.
4. **Hygiene + conformance** (build 5) — fleet convergence, allowlist sync, dogfood harness.
- **Cross-cutting retirements:** flat-file orchestration tracking (hard cutover to DB), the 3 duplicate MACP islands, the silent `MOSAIC BYPASS`.
## Standing directives (Jason, 2026-07-31)
- **Dogfooding:** validate EACH fix against the live fleet failure that motivated it. Seed acceptance tests:
Pi brick (RECOVERY), scout-bounce (INBOX/FLEET), gate-6 inert + #1019 recursion (QUEUE), identity drift
(WRAPPER), auto-sync sweep (WORKFLOW), #1018 stale-consumed (INBOX). The fleet is its own test bed.
- **Orchestration tracking → DB**, hard cutover ("rip off the bandaid"), NO flat-file interim. jarvis-brain
PDA flat-files untouched. Current flat-file tracking runs as-is/unhardened until DB tracking is real, then one clean replace.
- ⚠ **QUALIFIED 2026-07-31 (DECISION-2, Mos):** the DB spine **must NOT be a single-point hard-stop.**
A broker-independent / degraded mode **and** a rehearsed rollback artifact are **design requirements**
(P-RECOVERY-001), binding now on RM-12, RM-13, RM-23, RM-36 and RM-53. This **supersedes** the earlier
orchestrator recommendation to pre-commit "no DB ⇒ the fleet stops" — that answer is _not_ on record.
Only the specific availability _target_ remains open, queued for Jason; it does **not** block current work.
## The 15 decisions (one-line; full rationale in the checkpoint)
1. **P-ACTIVATION-001** accept — transactional CLI+hooks+broker+version release; block launch on skew, fail-SAFE.
2. **P-AUTHORITY-001** MODIFY — structured authenticated inbox; envelope carries comms-PROTOCOL version; version the protocol not participants; N-version window.
3. **P-LIFECYCLE-001** accept — rotation not recursive compaction; pre-empt at token threshold; enforcer = deterministic coordinator; = finish Mission Control Plane.
4. **P-MISSION-001** accept — bind lanes to mission+task ledger; convention exists, ENFORCEMENT is the gap; mission+tasks → DB spine (hard cutover).
5. **P-QUEUE-001** accept — repair queue transport + exit-asserting non-null-case tests (gate-6 was INERT fleet-wide; #1019 fix recursed the same bug).
6. **P-STATE-001** accept — typed claims (source/confidence/TTL) not prose blob; MACP typed record; integrity fail-closed HMAC; don't fork a 4th island.
7. **P-AUDIT-001** accept — MACPEvent lifecycle ledger; EXTEND enum to lifecycle events; runtime-neutral (executor-emitted); retire duplicate Python ledger.
8. **P-WRAPPER-001** accept — identity derives from seat name + survives respawn; tri-state write outcomes MANDATORY; name safe target metadata.
9. **P-CONTRACT-001** accept — bind session to contract hash; re-anchor on policy-change OR compaction-detected; stale generation loses authority MECHANICALLY.
10. **P-INBOX-001** MODIFY — sole-path comms SERVICE; PG durable SoR + Redis hot queue (outbox, reconciliation sweeper); pluggable adapters; protocol-first, PG-first-then-Redis.
11. **P-RECOVERY-001** accept — broker-independent bootstrap recovery; honest capability labeling; break-glass LOUD+AUDITED+TEMPORARY not silent permanent bypass.
12. **P-GUIDE-001** accept — delete `/compact and continue` from orchestrator path (keep for ephemeral); removal = substitution (wire rotation trigger).
13. **P-FLEET-001** accept — one roster-owned socket/host; quarantine unmanaged; stale-session GC; prerequisite for INBOX identity-addressing.
14. **P-WORKFLOW-001** accept — auto-sync ALLOWLIST not denylist; worktree/lease isolation for agent docs/source; DB-tracking obviates the flat-file-sweep criterion.
15. **P-CONFORMANCE-001** accept — fleet lifecycle harness on REAL runtime artifacts + fault injection; the 100-rotations-lossless bar is a test; target the DB substrate.
## Fleet operating model
- **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.
- **Compaction survival:** see `KICKSTART.md` in this dir — the resume procedure. Persist typed state, not transcript.
+894
View File
@@ -0,0 +1,894 @@
# Remediation Backlog — Reconciled Execution Plan
**Owner:** `mos-remediation` (sole writer). Workers read; they never modify this file.
**Sources:** [`DECOMP-OPUS.md`](./DECOMP-OPUS.md) (robustness, 38 tasks / 8 dissents) and
[`DECOMP-SOL.md`](./DECOMP-SOL.md) (pragmatic, 25 tasks / 10 defers / 7 dissents), produced
**independently** — neither planner read the other. Charter: [`MISSION.md`](./MISSION.md).
**Status:** EXECUTING — all three blocking decisions RULED by Mos on 2026-07-31 (§5). **RM-01 is
dispatched.** RM-03 is held pending Jason's disposition of PR #1023; nothing else is blocked.
> **Provenance of the inputs (both clean).** `planner-opus` ran in a fresh session throughout.
> `planner-sol` initially began work at 64.3% dirty context despite a brief instructing it to reset;
> that run was **interrupted and discarded before it produced any output**, the seat was reset
> out-of-band to 0.0%, and the brief was re-dispatched. `DECOMP-SOL.md` is the product of the clean
> run only (it peaked at ~26% context). Both decompositions are therefore clean-context artifacts and
> are weighted equally here. The discarded dirty run is banked as dogfood seed D-4 and as task RM-58 —
> the failure it demonstrates is that _asking_ an agent to reset is not enforcement.
---
## 1. What the two planners agreed on without collusion
Independent convergence is the strongest signal available here, because neither planner could see the
other's file. Where both arrived at the same conclusion from opposite biases, I treat it as settled.
| # | Convergent finding | OPUS | SOL |
| --- | --------------------------------------------------------------------------------------------------------------------- | ------------------- | -------------- |
| C1 | **The charter's wire-in point is wrong.** Do NOT wire the choke point into `mosaic_orchestrator.py::run_single_task`. | D2 (headline) | Dissent 1 |
| C2 | P0 hygiene/gate work must precede the spine, not follow it. | D1, phase P0 | G0, SOL-01/02 |
| C3 | No new deployable microservice; the executor is a library + the coord daemon. | implicit throughout | Dissent 3 |
| C4 | Comms adapters beyond tmux are out of scope for this mission. | D7 | DEFER 4/5/6 |
| C5 | The "100 rotations lossless" bar is a late conformance gate, not an early tax. | D4 | Dissent 7 |
| C6 | Redis is a derived hot path, never an authority; PG commits first. | R-013, R-054 | SOL-11, SOL-21 |
| C7 | Reuse `packages/coord`; do NOT revive the untracked `apps/coordinator` residue. | R-042 | SOL-15 AC5 |
**C1 is the single most consequential output of this exercise.** The charter (`MISSION.md`) and my
kickoff instruction both name `mosaic_orchestrator.py::run_single_task:126-276` as the integration
point. Both planners independently rejected it on the same evidence: that controller is
`"enabled": false` (`.mosaic/orchestrator/config.json:2`) and references a dispatcher path
(`tools/macp/dispatcher/pi_runner.ts`) that does not exist in this checkout. Wiring the new choke
point into a disabled rail produces **a stranded executor — the identical built-but-unwired disease,
one layer up, that would look "done" in a PR.** The live paths are
`packages/mosaic/src/commands/launch.ts` and `packages/coord/src/runner.ts`.
This contradicted the charter and was escalated as DECISION-1 — **now RULED in the planners' favour by
Mos (§5)**. The corrected target is a new production Node `TaskExecutor` on the live dispatch path
(`packages/mosaic` launch + `packages/coord`) that Coord/Forge/live dispatch submit through; the
Python rail is deleted, not ported.
---
## 1a. ★ KEYSTONE DOGFOOD CASE — an inert gate that erased its own evidence
**A merged commit shipped past `pnpm format:check` — and then the evidence quietly erased itself.**
Verified chain (blob-level, under the repo's own prettier config, at the file's real path):
| commit | state of `packages/mosaic/framework/tools/orchestrator/README.md` |
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `b79336a8`**merged PR #868** | blob `3ee7f104`**FAILS** `pnpm format:check` |
| `48fd1df2` — merged PR #872 (unrelated: ci-queue-wait 404 handling) | blob `3d3bb132` — passes; incidentally reformatted by that PR's `lint-staged` |
| current `origin/main` (`06e0d403`) | passes — **the gate now looks green** |
So: PR #868 merged a file that fails a required gate ⇒ **the CI format gate did not block it.** The
gate was inert for that merge. Then an unrelated later PR's pre-commit hook reformatted the file as a
side effect, so `main` went green again **without anyone ever learning the gate had failed to fire.**
> **Correction on record:** my first report to Mos said "format:check is RED on main _now_." That was
> true of the `main` my checkout was pinned to (`b79336a8`) and is **no longer true of current `main`**,
> which advanced mid-session. The inert-gate finding itself is unchanged and verified; only its
> present-tense framing was wrong. The hygiene PR therefore carries the `.prettierignore` fix only —
> the README needs no fix today.
### Third live instance, same class — the queue guard, hit by this orchestrator
Running the **mandated** pre-push guard during TASK-0:
```
$ ~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
[ci-queue-wait] platform=gitea purpose=push branch=main sha=06e0d403…
[ci-queue-wait] state=unknown purpose=push branch=main
$ echo $? → 0
```
**Two distinct defects in one tool**, both feeding RM-03:
1. **Wrong exit**`state=unknown``exit 0`. The defect at `ci-queue-wait.sh:282-288` that OPUS
documented and that PR #1023 is parked on. A required gate returned PASS on an indeterminate result.
2. **Wrong branch** — it evaluated `branch=main`, not the branch actually being pushed. Even a
correctly-exiting guard would have been answering the wrong question.
**Standing doctrine (Mos):** until RM-03 lands, a green from this guard carries **zero information**
and must not be cited as merge evidence. Rely on reviewer clearance + real CI.
Three independent live instances in a single session — format gate, agent context reset, queue guard —
is the class confirmed, not anecdote.
### D-20 — the orchestrator's own documentation overclaimed, and a reviewer disproved it empirically
`rev-974` blocked PR #1027 a second time. **The defect was not in the code — it was in this file**, at
D-18's entry, written by the orchestrator.
Two faults, both mine:
1. **D-18's AC2 restatement omitted the scope clause** that D-19 later established as mandatory
("within an accidental/independent-mutation threat model").
2. **D-18 asserted that the tampered-manifest control turns integrity "from a claim into a property."**
It does not, and _cannot_. That sentence was written **before** D-19 proved the property impossible
at this layer, and was never revised when D-19 landed.
**The reviewer did not merely read it — it disproved it.** It performed a **same-UID consistent
manifest + marker rewrite**, and **preflight passed**. My documented claim was falsified by experiment.
Code, README, scratchpad and PR body all stated both threat-model directions correctly; **this file was
the only place still overclaiming.**
**This is two banked findings firing on the orchestrator at once:**
- **The integrity-claim corollary** — I wrote an integrity _claim_ in the voice of an integrity
_property_, in the very document that defines the rule against doing so.
- **D-14 (propagation)** — D-19 superseded D-18's assertion. I propagated the consequence into the
charter and the delivery conditions, but **not back into D-18 itself.** A ruling that fails to
propagate _backwards_ into the finding it supersedes is the same defect as one that fails to
propagate forwards, and I did not audit for that direction.
**Corrected in place**, with the original wording quoted and the empirical disproof recorded, rather
than silently rewritten — the same standard demanded of any restated criterion.
**Requirement on RM-02 (fifth clause).** Documentation asserting a _security or integrity_ property is
itself a claim requiring a negative control. Where a document states "X is guaranteed", the registry
must hold a case that **fails if X is not guaranteed** — and that case must have been observed red.
**Prose is not exempt from the mission's own evidentiary standard**, and prose in the _governing_
document least of all: it is the artifact most likely to be quoted as authority long after the code has
moved on.
**Reviewer credit.** rev-974 was briefed that its highest-priority check was "confirm the PR claims no
more than it can deliver, and a softened or omitted boundary is a finding even though the code works."
It applied that instruction **to the orchestrator's own governing document** and produced an experiment
to settle it. That is the review standard this mission is trying to make ordinary.
### D-19 — an integrity property that cannot exist at the layer it was specified
Implementing D-18's manifest, the seat + a Codex security review reached **CWE-345**: the symlink
manifest and the source-hash marker both live in the **same same-UID writable generated tree**, so an
actor with that UID can plant a rogue link, regenerate _both_, retain the fingerprint, and pass. **No
local cryptographic construction fixes self-authentication** without a key outside that actor's
authority; relocating the marker changes the path, not the authority.
The seat **escalated rather than describing self-authentication as tamper-resistant** — the explicit
failure mode the charter corollary demands. That is the corollary working, on its first real test.
**Ruling — Option A: scope AC2 to accidental / independent / stale mutation; retain the design.**
Rationale, recorded so it can be challenged:
1. **The undefendable boundary is not the weak link.** An actor with same-UID write can already edit the
source, the tests, `scripts/preflight.mjs` itself, and `.husky/*`. If they have that, _nothing_ in the
local checkout is trustworthy — hardening the manifest buys no real security while **implying
protection that does not exist**, which is worse than the gap.
2. **What AC2 is actually for.** These checks exist because a five-month-stale `.next` produced 19
phantom `TS2307` errors indistinguishable from real ones (**D-5**). That is staleness, drift and
foreign residue — and against that class the design demonstrably works.
3. **A real trust anchor arrives later, from this mission's own architecture.** An anchor must live
outside the actor's authority; for a fleet running as one user that means a separate service —
precisely the **choke-point executor + PG spine** of Builds 12, which verify outside the worktree's
authority. Hand-rolling key distribution for a local preflight now would duplicate that work badly.
4. Option C (structural policy, no manifest) is strictly worse — it cannot detect a **removed** expected link.
**Option A is acceptable only with honest labelling**, or it becomes the disease it is meant to cure.
Conditions (last two added/sharpened by Mos):
- Threat model stated verbatim in the code **and** the PR; the words _tamper-proof / tamper-evident /
secure_ **barred** from that context; the scope carried in AC2's restatement; every control kept
RED-first including manifest-only tamper.
- **State the boundary in BOTH directions.** Not only what it does _not_ defend (same-UID write; no
local construction can) but, beside it, what it **does** defend: accidental / independent / stale /
foreign-residue mutation — the **D-5** class it was born from (the five-month `.next` and its 19
phantom `TS2307`s). _A reader who sees only the negative dismisses the check as worthless; one who
sees only the positive over-trusts it. Both together is the honest artifact._
- **The residual risk is a HARD TRACKED DEPENDENCY EDGE, not a comment.** It is **RM-59**, owned by the
choke-point executor + spine work (`depends_on: RM-12, RM-21, RM-25`), and the AC2 scope note must
cite that id. _"Record where the real guarantee comes from" only holds if the record is a live
dependency someone must close._ **A documented gap with no owner becomes a permanent gap that reads
as intentional.**
**The generalizable rule.** When a required property **cannot exist at the layer where it was
specified**, the honest moves are: implement what the layer _can_ guarantee, **state the boundary
precisely**, and record where the real guarantee will come from. **A known gap that is written down is
acceptable; a gap that is implied fixed is not.** Silence here would have shipped a verification
artifact that verifies nothing — with a green to prove it.
### D-18 — two pre-registered criteria were mutually unsatisfiable, discoverable only at implementation
Implementing D-17's fix surfaced a conflict **between** pre-registered criteria:
- **AC2** (as written) — reject symlinked generated state.
- **AC4** — the canonical `pnpm -w build` succeeds and leaves no residue.
Verified independently rather than taken on report: `apps/web/next.config.ts:4` sets
`output: 'standalone'`, and the built tree contains **42 legitimate pnpm dependency symlinks** under
`.next/standalone/node_modules`. A blanket descendant-symlink rejection makes the canonical build fail
its own preflight with exit 43. **AC2 read literally is unsatisfiable alongside AC4 under this
configuration**, and nothing short of building the tree would have revealed it.
**Third distinct failure mode of a pre-registered check set**, completing the chain:
| finding | a pre-registered check set can be… |
| ------- | ------------------------------------------------------------------ |
| D-8 | **wrong** — a check that does not test what it claims |
| D-17 | **incomplete** — green while a criterion's requirement is untested |
| D-18 | **internally inconsistent** — two criteria that cannot both hold |
The implementing seat escalated instead of silently picking a winner. That matters: **quietly resolving
a conflict between pre-registered criteria destroys the point of pre-registering them** — the registration
exists so that changes of meaning are auditable rather than absorbed.
**Resolution (orchestrator ruling).** Approved a **build-certified symlink manifest**: `.next` itself is
still rejected as a symlink; descendants are rejected unless _exactly_ certified by a manifest the build
publishes atomically. Strictly **stronger** than blanket rejection — it also catches a **retargeted**
symlink, which blanket rejection cannot distinguish from a legitimate one.
**AC2 restated (recorded, not absorbed).** _Generated state must reject `.next` itself being a symlink
or non-directory, and must reject any descendant symlink not exactly certified by the build manifest —
added, removed, retargeted, or manifest-only-tampered all fail with exit 43 — **within an accidental /
independent-mutation threat model.**_
> ⚠ **This entry is superseded in part by [D-19](#d-19). Do not read D-18 standalone.** The scope clause
> above is load-bearing: the design **cannot** defend against a same-UID actor, which can rewrite the
> manifest and the marker consistently (CWE-345). D-18 was written **before** that impossibility was
> established.
**Hardening required before this counts.** The manifest is itself generated state, so **a manifest
writable by whoever plants a rogue symlink certifies the attack** — that is the one way this design
fails. It must sit inside the same ownership/fingerprint envelope, published atomically via the existing
marker mechanism, with negative controls **observed red first** for: added, removed, retargeted,
**manifest-only-tampered**, plus a positive control that the canonical build passes.
> ⚠ **CORRECTED (D-20).** This paragraph originally ended: _"without it, integrity is a claim rather
> than a property."_ **That overclaimed**, by implying the control makes integrity a _property_. It does
> not, and cannot. The manifest-only-tamper control detects **independent** mutation of the manifest;
> it confers **no authenticity** against an actor who rewrites manifest _and_ marker together.
> `rev-974` disproved the original wording empirically — a same-UID consistent manifest+marker rewrite
> **passed preflight**. Integrity here remains a scoped **drift-detection** property, never an
> authenticity one. See D-19 and the charter principle on properties that cannot exist at their
> specified layer.
**Requirement on RM-02 (fourth clause).** The registry must detect **conflicts between registered
criteria**, not only wrongness and coverage. Two criteria that cannot simultaneously hold is a registry
defect discoverable by construction — and when a criterion is restated, the registry must retain the
original text, the restatement, and the reason, so the evolution stays auditable.
### D-17 — a pre-registered criterion passed a green suite without being satisfied
`rev-974` returned **CHANGES REQUESTED** on PR #1027 with one blocking finding, and it is the sharpest
instance of the session's theme because it occurred **inside our own verification machinery**.
**AC2** was pre-registered before any code was written, and explicitly required that **symlinked
generated state be rejected**. The implementation does not do it:
```sh
ln -s /etc/hosts apps/web/.next/reviewer-symlink
pnpm preflight # → "checkout preflight passed", exit 0
# → required: generated-state exit 43
```
The acceptance suite was **21/21 green** throughout. Confirmed independently rather than relayed:
`scripts/preflight.mjs:82-92` rejects symlinks on the **source** path; `:28` merely _skips_ symlinked
directories rather than rejecting them; and the **generated-state** path at `:141-163` `lstat`s and
checks `uid` (ownership) but **never** calls `isSymbolicLink()`. The suite's only symlink cases
(`preflight.test.mjs:59`, `:115`) cover the turbo binary and a _source_ file. No generated-state case
exists anywhere.
**So: criterion pre-registered, suite green, requirement unmet.** Nobody was careless — the coverage gap
is _invisible from a green_, which is the entire problem.
**This sharpens D-8 rather than repeating it.** D-8 established that pre-registration does not confer
_correctness_ (a check can be wrong when written). D-17 establishes the adjacent failure:
**pre-registration does not confer _coverage_** — a suite can be green, and every registered criterion
can appear satisfied, while a criterion's actual requirement is untested. The two together mean a
registry of checks needs **two** properties, not one: each check must be _right_, and the set must
_actually exercise_ what it claims.
**Requirement on RM-02 (third clause).** The registry must bind each acceptance criterion to the
**specific case that exercises it**, and prove that case red before trusting its green. A criterion with
no case that can fail for _that criterion's stated reason_ is unregistered in substance however it
appears in the manifest. This is mutation testing pointed at the **criterion-to-case mapping**, not
merely at the gate.
**Credit where due:** the reviewer also declined to re-run AC8, stating plainly that the PR carried it
forward with no runnable command rather than silently substituting a different boundary test. That is
the D-8 clause working a second time, in the same review that produced D-17.
### D-16 — the local test gate and the CI test gate disagree by environment
Mos flagged a shape worth chasing: if `pnpm test` exits non-zero on a _pre-existing_ guard, then either
`main` is red and merges step around it (the #868 shape again), or CI does not run that path. **Both
branches turned out wrong, and the truth is a third thing.** Established by running it, not by asking:
CI runs **exactly** `pnpm test` (`.woodpecker/ci.yml`, `test` step) — the same command. So the path _is_
exercised. Yet:
| environment | result |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| CI container | `test` step **green** (#2158, #2167) |
| this host, clean worktree | **exit 97**`WAKE-ASSERT INIT ABORT: BASH_LINENO convention violated on bash 5.2.15(1)-release … expected [3 4], probe reported [3 5] (#973)`, after `PASS=18 FAIL=0` |
| this host, main checkout | **exit 1** — a _different_, second defect (below) |
The guard is **environment-dependent**: it aborts on this host's bash and not in CI's container. `git
diff origin/main...` confirms PR #1027 touches **zero** files under `packages/mosaic`, so the guard is
genuinely pre-existing and unrelated — **f10-coder's report was accurate in every particular**, and
`main` is equally affected on this host.
**So it is not "merges step around a red" — it is worse in one specific way: the local gate and the CI
gate do not agree about what passing means.** No agent on this host can obtain a green `pnpm test` at
all, on any branch, including `main`. A gate an operator cannot run is a gate that only CI enforces,
and a gate only CI enforces cannot be a pre-push gate. This is the hermeticity/portability class
already live as #1007 (PR #1024).
**Second, independent defect found while establishing the above.** In the main checkout the same
package fails differently — exit 1 — because a test **scans the working tree** and asserts on files it
finds, picking up `apps/coordinator/venv/**` (third-party `site-packages`: `pi = math.pi` in `rich`,
`setuptools`, `mypy`). **A test whose result depends on untracked files present in the tree is not
hermetic.** This is the _same_ contamination source that made `pnpm format:check` unpassable (D-1/D-7
hygiene) — one untracked foreign tree silently breaking two independent gates.
**Requirements.** RM-01/RM-04: a gate must produce the same verdict on a developer host and in CI, or
declare loudly that it cannot run here — never diverge silently. RM-02 registers both as cases: the
environment-divergence guard, and a hermeticity control asserting a suite's verdict is unchanged by the
presence of untracked directories. Coordinate with #1007/#1024 rather than opening a third lane.
**Ownership (Mos, 2026-07-31).** The hermeticity fix **is** PR #1024, which sits in **Jason's parked
delivery stack** — so, like #1023, its disposition is Jason's. Marked `SUPERSEDED-PENDING-JASON`
alongside #1023. D-16 strengthens the urgency but does not transfer ownership: **we do not open a third
lane on a parked PR.** The one-line escalation for Jason: _two independent gates
(`format:check`, `pnpm test`) were broken by a single untracked directory, and a third
(`pnpm test`) disagrees between host and CI — non-hermetic gates make every green host-dependent._
**Sharpened statement of the class (Mos).** A pre-push gate an operator _cannot run locally_ is a gate
only CI enforces — so pointing `.husky/pre-push` at it **misrepresents where the gate lives**. Combined
with the shared root cause across two gates, the finding is: **non-hermetic gates make every green
host-dependent.** A gate that only appears to pass depending on which host runs it is this mission's
exact subject, one meta-level up.
**#1027 disposition (Mos):** proceeds on **CI-green**. CI is the authoritative gate; the local exit-97
is a known host-specific guard abort, irrelevant to the merge decision.
### D-15 — token scope is not repository permission (a THIRD capability layer)
`f10-coder` was provisioned with `gitea-mosaicstack-f10-coder.token`, scopes `write:repository` +
`write:issue`, and the mint was verified by "repo access returns 200". It then failed to push:
```
remote: error: User permission denied for writing.
remote: error: pre-receive hook declined
```
Verified objectively rather than inferred (per the charter principle):
| probe | result |
| ------------------------------------------------------ | ------------------------------------------- |
| `GET /repos/mosaicstack/stack/collaborators/f10-coder` | **404** — not a collaborator |
| repo permissions as seen by **its own token** | `admin: false`, `push: false`, `pull: true` |
**Capability has at least three independent layers, and satisfying two proves nothing about the third:**
1. **Token file exists** → raw-API authentication works (D-11b).
2. **`tea` login exists** → tea-dependent wrapper paths work (D-13).
3. **Repository permission granted** (collaborator/team membership) → _writes_ are actually authorised.
A token can carry `write:repository` scope and still be refused, because **scope bounds what a token
may attempt; repository permission decides what the user may do.** They are different systems.
**This is the charter principle failing on the very check meant to confirm capability.** The mint was
validated by an HTTP 200 on a _read_. A 200 proves reachability; it does not prove the property that
was required, which was **write**. Both the provisioner and I accepted it — the same
`written-unverified` treated as `verified` as D-12, one layer up, on a check whose entire purpose was
verification.
**Requirements.** RM-50's pre-dispatch capability check must probe the **effective permission for the
operation intended** — for push authority, assert `permissions.push == true` as that seat, not token
existence and not a 200 on a read. RM-04's registry reconciliation covers all three layers, with a
must-fail control for each. A capability check that cannot fail on a seat lacking write permission is
itself an inert gate.
### D-14 — a ruled decision did not propagate to the authoritative record
DECISION-1 (the corrected choke-point wire-in target) was ruled by the coordinator and applied to
`TASKS.md`. **`MISSION.md` — the charter, the document a cold-starting seat reads first — kept the
superseded target for hours.** It was flagged `CONTESTED` in a board note, then the ruling landed and
nobody edited the charter. A seat resuming from the charter would have read the _rejected_ target as
authoritative and wired the choke point into a disabled rail — the precise failure the ruling existed
to prevent.
Caught by hand, during an unrelated edit. Nothing would have caught it otherwise.
**This is P-MISSION-001 turned on ourselves.** The mission's own thesis is that convention exists and
_enforcement_ is the gap: a decision that lives in a chat ruling and a board note, but not in the
source of truth, has not actually been made — it has been _agreed_. The two are different, and the
difference is exactly what this mission is about.
> ⚠ **AMENDED by D-20 — propagation is BIDIRECTIONAL.** As first written, this requirement was read by
> both the orchestrator and the coordinator as _forward_ propagation only: a ruling reaches the
> documents that state the new rule. **D-20 proved that insufficient.** When D-19 superseded part of
> D-18, the consequence propagated forward into the charter and the delivery conditions but **never
> backward into D-18 itself**, which went on asserting a withdrawn claim — and a reviewer disproved it
> by experiment. **A supersession must update BOTH the documents that render the new rule AND the
> finding it retires, with the retired wording quoted rather than deleted.** Backward propagation is
> the same defect as forward; neither of us audited that direction until it bit.
**Requirement (not merely a fix).** A ruled decision must propagate **mechanically** to the
authoritative record; it must not depend on someone remembering to edit a second file. Concretely, once
mission state is DB-backed (RM-53 / the P-MISSION cutover):
- a decision is a **record**, not prose duplicated across documents;
- documents _render_ decisions rather than restating them, so there is one place to be wrong;
- and where duplication is unavoidable, a check asserts the authoritative record and the derived
document agree — with a must-fail control proving divergence is detected.
Until then, the interim rule: **the same commit that records a ruling updates every document that
states it — and every finding it supersedes.** Interim rules are exactly what the DB cutover exists to replace.
**The rule found a second instance within minutes of being written.** Auditing the charter against all
rulings to date (rather than waiting to be bitten again) surfaced that **DECISION-2 had also not
propagated**: `MISSION.md`'s standing directives still stated the DB hard-cutover with no mention of
Mos's binding qualification that _the spine must not be a single-point hard-stop_ (degraded mode +
rollback artifact required). A seat reading the charter would have designed toward an availability
posture the coordinator had explicitly rejected — and would have found the superseded
"no DB ⇒ the fleet stops" recommendation nowhere contradicted. Now corrected in place.
**Two un-propagated rulings out of two rulings that touched charter text.** The propagation gap is not
an oversight that happened once; without a mechanism it is the _default outcome_. That is the argument
for making this a requirement rather than a discipline.
### D-13 — two credential registries that can disagree (why the `--draft` fallback fired at all)
Diagnosing D-12's root cause surfaced a distinct defect. There are **two parallel credential
registries**, and capability in one does not imply capability in the other:
| registry | contents for identity `mos-dt-0` on `mosaicstack` |
| ------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| token files — `~/.config/mosaic/secrets/gitea-tokens/` | `gitea-mosaicstack-mos-dt-0.token` **EXISTS** |
| `tea login list` | **NO** `mosaicstack` login for `mos-dt-0` (only `mosaicstack-mos` and `mosaicstack-rev-974`) |
So `get_gitea_token` succeeds and every raw-API path works, while every **tea-dependent** wrapper path
fails its login validation and silently degrades to the API fallback — which is exactly what dropped
`--draft`. **tea is not "stale"; the login simply does not exist for that identity.**
This matters beyond one flag: capability was declared authoritative by the token-file set (D-11b), but
that registry does not govern the tea path. A seat can be _fully provisioned_ by the authoritative
registry and still lose functionality with no error — only a warning, and only on the degraded path.
**Requirements.** RM-04 (activation coherence): the two registries must be reconciled — one source of
truth, or a startup check asserting they agree, with a must-fail control proving disagreement is
detected. RM-50: the pre-dispatch capability check must verify capability for the **path actually
used**, not merely token-file presence.
**Confirmed working despite the gap** (so this is degradation, not outage): pushes, `pr-merge.sh`,
PR/issue creation via API fallback, comment posting, and all reads. Impact is confined to
tea-only features — `--draft`, `--labels`, `--milestone`.
**Reconciliation run by Mos (the manual form of RM-04's assert-agreement, done once by hand).** For
`git.mosaicstack.dev`, the token-file registry holds **six** seats; `tea` holds logins for **two**:
| state | seats |
| ------------------------------------------------ | -------------------------------------------------------- |
| token file present, **no** mosaicstack tea login | `f10-coder`, `jarvis`, `mos-admin`, `mos-dt-0`, `pepper` |
| token file present **and** tea login present | `rev-974` (only) |
**Five of six provisioned seats are silently degraded on tea-only features.** This is _systemic_, not
a one-off — which is why the fix is registry reconciliation (RM-04) and not a per-seat mint. Minting
one seat would clear a symptom and leave the class live.
Mos deliberately deferred the mint: it is not on RM-01's critical path, and additively editing shared
`tea` config underneath running work is a change he declined to make without cause. Full remediation —
mint the five missing logins **and** wire the startup must-fail assertion that _detects_ disagreement —
lands as RM-04 at a non-critical seam, or immediately if any seat needs a tea-only feature to progress.
**Correction of record:** this supersedes D-11(b)'s claim that the token-file set is _the_ authoritative
capability registry. It is **necessary but not sufficient**. Capability is **per-path**: the token file
governs the raw-API path, the tea login governs the tea path, and the two can disagree silently.
### D-12 — a requested SAFETY flag was silently degraded, and I did not check
I created PR #1027 with `pr-create.sh ... -d` (draft) because it carries **partial, unproven work**.
`tea` authentication was stale, so the wrapper fell back to its raw-API path — which cannot set draft —
and emitted:
```
Warning: API fallback applies title/body/head/base only; labels/milestone/draft require authenticated tea setup.
```
The PR was created **not-draft**. I read the success output, saw the PR number, and moved on. I then
reported to the coordinator that the PR was "opened as draft". **It was open, mergeable, and marked
ready for ~25 minutes**, protected only by the words "DRAFT" and "do not merge" in its title and body —
i.e. by prose a human might read, not by the platform control I asked for. Detected only because a
watcher polled `draft:` and the value disagreed with my belief. Corrected by setting the `WIP:` title
prefix (Gitea's draft mechanism); `draft: True` verified after.
**Three distinct failures, and the third is mine:**
1. **Silent degradation of a safety flag.** The fallback path dropped `--draft` and still exited 0. A
fallback that cannot honour a _safety_ argument must fail, not proceed — degrading `--labels` is a
nuisance; degrading `--draft` publishes unproven work as ready to merge.
2. **The warning went to stderr and nothing consumed it.** It was correct, specific, and ignored — a
warning nobody acts on is indistinguishable from no warning.
3. **I did not verify the flag took effect.** I checked that the PR existed, not that it had the
property I required. This is the mission's own thesis turned on me: **I trusted a success exit code
over an observed state**, on exactly the class of tool this mission exists to distrust.
**Requirements.** RM-02: a wrapper that cannot honour a safety-relevant argument must exit non-zero —
registered with a must-fail control asserting `--draft` on a degraded path fails rather than proceeds.
RM-24 (tri-state write outcomes): this is precisely `written-unverified` being treated as `verified`
the PR write succeeded, the _requested property_ was never confirmed, and no one looked.
### D-11 — seat identity did not survive into git, and seat capability is invisible at dispatch
Two defects, one dispatch (RM-01 → `f10-coder`):
**(a) Identity drift — P-WRAPPER-001, reproduced on our own delivery.** The seat's commits are
authored `mosaic-coder <[email protected]>` — the generic fallback. **You cannot tell from
git history which seat did this work.** Recorded, not rewritten: the drift is the evidence.
> **Mechanism, corrected (Mos).** My original framing here was wrong, and the error was in the brief
> before it was in the finding. `MOSAIC_GIT_IDENTITY` resolves the **token** (which per-slot credential
> the wrappers act with). The **commit author** comes from `git config user.name` / `user.email`, which
> is a **separate setting** — it fell back to the generic value because nothing set it. Exporting the
> identity could never have fixed authorship. **My worker brief instructed only the export, so the
> seat did exactly what it was told and the commits were still mis-attributed.**
>
> **The requirement is coherence: token and authorship must agree.** A seat acting with
> `gitea-mosaicstack-f10-coder` must also commit as `f10-coder <[email protected]>`.
> Either half alone is identity drift — one produces the right credential with the wrong author, the
> other the reverse. That coherence _is_ P-WRAPPER-001, and it belongs in seat setup, not in prose
> instructions a seat may follow correctly and still end up wrong.
**(b) Capability opacity.** Nothing at dispatch time revealed that `f10-coder` had no credential for
the target provider. Per-slot tokens live at `~/.config/mosaic/secrets/gitea-tokens/`; the seat holds
`gitea-usc-f10-coder` but not `gitea-mosaicstack-f10-coder`. This surfaced only when the seat failed
**mid-task, after ~$9 and 69% of its context.** The orchestrator (me) selected a seat without any way
to check it could act on the target repo — and there was no way to check.
`get_gitea_token` behaved **correctly**: it refused to fall through and borrow another slot's token,
failing loud precisely to protect gate-16 attribution. The tooling was right; the _dispatch-time
information_ did not exist.
**This is P-RECOVERY-001's "honest capability labeling" applied to seats rather than services.** A seat
should declare what it can actually do — which providers, which repos, which credentials — and that
declaration must be **checkable before dispatch**, not discovered by failure after the budget is spent.
**Requirements.**
- **RM-04 (activation coherence)** gains the identity-binding half: seat setup must set **both** the
token identity **and** `git config user.name`/`user.email`, coherently. Verified by an
exit-asserting test that makes a commit and asserts its author — never assumed from an instruction
in a brief.
- **RM-50 (roster ownership)** gains per-seat capability declaration plus a **pre-dispatch capability
check**. Mos (who owns provisioning) confirms the check is mechanically trivial: **capability is
token-file existence.** Before dispatching seat `X` to provider `Y`, test that
`~/.config/mosaic/secrets/gitea-tokens/gitea-<Y>-<X>.token` exists; if absent, provision it or pick a
provisioned seat. **The token-file set is the authoritative capability registry.** A one-second check
would have replaced a mid-task failure that cost ~$9 and 69% of a seat's context.
### D-10 — the queue guard's failure modes are exactly backwards
`ci-queue-wait.sh` — a **required** pre-push/pre-merge gate — was observed this session doing both of
these:
- **Fails OPEN on an unknown result.** `state=unknown ⇒ exit 0`, five times, during real pushes and
real merges. It also evaluates `branch=main` rather than the branch being acted on.
- **Fails CLOSED on credential resolution.** In a worker seat it aborted with
`Gitea token not found`, hard-blocking a legitimate push of completed, tested work. The worker
correctly stopped (Constitution gate 8). The identical command run from that worker's _own worktree_
in another shell succeeded, so the checkout and remote were fine — the difference was the worker's
process environment.
**A gate that waves through work it never checked, and blocks work that is ready, has its failure
modes inverted.** Availability failures (cannot reach the provider, cannot resolve a credential)
should degrade to a loud, auditable _inability to assert_ — never to a hard stop on delivery, and
never to a silent pass. Correctness failures (unknown, malformed, terminal-failure) are what must
block.
This is also the **Pi-brick shape** (P-RECOVERY-001): a gate whose own unavailability prevents the
work needed to recover from it.
**Requirement on RM-03, extending its existing two defects:** the guard must distinguish
`CANNOT_ASSERT` (credential/transport/provider unavailable — loud, audited, does not silently pass and
does not permanently block) from `ASSERTED_NOT_READY` (a real non-green CI state — blocks). Both are
registered R-002 cases with must-fail controls; neither may exit 0 silently.
### D-9 — the comms path shell-interprets message bodies (injection-shaped, found by accident)
Sending a status message with `agent-send.sh -m "...`backticks`..."` caused bash to **execute** the
backticked text as command substitution. The recipient received a mangled body plus a
`No such file or directory` error; the intended sentence never arrived. The message was reported as
delivered.
This is the **same class** as the already-noted `pr-create.sh` backtick-quoting bug (M2 scratchpad):
**two tools in the comms path treat a message body as shell input.** A body that can execute on the
sender is a _correctness_ bug before it is ever a security one — and note the failure mode: the
send reported success while silently transmitting something other than what was written. Silent
corruption with a success receipt is precisely the pattern this mission exists to eliminate.
**Requirement on RM-40 / RM-42 (comms/v1), hardened by Mos.** The envelope must carry its payload
**verbatim** and must not be subject to shell interpretation at **any** hop — sender, transport, or
adapter. Concretely: **file/stdin transport, never argv interpolation.**
**Standing interim rule, effective now (Mos).** Until the envelope lands, use `agent-send.sh -f
<file>` for any message body containing special characters — **never `-m`**. Passing a file sidesteps
argv interpolation entirely. **This rule is mandatory in every worker brief this mission issues**,
alongside the D-8 "if a check is unrunnable, say so" clause. Round-trip fidelity (send a body containing backticks, `$(…)`, quotes, and newlines; assert
byte-identical receipt) is a required registered test case under RM-02, including a must-fail control
proving the assertion can detect corruption.
### D-8 — a PRE-REGISTERED acceptance check that was not runnable as written
On PR #1025 the author (me) pre-registered AC2 with the fixture snippet `mkdir -p apps/*/venv/lib`.
In bash, when no `venv` exists the glob is unmatched and passes through literally, creating a
directory named `apps/*/venv/lib` rather than one per workspace. The check as written did not test
what it claimed to test.
`rev-974` ran it **exactly as written**, observed the wrong behaviour, then re-ran the intended
assertion at an explicit path — **and said so in the review** rather than silently substituting a
working fixture and reporting PASS.
Two things this establishes:
1. **The instruction "do not adjust a check to fit the diff; if it is unrunnable, say so explicitly"
worked.** A silent substitution here would have produced a green AC2 that proved nothing, on the
exact task whose subject is gates that appear to work. The disclosure is what made the PASS
meaningful.
2. **Pre-registration does not confer correctness.** A pre-registered check is protected from being
retrofitted to the implementation; it is not protected from being _wrong when written_. This is a
small instance of the mission's own class — an unverified gate — occurring inside the mechanism
built to catch unverified gates.
**Requirement on RM-02 (non-negotiable, sharpened by Mos).** The registry must **self-verify** that
every registered case demonstrably **runs** and demonstrably **fails on a known-bad input**.
Presence in the registry is **not** evidence. **A check is not trusted until it has been shown to
fail.** This is mutation testing / negative control applied _at the registry level_ — meaning
**the conformance harness must itself be conformance-tested.** A registered case that cannot fail, or
cannot run, is exactly as inert as an unregistered one, and the registry check must detect that
itself rather than assume it.
**Requirement on RM-55.** The same recursion applies to the harness: it must be observed red before
its green is worth anything (OPUS R-063 AC1 already states this; D-8 is the empirical case for it).
**Second, equally load-bearing lesson — reviewer disclosure is what makes a review trustworthy.**
rev-974 could have silently swapped in a working fixture and reported `AC2 PASS`. Nothing in the
process would have caught it, and the resulting green would have certified nothing — on the very task
whose subject is gates that only appear to work. The brief's instruction — _"do not adjust a check to
fit the diff; if it is genuinely unrunnable as specified, say so explicitly and explain why rather
than silently substituting your own"_ — is therefore not boilerplate. It is the clause that makes a
PASS mean something, and it must appear in **every** reviewer brief this mission issues.
### D-7 — shared-tmpfs contention → cascading ENOSPC (live incident, 2026-07-31)
The shared 30 G `/tmp` hit **100% ENOSPC** mid-session. It broke tool calls in **two different seats**
(mine and Mos's) — a single full disk degrades every agent on the host at once. Recurring: prior
incidents 2026-06-18 and 2026-07-17.
Attribution matters, because the wrong owner cleans the wrong thing. Measured:
| path | size | last modified | owner |
| ---------------------------------------------- | --------- | ---------------------------- | ------------------------------------------------- |
| `…/-src-mosaic-stack/6d2faee6…` (this session) | **88 K** | live | mos-remediation |
| `…/-src-mosaic-stack/c743185d…` | **3.6 G** | **2026-07-22** (9 days dead) | abandoned session, same project path |
| `…/claude-1001/pnpm-store` | **1.6 G** | **2026-07-23** (8 days dead) | abandoned; the live store is correctly on `$HOME` |
So ~5.2 G — the bulk of the pressure — is **dead session scratch that nothing will ever read again**.
This is not a quota problem; it is **P-FLEET-001's stale-session GC, applied to disk instead of tmux
sessions.** The same missing capability (nothing owns reaping dead ephemeral state) produces both the
orphaned-session failure and this one. Reaping dead-session scratch belongs in RM-50 alongside stale
tmux-session GC.
**Added to RM-01 as acceptance criteria:** heavy build artifacts (node_modules, package stores, build
output) must land on the main disk in the worktree, never on the shared 30 G `/tmp`.
**Resolution, and the part that is actually the finding.** Mos verified the attribution independently
(mtimes, no process or `lsof` holding either path, no live session maps) and reaped both as lead
coordinator: `/tmp` went to 79%, 6.0 G free. But note _how_ it was resolved — **a human-authority seat
did it by hand, because the authority exists and the reaper does not.** That gap is the finding, not
the disk usage.
Two doctrine points fall out, both binding on RM-50:
1. **The fix is not "agents should tidy up."** Asking each seat to clean its own scratch is
`instructions are not enforcement` (D-4) wearing a different hat. A deterministic reaper must own
it — same conclusion the north star reaches for every other class in this mission.
2. **Refusing to unilaterally delete another session's scratch was correct, and the resolution is not
"be braver about deleting."** An agent guessing that someone else's state is garbage is exactly the
unreviewed destructive act the Constitution forbids. The resolution is that _ownership and liveness
become mechanically decidable_, so reaping is a determination rather than a judgement call.
**Reaper requirements for RM-50:** liveness determined mechanically (process/`lsof`/session-map, not
mtime alone); an age threshold; a dry-run that reports what it would reap and why; and an audit event
per reap. Never a heuristic sweep — that would reintroduce the P-WORKFLOW-001 auto-sync failure in a
more destructive form.
**The self-erasure is the important part.** An inert gate that is masked by unrelated downstream
commits produces no lasting artifact, which is precisely why this class survives for months. Detection
cannot rely on "is `main` currently red" — it must be per-merge-commit.
This matters more than the one-line fix:
- It is the **P-QUEUE-001 / P-CONFORMANCE-001 class** ("gate-6 was inert fleet-wide"), reproduced in
the repository this mission is remediating, discovered incidentally.
- It independently **validates OPUS premise A1** ("every gate is inert until proven otherwise") with
live evidence rather than argument — which is why RM-02 is adopted as the keystone (§2, X2).
- The file fix rides in its own hygiene PR. **The inert gate itself is NOT quiet-patched.** Per Mos:
it stays a first-class backlog item, because patching the symptom would destroy the signal.
**Binding requirement on RM-02 and RM-55:** the gate registry and the conformance harness must assert
**"every merged commit passed every required gate"** — evaluated **per merge commit, against that
commit's own tree**, not against current `main`. As the table above proves, a "is main green today"
check would have reported all-clear. A merged-commit-that-fails-a-required-gate is the exact detection
signal, and it must be a registered must-fail case. A gate that cannot prove it blocked something has
not been shown to work.
---
## 2. Where they genuinely disagree (not averaged — adjudicated)
| # | Axis | OPUS | SOL | My ruling |
| --- | ------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| X1 | **Total cost** | 38 tasks, ~5.3M tok | 25 tasks, ~294K tok | **~18× apart.** Not reconcilable by splitting. They measure different things: SOL explicitly excludes orchestration/review/iteration overhead and assumes one remediation pass; OPUS prices the full loop. Adopt **SOL's scope** with **OPUS's rigor**, and treat SOL's G1 as a hard budget checkpoint (§4). Re-estimate empirically after the first three merged PRs rather than trusting either number. |
| X2 | **Gate registry (OPUS R-002)** | Keystone; blocks all P2 | Absent; only a queue-guard fix | **ADOPT OPUS.** Empirically validated in this very session: I found `pnpm format:check` red on `main` via merged PR #868 — a required gate that did not block. OPUS's premise A1 ("every gate is inert until proven otherwise") is not theoretical; it reproduced today, unprompted. Scope it tighter than 120K. |
| X3 | **Drizzle PG first-install defect (R-010)** | Hidden blocker; everything downstream depends on it | Not mentioned | **ADOPT OPUS.** `packages/db/src/migrate.ts:30-38` carries a TODO admitting postgres-tier first-install fails today. The spine has only ever been proven on PGlite. Every later migration silently depends on this. SOL missed it. |
| X4 | **Rollback artifact for the hard cutover** | D3: hard cutover needs a rehearsed rollback snapshot | SOL-07: import-only, explicitly no dual-write | Both obey "no flat-file interim." OPUS wants a one-directional snapshot nothing reads as authority. I read that as compatible with the directive, but it is Jason's call → **DECISION-2** (§5). |
| X5 | **Where the queue guard sits** | P0, independent of spine | SOL-02, also early | Agree it is P0. But ownership collides with **parked PR #1023****DECISION-3** (§5). |
| X6 | **Report-only rollout** | D5: only with a hard expiry, else withdraw | not raised | **ADOPT OPUS.** A report-only gate is by definition inert; expiry is the mechanism that stops it becoming the new fail-open. |
| X7 | **Availability trade (FC-7/FC-11)** | D8: "no DB ⇒ fleet stops" must be pre-committed in writing | not raised | Genuine availability regression, correctly identified. Needs Jason → folded into **DECISION-2**. |
---
## 3. Reconciled DAG
Phases run in order; `⛔` marks a hard barrier. `src` shows lineage (`O`=opus, `S`=sol, `O+S`=both).
Estimates are given as a **range** (SOL low / OPUS high) rather than a fabricated midpoint — the
spread is itself information, and X1 says we calibrate on real merged PRs.
### P0 — Make gates provable, and stop the fleet re-bricking
⛔ _No gate-introducing task in any later phase may merge before RM-02._
| id | task | src | depends_on | est (S/O) | tier |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------ | ---------------- | ------ |
| RM-01 | Reproducible non-root checkout; gate fails on **code, not env**; heavy artifacts OFF shared `/tmp` (banks D-1/D-2/D-5/D-7) | O+S+live | — | 6K / 60K | codex |
| RM-02 | **Gate registry + negative-control CI check** (anti-inert-gate harness) ★keystone | O | RM-01 | — / 120K | opus |
| RM-03 ⏸**HOLD** | Queue-guard: **two** defects — (a) `unknown`/`no-status`/malformed ⇒ ≠0, (b) guard evaluates `branch=main` instead of the branch being pushed | O+S+live | RM-02 | 8K / 100K | sonnet |
| RM-04 | Activation/version coherence; block launch on skew, fail SAFE; honest `doctor` labels | O+S | RM-01 | (in S-01) / 140K | sonnet |
| RM-05 | Break-glass replaces the three silent `MOSAIC BYPASS` fail-opens | O | RM-04, RM-02 | — / 120K | opus |
> ⚠ **RM-05 must not merge before RM-04.** The bypasses exist because the lease-broker daemon was
> never _deployed_ on this host — removing the fail-open before deployment coherence is real
> re-creates the 2026-07-22 bricking incident. Hard edge, from OPUS.
### P1 — Durable spine (PG)
⛔ _No migration may merge before RM-10._
| id | task | src | depends_on | est (S/O) | tier |
| ----- | --------------------------------------------------------------------------------------------- | --- | ---------- | ---------- | ------ |
| RM-10 | **Fix the Drizzle postgres-tier first-install defect** ★hidden blocker | O | RM-01 | — / 90K | sonnet |
| RM-11 | Orchestration spine schema (tasks, attempts, gate_results, hash-chained ledger, typed claims) | O+S | RM-10 | 12K / 160K | opus |
| RM-12 | Spine client, fail-closed connection (no silent PGlite in prod) | O | RM-11 | — / 80K | sonnet |
| RM-13 | Atomic claims/transitions + transactional outbox + reconciliation sweeper | O+S | RM-12 | 12K / 140K | opus |
### P2 — The single choke point
⛔ _RM-25 (no-second-path) lands in the same milestone as RM-20, or the choke point is optional._
| id | task | src | depends_on | est (S/O) | tier |
| ----- | ---------------------------------------------------------------------------------------------- | --- | ------------------- | ---------------- | ------ |
| RM-20 | Canonical MACP contract completion (Task/Result/Event/Claim/tri-state outcome) | S | — | 8K / (in R-020) | codex |
| RM-21 | **Production `TaskExecutor`** backed by `@mosaicstack/macp` ★keystone | O+S | RM-12, RM-02, RM-20 | 16K / 220K | opus |
| RM-22 | Gate-runner hardening: `fail_on`, timeouts, **empty gate set = failure** | O | RM-21 | — / 120K | sonnet |
| RM-23 | Hash-chained MACPEvent ledger in PG + lifecycle EventType extension | O+S | RM-21, RM-11 | — / 160K | opus |
| RM-24 | Seat identity from `MOSAIC_AGENT_NAME` + **mandatory** tri-state write outcomes | O+S | RM-21 | (in S-03) / 150K | opus |
| RM-25 | **No-second-path gate:** terminal status writable only by the executor | O | RM-21, RM-23 | — / 140K | opus |
| RM-26 | `packages/coord` submits through the executor (retire direct spawn) | O+S | RM-21 | 16K / 140K | sonnet |
| RM-27 | `mosaic yolo/claude/codex/pi` launch path records typed Task + events | O | RM-21, RM-23 | — / 160K | sonnet |
| RM-28 | Delete the Forge stub executor (empty-gate-list "success"); Forge submits through the real one | O+S | RM-21 | 10K / 90K | codex |
| RM-29 | One-shot flat-file import + cutover readiness audit (dry-run, idempotent, no dual-write) | S | RM-13 | 8K / (in R-062) | codex |
> **★ G1 — FIRST DOGFOOD. Stop here and prove it.** One live fleet task travels
> PG claim → TaskExecutor → worker → gates → terminal PG result/event, with **no** flat-file state.
> Adopted from SOL wholesale. If G1 cannot carry a real task, **do not build Redis, rotation, comms,
> or conformance** — remediate instead. This is the budget escape hatch (§4).
### P3 — Rotation lifecycle (finish the Mission Control Plane)
| id | task | src | depends_on | est (S/O) | tier |
| ----- | -------------------------------------------------------------------------------------------- | --- | ------------ | ---------------- | ------ |
| RM-30 | Typed state claims (source/confidence/TTL) with HMAC integrity, fail-closed | O+S | RM-11, RM-21 | (in S-03) / 170K | opus |
| RM-31 | Contract-hash binding; stale generation loses mutation authority **mechanically** | O+S | RM-21, RM-30 | 12K / 180K | opus |
| RM-32 | Durable compaction/token sensor (per-runtime thresholds, PreCompact event) | O | RM-23, RM-31 | — / 130K | sonnet |
| RM-33 | Typed checkpoint writer (structured claims, never transcript) + digest | O+S | RM-30, RM-32 | 12K / 150K | opus |
| RM-34 | **Rotation daemon:** watch → checkpoint → revoke → kill → relaunch → rehydrate | O+S | RM-33, RM-26 | 16K / 240K | opus |
| RM-35 | Rehydration attestation gate: refuse to act on an incomplete claim set | O | RM-33 | — / 130K | opus |
| RM-36 | Broker-independent recovery; remove silent bypass; honest capability labels | S | RM-34 | 12K / (in R-004) | sonnet |
| RM-37 | Delete `/compact and continue` from the persistent-seat path (**substitution**, not removal) | O+S | RM-34, RM-44 | (in S-16) / 60K | codex |
### P4 — Comms service
⛔ _RM-50 (one roster-owned socket per host) precedes identity-addressed delivery._
| id | task | src | depends_on | est (S/O) | tier |
| ----- | ---------------------------------------------------------------------------- | --- | ------------ | ---------------- | ------ |
| RM-40 | `comms/v1` envelope + protocol-version negotiation, LOUD reject | O+S | RM-11, RM-31 | 8K / 140K | opus |
| RM-41 | Comms service: PG state machine PENDING→RECEIVED→CONSUMED→DEAD-LETTER | O+S | RM-40, RM-13 | 16K / 200K | opus |
| RM-42 | tmux transport as a **dumb adapter**; durable retry before cursor advance | O+S | RM-41, RM-50 | (in S-19) / 160K | sonnet |
| RM-43 | Per-class coalescing + supersede (the stale-consumed-as-live fix) | O+S | RM-41 | 12K / 130K | sonnet |
| RM-44 | Redis Streams hot delivery + provenance guard (**Redis is never authority**) | O+S | RM-41, RM-13 | 12K / 170K | opus |
| RM-45 | Retire direct tmux sends; only the service may write a pane | O+S | RM-42, RM-43 | (in S-20) / 100K | codex |
### 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 |
**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`.
---
## 4. Execution discipline
- **Every row is one PR.** Author ≠ reviewer; `rev-974` is the mosaicstack reviewer identity.
- **Pre-registered, diff-blind acceptance checks are committed BEFORE the reviewer reads the diff.**
Both decomps wrote their ACs in runnable `⇒0` / `⇒≠0` form specifically to make this possible.
- **Every gate-introducing task carries at least one registered must-fail negative control.** This is
RM-02's whole purpose; a gate with no proven failure path manufactures evidence.
- **Cost tiers:** codex for mechanical/unambiguous, sonnet for normal feature work, opus reserved for
security/integrity/cross-cutting-invariant tasks. SOL priced 0 opus tokens; OPUS priced 14 opus
tasks. I am keeping opus only where the failure is _integrity_, not merely complexity.
- **G1 is the budget checkpoint.** If the first-dogfood slice overruns SOL's estimate by >3×, stop and
re-plan rather than spending the remainder. X1 says neither estimate is trustworthy until calibrated.
- **Defer list adopted from SOL** (10 items): mission dashboard/TUI, PRD-to-board auto-decomposition,
heuristic churn scoring, Discord/Slack/Telegram adapters, public MCP comms surface, protocol-v2
negotiation, multi-region PG/Redis, event analytics UI.
---
## 5. Decisions — all three ruled by Mos, 2026-07-31
**DECISION-1 — the wire-in point. ✅ RULED: accept the planners (Mos, 2026-07-31).**
The charter's `mosaic_orchestrator.py::run_single_task` target is the **disabled Python controller
this mission retires**; wiring the new choke point into the rail we are deleting is wrong.
> **Corrected target (authoritative):** a **new production Node `TaskExecutor`** sitting on the
> **live dispatch path**`packages/mosaic` launch + `packages/coord` — which Coord, Forge, and live
> dispatch all **submit through**. This is the MACP scout's _full_ recommendation ("replace the block
> **with** a Node executor **and** make Coord/Forge submit through it"), not a resurrection of the
> Python controller. RM-52 is therefore a **deletion** task, and Build 1's acceptance is measured on a
> live `mosaic yolo` invocation.
Mos ruled this resolvable from the already-accepted retire-the-Python-rail decision — his authority,
not a Jason escalation. RM-21/RM-26/RM-27/RM-52 all take the corrected target.
**DECISION-2 — rollback artifact + availability trade. ⏸ JASON-PENDING — NOT BLOCKING.**
The DB build is phases away, so this is queued for Jason's next session rather than escalated now.
**Binding requirement in the meantime (Mos, from P-RECOVERY-001):** the DB spine **must NOT be a
single-point hard-stop.** Design for a broker-independent / degraded mode **plus** a rollback
artifact. Jason finalises only the specific availability target. This reverses my earlier reading of
OPUS D8 ("the fallback is: the fleet stops") — that answer is **not** pre-committed; a degraded mode
is now a design requirement on RM-12, RM-13, RM-23, RM-36 and RM-53.
**DECISION-3 — RM-03 vs. parked PR #1023. ✅ RULED: HOLD RM-03 (Mos, 2026-07-31).**
Do **not** open a third gate-6 lane — that is the postmortem's own anti-pattern performed by the
remediation. PR #1023 sits in Jason's **parked delivery stack**; its disposition (close, or supersede
by RM-03) is Jason's at his next session.
- **PR #1023 → `SUPERSEDED-PENDING-JASON`.** RM-03 stays `HOLD`; when Jason rules, RM-03 proceeds as
the single correct lane.
- **RM-02 and RM-55 proceed independently and are NOT held.** The per-merge-commit gate-assertion
requirement is the _conformance_ capability, not the gate-6 fix itself — different scope, no
ownership collision.
---
## 6. Status
| phase | state |
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
| Decomposition | DONE — both planners delivered independently |
| Reconciliation | DONE — this document |
| Blocking decisions | **RULED** — all 3 closed by Mos 2026-07-31 (§5); D-2's availability target is Jason-pending but non-blocking |
| Dispatch | **RM-01 IN FLIGHT** — f10-coder (codex), worktree-isolated, AC1AC8 pre-registered |
| Review | PR #1025 with rev-974; ACs pre-registered 22:12:26Z before diff exposure |
+449
View File
@@ -0,0 +1,449 @@
# RFC-001 — MACP: A Mosaic-Native, Matrix-Native Comms Layer
- **Status:** DRAFT — for Team Lead → Orchestrator staffing
- **Author:** MS-LEAD (reviewer identity `ms-lead-reviewer`)
- **Sponsor / veto:** Jason (human lead)
- **Date:** 2026-07-24
- **Program:** Mosaic Stack comms-evolution
- **Supersedes backbone:** the Hermes MCP chat bridge (strangler-retired, see §9)
- **Audience:** Team Leads, the Mosaic orchestrator, infra, and any harness maintainer (Claude Code / Codex / Pi / Goose)
> This is a **design document**. No code ships from this RFC. It exists to be decomposed into missions (P1→P5, §10) with per-phase acceptance criteria. Where a claim is uncertain or needs live validation, it is flagged **[VERIFY]**.
---
## 0. TL;DR
We are building a **Mosaic-native comms layer on Matrix**. We self-host a **Synapse** homeserver and register a privileged **Mosaic Application Service** (the "appservice") that the orchestrator controls. The appservice bulk-provisions one Matrix identity per agent-spin, creates and manages rooms, posts agent introductions, and tracks presence/liveness. Agents talk to it through a thin `packages/comms` client SDK. **tmux stays the P0 same-host fast path**; Matrix is the durable, presence-aware, federated layer above it, and **MACP** (the Mosaic Agent Comms Protocol) is the standard that says which path to use when. Federation is **per-site Synapse homeservers federated over TLS we control** — a direct answer to "the homelab agent went dark and took comms with it." We adopt **Buzz's patterns** (auto-detect/enroll, signed identity, unified event log, humans-and-agents on one surface) without adopting Buzz/Nostr as transport. We migrate off Hermes by the **strangler** pattern: stand native alongside, move channels as proven, retire at parity.
The **first standalone shippable slice is presence** (P1).
---
## 1. Goals / Non-Goals
### 1.1 Goals
- **G1 — Presence & liveness first.** A Team Lead must be able to answer "is my coordinator online, away, or dead?" in seconds, not by polling for 13 hours. Presence is the P1 slice and ships before anything else.
- **G2 — A native backbone we own.** Replace _Hermes-as-backbone_ with a self-hosted Synapse + a Mosaic-controlled appservice. External chat bridging becomes an optional edge, not the spine.
- **G3 — Turnkey harness enrollment.** `mosaic enroll` auto-detects the harness and self-registers the agent via the appservice on spin. No hand-rolled per-bot identity juggling.
- **G4 — A real protocol (MACP v1).** Structured, versioned event schema over Matrix custom event types; a documented routing contract for tmux vs Matrix; a documented escalation policy.
- **G5 — No central SPOF.** Per-site homeservers federated over TLS/DNS we already control, so one site going dark cannot take the fleet's comms with it.
- **G6 — Gate-action integrity.** Reviews / merges / approvals carry **signed authorship** (Buzz pattern) so a gate-critical action is cryptographically attributable, retiring the fragile "distinct bot identity" juggling.
### 1.2 Non-Goals
- **NG1 — Do NOT rip out working comms mid-MVP.** tmux fast-path and the existing `mos-comms` git-branch channel keep working until their replacement is proven at parity. This RFC is strangler, not big-bang.
- **NG2 — tmux is NOT being replaced.** tmux inter-agent comms remains **P0**. Matrix is _above_ it, not instead of it. MACP defines the boundary; it does not move it.
- **NG3 — Not adopting Buzz/Nostr as transport.** We adopt Buzz's _patterns_; the wire is Matrix.
- **NG4 — Not building a new chat client in P1P4.** HIL uses an existing Matrix client (Element or equivalent) until/unless a custom client is justified (open question, §11).
- **NG5 — Not federating to the public Matrix network.** Federation is Mosaic-site-to-Mosaic-site over infrastructure we control. Public `matrix.org` federation is out of scope (and should likely be firewalled off).
- **NG6 — Not a Hermes feature-clone.** We reach _parity on the channels that matter_ (§9 checklist), not bug-for-bug Hermes compatibility.
---
## 2. Architecture
### 2.1 Layer diagram
```
┌───────────────────────────────────────────────┐
│ HUMAN (Jason / HIL) │
│ Element (or custom client) — §11 │
└───────────────────────┬───────────────────────┘
│ (same Matrix surface as agents)
┌──────────────────────────────────────────────▼──────────────────────────────────────────────┐
│ SYNAPSE HOMESERVER (self-hosted, ours) │
│ - Client-Server API (agents + humans send/receive events) │
│ - Application Service API (privileged AS hooks: transactions, user/room namespaces) │
│ - Presence EDUs, receipts, typing │
│ - Federation API (S2S) over TLS ── to peer site homeservers (§6, P4) │
└───────▲───────────────────────────────────▲──────────────────────────────────────▲───────────┘
│ AS API (hs_token / as_token) │ C-S API (per-agent access_token) │ S2S
│ │ │
┌───────┴───────────────────────┐ ┌────────┴─────────────────┐ ┌─────────┴──────────┐
│ MOSAIC APPSERVICE │ │ packages/comms (SDK) │ │ PEER SITE Synapse │
│ (apps/matrix-appservice) │ │ used by every harness │ │ (site-B, site-C…) │
│ THE "native layer" │ │ - login/whoami │ │ own appservice │
│ replacing Hermes-backbone │ │ - send MACP events │ │ own agents │
│ │ │ - subscribe/sync │ └────────────────────┘
│ - bulk-provision MXIDs │◄───┤ - presence heartbeat │
│ (@mosaic_<agent>:site) │ │ - signed-authorship │
│ - create/manage rooms │ │ envelope (gate acts) │
│ - post introductions │ └────────────┬─────────────┘
│ - track presence/liveness │ │ in-process / IPC
│ - enforce room taxonomy │ ┌─────────▼──────────────────────────────────────────┐
│ - escalation watchdog (§5) │ │ AGENT HARNESS │
│ - controlled by ORCHESTRATOR │ │ Claude Code / Codex / Pi / Goose │
└───────▲───────────────────────┘ │ `mosaic enroll` runs on spin (§4.1) │
│ orchestrator drives AS └─────────┬───────────────────────────────────────────┘
┌───────┴───────────────────────┐ │
│ MOSAIC ORCHESTRATOR │ │ P0 FAST PATH (same host, low-latency)
│ (~/.config/mosaic) │ ┌─────────▼──────────┐ tmux send-keys / pane I/O
│ spins agents, owns rooms, │◄──────►│ tmux (P0) │◄─►│ peer agent on same host │
│ sets escalation policy │ MACP └────────────────────┘ └─────────────────────────┘
└───────────────────────────────┘ routing rules decide tmux vs Matrix per message (§4.6)
```
Key idea: **the appservice is the backbone.** It is a long-lived privileged process registered with Synapse via an appservice registration file (`hs_token`/`as_token`, namespaces). It is the thing that used to be "Hermes-as-backbone," except we own it, it is inside the orchestrator's control plane, and it speaks native Matrix.
### 2.2 Message flow: agent spin-up → auto-enroll → room join → introduction → presence-online
```
Orchestrator Harness (mosaic enroll) Mosaic Appservice Synapse
│ │ │ │
1. spin agent ─────────────────► │ │ │
│ │ 2. auto-detect harness │ │
│ │ (Claude/Codex/Pi/Goose) │ │
│ │ 3. POST /enroll {agent meta} ─► │
│ │ │ 4. provision MXID │
│ │ │ @mosaic_<agent>:site │
│ │ │ via AS API register ─► (201, in namespace)
│ │ │ 5. mint access_token │
│ │ 6. ◄── {mxid, token, rooms}──┤ (or as_token masq) │
│ │ │ 7. invite+join rooms ─► (mission/team/fleet)
│ │ 8. /sync (via packages/comms)─────────────────────────► (joined state)
│ │ │ 9. post introduction ─► m.room.message +
│ │ │ (mosaic.introduction) custom event → rooms
│ │ 10. set presence ONLINE ─────────────────────────────► presence EDU
│ │ 11. start heartbeat loop │ │
│ │ (mosaic.presence ping) │ │
│ 12. appservice reports agent │ │ │
│ ◄──── live in fleet room ────┤ (watchdog now tracks liveness) │
```
Notes on the steps that matter:
- **Step 4/5** use the **Application Service API**: the appservice can register users inside its namespace (`@mosaic_*:site`) and act on their behalf. Two viable modes: (a) mint a real per-agent `access_token` via appservice login, or (b) have the appservice **masquerade** using `user_id` query param on C-S calls with the `as_token`. **Recommendation: mint per-agent tokens** for P2 so the agent process holds only its own credential (blast-radius containment, §8); reserve masquerade for bulk/bootstrap operations the appservice itself performs. **[VERIFY]** exact token-lifetime and refresh behavior against the running Synapse version.
- **Step 9** — the introduction is both a human-readable `m.room.message` _and_ a structured `mosaic.introduction` custom event (so other agents can machine-parse capabilities without scraping prose).
- **Step 10/11** — presence goes online immediately, then a **heartbeat** keeps liveness fresh. Native Matrix presence auto-decays to `unavailable`/`offline`, but we do **not** rely solely on it (Synapse presence timeouts are coarse and federation presence is lossy **[VERIFY]**); MACP adds an explicit `mosaic.presence` heartbeat event for deterministic liveness (§4.5, §5).
---
## 3. Repo-home decision (RESOLVED — recommendation)
The core tension: **product monorepo** (`mosaicstack/stack`, this checkout `/src/mosaic-stack`) vs **framework** (`~/.config/mosaic`, the agent/harness runtime that every agent shares regardless of product). The boundary rule I am ratifying:
> **Product-monorepo owns the deployed _services and libraries_. Framework owns the _agent/harness contract_ — anything an agent needs the moment it spins, before any product code is checked out.**
Applying that rule:
| Piece | Home | Rationale |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Synapse deployment** (compose/helm, config, TLS, `.well-known`, pipelines) | **Product monorepo**`infra/matrix/` + CI in the monorepo's pipeline dir | It is deployed infrastructure with the same lifecycle/observability as Gateway/Web/DB. Lives beside other `infra/` and Woodpecker pipelines. |
| **Mosaic Appservice** | **Product monorepo**`apps/matrix-appservice` | It is a first-class deployed service (NestJS-style app, same conventions: ESM, `@Inject()`, DTOs, OTEL-before-bootstrap). It talks to Synapse, holds `hs_token`/`as_token`, and is operated like any other app. It is **controlled by** the orchestrator but **is not** the orchestrator. |
| **Client SDK** | **Product monorepo**`packages/comms` | A workspace library consumed by product apps _and_ by harnesses. Published/linked like `packages/queue`, `packages/db`. Versioned with the product. |
| **MACP spec** (the standard doc + JSON schemas) | **Framework**`~/.config/mosaic/spec/macp/` (mirrored/vendored into `packages/comms` at build) | The protocol is an agent-level contract that must exist independent of any one product checkout. Framework is the source of truth; `packages/comms` vendors a pinned copy so the SDK and spec cannot drift silently. |
| **`mosaic enroll` harness glue** (auto-detect, spin hook) | **Framework**`~/.config/mosaic/tools/enroll/` | Agents/harnesses are framework-level. Enrollment must run _before_ the agent has product context; it cannot depend on `/src/mosaic-stack` being present. This is exactly parallel to the existing `~/.config/mosaic/tools/*` wrappers. |
**Boundary summary:** _the wire and the services are product; the contract and the spin-time glue are framework._ The one deliberate coupling is **MACP**: framework is authoritative, but `packages/comms` pins a vendored copy and CI fails if they diverge, so an agent enrolling via framework and a service validating via `packages/comms` agree on the schema by construction.
**Rejected alternative:** putting the appservice in the framework. Rejected because the appservice is a stateful, deployed, secret-holding network service that needs the product's CI/observability/secret plumbing; burying it in `~/.config/mosaic` would split its operational story from every other Mosaic service.
---
## 4. MACP v1 — the standard
MACP (Mosaic Agent Comms Protocol) v1 is a **profile of Matrix**: it does not invent a transport, it constrains how Mosaic agents use Matrix so that behavior is uniform across harnesses. Versioned via a `macp_version` field on every custom event; v1 is frozen at ratification (P3).
### 4.1 Enrollment contract
`mosaic enroll` MUST, on agent spin, in order:
1. **Auto-detect harness.** Detection order + signal:
- Claude Code — presence of the Claude Code runtime/env (e.g. `CLAUDE_CODE_*` env, `~/.claude`) **[VERIFY exact signal per harness]**
- Codex — Codex runtime markers
- Pi — Pi SDK runtime (`packages/agent` / `packages/mosaic` context)
- Goose — Goose runtime markers
- Fallback: explicit `--harness` flag; if undetectable, enroll as `generic` and warn.
2. **Provision identity** — call appservice `POST /enroll` with `{agent_slug, harness, host, mission_id?, team_id?, capabilities[]}`. Appservice returns `{mxid, access_token, homeserver, rooms[]}` (§2.2 step 46).
3. **Join rooms** — accept invites / join the returned room set per taxonomy (§4.6).
4. **Introduce** — post `mosaic.introduction` (+ human-readable `m.room.message`) to each joined room.
5. **Go present** — set Matrix presence `online` and start the `mosaic.presence` heartbeat loop.
Enrollment is **idempotent**: re-running `mosaic enroll` for an existing agent slug rebinds to the same MXID (re-mints token if needed) rather than creating a duplicate identity. This is what retires the "distinct bot identity juggling."
### 4.2 Structured event schema (Matrix custom event types)
All MACP events carry a common envelope in `content`:
```jsonc
{
"macp_version": "1.0",
"macp_type": "<see below>",
"agent": { "mxid": "@mosaic_teamlead-3:site-a", "slug": "teamlead-3", "harness": "claude-code" },
"ts": 1753300000000,
"mission_id": "KBN-101", // optional
"signature": { ... } // present ONLY for gate actions, §4.4
// ...type-specific fields...
}
```
Event types (Matrix `type` shown; timeline events use `m.room.message` with a custom `msgtype` where a human-visible fallback is desirable, state events use a dotted custom `type`):
| MACP type | Matrix carrier | Purpose | Notable fields |
| --------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| **message** | `m.room.message`, `msgtype: "mosaic.message"` (+ `body` fallback) | ordinary agent/human chat | `body`, `thread?` |
| **presence** | `m.room.message` msgtype `mosaic.presence` in fleet room **or** custom EDU-backed state | heartbeat/liveness ping | `status: online\|away\|offline`, `seq`, `interval_ms` |
| **workflow-step** | state event `mosaic.workflow.step` (state_key = step id) | durable record of a workflow/mission step | `step`, `status: started\|blocked\|done\|failed`, `detail` |
| **review / approval** | `m.room.message` msgtype `mosaic.review` | a review verdict or merge/approval | `subject` (PR/commit ref), `verdict: approve\|reject\|request-changes`, `signature` (REQUIRED) |
| **escalation** | `m.room.message` msgtype `mosaic.escalation` | raise a stuck/dark condition to HIL/fallback | `reason`, `target`, `severity`, `since_ts` |
Rationale for the carrier split: **timeline events** (`m.room.message` variants) are the durable, receipted, replayable log (this _is_ Buzz's "unified event log," §7). **State events** (`mosaic.workflow.step`, presence-as-state) give last-writer-wins current status that a newly-joined agent reads instantly from room state without replaying history.
Every custom event is validated against a JSON Schema shipped in the MACP spec (§3). Unknown/newer `macp_version` → consumers MUST degrade gracefully (render `body`, ignore unknown fields).
### 4.3 Agent identity model
- **MXID:** appservice-namespaced `@mosaic_<slug>:<site-domain>`. The `mosaic_` prefix is the AS **user namespace** declared in the registration file (`namespaces.users` regex `@mosaic_.*`), so Synapse routes those users to our appservice and no human can squat the namespace.
- **Provisioning:** exclusively via the appservice (AS API register). Never hand-created. One MXID per agent-spin; idempotent rebind on re-enroll (§4.1).
- **Signed authorship** overlays identity for gate actions only (§4.4). MXID answers "who is this account"; signature answers "did the real key-holder authorize this gate action."
### 4.4 Signed-authorship for gate-critical actions (Buzz pattern, scoped)
Gate-critical = **reviews, merges, approvals** — anything that can move code to `main` or unblock a mission gate.
- Each enrolled agent is issued (or generates) an **Ed25519 keypair**; the **public** key is registered with the appservice at enrollment and published as agent profile state (`mosaic.identity.pubkey`). Private key custody per §8.
- A gate action event carries `content.signature = { alg: "ed25519", key_id, sig }` over a canonical serialization of the envelope (canonical-JSON of `{macp_type, agent.mxid, subject, verdict, ts, mission_id}`).
- Verifiers (the appservice gate-watcher, and any agent acting on a verdict) MUST reject an unsigned or bad-signature gate event. Non-gate events are unsigned (keeps the hot path cheap).
This is deliberately **narrow**: we do not sign every chat line (Buzz signs everything; we take the pattern only where forgery has teeth). Scope may widen post-P5 if warranted.
### 4.5 Presence & liveness model
Three visible states plus an explicit heartbeat:
- **online** — agent set presence online AND last `mosaic.presence` heartbeat within `heartbeat_interval` (default **30s [VERIFY tuning]**) × miss-tolerance (default 2).
- **away** — presence `unavailable`, or heartbeats late but < dark threshold.
- **offline / dark** — no heartbeat for `dark_threshold` (default **N minutes**, policy value, §5/§11) OR presence `offline`.
Why not rely on native Matrix presence alone: Synapse presence is (a) coarse-grained, (b) can be disabled for load reasons, and (c) **degrades across federation** **[VERIFY]**. So MACP layers an explicit heartbeat carried as a lightweight timeline/state event in the **fleet presence room**, giving a deterministic, federation-safe liveness signal the escalation watchdog (§5) can reason about. Native presence EDUs are still emitted (they make Element show the right dot for humans) but the _authoritative_ liveness source is the heartbeat.
### 4.6 Room / channel taxonomy (orchestrator-owned)
The **orchestrator** (via the appservice) owns room lifecycle. Agents never create backbone rooms ad hoc.
| Room | Scope | Membership | Purpose |
| ----------------------- | ------------------------------------------ | ---------------------------------------- | ----------------------------------------------------------------------- |
| **Fleet presence room** | one per site (federated view across sites) | every enrolled agent + HIL | heartbeats, the single "who's alive" board. This is the P1 deliverable. |
| **Per-mission room** | one per mission (e.g. `#mission-KBN-101`) | agents on that mission + Team Lead + HIL | workflow-steps, mission chat, reviews for that mission |
| **Per-team room** | one per team | team members + Team Lead | intra-team coordination |
| **HIL room** | one (or one per site) | humans + escalation-privileged agents | where escalations land; Jason's pane on the fleet |
Rooms are created with orchestrator-controlled power levels: appservice = admin (PL100), Team Leads elevated, worker agents default. Room aliases (`#mission-KBN-101:site-a`) are stable handles.
### 4.7 tmux ↔ Matrix routing rules (the fast-path/durable boundary)
MACP mandates this decision per message. **Default bias: if it must survive the agent, be seen by an offline party, cross a host, or be audited — Matrix. If it is same-host, synchronous, and ephemeral — tmux.**
| Signal | Route | Why |
| ---------------------------------------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------- |
| Same-host, live pane-to-pane prompt/nudge, sub-second need | **tmux (P0)** | lowest latency, no server round-trip; this is the working fast path we keep |
| Recipient may be offline / on another host | **Matrix** | durability + store-and-forward; tmux can't reach a dark or remote pane |
| Presence / heartbeat / liveness | **Matrix** | must be observable fleet-wide, including by the watchdog and HIL |
| Workflow-step, review/approval, escalation | **Matrix** | must be durable, receipted, auditable, signed (gate acts) |
| Cross-site anything | **Matrix (federated)** | tmux is same-host only |
| Bulk log spew / high-frequency scratch between co-located agents | **tmux**, with periodic **Matrix** checkpoints | avoid flooding the durable log; keep an audit checkpoint |
Rule of thumb encoded in the SDK: `comms.send()` takes a `durability` hint; `ephemeral+same-host` short-circuits to tmux, everything else goes Matrix. A message can be **dual-routed** (tmux for immediacy + a Matrix checkpoint) when both speed and durability matter.
---
## 5. Coordinator-availability + HIL escalation (the 13h-stall / homelab-dark fix)
**The failure we are killing:** a Team Lead blocked ~13h, polling every 15 min, unable to distinguish "coordinator offline" from "coordinator busy"; and a homelab agent that goes dark taking comms with it.
**The fix — presence-driven, policy-encoded escalation:**
1. **Deterministic liveness (§4.5).** Every agent heartbeats into the fleet presence room. The appservice **escalation watchdog** subscribes and maintains `last_seen` per agent. No polling by the Team Lead — it _subscribes_ (Matrix `/sync` long-poll) and is pushed state changes.
2. **Encoded policy in MACP:** a machine-readable escalation policy attached to each agent/role, e.g.:
```jsonc
{
"role": "team-lead",
"coordinator": "@mosaic_coordinator-1:site-a",
"dark_threshold_min": 10, // OPEN QUESTION §11 — Jason/Mos to set N
"on_coordinator_dark": {
"action": "escalate",
"fallback": "@mosaic_coordinator-2:site-b", // cross-site fallback
"then": "notify-HIL",
"hil_room": "#hil:site-a",
},
}
```
3. **Detection & action by the Team Lead:** when the watchdog (or the Team Lead's own SDK) observes `coordinator.last_seen > dark_threshold`, it:
- emits a `mosaic.escalation` event into the mission room and the HIL room (`reason: "coordinator dark", since_ts, severity`),
- **re-routes** to the declared fallback coordinator (possibly on another site — this is why federation matters),
- if no fallback answers within a second threshold, pages **HIL** (Jason) in the HIL room.
The Team Lead **never sits blocked polling**; a dark coordinator is a _pushed event_, and the fallback/HIL path is automatic.
4. **Homelab-dark specifically:** because heartbeats are federated into a cross-site fleet room, a whole _site_ going dark is visible from other sites — the watchdog on site-B sees site-A's coordinator stop heartbeating and triggers the same escalation. A dark homelab can no longer silently strand its agents, because the liveness signal and the fallback live _off that host_.
**Design invariant:** liveness authority and fallback targets must never be co-located with the thing they monitor. The watchdog for site-A's coordinator should also run (or be mirrored) on site-B.
---
## 6. Federation (P4)
**Model:** each site runs its **own Synapse homeserver** with its **own Mosaic appservice** and its own agents. Sites **federate** with each other over the standard Matrix server-to-server (S2S) API, restricted to Mosaic sites.
**Why per-site, not one central server:**
- **No SPOF.** The homelab going dark is the founding trauma of this program. A single central homeserver would recreate exactly that risk at fleet scale. Per-site means a site outage is contained: its agents drop, but every other site's comms and the cross-site fleet room survive.
- **Locality.** Same-site agents get low-latency local homeserver traffic; only cross-site events pay the federation cost.
- **Blast radius.** A compromised or misbehaving site can be defederated without touching the rest.
**How federation is wired (real Matrix mechanics):**
- **Server discovery** via `https://<domain>/.well-known/matrix/server` returning `{"m.server": "matrix.<domain>:443"}`, and/or an `_matrix._tcp` **SRV** record. We control the DNS/domains, so we control the federation graph. **[VERIFY]** current `.well-known` vs SRV precedence for the deployed Synapse version.
- **TLS:** federation requires valid TLS on the federation endpoint; we terminate with certs from our own CA/ACME on domains we own.
- **Allowlist:** use Synapse `federation_domain_whitelist` to restrict federation to the set of Mosaic site domains — **no public-network federation** (NG5). This is a hard security boundary.
- **Cross-site rooms:** the fleet presence room and any cross-mission rooms are federated rooms whose membership spans site homeservers. Room state replicates via S2S; presence heartbeats propagate as events (not relying on lossy presence EDUs across federation, §4.5).
**Cross-site identity:** an agent on site-B is `@mosaic_<slug>:site-b`. The signed-authorship pubkey travels in profile state, so a site-A verifier can validate a site-B agent's gate action without trusting site-B's homeserver blindly (signature ≠ homeserver trust).
---
## 7. Buzz-pattern adoption map
We adopt Buzz's **ideas**, on Matrix rails, phased:
| Buzz idea | Adopt? | How, on Matrix | Phase |
| --------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| **Harness auto-detect / enroll** | **Yes** | `mosaic enroll` detects Claude/Codex/Pi/Goose and self-registers via the appservice (§4.1) | P2 (enroll v1); scan-machine-and-offer-integrate is **v2** |
| **Keypair signed identity** | **Yes, scoped** | Ed25519 signed-authorship on **gate actions only** (reviews/merges/approvals), pubkey in profile state (§4.4) | P5 (hardening); keys issued at enroll from P2 |
| **Unified event log (humans + agents, one log)** | **Yes** | Matrix room timeline _is_ the unified, receipted, replayable event log; MACP custom events are first-class entries (§4.2) | P2→P3 |
| **Humans and agents on the same surface** | **Yes** | HIL uses the same Matrix rooms via Element/custom client; escalations land where Jason already is (§4.6, §5) | P1 (fleet room) → P2 |
| **Scan machine, offer to integrate existing tools** | **Deferred (v2)** | enroll v1 auto-detects the _harness_; scanning a host for other integratable tools is explicitly **enroll v2**, post-P5 | v2 |
| **Buzz/Nostr as transport** | **No** | transport is Matrix; only patterns are borrowed (NG3) | — |
---
## 8. Security
- **Homeserver hardening.** Disable open registration (`enable_registration: false`); agents come only via the appservice. `federation_domain_whitelist` to Mosaic sites only (§6). Rate-limiting on. Media repo locked down or disabled if unused. Admin API bound to localhost / behind auth. Run Synapse behind our reverse proxy with TLS termination we control. **[VERIFY]** current recommended hardening flags for the deployed Synapse version.
- **Appservice token custody (`hs_token` / `as_token`).** These are the crown jewels — `as_token` lets the holder act as any user in the namespace; `hs_token` authenticates the homeserver → appservice callbacks. They live **only** in the appservice's secret store (see Vault interaction below), never in an agent process, never in the SDK, never in git. Registration file (which contains them) is a secret artifact, mounted at deploy, not committed. Rotate on suspected exposure; rotation requires updating both Synapse's registration and the appservice.
- **Per-agent credentials (the hard part — fleet key management).**
- Agents hold **only their own** per-agent `access_token` (minted by the appservice at enroll), not the `as_token`. Compromise of one agent ≠ compromise of the namespace.
- **Issuance:** at `mosaic enroll`, the appservice mints the token and returns it over the enroll channel (which must itself be authenticated — enroll requests carry a spin-time bootstrap secret / orchestrator-signed nonce **[VERIFY design in P2]**, so a random process can't enroll a rogue agent).
- **Signed-authorship keys:** Ed25519 private keys should be generated agent-side and only the public key leaves the agent (best custody: private key never transits the network). Where agents are ephemeral, keys are minted per-spin and discarded on teardown; the pubkey-in-profile record remains for audit.
- **Rotation:** tokens are short-lived where the Synapse version supports refresh **[VERIFY]**; otherwise the appservice supports explicit re-issue on re-enroll. A rotation runbook is a P2 deliverable.
- **Federation TLS.** Valid certs on federation endpoints; whitelist-only federation; monitor for cert expiry (a cert lapse silently defederates a site — add to observability).
- **Interaction with existing Vault/secrets.** The appservice's `hs_token`/`as_token` and the enroll bootstrap secret are stored in the existing secret manager (Vault or the project's chosen store — **open question §11**) and injected at deploy, consistent with how Gateway/DB secrets are handled today. No new bespoke secret store. Per-agent tokens are _transient runtime_ secrets, not persisted to Vault (they're re-mintable). **[VERIFY]** current Mosaic secret-management choice — the CLAUDE.md notes secrets/KBN work in flight, so align with whatever KBN-101 lands.
---
## 9. Migration — strangler off Hermes
**Principle:** stand the native layer up _alongside_ Hermes, move channels over **as each is proven at parity**, retire Hermes only when the parity checklist is green. Never a flag-day cutover (NG1).
**Sequence:**
1. **Stand alongside.** Native Synapse + appservice + fleet presence room live in parallel; Hermes still carries everything it carries today. Presence (P1) is _additive_ — it gives us something Hermes never had, at zero risk to existing flows.
2. **Move channels as proven.** Per channel (fleet presence → mission coordination → reviews/approvals → external-chat edge), cut traffic to Matrix, keep Hermes as hot fallback until the channel meets parity for a soak period.
3. **Retire at parity.** When every checklist item is green and soaked, decommission the Hermes MCP bridge as backbone. (External chat platforms, if still needed, can be re-attached as a _bridge at the edge_ of Matrix rather than the spine.)
**Parity checklist (must all be green before Hermes retires):**
- [ ] Every message class Hermes carries today has a MACP equivalent (message, review/approval, escalation, workflow-step).
- [ ] Presence/liveness is strictly better than today (it is: today = none).
- [ ] Cross-site delivery works over federation with no central SPOF.
- [ ] HIL (Jason) can see and act on escalations on the Matrix surface.
- [ ] Signed-authorship enforced on gate actions (no unsigned merge/approve accepted).
- [ ] `mosaic enroll` auto-onboards all four harnesses (Claude/Codex/Pi/Goose) with zero manual identity setup.
- [ ] Delivery receipts / durability demonstrably ≥ Hermes (no lost messages over a soak window).
- [ ] Runbooks exist: appservice token rotation, site defederation, dark-site escalation, homeserver restore.
- [ ] Observability: appservice + Synapse traced into OTEL/Jaeger like the rest of the stack.
- [ ] Rollback path documented (re-enable Hermes channel) for the soak period.
---
## 10. Phased delivery plan (P1 → P5) with acceptance criteria
> This section is the decomposition surface: each phase → one or more missions for the orchestrator.
### P1 — Presence / availability on a minimal single-site Synapse _(the first standalone shippable slice)_
**Scope:** one Synapse homeserver, a **minimal** appservice (or even a scripted provisioner) whose only job is: register a handful of agent MXIDs, create the **fleet presence room**, and carry heartbeats; a minimal `packages/comms` slice that sets presence and heartbeats; native presence surfaced to a human via Element.
**Acceptance criteria:**
- A1. Single-site Synapse deployed (`infra/matrix/`), reachable over TLS, open registration OFF.
- A2. ≥3 agents enroll (even if semi-manually) and appear in a **fleet presence room** with a live online/away/offline indicator.
- A3. `mosaic.presence` heartbeat implemented; an agent killed hard flips to **offline/dark** within `dark_threshold` deterministically (not dependent on native presence timeout alone).
- A4. A human (Jason) can open Element, join the fleet room, and see fleet liveness at a glance.
- A5. Zero impact to existing tmux + `mos-comms` flows (they still work untouched).
### P2 — Native appservice + orchestrator auto-enroll / room-provisioning
**Scope:** full **`apps/matrix-appservice`** registered with Synapse (`hs_token`/`as_token`, namespaces); `mosaic enroll` harness auto-detect; orchestrator-owned room taxonomy; per-agent token minting; introductions.
**Acceptance criteria:**
- B1. Appservice registered with Synapse via registration file; owns `@mosaic_*` user namespace and room-alias namespace.
- B2. `mosaic enroll` auto-detects all four harnesses (Claude/Codex/Pi/Goose) and self-registers on spin, idempotently.
- B3. On spin, an agent is provisioned an MXID, minted its **own** access token, joined to the correct mission/team/fleet rooms, and posts a `mosaic.introduction`.
- B4. Orchestrator can create/destroy mission & team rooms with correct power levels via the appservice.
- B5. Enroll bootstrap is authenticated (a rogue local process cannot enroll a rogue agent).
- B6. Appservice + Synapse traced into OTEL/Jaeger.
### P3 — MACP v1 spec ratified
**Scope:** freeze the standard (§4): envelope, event types + JSON Schemas, identity model, presence model, room taxonomy, tmux↔Matrix routing rules. Spec lives in framework (`~/.config/mosaic/spec/macp`), vendored+pinned into `packages/comms` with CI drift-check.
**Acceptance criteria:**
- C1. MACP v1 document ratified (MS-LEAD sign-off, Jason veto window closed).
- C2. JSON Schemas for all five event types published; `packages/comms` validates outbound/inbound against them.
- C3. CI fails if framework spec and vendored `packages/comms` copy diverge.
- C4. Routing-rule conformance test: SDK provably sends ephemeral+same-host over tmux, everything else over Matrix.
- C5. Unknown-`macp_version` graceful-degrade behavior tested.
### P4 — Federation
**Scope:** a second site homeserver + appservice; S2S federation over our DNS/TLS; cross-site fleet room; whitelist-only federation; cross-site escalation.
**Acceptance criteria:**
- D1. Two sites federate via `.well-known`/SRV over TLS we control; `federation_domain_whitelist` restricts to Mosaic sites (no public federation).
- D2. A cross-site fleet presence room shows agents from both sites; heartbeats propagate as events across federation.
- D3. **Homelab-dark test:** killing site-A's coordinator is observed from site-B within `dark_threshold`, and the escalation/fallback fires cross-site (§5).
- D4. Cross-site gate action: a site-B agent's signed review is verified by a site-A verifier.
- D5. Defederation runbook proven (a site can be cut off cleanly).
### P5 — Buzz-hardening + signed-authorship + Hermes retired
**Scope:** Ed25519 signed-authorship enforced on gate actions; security hardening pass; complete the strangler and retire Hermes at parity.
**Acceptance criteria:**
- E1. Every merge/approve/review gate action is signed; unsigned or bad-sig gate events are rejected by the appservice watcher and by consuming agents.
- E2. Token/key rotation runbooks executed at least once in anger (rotate `as_token`, rotate a per-agent key).
- E3. Security review complete: homeserver hardening flags, token custody, federation TLS/whitelist all verified.
- E4. **Parity checklist (§9) fully green + soaked.**
- E5. Hermes MCP bridge retired as backbone (optionally re-attached as an edge bridge only).
---
## 11. Open questions for Jason / Mos (need a human/coordinator ruling)
1. **DNS / domains per site.** What domain(s) do we own and want to use per site for homeserver names and `.well-known` (e.g. `site-a.mosaicstack.dev`)? Federation identity is permanent-ish once agents mint MXIDs against it — this needs a ruling **before P1 hardens** because MXIDs bake in the domain.
2. **Secret-management choice.** Is it Vault, or whatever KBN-101 lands? The appservice `hs_token`/`as_token` and enroll bootstrap secret custody depend on this (§8). CLAUDE.md signals secrets work is in flight — need the authoritative target.
3. **N-minute escalation threshold.** What is `dark_threshold_min` for a coordinator, and the second threshold before HIL is paged (§5)? Default proposed: 10 min → fallback, +5 min → HIL. Jason/Mos to confirm per role.
4. **HIL client: Element vs custom.** Do humans use off-the-shelf **Element** (fast, free, P1-ready) or do we invest in a custom HIL client? Proposed: **Element for P1P4**, revisit custom only if HIL ergonomics demand it.
5. **Ephemeral vs persistent agent keys.** For signed-authorship, do we mint Ed25519 keys per-spin (simplest custody, no long-term private key at rest) or issue durable per-agent keys (stable identity across spins, but key-at-rest custody problem)? Proposed: **per-spin**, pubkey retained for audit.
6. **Federation topology / trust.** Full mesh between all sites, or hub-and-spoke-with-redundancy? Full mesh maximizes no-SPOF but grows O(n²); needs a call once site count is known.
7. **Fallback-coordinator assignment authority.** Who assigns each Team Lead's fallback coordinator, and is it always cross-site? (Design invariant §5 wants the fallback off the monitored host — confirm this is acceptable operationally.)
8. **Retention / compliance.** How long do we retain the Matrix event log (the unified audit trail)? Affects Synapse storage sizing and any purge policy.
---
## Appendix A — Real Matrix concepts this RFC leans on (quick reference)
- **Application Service (AS) API** — a privileged service registered with the homeserver via a registration YAML declaring `id`, `url`, `as_token`, `hs_token`, and `namespaces` (users/aliases/rooms regexes). The homeserver pushes events to the AS in transactions; the AS can register/act-as users in its namespace. _(This is our appservice backbone.)_
- **`hs_token` / `as_token`** — `hs_token`: homeserver→AS authentication on pushed transactions; `as_token`: AS→homeserver authentication, grants acting-as any namespaced user. Both are high-value secrets (§8).
- **Masquerade (`user_id` query param)** — the AS may act as a namespaced user on C-S calls using `as_token` + `?user_id=`. We prefer per-agent tokens for blast-radius; masquerade for AS-internal bulk ops.
- **Custom event types** — timeline events via `m.room.message` with a custom `msgtype` (keeps a human-visible `body` fallback) and/or fully custom `type` (dotted, e.g. `mosaic.workflow.step`); **state events** for last-writer-wins current status readable from room state without history replay.
- **Presence EDUs** — native online/unavailable/offline signals; coarse and lossy over federation, so MACP adds an explicit heartbeat event as the authoritative liveness source (§4.5).
- **Federation (S2S API)** — server-to-server over TLS; discovery via `/.well-known/matrix/server` and/or `_matrix._tcp` SRV; restrictable with `federation_domain_whitelist`.
- **Synapse config knobs cited**`enable_registration`, `federation_domain_whitelist`, appservice registration file, rate-limiting, admin API binding. **[VERIFY]** exact flags/paths against the deployed Synapse version at implementation time.
_All Matrix mechanics above are cited from architecture knowledge and MUST be re-verified against the actual deployed Synapse version during P1 — every **[VERIFY]** in this document is a checkpoint, not an assumption._
@@ -0,0 +1,622 @@
# RFC-002 — Install, Configuration & Topology for the Mosaic Matrix/MACP Comms System
- **Status:** DRAFT — for Team Lead → Orchestrator staffing
- **Author:** MS-LEAD (reviewer identity `ms-lead-reviewer`)
- **Sponsor / veto:** Jason (human lead)
- **Date:** 2026-07-24
- **Program:** Mosaic Stack comms-evolution
- **Companion to:** RFC-001 — _MACP: A Mosaic-Native, Matrix-Native Comms Layer_. RFC-001 is the architecture (self-hosted Synapse + Mosaic appservice backbone + `packages/comms` SDK + MACP standard + per-site federation). **RFC-002 is the config substrate the whole thing installs and runs on.**
- **Audience:** Team Leads, the Mosaic orchestrator, infra, harness maintainers, and — critically — **strangers who install this open-source product on hardware we will never see.**
> This is a **design document**. No code ships from this RFC. It is written to be decomposed into missions with per-phase acceptance criteria, and it slots under RFC-001's P1→P5. Every uncertain or must-live-validate claim is flagged **[VERIFY]**.
> **The one framing that governs every decision below:** this is an **open-source product**. Someone we have never met will `git clone` it and run it on their own domains, their own DNS, their own certs, their own hardware. **NOTHING may hardcode our fleet's topology.** There is no `woltje.com` in the code, no assumption that DNS exists, no assumption that a second site exists. Every topology fact is **user-supplied config**. Where this doc uses `mosaic.woltje.com` / `mosaic.uscllc.com`, those are **illustrative operator values** (Jason's real installs), never defaults and never literals in the product.
---
## 0. TL;DR
The comms system installs against a **user-supplied topology**, never a baked-in one. At install the operator declares exactly one of **three topology modes**: **(A) split-domain** (identity `server_name` ≠ homeserver host, wired via Matrix delegation — this is Jason's `mosaic.woltje.com` identity + `matrix.woltje.com` host setup), **(B) single-domain** (`server_name` == homeserver host), or **(C) IP-only standalone** (no DNS, no federation, fully supported for local/airgapped). **The PRIMARY/home instance is ALWAYS configured; federation is OPTIONAL.** A single standalone instance MUST work with zero federation.
**Federation is a hard-gated capability: it REQUIRES DNS + valid certificates. IP-only federation is not possible and is not supported.** IP-only means standalone-only, forever, until the operator acquires DNS + certs.
Certificates are **one ACME integration** with a user-chosen **directory URL**: either **step-ca** (self-hosted private ACME CA, for total control and private/internal domains public CAs can't issue for) or **Let's Encrypt** (public ACME, ease-of-use). The operator also picks a challenge type (HTTP-01 / DNS-01 / TLS-ALPN-01); **DNS-01 is the answer for private/split-horizon domains.**
Secrets go through a **pluggable `SecretBackend` interface** — no forced paid dependency. Ships with a **Vault** implementation and a **Vaultwarden** implementation; the operator picks at install. The Vaultwarden model (org + orchestrator enrolled as authority + per-agent scoped access) is designed-for, with an honest **[VERIFY]** on how far Vaultwarden's machine-account coverage has matured.
Config is **DB-backed with sane defaults and install-time overrides**. Precedence: **install-time → DB override → default.** Config is split into **install-time-immutable** (e.g. `server_name`, which is baked into every MXID and cannot change without re-homing every identity) and **runtime-tunable** (e.g. dark-threshold).
RFC-002 is the substrate; **RFC-001's P1 (presence) needs only Mode A/B single-instance clean-domain and does NOT require federation, IP-only, or the secret-backend rotation story resolved.**
---
## 1. Goals / Non-Goals
### 1.1 Goals
- **G1 — Installable by a stranger.** A person with no relationship to our fleet can install, configure, and run the comms system from published artifacts and a guided installer, on their own hardware and domains, with no edits to product code.
- **G2 — Zero hardcoded topology.** Every topology fact — `server_name`, homeserver host/IP, delegation method, federation peers, cert mode, secret backend — is **user-supplied config**, validated at install, stored in the product DB. No fleet-specific literal ships in the product.
- **G3 — Standalone MUST work.** The PRIMARY instance is always fully functional with **zero federation**, including with **no DNS at all** (Mode C, IP-only). Presence, rooms, MACP, HIL-via-Element all work single-instance.
- **G4 — Federation is optional but honestly gated.** Federation is opt-in and, when opted into, **requires DNS + valid certificates as a hard precondition.** The installer must refuse to _claim_ federation is working when the DNS/cert preconditions aren't met.
- **G5 — One ACME integration, two CA choices.** Build a single ACME cert-provisioning path; the operator selects step-ca or Let's Encrypt by supplying an **ACME directory URL** plus a challenge type. No second, bespoke cert path.
- **G6 — No forced paid dependency for secrets.** A pluggable `SecretBackend` with at least Vault and Vaultwarden implementations, chosen at install. Open-source ethos: the free/self-hostable path must be first-class.
- **G7 — Defaults that just work, overrides where they matter.** DB-backed config with sane defaults so most operators change little; install-time overrides for the topology-critical values; a clear immutable-vs-tunable boundary so operators can't foot-gun `server_name`.
- **G8 — A clean upgrade path.** An operator who starts standalone can later turn on federation with a documented, honest procedure (including the real cost if they started IP-only and must now acquire a stable `server_name`).
### 1.2 Non-Goals
- **NG1 — Not hosting a managed service.** This RFC is about _self-install_. We are not building multi-tenant SaaS provisioning; each operator runs their own instance(s).
- **NG2 — Not a new cert stack.** We do not write our own CA, our own ACME client protocol, or a non-ACME cert path. We integrate ACME and let the operator point it at step-ca or Let's Encrypt. (We _may_ bundle/recommend step-ca as the self-hosted CA, but via its standard ACME provisioner, not a fork.)
- **NG3 — Not a new secret manager.** We define an interface and ship adapters. We do not build a secret store; we do not force one.
- **NG4 — Not public-network Matrix federation.** Consistent with RFC-001 NG5: federation is Mosaic-site-to-Mosaic-site over infrastructure the operator controls, allowlisted. No `matrix.org` federation.
- **NG5 — Not making IP-only federate.** We will not ship a hack (self-signed S2S trust bundles, `/etc/hosts` federation) that pretends IP-only can federate. IP-only is standalone. This is a deliberate, honest boundary (§2.4, §7).
- **NG6 — Not re-homing identities silently.** We will not offer a "just change your `server_name`" button that quietly orphans every MXID. Any path that changes `server_name` is a flagged, gated, documented identity re-home (§5.3, §7).
---
## 2. The topology model
### 2.1 The core split: `server_name` vs homeserver host
Matrix has exactly the split Jason described, natively:
- **`server_name`** — the Synapse config value that is the server's **identity domain**. It is the part after the colon in every MXID (`@mosaic_agent:mosaic.woltje.com`) and every room alias (`#mission-KBN-101:mosaic.woltje.com`). It is **baked into every identity the moment that identity is minted.** Changing it re-homes everything. This is `server_name` in Synapse's `homeserver.yaml`.
- **Homeserver host** — the actual network location (hostname:port or IP:port) where the Synapse process answers federation and (optionally proxied) client traffic. It **can differ** from `server_name`. Matrix reconciles the difference through **delegation**: `https://<server_name>/.well-known/matrix/server` returning `{"m.server": "matrix.woltje.com:443"}`, and/or a `_matrix._tcp.<server_name>` **SRV** record. **[VERIFY]** `.well-known` vs SRV precedence on the deployed Synapse version (RFC-001 §6 flags the same).
So Jason's "mosaic._ app-domain + matrix._ homeserver-domain" split maps precisely: **`server_name = mosaic.woltje.com` (identity, in MXIDs), homeserver runs at `matrix.woltje.com` (discovered via delegation).** That is **Mode A**.
### 2.2 The topology config schema
One canonical config object, stored in the product DB (§5), populated at install (§6). Illustrative shape (field names decomposition-ready, not frozen):
```jsonc
{
"topology": {
"mode": "split-domain | single-domain | ip-only-standalone", // A | B | C — install-time-immutable
"identity": {
"server_name": "mosaic.woltje.com", // INSTALL-TIME-IMMUTABLE. In MXIDs. Never change without re-home (§5.3).
"server_name_kind": "domain | ip", // "ip" only legal in Mode C
},
"homeserver": {
"host": "matrix.woltje.com", // where Synapse actually listens (Mode A: differs from server_name)
"port": 8448, // federation port (default 8448) or 443 if proxied — [VERIFY] per deploy
"client_bind": "https://matrix.woltje.com", // C-S API public URL (proxied)
"bind_ip": null, // Mode C: e.g. "192.168.1.50" ; Modes A/B: null (DNS-resolved)
},
"delegation": {
"method": "well-known | srv | none", // Mode A: well-known or srv ; Mode B/C: none
"well_known_server": { "m.server": "matrix.woltje.com:443" }, // if method=well-known
"srv_record": "_matrix._tcp.mosaic.woltje.com. 3600 IN SRV 10 0 443 matrix.woltje.com.", // if method=srv (documented, operator provisions)
},
"federation": {
"enabled": true, // OPTIONAL. Mode C forces false.
"domain_whitelist": [
// Synapse federation_domain_whitelist — allowlist ONLY
"mosaic.woltje.com",
"mosaic.uscllc.com",
],
"peers": [
// operator-declared peer sites (for room/fleet wiring)
{ "server_name": "mosaic.uscllc.com", "role": "secondary", "fleet_room": true },
],
},
"tls": {
"acme": {
"directory_url": "https://acme.mosaic.woltje.com/acme/acme/directory", // step-ca OR https://acme-v02.api.letsencrypt.org/directory
"ca_kind": "step-ca | letsencrypt", // informational label; the directory_url is the real switch
"challenge": "dns-01 | http-01 | tls-alpn-01",
"account_email": "ops@woltje.com", // ACME account contact
"eab": { "kid": null, "hmac_key_ref": null }, // External Account Binding if the CA requires it (some step-ca provisioners) — secret via SecretBackend
},
"client_tls_mode": "acme | self-signed", // Mode C may use self-signed for local C-S TLS (weaker trust, §8)
},
"secrets": {
"backend": "vault | vaultwarden", // pluggable, install-time choice (§4)
"connection": {
"address": "https://vault.woltje.com:8200", // or Vaultwarden/Bitwarden server URL
"auth_ref": "…", // how the appservice authenticates to the backend (bootstrap, §4/§8)
"namespace_or_org": "mosaic-fleet", // Vault namespace / mount, OR Vaultwarden org id
},
},
},
}
```
### 2.3 The three modes, concretely
Exactly three supported modes (Jason's ruling — no others):
**Mode A — split-domain (identity ≠ host, delegated).** _Jason's PRIMARY._ Federation-capable. This is the recommended production shape because it lets identity live on a clean app-domain while the homeserver runs on a separate operational host.
```jsonc
// Mode A — mosaic.woltje.com identity, matrix.woltje.com host, federated with a second site
{
"topology": {
"mode": "split-domain",
"identity": { "server_name": "mosaic.woltje.com", "server_name_kind": "domain" },
"homeserver": {
"host": "matrix.woltje.com",
"port": 443,
"client_bind": "https://matrix.woltje.com",
"bind_ip": null,
},
"delegation": {
"method": "well-known",
"well_known_server": { "m.server": "matrix.woltje.com:443" },
},
"federation": {
"enabled": true,
"domain_whitelist": ["mosaic.woltje.com", "mosaic.uscllc.com"],
"peers": [{ "server_name": "mosaic.uscllc.com", "role": "secondary", "fleet_room": true }],
},
"tls": {
"acme": {
"directory_url": "https://acme-v02.api.letsencrypt.org/directory", // public LE, or a step-ca directory
"ca_kind": "letsencrypt",
"challenge": "dns-01",
"account_email": "ops@woltje.com",
},
"client_tls_mode": "acme",
},
"secrets": {
"backend": "vaultwarden",
"connection": { "address": "https://vw.woltje.com", "namespace_or_org": "mosaic-fleet" },
},
},
}
```
MXIDs on this instance: `@mosaic_coordinator-1:mosaic.woltje.com`. A human/agent's homeserver is discovered by resolving `.well-known/matrix/server` on `mosaic.woltje.com``matrix.woltje.com:443`.
**Mode B — single-domain (identity == host).** Simpler; the `server_name` _is_ the host. No delegation needed. Federation-capable (still needs DNS + cert on that one domain).
```jsonc
// Mode B — one domain does everything
{
"topology": {
"mode": "single-domain",
"identity": { "server_name": "matrix.example.org", "server_name_kind": "domain" },
"homeserver": {
"host": "matrix.example.org",
"port": 8448,
"client_bind": "https://matrix.example.org",
"bind_ip": null,
},
"delegation": { "method": "none" },
"federation": { "enabled": false, "domain_whitelist": [], "peers": [] }, // optional — off here
"tls": {
"acme": {
"directory_url": "https://acme-v02.api.letsencrypt.org/directory",
"ca_kind": "letsencrypt",
"challenge": "http-01",
"account_email": "admin@example.org",
},
"client_tls_mode": "acme",
},
"secrets": {
"backend": "vault",
"connection": { "address": "https://vault.example.org:8200", "namespace_or_org": "mosaic" },
},
},
}
```
MXIDs: `@mosaic_coordinator-1:matrix.example.org`. Here `server_name == host`, so `@:matrix.example.org` is both the identity domain and where Synapse actually answers.
**Mode C — IP-only standalone (no DNS, no federation).** Fully supported for local/airgapped/homelab-without-DNS. `server_name` is an IP:port literal. **Cannot federate — ever — in this mode** (federation requires a real domain + valid CA cert; §2.4). Client TLS may be self-signed (weaker trust, §8).
```jsonc
// Mode C — airgapped / local, no DNS, standalone only
{
"topology": {
"mode": "ip-only-standalone",
"identity": { "server_name": "192.168.1.50:8448", "server_name_kind": "ip" },
"homeserver": {
"host": "192.168.1.50",
"port": 8448,
"client_bind": "https://192.168.1.50:8448",
"bind_ip": "192.168.1.50",
},
"delegation": { "method": "none" },
"federation": { "enabled": false, "domain_whitelist": [], "peers": [] }, // FORCED false in Mode C
"tls": { "acme": null, "client_tls_mode": "self-signed" }, // may use a private step-ca or self-signed for C-S TLS
"secrets": {
"backend": "vaultwarden",
"connection": { "address": "http://192.168.1.51:8080", "namespace_or_org": "mosaic-local" },
},
},
}
```
MXIDs: `@mosaic_coordinator-1:192.168.1.50:8448`. **Warning surfaced at install:** this `server_name` is an IP literal; if the operator ever wants federation they must move to a domain, which is an **identity re-home** (§5.3, §7).
> **[VERIFY]** Synapse accepts an `ip:port` `server_name` and mints usable MXIDs against it for local/standalone use. This is believed workable for non-federated operation but must be validated against the deployed Synapse version; some Synapse versions/tools assume a DNS-resolvable `server_name`. If an IP literal is rejected, Mode C falls back to a **fabricated local domain** (e.g. `mosaic.local`) resolved via `/etc/hosts` or a local resolver — still standalone-only, same re-home caveat.
### 2.4 The hard federation gate (Jason's HARD STOP)
**Federation REQUIRES DNS + valid certificates. This is a hard stop, enforced by the installer and by the config validator, not a suggestion.**
| Precondition | Why | Enforced where |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `server_name` resolves in public/peer DNS (or delegated target does) | S2S discovery uses `.well-known`/SRV over DNS; peers must resolve you | installer reachability check (§6); config validator rejects `federation.enabled=true` with `server_name_kind=ip` |
| Valid TLS cert on the federation endpoint, chained to a CA the peer trusts | S2S is TLS; a peer validates your cert. Self-signed/untrusted ⇒ peer refuses ⇒ you are defederated | installer cert probe (§6); cert monitor (§3.4) |
| Federation `domain_whitelist` non-empty and mutually consistent with peers | allowlist-only federation (RFC-001 NG5/§6) | config validator |
**IP-only ⇒ federation is impossible.** There is no valid public/peer CA cert for a bare IP in our trust model (and we will not ship a self-signed S2S trust hack — NG5). Therefore **Mode C is standalone-only by construction**, and the config validator makes `mode=ip-only-standalone ∧ federation.enabled=true` an **illegal state that cannot be persisted.**
This is the honest, load-bearing boundary of the whole topology model:
```
DNS + valid cert?
┌─────────────┴─────────────┐
YES NO
│ │
Mode A or B Mode C (IP-only)
federation OPTIONAL STANDALONE ONLY
(opt-in, allowlisted) (federation impossible)
```
---
## 3. Certificate provisioning — one ACME integration
### 3.1 Single integration, directory-URL as the switch
We build **exactly one** cert-provisioning integration: an **ACME client integration**. Both supported CAs are ACME CAs. The operator does **not** choose between two code paths; they choose an **ACME directory URL** and a **challenge type**. That is the entire surface.
| CA choice | What it is | ACME directory URL (illustrative) | Why an operator picks it |
| ----------------------- | ------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **step-ca (Smallstep)** | self-hosted **private** ACME CA | `https://acme.<internal-domain>/acme/<provisioner>/directory` | **Total control**; issues for **private/internal/split-horizon domains** a public CA can't (e.g. `mosaic.internal`, RFC-1918 split-horizon); airgap-friendly; you own the root |
| **Let's Encrypt** | public ACME CA | `https://acme-v02.api.letsencrypt.org/directory` (staging: `.../acme-staging-v02...`) | **Ease of use**; universally trusted chain (ISRG Root X1); zero CA to operate; ideal for public domains |
Because both speak ACME, the same client (account key, order, authorization, challenge, finalize, cert-fetch, renew) drives either. The `ca_kind` label in config is informational for UX; the **`directory_url` is the real determinant**. **[VERIFY]** whether the chosen ACME library requires per-CA quirks (LE rate limits, staging switch; step-ca **External Account Binding** on some provisioners — if EAB is required the `kid`/`hmac_key` come from the SecretBackend, §4).
### 3.2 Challenge-type matrix (which challenge for which topology)
The operator picks one challenge type per the domains they're covering. This is the crux for **public vs private/split-horizon**:
| Challenge | How it proves control | Best for | Cannot / caveat |
| --------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **HTTP-01** | CA GETs `http://<domain>/.well-known/acme-challenge/<token>` on port 80 | **Public, single hostname**, port 80 reachable from CA (Mode B, or Mode A's homeserver host) | Needs inbound :80 from the CA; **cannot** do wildcards; **useless for private domains** a public CA can't reach |
| **DNS-01** | CA checks a `_acme-challenge.<domain>` **TXT** record you publish | **Private / split-horizon / internal domains**, **wildcards**, and any domain where inbound HTTP from the CA is impossible | Requires **programmatic DNS API** access to publish TXT (or manual for step-ca where you own the resolver). **This is the answer for step-ca on private domains** and for Mode A when the homeserver host isn't publicly HTTP-reachable |
| **TLS-ALPN-01** | CA connects TLS on :443 with ALPN `acme-tls/1` | Public host where **:443 is free** but :80 is blocked | Needs the ACME client to own the :443 TLS handshake briefly; awkward behind some reverse proxies — **[VERIFY]** against our proxy (RFC-001 terminates TLS at a reverse proxy) |
**Guidance baked into the installer:**
- **Let's Encrypt + public domain, port 80 open →** HTTP-01 (simplest). Wildcard or no inbound :80 → DNS-01.
- **step-ca + private/internal/split-horizon domain →** **DNS-01** (the private CA can validate against a resolver you control; public HTTP reachability is irrelevant). This is the combination that lets a private homelab domain get real certs.
- **:443-only public host →** TLS-ALPN-01.
### 3.3 Delegation setup for split-domain (Mode A)
For Mode A, the cert and the delegation must agree. Concretely, at install for `server_name=mosaic.woltje.com`, host `matrix.woltje.com`:
1. **Cert(s):** obtain a valid cert for **`matrix.woltje.com`** (the federation/host endpoint — this is where the TLS handshake actually lands). If serving `.well-known` over HTTPS on `mosaic.woltje.com`, that origin **also** needs a valid cert for `mosaic.woltje.com`. So Mode A typically provisions **two** SANs/certs: identity-domain (`mosaic.woltje.com`, serves `.well-known`) and host (`matrix.woltje.com`, serves S2S+C-S). **[VERIFY]** whether a single multi-SAN cert is preferable operationally.
2. **Delegation record**, one of:
- **`.well-known`:** serve `https://mosaic.woltje.com/.well-known/matrix/server``{"m.server":"matrix.woltje.com:443"}` (and `.well-known/matrix/client` for C-S discovery so agents/Element find the host).
- **SRV:** `_matrix._tcp.mosaic.woltje.com. IN SRV 10 0 443 matrix.woltje.com.` The installer **documents and validates** the record but the operator provisions it in their DNS (we don't run their DNS). **[VERIFY]** `.well-known` vs SRV precedence on the deployed Synapse.
3. **Validate:** installer fetches the operator's own `.well-known`/SRV and confirms it points at the configured host, and that the host presents a valid cert (§6). Only then does it declare Mode A "federation-ready."
### 3.4 Renewal & monitoring — a lapsed federation cert silently defederates
**This is the operational trap and it must alarm.** ACME certs are short-lived (LE = 90 days; step-ca often shorter by policy). A federation cert that lapses does **not** throw a loud error — peers simply **stop trusting the S2S handshake and the site silently drops out of federation.** From inside, everything looks fine; from peers, the site went dark. That is exactly the "homelab went dark and took comms with it" trauma (RFC-001 §5), but caused by a cert, not a host.
Requirements:
- **Auto-renew** on the standard ACME schedule (renew at ~⅓ lifetime remaining; LE guidance ~30 days before expiry). The ACME integration owns this loop.
- **Expiry monitoring as a first-class alarm.** Emit cert-days-remaining into OTEL/Jaeger metrics (consistent with RFC-001 §8's "monitor for cert expiry — a cert lapse silently defederates"). Alarm thresholds (e.g. warn <14d, critical <3d) are **runtime-tunable** config (§5).
- **Federation-health probe:** periodically resolve our own delegation and validate our own cert _as a peer would_ (external vantage where possible), so a broken renewal is caught as "we would fail a peer's validation" before a peer notices.
- **Escalation tie-in:** a critical cert-expiry or federation-health failure raises a `mosaic.escalation` (RFC-001 §4.2/§5) into the HIL room. A cert lapse is a fleet-visibility incident, not a silent config drift.
---
## 4. Secret backend interface
### 4.1 The `SecretBackend` contract
A single pluggable interface. The appservice and orchestrator depend on the **interface**, never on Vault or Vaultwarden directly. Chosen at install; swappable without touching callers. Illustrative contract (decomposition-ready, not frozen):
```ts
interface SecretBackend {
// --- static secret CRUD (appservice tokens, ACME EAB, DB creds) ---
get(ref: SecretRef): Promise<SecretValue>;
put(ref: SecretRef, value: SecretValue, opts?: { immutable?: boolean }): Promise<void>;
rotate(
ref: SecretRef,
next: SecretValue,
): Promise<{ previous: SecretVersion; current: SecretVersion }>;
list(prefix: SecretRef): Promise<SecretRef[]>;
delete(ref: SecretRef): Promise<void>;
// --- agent-credential lifecycle (the fleet-identity part) ---
enrollAgent(input: {
agentSlug: string;
scope: CredentialScope; // which rooms/secrets this agent may read
ttl?: Duration; // ephemeral-by-default per RFC-001 §8
}): Promise<AgentCredentialHandle>; // wraps the per-agent access_token + optional pubkey record
revokeAgent(agentSlug: string): Promise<void>; // must be authoritative & immediate
// --- health / bootstrap ---
health(): Promise<BackendHealth>;
authenticateSelf(bootstrap: BootstrapAuth): Promise<void>; // how the appservice/orchestrator logs into the backend
}
```
Design intent: **`get/put/rotate`** cover the static crown-jewel secrets (appservice `hs_token`/`as_token`, ACME account/EAB keys, DB DSN). **`enrollAgent/revokeAgent`** cover the _fleet-identity_ lifecycle — this is where RFC-001's "mint per-agent token at enroll, discard on teardown" (RFC-001 §4.1, §8) actually lands.
### 4.2 How appservice / agent tokens map onto it
RFC-001 defines three tiers of Matrix secret. They map cleanly:
| RFC-001 secret | Sensitivity | `SecretBackend` treatment |
| ------------------------------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`as_token`** (AS→HS, acts as any namespaced user) | crown jewel | `put(immutable-ish)` + `rotate`; **only** the appservice may `get` it; stored under a fleet-admin scope; never handed to an agent |
| **`hs_token`** (HS→AS callback auth) | crown jewel | same as `as_token`; both live only in appservice scope (RFC-001 §8) |
| **enroll bootstrap secret / orchestrator-signed nonce** | high | `get` by orchestrator + appservice only; used to authenticate `POST /enroll` so a rogue process can't enroll a rogue agent (RFC-001 §8, B5) |
| **per-agent `access_token`** | transient runtime | minted via `enrollAgent`, scoped to that agent, **short-TTL / re-mintable**, discarded on teardown; **not** durably persisted (RFC-001 §8 says per-agent tokens are transient) — the backend may hold a short-lived handle or a personal-vault entry for the agent's own lifetime |
| **Ed25519 signed-authorship keypair** | high (private key) | private key generated **agent-side**, only pubkey leaves the agent (RFC-001 §4.4/§8); the SecretBackend stores the **pubkey record** for audit; per-spin keys need no at-rest custody |
The key blast-radius property (RFC-001 §8) is preserved: agents receive **only their own** credential via `enrollAgent`; the `as_token` never leaves appservice scope.
### 4.3 Vault implementation
Vault maps naturally:
- Static secrets → **KV v2** at a mount/namespace (`mosaic-fleet/`), with versioning giving `rotate` semantics for free.
- **`enrollAgent`** → issue a scoped, TTL'd token or use **AppRole** / a scoped policy per agent; Vault's native TTL + revocation is exactly the transient per-agent model. `revokeAgent` → Vault token/lease revoke (authoritative, immediate).
- **`authenticateSelf`** → the appservice authenticates to Vault via AppRole (role_id from config, secret_id injected at deploy) or a platform auth method; consistent with how Gateway/DB secrets are handled today (RFC-001 §8). **[VERIFY]** align with whatever KBN-101 lands for Mosaic secret management (CLAUDE.md flags secrets work in flight).
- **Trade-off (honesty):** Vault is the most capable backend but is **not** the free-and-simple default for a hobbyist stranger; hence it must not be _forced_ (G6).
### 4.4 Vaultwarden implementation + the org/enroll/revoke agent-account model
Vaultwarden (self-hostable Bitwarden-compatible server) is the **open-source-ethos default candidate** — free, self-hostable, familiar. Jason's model, mapped onto Bitwarden/Vaultwarden's org primitives:
1. **Operator creates one or more Bitwarden orgs** at install (e.g. `mosaic-fleet`).
2. **The orchestrator is enrolled into the org and granted authority** to enroll/revoke agent sessions — it is the org's automation principal (admin/manager over an agents **collection**).
3. **Agents get scoped credential access:** each agent gets access to a **collection** (or a personal vault provisioned for its spin) holding exactly the secrets its scope allows. `enrollAgent` = grant the agent principal access to its collection + provision its per-agent Matrix token entry; `revokeAgent` = remove the agent principal / revoke its access, immediately.
4. **User + agents share scoped access:** the human operator and the agents both hold credentials in the same org, scoped by collection — humans and agents on one secret surface, mirroring RFC-001's "humans and agents on one comms surface" pattern.
Mapping to the interface:
| Interface op | Vaultwarden mechanism |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `get/put/rotate` (static) | items in an org **collection**; rotate = new item version / replace + old-version audit |
| `enrollAgent` | create/attach agent principal to its **collection**; provision per-agent token item, TTL enforced by our teardown (Vaultwarden itself is not TTL-native — see [VERIFY]) |
| `revokeAgent` | revoke the agent principal's org membership / collection access |
| `authenticateSelf` | orchestrator authenticates as the org automation principal |
**HONEST MATURITY FLAG — [VERIFY]:** Bitwarden's clean **machine-account / service-account** primitive lives in **Bitwarden Secrets Manager**, and **Vaultwarden's coverage of Secrets Manager / machine accounts is partial and evolving.** What is known to work today on Vaultwarden: **orgs, collections, per-user (incl. a per-agent "user") vaults, and collection-scoped sharing.** What **may not** be fully there: the polished **machine-account API**, native short-TTL service credentials, and fine-grained programmatic access-token issuance equivalent to hosted Bitwarden Secrets Manager. **[VERIFY]** current Vaultwarden version's Secrets Manager / machine-account support before P-level commitment.
**Why this is not a blocker:** the interface is designed so **either backend is viable**. If Vaultwarden's machine-account API isn't ready, the Vaultwarden adapter implements `enrollAgent` via the **personal-vault-per-agent + org-collection** model that works _today_ (create an agent principal, share the scoped collection, we enforce TTL via orchestrator teardown rather than backend-native TTL). If an operator needs backend-native short-TTL machine credentials now, they choose the **Vault** adapter. **We are not blocked on Vaultwarden maturing**, because the `SecretBackend` abstraction lets the polished-machine-account behavior land later without changing any caller.
---
## 5. Config system
### 5.1 Storage & precedence
Config is **DB-backed** (Postgres, per the stack — CLAUDE.md/RFC-001), with sane defaults compiled into the product and install-time overrides. **Precedence, highest wins:**
```
install-time value > DB override (runtime) > compiled default
```
- **Compiled default** — ships in the product; what a stranger gets with zero config for every non-topology-critical key.
- **Install-time value** — captured by the installer (§6), written to DB, and for **install-time-immutable** keys, **locked** (marked non-overridable).
- **DB override** — runtime tuning via admin surface, allowed **only** for keys classified runtime-tunable.
> Nuance: "install-time > DB override" applies to **immutable** keys — the install-time value is frozen and a DB override of it is rejected. For **tunable** keys, the DB override is the live value and the install-time value is just the initial seed. The classification (§5.3) is what makes the precedence unambiguous per key.
### 5.2 DB schema shape
Illustrative (Drizzle/Postgres, per stack conventions):
```sql
-- one row per config key
CREATE TABLE comms_config (
key text PRIMARY KEY, -- e.g. 'topology.identity.server_name'
value jsonb NOT NULL, -- current effective value
source text NOT NULL, -- 'install' | 'db-override' | 'default'
mutability text NOT NULL, -- 'install-immutable' | 'runtime-tunable'
set_by text, -- operator/agent/system that set it
set_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT immutable_not_overridable
CHECK (NOT (mutability = 'install-immutable' AND source = 'db-override'))
);
-- append-only audit of every change (esp. attempted immutable changes)
CREATE TABLE comms_config_audit (
id bigserial PRIMARY KEY,
key text NOT NULL,
old_value jsonb,
new_value jsonb,
actor text NOT NULL,
action text NOT NULL, -- 'set' | 'override' | 'rejected-immutable'
at timestamptz NOT NULL DEFAULT now()
);
```
The DB `CHECK` is a belt-and-braces backstop; the application-layer config service enforces mutability and records rejected immutable-change attempts in the audit table. Secrets are **referenced** here (a `SecretRef`), never stored inline — actual secret values live in the `SecretBackend` (§4).
### 5.3 Install-time-immutable vs runtime-tunable — the key table
The single most important classification: **what can never change after install** vs **what an operator tunes anytime.** Getting `server_name` on the wrong side of this line is a foot-gun that orphans every identity.
| Config key | Mutability | Rationale / cost of change |
| ------------------------------------------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `topology.mode` (A/B/C) | **install-immutable** | changing mode changes identity/federation semantics; a mode change is effectively a reinstall/re-home |
| `topology.identity.server_name` | **install-immutable** | **baked into every MXID and room alias.** Changing it re-homes every identity — see re-home note below. **This is THE immutable value.** |
| `topology.identity.server_name_kind` | **install-immutable** | domain↔ip change is a re-home (Mode C→A/B) |
| `topology.homeserver.host` | install-immutable (**delegation-tunable**) | in Mode A you _can_ move the host if you update delegation to match — the identity is unchanged; treat as immutable-with-migration, not free |
| `topology.homeserver.bind_ip` / `port` | runtime-tunable (ops) | operational network binding; no identity impact |
| `topology.delegation.method` / records | tunable-with-care | can switch well-known↔SRV as long as both still resolve to the same host; validated on change |
| `topology.federation.enabled` | **runtime-tunable (gated)** | can flip **on** only if DNS+cert preconditions pass (§2.4); flipping standalone→federated is the §7 upgrade path |
| `topology.federation.domain_whitelist` / `peers` | runtime-tunable | add/remove peers over time; each add re-validated |
| `tls.acme.directory_url` / `ca_kind` | runtime-tunable | can switch CA (e.g. LE→step-ca); triggers re-issue; monitor for trust-chain change |
| `tls.acme.challenge` | runtime-tunable | switch challenge type if DNS/HTTP reachability changes |
| `tls.client_tls_mode` | tunable-with-care | self-signed→acme is fine; acme→self-signed weakens trust (§8) |
| `secrets.backend` | install-immutable (**migration-only**) | switching Vault↔Vaultwarden requires a secret migration; not a live flip |
| `secrets.connection.*` | runtime-tunable | rotate backend address/auth without changing which backend |
| `presence.heartbeat_interval_ms` | runtime-tunable | RFC-001 §4.5 default ~30s; pure tuning |
| `presence.miss_tolerance` | runtime-tunable | RFC-001 §4.5 default 2 |
| `escalation.dark_threshold_min` | runtime-tunable | RFC-001 §5/§11 — default 10min → fallback |
| `escalation.hil_threshold_min` | runtime-tunable | RFC-001 §5 — default +5min → HIL |
| `cert.expiry_warn_days` / `expiry_critical_days` | runtime-tunable | §3.4 alarm thresholds (default 14 / 3) |
| `federation.health_probe_interval` | runtime-tunable | §3.4 |
**Identity re-home note (the cost of changing `server_name`):** because every MXID (`@mosaic_*:server_name`) and room alias (`#…:server_name`) embeds `server_name`, changing it means: every agent gets a **new identity**, all rooms must be **recreated/re-aliased**, signed-authorship pubkey records re-published, and federation peers re-pointed. There is **no in-place rename** in Matrix. Hence `server_name` is install-immutable and the installer gates it behind an explicit warning (§6.7). Changing it is a **migration/reinstall**, honestly (§7).
### 5.4 How the appservice / homeserver read config
- **Synapse (homeserver)** reads a _rendered_ `homeserver.yaml`. The config service **renders** Synapse's config (server_name, listeners, `federation_domain_whitelist`, `enable_registration: false`, appservice registration path, TLS/delegation) from the DB-backed config at deploy/reconfigure time. Synapse itself is not DB-config-aware; the source of truth is the product DB, and Synapse config is a **generated artifact**. Certain Synapse values (notably `server_name`) require a **Synapse restart** and are exactly the immutable ones — reinforcing §5.3.
- **The appservice** reads config **live** from the DB config service for runtime-tunable values (thresholds, whitelist changes, cert alarm thresholds) and from the `SecretBackend` for secrets. Immutable topology values are read once at boot (they can't change under it).
- **`packages/comms` SDK** receives the values it needs (homeserver client URL, presence intervals) from the appservice at enroll (RFC-001 §4.1 returns `{mxid, access_token, homeserver, rooms[]}`), so agents never read the config DB directly.
- **Delegation artifacts** (`.well-known/matrix/server`, `.well-known/matrix/client`) are likewise **rendered** from config and served by the reverse proxy / homeserver.
---
## 6. Installer UX flow
A guided installer (`mosaic comms install` or equivalent) that captures topology, provisions certs, wires secrets, and **validates before declaring success.** It must never report success it hasn't proven. Steps:
**6.1 — Preflight & detection.** Detect existing DNS records for a candidate domain, existing certs, an existing reachable Synapse, an existing Vault/Vaultwarden. Offer detected values as suggestions (never as silent defaults). Detect whether the host has public inbound :80/:443 (informs challenge-type guidance, §3.2).
**6.2 — Primary instance (ALWAYS).** The PRIMARY/home instance is always configured — there is no "skip primary." Prompt for its identity. This is non-optional and is what makes standalone work out of the box.
**6.3 — Pick topology mode (A/B/C).** Ask the shape:
- Do you have a domain? **No →** Mode C (IP-only standalone); warn federation is impossible here (§2.4) and that `server_name` will be an IP (re-home cost if they later want federation).
- Yes, and identity domain differs from the homeserver host? **Yes →** Mode A (split-domain); capture `server_name` + host + delegation method.
- Yes, one domain does everything? **→** Mode B (single-domain).
**6.4 — Pick CA (ACME directory).** step-ca vs Let's Encrypt → capture `directory_url`, account email, and challenge type with the §3.2 guidance surfaced (e.g. "private/internal domain? → DNS-01"). Capture EAB if the CA requires it (→ SecretBackend). For step-ca, offer to point at an existing step-ca or document standing one up.
**6.5 — Pick secret backend.** Vault vs Vaultwarden → capture connection (address, org/namespace, bootstrap auth). If Vaultwarden, walk the org/collection setup (§4.4) and **surface the machine-account maturity [VERIFY]** honestly so the operator chooses eyes-open.
**6.6 — Federation (OPTIONAL).** Only offered if Mode A/B. Ask whether to enable federation now; if yes, capture peer `server_name`s and build the `domain_whitelist`. If Mode C, federation is not offered (greyed out with the explanation). Federation-off is a first-class, fully-supported outcome.
**6.7 — The "what can't be changed later" warning gate.** Before writing immutable config, present an explicit confirmation:
```
⚠ IMMUTABLE CHOICES — read before confirming
server_name = "mosaic.woltje.com"
This becomes part of every agent identity (e.g. @mosaic_coordinator-1:mosaic.woltje.com)
and every room alias. It CANNOT be changed later without re-homing every identity
(new MXIDs for all agents, recreating all rooms). There is no in-place rename in Matrix.
topology.mode = "split-domain" — changing modes later is a reinstall.
secrets.backend = "vaultwarden" — switching backends later requires a secret migration.
Type the server_name to confirm you understand it is permanent: ____________
```
The operator must **retype `server_name`** to proceed — a deliberate friction gate on the one truly permanent value.
**6.8 — Provision & validate (no success claim until proven).** The installer then:
1. Renders Synapse config + delegation artifacts; brings up Synapse with `enable_registration: false`.
2. Runs the ACME flow; obtains cert(s); verifies they're valid and installed.
3. Authenticates to the SecretBackend; stores `hs_token`/`as_token`, enroll bootstrap; runs `health()`.
4. **Reachability & cert validation** appropriate to mode:
- Mode A: fetch our own `.well-known`/SRV, confirm it points at host; TLS-probe host cert as a peer would; confirm C-S discovery.
- Mode B: TLS-probe the single domain; confirm C-S + (if federation) S2S.
- Mode C: confirm local C-S reachability over the bind IP; confirm (self-signed or private-CA) client TLS; **explicitly report "standalone — federation not available."**
- If federation enabled: validate each peer resolves + presents a peer-trusted cert; confirm `domain_whitelist` mutual consistency. If any peer fails, **federation is reported NOT-ready** — the primary still succeeds standalone.
5. **Only now** declare success, with a per-capability report: `PRIMARY: ✅ | CERT: ✅ (expires in 90d, auto-renew on) | SECRETS: ✅ (vaultwarden) | FEDERATION: ✅ 1 peer / ⚠ not-ready / ⛔ n-a (Mode C)`.
**6.9 — Post-install.** Emit the cert-expiry monitor + federation-health probe (§3.4) into OTEL; write config to DB with correct mutability flags; print the immutable-values summary again for the record.
---
## 7. Standalone → federated upgrade path
An operator who started standalone later wants federation. The path depends on **whether they started with a domain**:
**Case 1 — started Mode A or B (had a domain), federation was just off.** _Cheap, no re-home._ `server_name` is already a real domain and identities are already minted against it. To federate:
1. Ensure DNS resolves for peers (their `server_name`s and yours) — likely already true.
2. Ensure a **valid, peer-trusted cert** on the federation endpoint (if they were running client-only self-signed, they now need a real ACME cert; if already ACME, done).
3. Set `federation.enabled = true`, populate `domain_whitelist` + `peers` (all runtime-tunable, §5.3).
4. Re-run the installer's **federation validation** (§6.8 step 4) against each peer. On green, federation is live. **No identity change** — existing MXIDs simply become reachable cross-site. This is the intended, low-friction upgrade.
**Case 2 — started Mode C (IP-only), now wants federation.** _Expensive — an identity re-home, and we say so plainly._ Federation requires DNS + a valid cert (§2.4), which an IP `server_name` can never satisfy. So the operator must:
1. **Acquire a domain** and DNS, and provision a **valid ACME cert** (LE public, or step-ca if the domain is private — but note a _private_ domain can only federate with peers who trust that private CA root, §8).
2. **Change `server_name` from the IP literal to the domain** — this is the **install-immutable value**, so this is a **re-home, not a config tweak**:
- Every agent identity `@mosaic_*:192.168.1.50:8448` becomes `@mosaic_*:newdomain` — **all new MXIDs.**
- Every room + alias must be **recreated** under the new `server_name`.
- Signed-authorship pubkey records re-published under the new identities.
- Any durable references to old MXIDs (escalation policies, fallback-coordinator targets, RFC-001 §5) must be re-pointed.
3. Effectively: **treat it as a fresh install in Mode A/B with a data migration of rooms/history**, not an in-place flip. Matrix has **no in-place `server_name` rename**; this cost is intrinsic to Matrix, not to our design.
**Honest guidance the installer gives Mode C operators up front (§6.3):** "If there is _any_ chance you'll want to federate later, start with a domain (Mode A/B) even if you keep federation off — flipping federation on later is free, but changing an IP `server_name` to a domain later is a full identity re-home." This lets an informed operator avoid the expensive path by choosing Mode B-with-federation-off instead of Mode C.
---
## 8. Security
**8.1 — Cert trust model per CA choice.**
- **Let's Encrypt (public):** chains to a universally-trusted root (ISRG). Peers, humans' browsers, and Element trust it with no extra distribution. Best for public domains; nothing to distribute.
- **step-ca (private):** chains to a **root you operate**. Nothing trusts it by default. Therefore the **step-ca root must be distributed** to everyone who validates certs: peer homeservers (so cross-site S2S validates — a peer must add your root to its federation trust store, **[VERIFY]** Synapse's mechanism for trusting a custom federation CA), agent hosts, and any human client. This is the price of "total control" and airgap capability. For **federation between two private-CA sites**, both sites must trust each other's roots (or a shared root). Getting this wrong reproduces the silent-defederation failure (§3.4) — a peer that doesn't trust your root silently refuses your S2S.
- **Mode C self-signed client TLS:** weakest — see 8.4.
**8.2 — Federation whitelist.** `federation_domain_whitelist` is a **hard allowlist** (RFC-001 §6/NG5): only listed Mosaic site domains may federate; no public-network federation. The installer/config validator keeps the whitelist consistent with the declared peer list. Adding a peer is an explicit, audited config change.
**8.3 — Secret-backend auth.** The appservice/orchestrator authenticate to the `SecretBackend` via a **bootstrap credential injected at deploy** (Vault AppRole secret_id, or Vaultwarden org automation principal), never committed, consistent with existing Gateway/DB secret handling (RFC-001 §8). The `as_token`/`hs_token` live **only** in backend + appservice memory; agents get only their own scoped, re-mintable token (§4.2). Enroll is authenticated (RFC-001 B5) so a rogue local process can't enroll a rogue agent. Backend access is scoped: an agent's credential can read only its collection/policy, never the fleet-admin scope holding the crown jewels.
**8.4 — Honest note: IP-only standalone with self-signed client TLS is a weaker-trust local mode.** In Mode C, client TLS may be self-signed (or a local private CA). This means: no third party vouches for the endpoint; clients must be told to trust the self-signed cert (TOFU or manual root import); there is no external validation of who's on the other end. This is **acceptable and supported for local/airgapped/homelab** use where the network is already trusted, but it is **explicitly a weaker trust posture** than a real CA. The installer states this plainly at install (§6.8 Mode C). It is one more reason Mode C cannot federate: we will not extend this weaker-trust local posture across sites (NG5).
**8.5 — Homeserver hardening** (inherited from RFC-001 §8, config-rendered here): `enable_registration: false` always (agents come only via the appservice), rate-limiting on, admin API bound to localhost/behind auth, media repo locked/disabled if unused, TLS terminated at our controlled proxy. These are **rendered from config** (§5.4) so a stranger gets them by default, not by remembering to set them. **[VERIFY]** current recommended Synapse hardening flags at implementation.
---
## 9. How RFC-002 integrates with RFC-001's P1P5
RFC-002 is the **substrate**. Each RFC-001 phase consumes a subset of it. Critically, **P1 does not need the hard parts** — presence ships on a single clean-domain instance with no federation, no IP-only, and no secret-rotation story resolved.
| RFC-001 phase | RFC-002 pieces it NEEDS | RFC-002 pieces it does NOT need yet |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **P1 — Presence (first shippable slice)** | **Mode A or B, single-instance, clean domain** (§2.3). One ACME cert (LE or step-ca) via the single integration (§3). Config system minimal: `server_name` immutable + presence thresholds tunable (§5.3). Installer path 6.16.3, 6.4 (cert), 6.7 (immutable gate), 6.8 (validate single-instance). A **minimal** SecretBackend just holding the appservice token. | **No federation** (§2.4 gate irrelevant — single site). **No IP-only** needed for P1 (P1 wants a clean domain so Element/humans connect cleanly). **No agent-credential rotation** maturity. **No peer/whitelist** config. Vaultwarden machine-account [VERIFY] does **not** block P1. |
| **P2 — Appservice + auto-enroll** | Full `SecretBackend` **`enrollAgent`/`revokeAgent`** (§4.1), `hs_token`/`as_token` custody (§4.2), enroll-bootstrap secret. Config: room taxonomy, per-agent token classification. Chosen backend (Vault or Vaultwarden) real. | Federation, cross-site, IP-only. |
| **P3 — MACP v1 spec** | Config keys for MACP versioning/thresholds as runtime-tunable (§5.3); nothing new topology-wise. | Federation, secret rotation-in-anger. |
| **P4 — Federation** | **The whole federation half of RFC-002:** Mode A delegation (§3.3), the hard DNS+cert gate (§2.4), `domain_whitelist`+peers config, cert monitoring/silent-defederation alarm (§3.4), per-CA trust distribution for cross-site (§8.1). This is where standalone→federated (§7 Case 1) and Jason's `mosaic.woltje.com``mosaic.uscllc.com` shape land. | IP-only (federation excludes it by construction). |
| **P5 — Hardening + signed-authorship + Hermes retired** | Secret **rotation runbooks** executed in anger (§4, RFC-001 E2), pubkey-record custody for Ed25519 (§4.2), cert-rotation runbook (§3.4), full homeserver hardening validated (§8.5), backend auth review (§8.3). | — |
**One-line integration statement:** P1 rides on the _smallest_ slice of RFC-002 (single clean-domain instance + one cert + minimal config + minimal secret storage); the federation/IP-only/backend-maturity complexity is deferred to exactly the phases that need it (mostly P4/P5). RFC-002 therefore does not gate P1.
---
## 10. Open questions
Deliberately few — most topology/cert/secret decisions are resolved by Jason's rulings and baked in above.
1. **[VERIFY] IP-only `server_name` acceptance.** Does the deployed Synapse version accept an `ip:port` `server_name` and mint usable MXIDs for standalone (§2.3)? If not, Mode C uses a fabricated local domain (`mosaic.local` via local resolver) — confirm which, since it affects the re-home wording for Mode C→A/B (§7).
2. **[VERIFY] Vaultwarden machine-account maturity.** Confirm the current Vaultwarden version's Secrets Manager / machine-account coverage (§4.4). Determines whether the Vaultwarden adapter's `enrollAgent` uses native machine accounts or the personal-vault-per-agent + collection model. Does **not** block (interface absorbs either), but sets P2 expectations.
3. **[VERIFY] step-ca root distribution for cross-site federation.** Confirm Synapse's supported mechanism for trusting a **custom federation CA root** (§8.1) so two private-CA sites can federate. If Synapse won't easily trust a private federation CA, private-domain federation may in practice require public certs (LE) on the federation SANs even when internal traffic uses step-ca.
4. **Default secret backend for the published installer.** Given the open-source ethos (G6), should the installer _default-suggest_ Vaultwarden (free, self-hostable) while clearly offering Vault, or present them neutrally? Recommendation: suggest Vaultwarden as the zero-cost path with the maturity caveat surfaced, Vault as the "I need native short-TTL machine creds now" path. Jason to confirm the framing.
5. **Single multi-SAN cert vs two certs in Mode A** (§3.3) — operational preference for identity-domain + host coverage. Minor; validate during P4.
6. **Reconfigure-time Synapse restart policy.** Which rendered-config changes (§5.4) require a Synapse restart vs hot-reload on the deployed version, so the config service knows when a tunable change needs a bounce. **[VERIFY]** at implementation.
---
## Appendix A — Real mechanics this RFC leans on (quick reference)
- **`server_name`** — Synapse identity domain; the `:suffix` of every MXID/alias; install-immutable; changing it = re-home (no in-place rename). Distinct from where Synapse _listens_.
- **Delegation**`https://<server_name>/.well-known/matrix/server``{"m.server":"host:port"}` and/or `_matrix._tcp.<server_name>` **SRV**; how identity-domain ≠ host is reconciled (Mode A). **[VERIFY]** precedence on deployed Synapse.
- **`.well-known/matrix/client`** — C-S discovery so agents/Element find the homeserver host from the identity domain.
- **`federation_domain_whitelist`** — Synapse allowlist; only listed domains federate; our hard no-public-federation boundary.
- **ACME** — single provisioning protocol for both CAs; operator supplies **directory URL** (step-ca vs Let's Encrypt) + **challenge type**.
- **HTTP-01 / DNS-01 / TLS-ALPN-01** — challenge types; **DNS-01 is the one for private/split-horizon/wildcard**; HTTP-01 for public single host with :80; TLS-ALPN-01 for :443-only public.
- **step-ca ACME provisioner** — Smallstep's self-hosted CA exposing an ACME directory; enables private/internal-domain certs and total control; may require **EAB**; root must be distributed to validators.
- **Let's Encrypt** — public ACME CA; universally-trusted chain; 90-day certs; staging endpoint for testing.
- **Bitwarden/Vaultwarden org + collection + machine/service-account** — org holds collections; collections scope access; machine/service accounts (Bitwarden **Secrets Manager**) are the clean automation primitive but **Vaultwarden coverage is partial/evolving [VERIFY]**; personal-vault-per-agent + org-collection works today.
- **Vault KV v2 / AppRole / lease-TTL / revoke** — the capable backend; native versioning=rotate, TTL+revoke=transient per-agent creds.
- **Silent defederation** — a lapsed/renewal-failed federation cert causes peers to stop trusting S2S with no local error; must be monitored + alarmed (§3.4).
_All Matrix/ACME/secret-backend mechanics above are cited from architecture knowledge and MUST be re-verified against the actually deployed versions during implementation — every **[VERIFY]** is a checkpoint, not an assumption. Every illustrative domain (`mosaic.woltje.com`, `mosaic.uscllc.com`, `matrix.woltje.com`) is an operator-supplied example, never a product default or literal._
@@ -0,0 +1,118 @@
# RM-61 — CI contract exemption for #1000 teardown artifact
**Tracking:** RM-61 / issue #1000
**Branch:** `fix/rm-61-ci-contract-exemption`
**Owner:** `coder-mos1`
## Objective
Determine, by red-first provider controls, whether the `ci-postgres` pod-not-found teardown signature discriminates from a real PostgreSQL failure. Only if it discriminates may a named, bounded CI-contract exemption be implemented. The exemption must retire when #1000 is fixed; fixing #1000 is the closure path.
## Pre-registered kill criterion
If an injected real `ci-postgres` failure also yields `pods "wp-svc-<ULID>-ci-postgres" not found` as the service's provider-visible failure, the signature does not discriminate. Option B is unsafe; stop exemption implementation and fall to Option A (#1000).
## Plan
1. Capture full `-f json` records for the 11 supplied observations and state counts.
2. Run one startup-failure control using the real pgvector/PostgreSQL image with an invalid `initdb` argument.
3. Run one post-readiness crash control using real PostgreSQL, `pg_isready`, and a deliberate postmaster kill while a DB-dependent probe is active.
4. Compare the raw `ci-postgres` service record independently of failures in dependent steps.
5. Investigate runner/time/head clustering only as a hypothesis; never encode incidental correlates or retries into policy.
6. If and only if the controls discriminate, implement and test the exact exemption, document its two-way boundary, and track retirement at #1000.
## Budget
No explicit token cap supplied. Working estimate: 20K30K tokens. Limit provider controls to the two pre-registered runs; no retries or re-roll policy.
## Initial evidence
Historical JSON saved locally under `.evidence/rm-61/` (not for commit). Supplied pipelines: 11 total. Child-step counts: five pipelines with 9 children and six with 10 children. Seven contain the `ci-postgres` pod-not-found failure (#2170, #2175, #2180, #2181, #2182, #2187, #2188); four do not (#2158, #2167, #2184, #2186). Every observed workflow reports `agent_id=44`, so the available JSON does not separate clean and artifact runs by runner. This refutes runner identity as a discriminator in the sampled record.
## Progress
- [x] Requirements and kill criterion recorded before control implementation.
- [x] Historical full-JSON records captured.
- [x] Startup-failure control observed terminal.
- [x] Post-readiness crash control observed terminal.
- [x] Discrimination verdict recorded: Option B may proceed.
- [x] Conditional exemption implementation.
## Tests / evidence
### Control 1 — real startup failure
- Commit: `3931b0e29eb834914f7b17e4db7e221481d436fa`
- Pipeline: #2189, exact commit match.
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
- `ci-postgres`: `state=failure`, `exit_code=1`, `error=null`, with a five-second execution window.
- `test`: `state=failure`, `exit_code=1` after the readiness budget expired.
- Pipeline/workflow: terminal `failure`.
This control is red and its service record differs from #1000 (`exit_code=0` plus pod-not-found). It proves the startup-failure direction only. It does not settle the dangerous post-readiness crash/garbage-collection path.
### Control 2 — real post-readiness crash
- Commit: `25ac59715a94dd1b52ef42577472eb44ecc4b446`
- Pipeline: #2191, exact commit match.
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
- Service log proves PostgreSQL reached `database system is ready to accept connections`, the test created the arm table, and the service then killed postmaster PID 7.
- Test log proves a successful `SELECT 1` followed by `Connection refused`; it exited the pre-registered control code 61.
- `ci-postgres`: `state=failure`, `exit_code=137`, `error=null`, with a 203-second execution window.
- `test`: `state=failure`, `exit_code=61`.
- Pipeline/workflow: terminal `failure`.
This is the dangerous post-readiness crash path. Its service record is not pod-not-found and therefore differs from #1000 independently of the dependent test failure.
### Discrimination verdict
Both real failures are provider-visible as process exits (`exit_code=1` startup; `exit_code=137` crash) with no pod-not-found error. The seven observed #1000 artifacts are provider reconciliation misses (`exit_code=0` plus the exact pod-not-found error). The declared kill criterion did not fire, so Option B may proceed with a matcher requiring the full conjunction. This evidence does **not** prove every future Kubernetes failure is distinguishable; it proves these two concrete real-failure classes remain blocking and bounds the exemption to the observed reconciliation shape.
### Unit red-first checkpoint
The nine-case contract harness was written before the verifier. First execution exited 1 because `verify-terminal-green.py` did not exist; no exemption implementation was live. Cases pre-register ordinary green, the exact artifact, both provider controls, near-miss signatures, an independent failure, and a skipped step.
### Control 2 setup attempt — invalid, excluded from evidence
- Commit: `9455cd6a2650b2b7e70f746c07933d96e5cb3d20`
- Pipeline: #2190, exact commit match.
- Full JSON child scan: 9 total — 7 success, 2 failure, 0 skipped/pending/running.
- Service log: `/bin/sh: 0: -c requires an argument`.
- Root cause: Woodpecker service `commands` did not become the third `sh -c` argument. PostgreSQL never started, so this run is **not** the post-readiness crash control and provides no discrimination evidence.
- Focused remediation: place the script directly in the third `entrypoint` element and supply `PGPASSWORD` for the marker query. This is a control-fixture correction, not a retry of #1000 and not evidence for either verdict.
## Implementation evidence
- `verify-terminal-green.py` consumes only the full JSON/API record; it performs no fetch, retry, or trigger.
- Exact #2188 record: exit 0, 10 children, 9 success + 1 named exemption.
- Historical set: #2158/#2167/#2184/#2186 pass with no exemption; #2170/#2175/#2182/#2187/#2188 pass with one named exemption; #2180/#2181 remain red because independent failures exist.
- Provider controls: #2189 and #2191 both exit 1 under the verifier; neither is exempted.
- Unit harness: initial 9/9 cases passed after the red-first checkpoint; review remediation expands this to 12 cases with expected-head match/missing/mismatch coverage.
- Test-membership guard: PASS, population 45; 26 enumerated, 19 signed exclusions; all 39 surface paths present.
- Python compile: PASS.
- `pnpm typecheck`: PASS, 45/45 tasks.
- `pnpm lint`: PASS, 25/25 tasks.
- `pnpm format:check`: PASS after moving local evidence outside the repository tree.
- `test:framework-shell`: RM-61 and all preceding suites passed, then the pre-existing wake assertion aborted with exit 97 because this host's Bash 5.2.15 reports `BASH_LINENO [3 5]` where that suite requires `[3 4]`. RM-61 does not modify the wake suite; the command is not fully runnable on this host as written and no substitute result is claimed.
## Independent review
- Review 67 / comment 20403 at exact head `e7b29219e11efd0a19395156ac0b154bec0c3a73`: **REQUEST CHANGES**.
- Blocker: the verifier echoed the pipeline commit but did not bind it to the current PR head; mutating only #2188's commit still returned terminal-green.
- Remediation: require `--expect-commit <full-40>`, add a pipeline anomaly on missing/mismatched record commits, emit expected and observed values, wire both CI documentation and the merge-gate baseline to pass provider PR head, and add match/missing/mismatch tests.
- This binding is not prohibited head-based clustering policy: it proves the evidence belongs to the commit under verdict. Runner/node/time/head correlation remains excluded from the teardown signature itself.
- Review 69 later approved the commit-binding remediation at exact head `033b2ffb46674b2c0bcc5197273c109b461f62d9`; pipeline #2193 was 9/9 success. Before merge-gate, an independent adjudicator found that Python treats JSON `false == 0`, allowing a non-integer exit value to match. The prior gate-ready state was withdrawn. The type-strict set distinguishes genuine red-first controls (`false`, `0.0`, which wrongly exempted) from regression guards (`true`, `"0"`, `null`, which already blocked). Remediation requires the decoded type to be exactly `int` and excludes `bool` explicitly.
## Documentation checklist
- [x] CI contract documented in the canonical framework CI/CD guide.
- [x] Operator command documented in the Woodpecker tool README.
- [x] Merge-gate baseline points to the deterministic verifier and named retirement.
- [x] Tracking and retirement cite issue #1000.
- [x] Both positive and negative guarantee boundaries are stated.
- [x] No API/auth/schema/user-facing navigation change; OpenAPI, user guide, and sitemap are not applicable.
## Risks
The controls establish discrimination for deterministic startup failure and an armed post-readiness postmaster crash on the current Woodpecker Kubernetes provider. They cannot prove that every future Kubernetes failure mode will preserve a non-zero exit before reconciliation. The exact matcher minimizes that residual risk, and issue #1000 remains the mandatory provider-seam closure and retirement trigger.
@@ -0,0 +1,58 @@
# RM-01 — Reproducible checkout
- Task/ref: RM-01 (`docs/remediation/TASKS.md`, internal mission tracking)
- Objective: make checkout/install/typecheck hooks fail on code rather than environmental residue, for root CI and non-root seats.
- Scope: pnpm store configuration, transactional Husky installation, dependency/generated-state preflight, checkout regression tests, developer documentation.
- Constraints: isolated worktree; no skip-switch fixes; no writes under `/root` or `/tmp`; workers do not edit `docs/remediation/TASKS.md`; author does not review or merge.
- Acceptance: 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
@@ -0,0 +1,120 @@
# RM-03 — CI Queue Guard Repair
- **Task:** RM-03
- **Issue:** #1019
- **Branch:** `fix/rm-03-queue-guard`
- **Owner:** coder-mos1
- **Reviewer:** rev-974 (independent; author != reviewer)
- **Started:** 2026-08-01
## Objective
Repair the mandatory CI queue guard so it reads provider payloads, blocks asserted non-green CI, distinguishes provider unavailability from a real non-green result, and inspects the branch actually being pushed or merged.
## Constraints
- Worktree only: `/home/hermes/agent-work/rm-03`; never mutate `/src/mosaic-stack`.
- JSON payload travels through stdin; never argv. Large payload must remain below no ARG_MAX dependency.
- TDD is mandatory. Every behavior case must be observed red before implementation.
- No bypass flags or hook suppression.
- Do not cite the existing guard's green as evidence; D-23 establishes it is zero-information.
- Gate-ready is a frozen exact head. Any push after a merge-gate verdict voids that verdict.
- No merge: coordinator holds the merge hand pending Jason.
## Design
1. Feed JSON to `python3 -c` on stdin, including pending-context rendering.
2. Classify valid green as `READY`; pending/failure/no-status/malformed/mixed as `ASSERTED_NOT_READY`; provider/credential/transport inability as `CANNOT_ASSERT`.
3. `ASSERTED_NOT_READY` exits nonzero. `CANNOT_ASSERT` emits a loud diagnostic and appends a local JSONL audit record. Push degrades to exit 0; merge holds with distinct retryable exit 75 until provider recovery, then self-clears without manual reset. Inability to write the audit exits nonzero.
4. Derive the current branch when `-B` is omitted. The merge wrapper passes the exact PR head branch, repository, and full commit SHA—not its `main` base—so fork PRs cannot resolve against an adjacent base-repository branch.
## Test matrix
| Case | Required outcome |
| --- | --- |
| success | exit 0; terminal-success |
| pending | nonzero after bounded timeout |
| failure | nonzero |
| no-status | nonzero |
| malformed | nonzero |
| >=150 KiB payload | unchanged classification; never rc126 |
| provider unreachable on push | loud audited CANNOT_ASSERT; degraded exit 0 |
| provider unreachable on merge | loud audited CANNOT_ASSERT; retryable exit 75/HOLD |
| audit unavailable | nonzero |
| implicit push branch | provider URL uses checked-out feature branch |
| merge wrapper | queue guard receives exact PR head branch/repository/full SHA |
## RED-first evidence
Observed against the unmodified `origin/main` implementation before source edits:
- `bash packages/mosaic/framework/tools/git/test-ci-queue-wait-tristate.sh` → rc 1 with 15 failed assertions.
- Success payload was reported `state=unknown`.
- Pending, failure, no-status, and malformed payloads each exited 0 and omitted `ASSERTED_NOT_READY`.
- The 160 KiB payload produced rc 141 because Python never consumed the pipe; it did not classify success.
- Provider-unreachable exited 7 with no `CANNOT_ASSERT` audit record.
- Implicit push queried `/branches/main`, not `/branches/fix/rm-03-fixture`.
- Audit-unavailable emitted no audit diagnostic.
- A credential-resolution hard-block mutant was then run before trusting that added case: `credential-unresolvable` returned rc 1 and omitted `CANNOT_ASSERT`; the matrix returned rc 1 with two named assertion failures.
- Review-blocker controls were observed red: structurally invalid `statuses` string and null-entry payloads each exited 0 as `terminal-success`; unsupported-platform discovery exited 1 without diagnostic or audit (seven named assertion failures total).
- After the push/merge asymmetry ruling, merge-side provider unavailability was observed red at rc 0; its registered case required distinct retryable rc 75.
- Aggregate `state=success` with zero contexts was observed red: it exited 0 as `terminal-success`; the registered case requires `no-status`/nonzero.
- Fork/exact-head controls were observed red: `pr-merge.sh` omitted the fork repository and full SHA, and an ignored-arguments mutant re-resolved through `/branches/` instead of the exact fork commit (two named failures).
- GitHub check-run-only success/pending/failure were each misclassified as `no-status`; the RED run had five named failures and proved the Checks API was never queried.
- The first merge-pin control was unrunnable because one `local` declaration referenced a variable before assignment under `set -u`; this was disclosed and corrected rather than counted. The runnable RED then showed Gitea payload `{"Do":"squash"}` lacked `head_commit_id`; a separate GitHub run showed `gh pr merge 123 --squash` lacked `--match-head-commit`.
- A stale-verdict mutant removed the `--expect-head` comparison and was observed red because a moved head reached the provider merge call.
- `bash packages/mosaic/framework/tools/git/test-pr-merge-queue-branch.sh` initially returned rc 1; captured call was `--purpose merge -B main -t 900 -i 15`.
Logs remain untracked under the worktree as `.mosaic-test-work-red-*.log` and will not be committed.
## Progress
- [x] Mission, remediation charter, task evidence, board, issue #1019, and superseded PR #1023 read.
- [x] Isolated worktree created and identity configured coherently.
- [x] Mutant tests authored and observed red.
- [x] Implementation green.
- [x] Baseline and focused situational gates green; full package suite has an unrelated framework-shell environment abort recorded below.
- [ ] Independent review clean (rev-974 requested changes at `44ffa99a`; bypass remediation committed and awaiting re-review).
- [ ] PR CI terminal-green at exact head by full step scan.
- [ ] Merge-gate verdict issued against frozen head.
## Scope disposition
- The five framework guides are consequential documentation: they define the purpose-aware tri-state contract, including audited push degradation and merge HOLD.
- The agent templates are consequential because they ship the same queue-guard instructions into newly seeded agent contracts; leaving them binary/stale would contradict the repaired tool.
- `pr-merge.sh` is consequential: it must inspect the PR's exact head branch/repository/SHA and enforce the exact-head merge pin.
- `pr-metadata.sh` is consequential only as the normalized source of that head branch/repository/SHA. Its diff is limited to exposing those fields on GitHub and Gitea.
- `test-pr-merge-gitea-empty-uid.sh` changes because exact-head Gitea merges now always use the API path (the only path that can send `head_commit_id`), superseding the prior tea-empty-identity fallback behavior.
## Review remediation
- rev-974 independently proved that the documented `--skip-queue-guard` merge option bypassed an exit-99 guard stub, reached the provider merge payload, printed success, and exited 0 at head `44ffa99a`.
- RED-first reproduction was added to `test-pr-merge-head-pin.sh` before the production fix: `FAIL merge-bypass: --skip-queue-guard reached the provider merge path`, suite rc 1. The test-only commit is `241113e6`.
- Production remediation `37aae650` removes the option from parsing, usage, help, and examples. Every merge-capable path now invokes the queue guard; `--dry-run` alone omits it and has a regression proving that it exits before provider dispatch and creates no merge payload.
- Existing Gitea merge tests now exercise a successful guard response rather than bypassing the guard.
## Risks / boundaries
- The local JSONL audit is durable operational evidence but not tamper-resistant against the same UID. RM-03 does not claim otherwise.
- Push-side audited exit 0 is an explicit owner ruling (Option B), accepted to avoid bricking recovery work; merge-side CANNOT_ASSERT remains retryable exit 75/HOLD. The automated security reviewer continues to flag the deliberate push availability tradeoff.
- Source/deployed-copy equality is owned by RM-02/D-22; this branch changes repository source and its tests only.
## Test evidence
Fresh after rescue checkpoint `b7175012`:
- Focused situational matrix: tri-state, GitHub checks pagination, branch-absent, merge head branch/repository/SHA, exact-head pin, and Gitea exact-head API regressions all passed.
- `bash -n` on the three production shell scripts passed.
- `shellcheck -x -P packages/mosaic/framework/tools/git ...` on all changed shell scripts passed.
- `pnpm typecheck` passed (45/45 Turbo tasks).
- `pnpm lint` passed (25/25 Turbo tasks).
- `pnpm format:check` passed.
- `pnpm --filter @mosaicstack/mosaic test`: Vitest passed 1508/1508 on the confirmation run; framework-shell then aborted at the pre-existing wake coordinate assertion with exit 97: `BASH_LINENO ... probe reported [3 5], expected [3 4] ... (#973)`. This is outside the RM-03 diff and is disclosed rather than substituted or called green.
- The prior package-suite attempt had one transient, out-of-diff `install-ordering-guard.spec.ts` failure (1/1508); its isolated rerun passed 19/19 and the confirmation full Vitest run passed 1508/1508.
- After bypass remediation: all six focused RM-03 queue/merge regressions passed, including bypass refusal and dry-run non-dispatch; shell syntax and source-aware ShellCheck passed; `pnpm typecheck`, `pnpm lint`, and `pnpm format:check` passed.
- Fresh `test:framework-shell` reached and passed every RM-03 test, then again aborted at the unrelated wake coordinate assertion with exit 97; it remains explicitly non-green rather than substituted.
- An ad hoc raw Prettier invocation over `.template` and `.sh` files was unrunnable because no parser is registered for those extensions; it was not used as a substitute for canonical `pnpm format:check`.
## Final evidence
Pending.
+1
View File
@@ -29,6 +29,7 @@ export default tseslint.config(
'apps/web/playwright.config.ts',
'apps/gateway/vitest.config.ts',
'plugins/discord/vitest.config.ts',
'packages/comms/vitest.config.ts',
'packages/db/vitest.config.ts',
'packages/storage/vitest.config.ts',
'packages/mosaic/vitest.config.ts',
+3
View File
@@ -0,0 +1,3 @@
# DEV runtime state: rendered config, sqlite db, signing key, self-signed
# certs, throwaway secrets. Never committed.
.data/
+53
View File
@@ -0,0 +1,53 @@
# infra/matrix — DEV Synapse for RFC-001 P1 (presence)
> **DEV-ONLY. LOCAL SANDBOX.** This stack is for local presence validation. It
> must **never** be pointed at, or run alongside, a live/production homeserver.
> It is single-instance, federation-OFF, self-signed TLS — the smallest slice
> RFC-002 §9 says P1 needs (Mode B single-domain, no federation, no IP-only, no
> secret-rotation story).
## What this is
A single Synapse homeserver rendered from committed **templates** (no hardcoded
topology — RFC-002 G2). Every topology fact is an environment variable with a
dev default:
| Var | Default | Meaning |
| -------------------- | ------------------ | --------------------------------------------------- |
| `MATRIX_SERVER_NAME` | `matrix.localhost` | Synapse `server_name` (the `:suffix` of every MXID) |
| `MOSAIC_AS_ID` | `mosaic-as` | appservice id / registration filename |
| `MATRIX_HTTP_PORT` | `18008` | host port → Synapse 8008 (plain HTTP) |
| `MATRIX_TLS_PORT` | `18448` | host port → Synapse 8448 (self-signed TLS) |
Secrets (`as_token`, `hs_token`, Synapse macaroon/form/registration secrets)
are **throwaway values generated at bring-up** into `.data/dev-secrets.env`
(gitignored). In production these are crown-jewel secrets held by the
SecretBackend (RFC-001 §8 / RFC-002 §4) — never committed.
## Files
- `docker-compose.dev.yml` — Synapse (+ optional Element under `--profile element`).
- `synapse/homeserver.dev.yaml.tpl` — rendered Synapse config (Mode B, `enable_registration: false`, appservice wired, native TLS).
- `synapse/log.config` — Synapse logging.
- `appservice/mosaic-as.dev.yaml.tpl` — minimal AS registration (declares the `@agent-*` user namespace).
- `dev-up.sh` / `dev-down.sh` — bring up / tear down (`--purge` wipes `.data`).
- `.data/`**gitignored** runtime state (rendered config, sqlite db, signing key, self-signed certs, dev secrets).
## Usage
```bash
./dev-up.sh # render config, gen signing key + TLS cert, boot Synapse
# ... run the validation harness (tools/matrix-presence-harness/run.sh) ...
./dev-down.sh # stop, keep .data
./dev-down.sh --purge # stop and wipe .data for a pristine next boot
# optional human view (A4) — Element pointed at the dev server:
docker compose -f docker-compose.dev.yml --profile element up -d element
# -> http://127.0.0.1:18080
```
## Acceptance evidence (A1)
- TLS (self-signed) reachable: `curl -sk https://127.0.0.1:18448/_matrix/client/versions``200`.
- Open registration OFF: `POST /_matrix/client/v3/register``M_FORBIDDEN "Registration has been disabled"`.
- AS-token registration bypasses the flag by design (that is how agents are provisioned).
@@ -0,0 +1,30 @@
# ============================================================================
# mosaic-as.dev.yaml.tpl — Mosaic Appservice registration (DEV)
# ============================================================================
#
# *** DEV-ONLY. The tokens below are throwaway placeholders rendered at
# bring-up by dev-up.sh. NEVER commit real hs_token/as_token — in
# production they are crown-jewel secrets held by the SecretBackend
# (RFC-001 §8, RFC-002 §4). ***
#
# This is the MINIMAL P1 registration: it declares the @agent-* user
# namespace so the P1 provisioner can register a few virtual agent MXIDs and
# carry heartbeats. It intentionally does NOT model the full P2 taxonomy /
# token-minting appservice.
#
# Rendered by infra/matrix/dev-up.sh (envsubst -> .data/${MOSAIC_AS_ID}.yaml).
# ----------------------------------------------------------------------------
id: "${MOSAIC_AS_ID}"
url: null # P1: provisioner drives the AS API directly; Synapse pushes no txns.
as_token: "${MOSAIC_AS_TOKEN}"
hs_token: "${MOSAIC_HS_TOKEN}"
sender_localpart: "mosaic-as"
rate_limited: false
namespaces:
users:
- exclusive: true
regex: "@agent-.*:${MATRIX_SERVER_NAME}"
aliases:
- exclusive: false
regex: "#mosaic-.*:${MATRIX_SERVER_NAME}"
rooms: []
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# dev-down.sh — tear down the DEV Synapse (matrix-p1-dev). DEV-ONLY.
# ./dev-down.sh # stop + remove containers, keep ./.data
# ./dev-down.sh --purge # also delete ./.data (fresh next bring-up)
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
docker compose -f "${HERE}/docker-compose.dev.yml" --profile element down -v --remove-orphans || true
if [[ "${1:-}" == "--purge" ]]; then
rm -rf "${HERE}/.data"
echo "[dev-down] purged ${HERE}/.data"
fi
echo "[dev-down] matrix-p1-dev stopped"
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# ============================================================================
# dev-up.sh — bring up the DEV Synapse for RFC-001 P1 (presence)
# ============================================================================
#
# *** DEV-ONLY. LOCAL SANDBOX. This script must never be pointed at live
# infra. It writes only into infra/matrix/.data (gitignored) and drives
# a dedicated compose project (matrix-p1-dev). ***
#
# It renders the Synapse config + appservice registration from the committed
# templates (envsubst), generates a DEV signing key + self-signed TLS cert,
# and starts Synapse. All topology facts come from the environment with dev
# defaults (RFC-002 G2: nothing hardcoded).
# ----------------------------------------------------------------------------
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DATA="${HERE}/.data"
COMPOSE=(docker compose -f "${HERE}/docker-compose.dev.yml")
# ---- Topology inputs (dev defaults; override via env) ----------------------
export MATRIX_SERVER_NAME="${MATRIX_SERVER_NAME:-matrix.localhost}"
export MOSAIC_AS_ID="${MOSAIC_AS_ID:-mosaic-as}"
export MATRIX_HTTP_PORT="${MATRIX_HTTP_PORT:-18008}"
export MATRIX_TLS_PORT="${MATRIX_TLS_PORT:-18448}"
# ---- DEV secrets (throwaway; regenerated if absent) ------------------------
SECRETS_ENV="${DATA}/dev-secrets.env"
mkdir -p "${DATA}"
if [[ ! -f "${SECRETS_ENV}" ]]; then
{
echo "MOSAIC_AS_TOKEN=devas_$(openssl rand -hex 16)"
echo "MOSAIC_HS_TOKEN=devhs_$(openssl rand -hex 16)"
echo "SYNAPSE_REG_SHARED_SECRET=devreg_$(openssl rand -hex 16)"
echo "SYNAPSE_MACAROON_SECRET=devmac_$(openssl rand -hex 16)"
echo "SYNAPSE_FORM_SECRET=devform_$(openssl rand -hex 16)"
} > "${SECRETS_ENV}"
echo "[dev-up] generated throwaway DEV secrets -> ${SECRETS_ENV}"
fi
# shellcheck disable=SC1090
set -a; source "${SECRETS_ENV}"; set +a
# DEV: let the synapse container user (uid 991) write the sqlite db / media.
chmod 0777 "${DATA}" || true
# ---- Render config from templates ------------------------------------------
envsubst < "${HERE}/synapse/homeserver.dev.yaml.tpl" > "${DATA}/homeserver.yaml"
envsubst < "${HERE}/appservice/mosaic-as.dev.yaml.tpl" > "${DATA}/${MOSAIC_AS_ID}.yaml"
cp "${HERE}/synapse/log.config" "${DATA}/log.config"
echo "[dev-up] rendered homeserver.yaml + ${MOSAIC_AS_ID}.yaml (server_name=${MATRIX_SERVER_NAME})"
# ---- DEV signing key -------------------------------------------------------
# Generate as the synapse container user (uid 991) so the running container
# can read it; owned-by-991 mode-600 is fine (the container IS 991).
SIGNING_KEY="${DATA}/${MATRIX_SERVER_NAME}.signing.key"
if [[ ! -f "${SIGNING_KEY}" ]]; then
docker run --rm --user 991:991 -v "${DATA}:/data" --entrypoint generate_signing_key \
matrixdotorg/synapse:latest -o "/data/${MATRIX_SERVER_NAME}.signing.key"
echo "[dev-up] generated DEV signing key"
fi
# ---- DEV self-signed TLS cert (A1) -----------------------------------------
if [[ ! -f "${DATA}/dev-cert.pem" ]]; then
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "${DATA}/dev-key.pem" -out "${DATA}/dev-cert.pem" \
-days 90 -subj "/CN=${MATRIX_SERVER_NAME}" \
-addext "subjectAltName=DNS:${MATRIX_SERVER_NAME},DNS:localhost,IP:127.0.0.1" >/dev/null 2>&1
echo "[dev-up] generated DEV self-signed TLS cert for ${MATRIX_SERVER_NAME}"
fi
# Files hermes owns must be world-readable so the synapse (uid 991) container
# can read them (DEV-only; the signing key stays owned by 991 from above).
chmod 0644 "${DATA}/dev-cert.pem" "${DATA}/dev-key.pem" \
"${DATA}/homeserver.yaml" "${DATA}/${MOSAIC_AS_ID}.yaml" "${DATA}/log.config" || true
# ---- Element config (optional human view, A4) ------------------------------
cat > "${DATA}/element-config.json" <<JSON
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://localhost:${MATRIX_TLS_PORT}",
"server_name": "${MATRIX_SERVER_NAME}"
}
},
"disable_guests": true,
"brand": "Mosaic P1 (DEV)"
}
JSON
# ---- Boot ------------------------------------------------------------------
"${COMPOSE[@]}" up -d synapse
echo -n "[dev-up] waiting for Synapse health"
for _ in $(seq 1 60); do
status="$("${COMPOSE[@]}" ps --format '{{.Health}}' synapse 2>/dev/null || true)"
if [[ "${status}" == "healthy" ]]; then echo " ... healthy"; break; fi
echo -n "."; sleep 2
done
if ! curl -fsS "http://127.0.0.1:${MATRIX_HTTP_PORT}/health" >/dev/null 2>&1; then
echo "[dev-up] ERROR: Synapse did not become healthy" >&2
"${COMPOSE[@]}" logs --tail 60 synapse >&2 || true
exit 1
fi
echo "[dev-up] Synapse UP:"
echo " plain HTTP : http://127.0.0.1:${MATRIX_HTTP_PORT}"
echo " TLS : https://127.0.0.1:${MATRIX_TLS_PORT} (self-signed, DEV)"
echo " server_name: ${MATRIX_SERVER_NAME} registration: OFF"
+60
View File
@@ -0,0 +1,60 @@
# ============================================================================
# docker-compose.dev.yml — Synapse DEV homeserver for RFC-001 P1 (presence)
# ============================================================================
#
# *** DEV-ONLY. LOCAL SANDBOX. Do NOT point this at, or run alongside, any
# live/production homeserver. ***
#
# Single-instance Synapse (RFC-002 Mode B, federation OFF) + an optional
# Element web client for the human view (A4). Brought up by ./dev-up.sh, which
# renders config into ./.data (gitignored) first.
#
# Isolated by design: dedicated project name + network + high host ports so it
# never collides with other stacks on the box (A5).
#
# Host ports: ${MATRIX_HTTP_PORT:-18008} -> Synapse 8008 (plain HTTP)
# ${MATRIX_TLS_PORT:-18448} -> Synapse 8448 (self-signed TLS)
# ${ELEMENT_PORT:-18080} -> Element web (profile: element)
# ----------------------------------------------------------------------------
name: matrix-p1-dev
services:
synapse:
image: matrixdotorg/synapse:latest
container_name: matrix-p1-synapse
restart: 'no'
# Run against the rendered config in /data (dev-up.sh puts it there).
environment:
SYNAPSE_CONFIG_DIR: /data
SYNAPSE_CONFIG_PATH: /data/homeserver.yaml
volumes:
- ./.data:/data
ports:
- '127.0.0.1:${MATRIX_HTTP_PORT:-18008}:8008'
- '127.0.0.1:${MATRIX_TLS_PORT:-18448}:8448'
networks:
- matrix-p1-net
healthcheck:
test: ['CMD-SHELL', 'curl -fsS http://localhost:8008/health || exit 1']
interval: 3s
timeout: 5s
retries: 40
start_period: 5s
# Optional human view (A4). `docker compose --profile element up -d element`.
element:
image: vectorim/element-web:latest
container_name: matrix-p1-element
restart: 'no'
profiles: [element]
volumes:
- ./.data/element-config.json:/app/config.json:ro
ports:
- '127.0.0.1:${ELEMENT_PORT:-18080}:80'
networks:
- matrix-p1-net
networks:
matrix-p1-net:
name: matrix-p1-net
driver: bridge
@@ -0,0 +1,84 @@
# ============================================================================
# homeserver.dev.yaml.tpl — Synapse DEV homeserver config (RFC-002 Mode B)
# ============================================================================
#
# *** DEV-ONLY. NOT FOR PRODUCTION. ***
#
# This is the RFC-001 P1 (presence) single-instance homeserver. It is
# RFC-002 "Mode B — single-domain" (server_name == homeserver host), with
# federation OFF (standalone), which is all P1 needs (RFC-002 §9 P1 row).
#
# NOTHING here is a production value. Every topology fact is a variable
# rendered by dev-up.sh from the environment; there is no baked-in fleet
# domain (RFC-002 G2 "zero hardcoded topology"). The secrets below are
# throwaway DEV placeholders rendered at bring-up — never reuse them.
#
# Rendered by: infra/matrix/dev-up.sh (envsubst -> .data/homeserver.yaml)
# Variables: MATRIX_SERVER_NAME, MOSAIC_AS_ID (+ dev secrets)
# ----------------------------------------------------------------------------
server_name: "${MATRIX_SERVER_NAME}"
pid_file: /data/homeserver.pid
report_stats: false
suppress_key_server_warning: true
# --- Listeners -------------------------------------------------------------
# 8008: plain HTTP (client + federation) for in-container/local tooling.
# 8448: TLS (self-signed in DEV) — satisfies A1 "reachable over TLS".
listeners:
- port: 8008
type: http
tls: false
bind_addresses: ['0.0.0.0']
x_forwarded: true
resources:
- names: [client, federation]
compress: false
- port: 8448
type: http
tls: true
bind_addresses: ['0.0.0.0']
resources:
- names: [client, federation]
compress: false
# --- DEV TLS (self-signed; generated by dev-up.sh) -------------------------
tls_certificate_path: "/data/dev-cert.pem"
tls_private_key_path: "/data/dev-key.pem"
# --- Store: sqlite is the simplest dev store (RFC-002 §1 allows it) --------
database:
name: sqlite3
args:
database: /data/homeserver.db
log_config: "/data/log.config"
media_store_path: /data/media_store
signing_key_path: "/data/${MATRIX_SERVER_NAME}.signing.key"
# --- Hardening (RFC-001 §8 / RFC-002 §8.5), rendered so a dev gets it ------
# Registration is OFF: agents come ONLY via the appservice (A1). AS user
# registration bypasses this flag, which is exactly the design.
enable_registration: false
enable_registration_without_verification: false
registration_shared_secret: "${SYNAPSE_REG_SHARED_SECRET}"
macaroon_secret_key: "${SYNAPSE_MACAROON_SECRET}"
form_secret: "${SYNAPSE_FORM_SECRET}"
# Presence EDUs ON so Element shows the native dot for humans (RFC-001 §4.5);
# the AUTHORITATIVE liveness is still the mosaic.presence heartbeat.
presence:
enabled: true
# --- Federation: OFF for P1 (standalone). Empty whitelist = federate with
# nobody (RFC-002 §2.4 / NG5). No public-network federation. ----------------
federation_domain_whitelist: []
trusted_key_servers: []
# --- Appservice registration wired in (A1). The file is rendered next to
# this one by dev-up.sh. -----------------------------------------------------
app_service_config_files:
- "/data/${MOSAIC_AS_ID}.yaml"
# Keep default rate-limiting ON (RFC-001 §8). No overrides here.
+16
View File
@@ -0,0 +1,16 @@
# Synapse DEV log config. DEV-ONLY.
version: 1
formatters:
precise:
format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s'
handlers:
console:
class: logging.StreamHandler
formatter: precise
loggers:
synapse.storage.SQL:
level: WARNING
root:
level: INFO
handlers: [console]
disable_existing_loggers: false
+6 -3
View File
@@ -6,11 +6,14 @@
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"test": "turbo run test",
"preflight": "node scripts/preflight.mjs",
"clean:generated": "node scripts/clean-generated.mjs",
"typecheck": "pnpm preflight && turbo run typecheck",
"test:checkout": "node --test scripts/*.test.mjs",
"test": "pnpm test:checkout && turbo run test",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,json,md}\"",
"prepare": "husky"
"prepare": "node scripts/install-hooks.mjs"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.0.0",
+41
View File
@@ -0,0 +1,41 @@
# @mosaicstack/comms
MACP presence SDK — the **P1 (presence)** slice of RFC-001 (§4.5 liveness,
§4.2 event envelope). Minimal by design: set Matrix presence, run the
`mosaic.presence` heartbeat, and compute **deterministic** fleet liveness.
Out of P1 scope (later phases): enrollment/auto-detect, room taxonomy,
per-agent token minting, signed-authorship, federation.
## API
- `classifyLiveness(ageMs, policy)` / `computeFleetLiveness(observations, now, policy)`
— pure, deterministic online/away/offline from heartbeat age. The
authoritative liveness source (RFC-001 §4.5): native Matrix presence is _not_
relied upon.
- `HeartbeatEmitter` / `startHeartbeatLoop(...)` — build and drive the
`mosaic.presence` heartbeat (monotonic `seq`, `interval_ms`).
- `MinimalMatrixClient` — tiny C-S client: `setPresence`, `sendHeartbeat`,
`readHeartbeats`, `joinRoom`. Supports Application-Service masquerade
(`actAsUserId`) for the P1 provisioner, or a per-agent `accessToken`.
- `PresenceAgent` — high-level: join the fleet room, go present, heartbeat.
`pauseHeartbeat()` models a crash (no graceful signal).
- `FleetLivenessReader` — reads the fleet room and computes the liveness board
(`read()` / `formatBoard()`), the surface a human or watchdog reads.
## Liveness policy (RFC-001 §4.5)
```
online : age <= heartbeatIntervalMs * missTolerance
away : age < darkThresholdMs
offline: otherwise (or never-seen / non-finite age -> fail safe to offline)
```
Defaults: interval 30s, miss-tolerance 2, dark-threshold 10min
(`DEFAULT_LIVENESS_POLICY`). All runtime-tunable per RFC-002 §5.3.
## Tests
`pnpm --filter @mosaicstack/comms test` — the liveness core is written
RED-FIRST; an end-to-end proof against a real Synapse lives in
`tools/matrix-presence-harness`.
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@mosaicstack/comms",
"version": "0.0.1",
"type": "module",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
"directory": "packages/comms"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@vitest/coverage-v8": "^2.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
"access": "public"
},
"files": [
"dist"
]
}
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HeartbeatEmitter, startHeartbeatLoop } from '../heartbeat.js';
import type { PresenceHeartbeatContent } from '../types.js';
const agent = { mxid: '@agent-alpha:matrix.localhost', slug: 'alpha', harness: 'claude-code' };
describe('HeartbeatEmitter', () => {
it('increments seq starting at 1 and stamps the envelope', () => {
let t = 1000;
const em = new HeartbeatEmitter({ agent, intervalMs: 5000, now: () => t });
const a = em.next();
t = 6000;
const b = em.next('away');
expect(a.seq).toBe(1);
expect(a.ts).toBe(1000);
expect(a.status).toBe('online');
expect(a.macp_type).toBe('presence');
expect(a.msgtype).toBe('mosaic.presence');
expect(a.macp_version).toBe('1.0');
expect(a.interval_ms).toBe(5000);
expect(a.agent).toEqual(agent);
expect(a.body).toContain('alpha');
expect(b.seq).toBe(2);
expect(b.ts).toBe(6000);
expect(b.status).toBe('away');
expect(em.currentSeq).toBe(2);
});
it('includes mission_id only when provided', () => {
const withMission = new HeartbeatEmitter({
agent,
intervalMs: 1000,
missionId: 'KBN-101',
}).next();
const without = new HeartbeatEmitter({ agent, intervalMs: 1000 }).next();
expect(withMission.mission_id).toBe('KBN-101');
expect(without.mission_id).toBeUndefined();
});
});
describe('startHeartbeatLoop', () => {
afterEach(() => vi.useRealTimers());
it('emits immediately, then once per interval, until stopped', () => {
vi.useFakeTimers();
const sent: PresenceHeartbeatContent[] = [];
const em = new HeartbeatEmitter({ agent, intervalMs: 1000, now: () => Date.now() });
const loop = startHeartbeatLoop({
emitter: em,
intervalMs: 1000,
send: (c) => {
sent.push(c);
},
});
expect(sent).toHaveLength(1); // immediate beat
vi.advanceTimersByTime(3000);
expect(sent).toHaveLength(4); // +3 beats
expect(sent.map((s) => s.seq)).toEqual([1, 2, 3, 4]);
loop.stop();
vi.advanceTimersByTime(5000);
expect(sent).toHaveLength(4); // no more after stop
loop.stop(); // idempotent
});
it('routes a rejected async send to onError without killing the loop', async () => {
vi.useFakeTimers();
const onError = vi.fn();
let n = 0;
const em = new HeartbeatEmitter({ agent, intervalMs: 1000 });
const loop = startHeartbeatLoop({
emitter: em,
intervalMs: 1000,
onError,
send: () => {
n += 1;
return Promise.reject(new Error(`boom ${n}`));
},
});
await vi.advanceTimersByTimeAsync(2000); // immediate + 2
expect(n).toBe(3);
expect(onError).toHaveBeenCalledTimes(3);
loop.stop();
});
});
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { classifyLiveness, computeFleetLiveness } from '../liveness.js';
import type { HeartbeatObservation, LivenessPolicy } from '../types.js';
// Small, dev-scale policy so the arithmetic is obvious:
// online window = interval * missTolerance = 1000 * 2 = 2000ms
// dark threshold = 5000ms
const policy: LivenessPolicy = {
heartbeatIntervalMs: 1000,
missTolerance: 2,
darkThresholdMs: 5000,
};
describe('classifyLiveness (deterministic, heartbeat-age based — RFC-001 §4.5)', () => {
it('is online when age is within interval * missTolerance', () => {
expect(classifyLiveness(0, policy)).toBe('online');
expect(classifyLiveness(1999, policy)).toBe('online');
expect(classifyLiveness(2000, policy)).toBe('online'); // inclusive boundary
});
it('is away when past the online window but before dark threshold', () => {
expect(classifyLiveness(2001, policy)).toBe('away');
expect(classifyLiveness(4999, policy)).toBe('away');
});
it('is offline/dark at or past the dark threshold', () => {
expect(classifyLiveness(5000, policy)).toBe('offline');
expect(classifyLiveness(50_000, policy)).toBe('offline');
});
it('treats a never-seen agent (Infinity age) as offline', () => {
expect(classifyLiveness(Number.POSITIVE_INFINITY, policy)).toBe('offline');
});
it('never returns online for a negative-but-huge misconfig (guards NaN)', () => {
// A NaN age must fail safe to offline, not silently report online.
expect(classifyLiveness(Number.NaN, policy)).toBe('offline');
});
});
describe('computeFleetLiveness (A2/A3 core)', () => {
const now = 100_000;
const obs = (slug: string, lastSeenTs: number, lastSeq = 1): HeartbeatObservation => ({
slug,
mxid: `@agent-${slug}:matrix.localhost`,
lastSeenTs,
lastSeq,
assertedStatus: 'online',
});
it('classifies a live fleet: fresh=online, stale=away, dark=offline', () => {
const result = computeFleetLiveness(
[
obs('alpha', now - 500), // 500ms old -> online
obs('bravo', now - 3000), // 3000ms old -> away
obs('charlie', now - 8000), // 8000ms old -> offline
],
now,
policy,
);
const byslug = Object.fromEntries(result.map((r) => [r.slug, r.status]));
expect(byslug).toEqual({ alpha: 'online', bravo: 'away', charlie: 'offline' });
});
it('A3: a previously-online agent flips to offline once age crosses dark threshold', () => {
const lastBeat = 100_000; // agent was hard-killed right after this beat
// Just before the threshold it is still merely "away"...
const justBefore = computeFleetLiveness([obs('victim', lastBeat, 7)], lastBeat + 4999, policy);
expect(justBefore[0]?.status).toBe('away');
// ...and the instant age reaches darkThresholdMs it is deterministically offline,
// with no dependence on native Matrix presence timeouts.
const atThreshold = computeFleetLiveness([obs('victim', lastBeat, 7)], lastBeat + 5000, policy);
expect(atThreshold[0]?.status).toBe('offline');
expect(atThreshold[0]?.ageMs).toBe(5000);
expect(atThreshold[0]?.lastSeq).toBe(7);
});
it('reports ageMs and preserves mxid/slug/seq for the human view', () => {
const [row] = computeFleetLiveness([obs('alpha', now - 1200, 42)], now, policy);
expect(row).toMatchObject({
slug: 'alpha',
mxid: '@agent-alpha:matrix.localhost',
ageMs: 1200,
lastSeq: 42,
status: 'online',
});
});
});
@@ -0,0 +1,131 @@
import { describe, expect, it, vi } from 'vitest';
import { MatrixError, MinimalMatrixClient, toMatrixPresence } from '../matrix-client.js';
const jsonResponse = (status: number, body: unknown): Response =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
// A fetch mock typed with the (URL, RequestInit?) shape the client actually
// calls, so mock.calls has a proper tuple type under noUncheckedIndexedAccess.
const mkFetch = (impl: (url: URL, init?: RequestInit) => Promise<Response>) => vi.fn(impl);
const cfg = {
homeserverUrl: 'https://matrix.localhost:8448',
accessToken: 'as-secret',
actAsUserId: '@agent-alpha:matrix.localhost',
};
describe('toMatrixPresence', () => {
it('maps liveness states to native presence EDU values', () => {
expect(toMatrixPresence('online')).toBe('online');
expect(toMatrixPresence('away')).toBe('unavailable');
expect(toMatrixPresence('offline')).toBe('offline');
});
});
describe('MinimalMatrixClient', () => {
it('setPresence PUTs native presence and masquerades via user_id', async () => {
const fetchMock = mkFetch(async () => jsonResponse(200, {}));
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
await client.setPresence('@agent-alpha:matrix.localhost', 'away', 'hb');
const [url, init] = fetchMock.mock.calls[0]!;
const u = new URL((url as URL).toString());
expect(u.pathname).toBe('/_matrix/client/v3/presence/%40agent-alpha%3Amatrix.localhost/status');
expect(u.searchParams.get('user_id')).toBe('@agent-alpha:matrix.localhost');
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
presence: 'unavailable',
status_msg: 'hb',
});
expect((init as RequestInit).method).toBe('PUT');
});
it('sendHeartbeat posts an m.room.message and returns the event_id', async () => {
const fetchMock = mkFetch(async () => jsonResponse(200, { event_id: '$evt1' }));
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
const id = await client.sendHeartbeat('!room:matrix.localhost', {
macp_version: '1.0',
macp_type: 'presence',
msgtype: 'mosaic.presence',
agent: { mxid: cfg.actAsUserId, slug: 'alpha', harness: 'claude-code' },
ts: 1,
body: 'alpha online (seq 1)',
status: 'online',
seq: 1,
interval_ms: 1000,
});
expect(id).toBe('$evt1');
const [url] = fetchMock.mock.calls[0]!;
expect((url as URL).pathname).toContain('/rooms/!room%3Amatrix.localhost/send/m.room.message/');
});
it('throws a MatrixError carrying errcode on a non-2xx', async () => {
const fetchMock = mkFetch(async () =>
jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'nope' }),
);
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
await expect(client.whoami()).rejects.toMatchObject({
name: 'MatrixError',
status: 403,
errcode: 'M_FORBIDDEN',
});
await expect(client.whoami()).rejects.toBeInstanceOf(MatrixError);
});
it('readHeartbeats reduces the timeline to the latest beat per agent', async () => {
// Timeline (dir=b => most-recent first). alpha has two beats; keep highest seq.
const chunk = [
{
sender: '@agent-bravo:matrix.localhost',
origin_server_ts: 9000,
content: {
msgtype: 'mosaic.presence',
agent: { slug: 'bravo', mxid: '@agent-bravo:matrix.localhost' },
seq: 5,
status: 'online',
ts: 8999,
},
},
{
sender: '@agent-alpha:matrix.localhost',
origin_server_ts: 8000,
content: {
msgtype: 'mosaic.presence',
agent: { slug: 'alpha', mxid: '@agent-alpha:matrix.localhost' },
seq: 12,
status: 'online',
ts: 7999,
},
},
{
// an ordinary chat message must be ignored
sender: '@human:matrix.localhost',
origin_server_ts: 7000,
content: { msgtype: 'm.text', body: 'hi' },
},
{
sender: '@agent-alpha:matrix.localhost',
origin_server_ts: 6000,
content: {
msgtype: 'mosaic.presence',
agent: { slug: 'alpha', mxid: '@agent-alpha:matrix.localhost' },
seq: 11,
status: 'online',
ts: 5999,
},
},
];
const fetchMock = mkFetch(async () => jsonResponse(200, { chunk }));
const client = new MinimalMatrixClient(cfg, fetchMock as unknown as typeof fetch);
const obs = await client.readHeartbeats('!room:matrix.localhost');
const bySlug = Object.fromEntries(obs.map((o) => [o.slug, o]));
expect(Object.keys(bySlug).sort()).toEqual(['alpha', 'bravo']);
expect(bySlug.alpha).toMatchObject({ lastSeq: 12, lastSeenTs: 8000 }); // highest seq wins, server ts
expect(bySlug.bravo).toMatchObject({ lastSeq: 5, lastSeenTs: 9000 });
const [url] = fetchMock.mock.calls[0]!;
const u = new URL((url as URL).toString());
expect(u.searchParams.get('dir')).toBe('b');
});
});
@@ -0,0 +1,151 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { FleetLivenessReader } from '../liveness-reader.js';
import type { MinimalMatrixClient } from '../matrix-client.js';
import { PresenceAgent } from '../presence-agent.js';
import type { HeartbeatObservation, LivenessPolicy, PresenceStatus } from '../types.js';
/**
* An in-memory fake homeserver room: records heartbeats with a controllable
* server clock and reduces them exactly like the real readHeartbeats. Lets us
* prove the PresenceAgent -> room -> FleetLivenessReader flow (including the A3
* hard-kill -> offline transition) deterministically, with no network.
*/
class FakeRoomClient {
readonly beats: Array<{
slug: string;
mxid: string;
seq: number;
ts: number;
status: PresenceStatus;
}> = [];
presence: Record<string, PresenceStatus> = {};
constructor(private readonly clock: () => number) {}
async joinRoom(roomId: string): Promise<string> {
return roomId;
}
async setPresence(userId: string, status: PresenceStatus): Promise<void> {
this.presence[userId] = status;
}
async sendHeartbeat(
_roomId: string,
content: { agent: { slug: string; mxid: string }; seq: number; status: PresenceStatus },
): Promise<string> {
this.beats.push({
slug: content.agent.slug,
mxid: content.agent.mxid,
seq: content.seq,
ts: this.clock(), // server receive time
status: content.status,
});
return `$evt${this.beats.length}`;
}
async readHeartbeats(): Promise<HeartbeatObservation[]> {
const bySlug = new Map<string, HeartbeatObservation>();
for (const b of this.beats) {
const prev = bySlug.get(b.slug);
if (!prev || b.seq > prev.lastSeq) {
bySlug.set(b.slug, {
slug: b.slug,
mxid: b.mxid,
lastSeenTs: b.ts,
lastSeq: b.seq,
assertedStatus: b.status,
});
}
}
return [...bySlug.values()];
}
}
const policy: LivenessPolicy = {
heartbeatIntervalMs: 1000,
missTolerance: 2,
darkThresholdMs: 5000,
};
describe('presence flow (A2 + A3 at unit level)', () => {
afterEach(() => vi.useRealTimers());
it('shows agents online while beating, then A3: a hard-killed agent goes offline within dark_threshold', async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const fake = new FakeRoomClient(() => Date.now());
const client = fake as unknown as MinimalMatrixClient;
const reader = new FleetLivenessReader({
client,
roomId: '!fleet',
policy,
now: () => Date.now(),
});
const mk = (slug: string) =>
new PresenceAgent({
client,
agent: { mxid: `@agent-${slug}:matrix.localhost`, slug, harness: 'claude-code' },
roomId: '!fleet',
intervalMs: 1000,
policy,
});
const alpha = mk('alpha');
const bravo = mk('bravo');
const charlie = mk('charlie');
for (const a of [alpha, bravo, charlie]) {
await a.connect();
a.start();
}
// native presence set online for all three (Element dot)
expect(fake.presence['@agent-alpha:matrix.localhost']).toBe('online');
// let a couple of beats flow — all three fresh => online (A2)
await vi.advanceTimersByTimeAsync(1500);
const board1 = Object.fromEntries((await reader.read()).map((r) => [r.slug, r.status]));
expect(board1).toEqual({ alpha: 'online', bravo: 'online', charlie: 'online' });
// HARD-KILL charlie: stop its loop, no more beats. alpha/bravo keep beating.
charlie.pauseHeartbeat(); // hard-kill: no graceful presence signal
// advance to just before dark threshold from charlie's last beat...
await vi.advanceTimersByTimeAsync(3000);
const mid = Object.fromEntries((await reader.read()).map((r) => [r.slug, r.status]));
expect(mid.alpha).toBe('online');
expect(mid.charlie).not.toBe('online'); // already stale (away)
// ...advance past dark_threshold: charlie is deterministically offline.
await vi.advanceTimersByTimeAsync(4000);
const final = await reader.read();
const byslug = Object.fromEntries(final.map((r) => [r.slug, r]));
expect(byslug.charlie!.status).toBe('offline');
expect(byslug.alpha!.status).toBe('online');
expect(byslug.bravo!.status).toBe('online');
for (const a of [alpha, bravo]) await a.stop();
});
it('formatBoard renders a human-readable liveness board (A4)', async () => {
const fake = new FakeRoomClient(() => 10_000);
fake.beats.push({
slug: 'alpha',
mxid: '@agent-alpha:matrix.localhost',
seq: 3,
ts: 9_500,
status: 'online',
});
const reader = new FleetLivenessReader({
client: fake as unknown as MinimalMatrixClient,
roomId: '!fleet',
policy,
now: () => 10_000,
});
const board = await reader.formatBoard();
expect(board).toContain('Fleet presence');
expect(board).toContain('alpha');
expect(board).toContain('online');
expect(board).toContain('online=1');
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* `mosaic.presence` heartbeat construction and loop (RFC-001 §4.2/§4.5).
*
* The emitter is deterministic and side-effect free (easy to unit test): it
* owns the monotonic `seq` and stamps each beat. The loop wires the emitter to
* a sender on an interval; timers are injectable so the loop is testable with
* fake clocks.
*/
import { MACP_VERSION, type PresenceHeartbeatContent, type PresenceStatus } from './types.js';
export interface HeartbeatAgentIdentity {
mxid: string;
slug: string;
harness: string;
}
export interface HeartbeatEmitterOptions {
agent: HeartbeatAgentIdentity;
/** Nominal interval advertised in each beat (interval_ms). */
intervalMs: number;
/** Optional mission correlation (RFC-001 §4.2 envelope). */
missionId?: string;
/** Injectable clock for deterministic tests. Default Date.now. */
now?: () => number;
}
/**
* Produces successive heartbeat contents with a monotonically increasing seq.
* The first `next()` returns seq=1.
*/
export class HeartbeatEmitter {
private seq = 0;
private readonly now: () => number;
constructor(private readonly opts: HeartbeatEmitterOptions) {
this.now = opts.now ?? Date.now;
}
/** Current sequence number (0 before the first beat). */
get currentSeq(): number {
return this.seq;
}
/** Build the next heartbeat content, advancing the sequence. */
next(status: PresenceStatus = 'online'): PresenceHeartbeatContent {
this.seq += 1;
const ts = this.now();
const content: PresenceHeartbeatContent = {
macp_version: MACP_VERSION,
macp_type: 'presence',
msgtype: 'mosaic.presence',
agent: {
mxid: this.opts.agent.mxid,
slug: this.opts.agent.slug,
harness: this.opts.agent.harness,
},
ts,
body: `${this.opts.agent.slug} ${status} (seq ${this.seq})`,
status,
seq: this.seq,
interval_ms: this.opts.intervalMs,
};
if (this.opts.missionId !== undefined) {
content.mission_id = this.opts.missionId;
}
return content;
}
}
export type HeartbeatSender = (content: PresenceHeartbeatContent) => void | Promise<void>;
export interface HeartbeatLoopOptions {
emitter: HeartbeatEmitter;
send: HeartbeatSender;
intervalMs: number;
/** Status supplier evaluated each beat. Default: always 'online'. */
status?: () => PresenceStatus;
/** Called if a beat's send rejects (so a transient failure doesn't kill the loop). */
onError?: (err: unknown) => void;
/** Injectable timer (tests). Defaults to global setInterval/clearInterval. */
setIntervalFn?: (cb: () => void, ms: number) => unknown;
clearIntervalFn?: (handle: unknown) => void;
}
/** A running heartbeat loop; call stop() to end it. */
export interface HeartbeatLoopHandle {
stop: () => void;
}
/**
* Start a heartbeat loop: emits one beat immediately, then every intervalMs.
* Returns a handle whose `stop()` is idempotent.
*/
export function startHeartbeatLoop(opts: HeartbeatLoopOptions): HeartbeatLoopHandle {
const status = opts.status ?? (() => 'online' as PresenceStatus);
const onError = opts.onError ?? (() => {});
const setIntervalFn = opts.setIntervalFn ?? ((cb, ms) => setInterval(cb, ms));
const clearIntervalFn =
opts.clearIntervalFn ?? ((h) => clearInterval(h as ReturnType<typeof setInterval>));
const beat = (): void => {
try {
const result = opts.send(opts.emitter.next(status()));
if (result instanceof Promise) {
result.catch(onError);
}
} catch (err) {
onError(err);
}
};
beat(); // immediate first beat so liveness is fresh at once
const handle = setIntervalFn(beat, opts.intervalMs);
let stopped = false;
return {
stop: () => {
if (stopped) return;
stopped = true;
clearIntervalFn(handle);
},
};
}
+42
View File
@@ -0,0 +1,42 @@
/**
* @mosaicstack/comms MACP presence SDK (RFC-001 P1).
*
* Minimal, dev-validated slice: set Matrix presence, run the `mosaic.presence`
* heartbeat, and compute deterministic fleet liveness. Enrollment, room
* taxonomy, token minting and signed-authorship are explicitly out of P1.
*/
export { classifyLiveness, computeFleetLiveness } from './liveness.js';
export {
HeartbeatEmitter,
startHeartbeatLoop,
type HeartbeatAgentIdentity,
type HeartbeatEmitterOptions,
type HeartbeatSender,
type HeartbeatLoopOptions,
type HeartbeatLoopHandle,
} from './heartbeat.js';
export {
MinimalMatrixClient,
MatrixError,
toMatrixPresence,
type MatrixClientConfig,
} from './matrix-client.js';
export { FleetLivenessReader, type FleetLivenessReaderOptions } from './liveness-reader.js';
export { PresenceAgent, type PresenceAgentOptions } from './presence-agent.js';
export {
DEFAULT_LIVENESS_POLICY,
MACP_VERSION,
type AgentLiveness,
type HeartbeatObservation,
type LivenessPolicy,
type MacpEnvelope,
type MatrixPresence,
type PresenceHeartbeatContent,
type PresenceStatus,
} from './types.js';
+58
View File
@@ -0,0 +1,58 @@
/**
* Fleet liveness reader (RFC-001 §4.5, A2/A4).
*
* Reads `mosaic.presence` heartbeats from the fleet presence room and computes
* deterministic online/away/offline for every agent. This is the surface a
* human (or the escalation watchdog, P2+) reads to answer "who's alive?".
*/
import { computeFleetLiveness } from './liveness.js';
import type { MinimalMatrixClient } from './matrix-client.js';
import { DEFAULT_LIVENESS_POLICY, type AgentLiveness, type LivenessPolicy } from './types.js';
export interface FleetLivenessReaderOptions {
client: MinimalMatrixClient;
/** The fleet presence room (id or resolved id). */
roomId: string;
policy?: LivenessPolicy;
/** Injectable clock for tests. Default Date.now. */
now?: () => number;
/** How many timeline events to scan back. Default 200. */
scanLimit?: number;
}
export class FleetLivenessReader {
private readonly policy: LivenessPolicy;
private readonly now: () => number;
constructor(private readonly opts: FleetLivenessReaderOptions) {
this.policy = opts.policy ?? DEFAULT_LIVENESS_POLICY;
this.now = opts.now ?? Date.now;
}
/** Read the room and compute current liveness for every seen agent. */
async read(): Promise<AgentLiveness[]> {
const observations = await this.opts.client.readHeartbeats(
this.opts.roomId,
this.opts.scanLimit ?? 200,
);
return computeFleetLiveness(observations, this.now(), this.policy);
}
/** A compact human-readable liveness board (A4 CLI view). */
async formatBoard(): Promise<string> {
const rows = await this.read();
rows.sort((a, b) => a.slug.localeCompare(b.slug));
const dot: Record<string, string> = { online: '🟢', away: '🟡', offline: '🔴' };
const lines = rows.map(
(r) =>
`${dot[r.status] ?? '⚪'} ${r.slug.padEnd(16)} ${r.status.padEnd(8)} ` +
`age=${(r.ageMs / 1000).toFixed(1)}s seq=${r.lastSeq} ${r.mxid}`,
);
const summary =
`online=${rows.filter((r) => r.status === 'online').length} ` +
`away=${rows.filter((r) => r.status === 'away').length} ` +
`offline=${rows.filter((r) => r.status === 'offline').length}`;
return [`Fleet presence — ${summary}`, ...lines].join('\n');
}
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Deterministic liveness computation (RFC-001 §4.5).
*
* The authoritative liveness signal is the `mosaic.presence` heartbeat, NOT
* native Matrix presence. Given the age of an agent's last heartbeat and a
* policy, these pure functions classify online/away/offline the same way every
* time which is exactly what makes the A3 "hard-killed agent flips to
* offline within dark_threshold" guarantee deterministic and testable without
* standing up a homeserver.
*/
import type {
AgentLiveness,
HeartbeatObservation,
LivenessPolicy,
PresenceStatus,
} from './types.js';
/**
* Classify a single agent from the age (ms) of its last heartbeat.
*
* - `age <= heartbeatIntervalMs * missTolerance` **online**
* - `age < darkThresholdMs` **away**
* - otherwise (or non-finite age) **offline / dark**
*
* A non-finite age (never seen / NaN) fails safe to `offline`: we never assert
* a liveness we cannot substantiate.
*/
export function classifyLiveness(ageMs: number, policy: LivenessPolicy): PresenceStatus {
if (!Number.isFinite(ageMs)) {
return 'offline';
}
const onlineWindowMs = policy.heartbeatIntervalMs * policy.missTolerance;
if (ageMs <= onlineWindowMs) {
return 'online';
}
if (ageMs < policy.darkThresholdMs) {
return 'away';
}
return 'offline';
}
/**
* Compute liveness for every observed agent at wall-clock `nowMs`.
* The result order mirrors the input order (stable for display).
*/
export function computeFleetLiveness(
observations: readonly HeartbeatObservation[],
nowMs: number,
policy: LivenessPolicy,
): AgentLiveness[] {
return observations.map((o) => {
const ageMs = nowMs - o.lastSeenTs;
return {
slug: o.slug,
mxid: o.mxid,
status: classifyLiveness(ageMs, policy),
lastSeenTs: o.lastSeenTs,
ageMs,
lastSeq: o.lastSeq,
};
});
}
+204
View File
@@ -0,0 +1,204 @@
/**
* Minimal Matrix Client-Server API client for the P1 presence slice.
*
* Deliberately tiny: only the calls presence needs (whoami, set native
* presence, send a timeline event, read recent timeline). Auth is a single
* bearer token; an optional `actAsUserId` enables Application-Service
* masquerade (`?user_id=`) so the P1 provisioner can drive several virtual
* agents with one as_token in dev (RFC-001 §2.2 step 4/Appendix A). Agents
* holding their own access_token simply omit `actAsUserId`.
*
* `fetch` is injectable for unit tests.
*/
import crypto from 'node:crypto';
import type {
HeartbeatObservation,
MatrixPresence,
PresenceHeartbeatContent,
PresenceStatus,
} from './types.js';
export interface MatrixClientConfig {
/** Client-Server API base, e.g. https://matrix.localhost:8448 */
homeserverUrl: string;
/** Bearer token (a per-agent access_token, or an as_token for masquerade). */
accessToken: string;
/** If set, all calls masquerade as this MXID via ?user_id= (AS mode). */
actAsUserId?: string;
}
export class MatrixError extends Error {
constructor(
readonly status: number,
readonly errcode: string | undefined,
message: string,
) {
super(message);
this.name = 'MatrixError';
}
}
type FetchLike = typeof fetch;
/** Map our authoritative liveness state to the native Matrix presence EDU. */
export function toMatrixPresence(status: PresenceStatus): MatrixPresence {
switch (status) {
case 'online':
return 'online';
case 'away':
return 'unavailable';
case 'offline':
return 'offline';
}
}
export class MinimalMatrixClient {
private readonly fetchImpl: FetchLike;
constructor(
private readonly cfg: MatrixClientConfig,
fetchImpl?: FetchLike,
) {
this.fetchImpl = fetchImpl ?? fetch;
}
private async request(
method: string,
path: string,
options: { query?: Record<string, string>; body?: unknown } = {},
): Promise<Record<string, unknown>> {
const url = new URL(this.cfg.homeserverUrl.replace(/\/$/, '') + path);
if (this.cfg.actAsUserId) {
url.searchParams.set('user_id', this.cfg.actAsUserId);
}
for (const [k, v] of Object.entries(options.query ?? {})) {
url.searchParams.set(k, v);
}
const res = await this.fetchImpl(url, {
method,
headers: {
Authorization: `Bearer ${this.cfg.accessToken}`,
'Content-Type': 'application/json',
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
const text = await res.text();
const data = (text ? JSON.parse(text) : {}) as Record<string, unknown>;
if (!res.ok) {
throw new MatrixError(
res.status,
typeof data.errcode === 'string' ? data.errcode : undefined,
`${method} ${path} -> ${res.status}: ${text.slice(0, 300)}`,
);
}
return data;
}
/** GET /account/whoami — resolves the acting MXID. */
async whoami(): Promise<string> {
const data = await this.request('GET', '/_matrix/client/v3/account/whoami');
if (typeof data.user_id !== 'string') {
throw new MatrixError(500, undefined, 'whoami returned no user_id');
}
return data.user_id;
}
/**
* Set the native Matrix presence EDU (so Element shows the right dot for
* humans). NOT the authoritative liveness signal the heartbeat is.
*/
async setPresence(userId: string, status: PresenceStatus, statusMsg?: string): Promise<void> {
const user = encodeURIComponent(userId);
await this.request('PUT', `/_matrix/client/v3/presence/${user}/status`, {
body: {
presence: toMatrixPresence(status),
...(statusMsg ? { status_msg: statusMsg } : {}),
},
});
}
/** Send an arbitrary timeline event; returns its event_id. */
async sendEvent(
roomId: string,
eventType: string,
content: Record<string, unknown>,
): Promise<string> {
const room = encodeURIComponent(roomId);
const txn = `mosaic-comms-${crypto.randomUUID()}`;
const data = await this.request(
'PUT',
`/_matrix/client/v3/rooms/${room}/send/${encodeURIComponent(eventType)}/${txn}`,
{ body: content },
);
if (typeof data.event_id !== 'string') {
throw new MatrixError(500, undefined, 'send returned no event_id');
}
return data.event_id;
}
/** Post a `mosaic.presence` heartbeat (m.room.message carrier) to the room. */
async sendHeartbeat(roomId: string, content: PresenceHeartbeatContent): Promise<string> {
return this.sendEvent(roomId, 'm.room.message', content as unknown as Record<string, unknown>);
}
/** Join a room (by id or alias). Idempotent on the server. */
async joinRoom(roomIdOrAlias: string): Promise<string> {
const data = await this.request(
'POST',
`/_matrix/client/v3/join/${encodeURIComponent(roomIdOrAlias)}`,
{ body: {} },
);
if (typeof data.room_id !== 'string') {
throw new MatrixError(500, undefined, 'join returned no room_id');
}
return data.room_id;
}
/**
* Read recent `mosaic.presence` heartbeats from a room and reduce them to the
* latest observation per agent. Walks the timeline backwards (most-recent
* first) and keeps, per slug, the beat with the highest seq.
*
* `lastSeenTs` uses the server's `origin_server_ts` (honest "when we last
* heard from it"), falling back to the agent-stamped envelope `ts`.
*/
async readHeartbeats(roomId: string, limit = 200): Promise<HeartbeatObservation[]> {
const room = encodeURIComponent(roomId);
const data = await this.request('GET', `/_matrix/client/v3/rooms/${room}/messages`, {
query: { dir: 'b', limit: String(limit) },
});
const chunk = Array.isArray(data.chunk) ? (data.chunk as Array<Record<string, unknown>>) : [];
const bySlug = new Map<string, HeartbeatObservation>();
for (const ev of chunk) {
const content = ev.content as Record<string, unknown> | undefined;
if (!content || content.msgtype !== 'mosaic.presence') continue;
const agent = content.agent as Record<string, unknown> | undefined;
const slug = agent && typeof agent.slug === 'string' ? agent.slug : undefined;
const mxid =
agent && typeof agent.mxid === 'string'
? agent.mxid
: typeof ev.sender === 'string'
? ev.sender
: undefined;
if (!slug || !mxid) continue;
const seq = typeof content.seq === 'number' ? content.seq : 0;
const serverTs = typeof ev.origin_server_ts === 'number' ? ev.origin_server_ts : undefined;
const envelopeTs = typeof content.ts === 'number' ? content.ts : undefined;
const lastSeenTs = serverTs ?? envelopeTs ?? 0;
const assertedStatus =
content.status === 'online' || content.status === 'away' || content.status === 'offline'
? (content.status as PresenceStatus)
: 'offline';
const prev = bySlug.get(slug);
if (!prev || seq > prev.lastSeq) {
bySlug.set(slug, { slug, mxid, lastSeenTs, lastSeq: seq, assertedStatus });
}
}
return [...bySlug.values()];
}
}
+92
View File
@@ -0,0 +1,92 @@
/**
* High-level presence agent (RFC-001 §4.1 steps 1011, §4.5).
*
* Ties the pieces together for one agent: join the fleet presence room, set
* native Matrix presence online (for Element's dot), and run the authoritative
* `mosaic.presence` heartbeat loop. This is the P1 slice of what a harness does
* on spin no enrollment/token-minting/introductions (those are P2).
*/
import {
HeartbeatEmitter,
startHeartbeatLoop,
type HeartbeatAgentIdentity,
type HeartbeatLoopHandle,
} from './heartbeat.js';
import type { MinimalMatrixClient } from './matrix-client.js';
import { DEFAULT_LIVENESS_POLICY, type LivenessPolicy, type PresenceStatus } from './types.js';
export interface PresenceAgentOptions {
client: MinimalMatrixClient;
agent: HeartbeatAgentIdentity;
/** Fleet presence room id (or alias) to heartbeat into. */
roomId: string;
/** Heartbeat cadence; defaults to the policy interval. */
intervalMs?: number;
policy?: LivenessPolicy;
missionId?: string;
onError?: (err: unknown) => void;
}
export class PresenceAgent {
private readonly intervalMs: number;
private readonly emitter: HeartbeatEmitter;
private loop: HeartbeatLoopHandle | undefined;
private resolvedRoomId: string | undefined;
constructor(private readonly opts: PresenceAgentOptions) {
const policy = opts.policy ?? DEFAULT_LIVENESS_POLICY;
this.intervalMs = opts.intervalMs ?? policy.heartbeatIntervalMs;
this.emitter = new HeartbeatEmitter({
agent: opts.agent,
intervalMs: this.intervalMs,
missionId: opts.missionId,
});
}
/** Join the fleet room and go present. Returns the resolved room id. */
async connect(): Promise<string> {
this.resolvedRoomId = await this.opts.client.joinRoom(this.opts.roomId);
await this.opts.client.setPresence(this.opts.agent.mxid, 'online', 'mosaic.presence heartbeat');
return this.resolvedRoomId;
}
/** Start the heartbeat loop (emits immediately, then every intervalMs). */
start(status: () => PresenceStatus = () => 'online'): void {
const roomId = this.resolvedRoomId ?? this.opts.roomId;
this.loop = startHeartbeatLoop({
emitter: this.emitter,
intervalMs: this.intervalMs,
status,
onError: this.opts.onError,
send: async (content) => {
await this.opts.client.sendHeartbeat(roomId, content);
},
});
}
get currentSeq(): number {
return this.emitter.currentSeq;
}
/**
* Stop only the heartbeat loop, sending NO graceful signal. This models a
* hard crash/kill: the authoritative liveness path must detect it purely from
* the absence of heartbeats (RFC-001 §4.5, A3), not from any native presence
* change. Idempotent.
*/
pauseHeartbeat(): void {
this.loop?.stop();
this.loop = undefined;
}
/** Graceful stop: stop heartbeating and drop native presence to offline. */
async stop(): Promise<void> {
this.pauseHeartbeat();
try {
await this.opts.client.setPresence(this.opts.agent.mxid, 'offline');
} catch (err) {
this.opts.onError?.(err);
}
}
}
+99
View File
@@ -0,0 +1,99 @@
/**
* @mosaicstack/comms MACP P1 (presence) types.
*
* Implements the presence/liveness slice of RFC-001 §4.5 and the MACP event
* envelope of RFC-001 §4.2. P1 scope only: presence heartbeat + deterministic
* liveness. No enrollment, room-taxonomy, token-minting or signed-authorship
* (those are P2+).
*/
/** The three human-visible liveness states (RFC-001 §4.5). */
export type PresenceStatus = 'online' | 'away' | 'offline';
/**
* Native Matrix presence EDU states. We still emit these (so Element shows the
* right dot for humans, RFC-001 §4.5) but they are NOT the authoritative
* liveness source the heartbeat is.
*/
export type MatrixPresence = 'online' | 'unavailable' | 'offline';
/**
* Common MACP event envelope carried in `content` on every custom event
* (RFC-001 §4.2). P1 uses only the fields the presence heartbeat needs; the
* `signature` field (gate actions, §4.4) is intentionally absent in P1.
*/
export interface MacpEnvelope {
macp_version: string;
macp_type: string;
agent: {
mxid: string;
slug: string;
harness: string;
};
ts: number;
mission_id?: string;
}
/**
* `mosaic.presence` heartbeat content (RFC-001 §4.2 "presence" row + §4.5).
* Carried as an `m.room.message` with `msgtype: "mosaic.presence"` and a
* human-visible `body` fallback, posted into the fleet presence room.
*/
export interface PresenceHeartbeatContent extends MacpEnvelope {
macp_type: 'presence';
msgtype: 'mosaic.presence';
/** Human-visible fallback so the event renders in a stock client. */
body: string;
/** Liveness state the agent asserts about itself. */
status: PresenceStatus;
/** Monotonic per-agent sequence number, increments once per beat. */
seq: number;
/** The agent's configured heartbeat interval, so readers can reason. */
interval_ms: number;
}
/**
* Deterministic liveness policy (RFC-001 §4.5). Defaults per §4.5/§5.3:
* interval 30s, miss-tolerance 2, dark threshold a policy value (10 min in
* prod §5; small in dev harness).
*/
export interface LivenessPolicy {
/** Nominal heartbeat interval in ms. Default 30_000. */
heartbeatIntervalMs: number;
/** How many intervals may be missed before "away". Default 2. */
missTolerance: number;
/** Age past which an agent is declared offline/dark. Default 600_000. */
darkThresholdMs: number;
}
/** A single agent's last observed heartbeat, as read from the fleet room. */
export interface HeartbeatObservation {
slug: string;
mxid: string;
/** Wall-clock ms of the last heartbeat seen for this agent. */
lastSeenTs: number;
/** Last seq observed (monotonic per agent). */
lastSeq: number;
/** The status the agent last asserted about itself. */
assertedStatus: PresenceStatus;
}
/** Computed liveness for one agent (what a human/watchdog reads). */
export interface AgentLiveness {
slug: string;
mxid: string;
/** Authoritative, heartbeat-derived status. */
status: PresenceStatus;
lastSeenTs: number;
/** now - lastSeenTs, in ms. */
ageMs: number;
lastSeq: number;
}
export const DEFAULT_LIVENESS_POLICY: LivenessPolicy = {
heartbeatIntervalMs: 30_000,
missTolerance: 2,
darkThresholdMs: 600_000,
};
export const MACP_VERSION = '1.0';
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/index.ts'],
},
},
});
@@ -0,0 +1,170 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://mosaicstack.dev/schemas/wake-watch-list.schema.json",
"title": "Mosaic Wake Watch-List",
"description": "Declarative watch-list for the wake/heartbeat detector (EPIC #892). The SCHEMA is framework-owned; the VALUES are operator-supplied (repos, board files, lane anchors, per-class SLOs). This is the W2 schema contract only — the detector (W4) and digest renderer (W3) consume it. Per CONVERGED-DESIGN §1.4: 'operator repo; schema is framework, values are operator.'",
"type": "object",
"required": ["schema_version", "watches"],
"additionalProperties": false,
"properties": {
"schema_version": {
"type": "integer",
"minimum": 1,
"description": "Watch-list schema version. The wake component's manifest.txt declares the supported range (schema_min/schema_max, Gate B); a watch-list outside that range is rejected by the component, not silently coerced."
},
"host": {
"type": "string",
"description": "Optional operator label for the host this watch-list serves. Per-host single-instance detector (§1.1). Operator-supplied; no semantic meaning to the schema."
},
"repos": {
"type": "array",
"description": "Git repositories to watch. Source SHAs are descriptors, not the cursor (§2.4).",
"items": {
"type": "object",
"required": ["id"],
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"description": "Operator-chosen stable identifier for this repo watch."
},
"remote": {
"type": "string",
"description": "Remote/clone locator (operator-supplied). No credentials inline; secrets are by-name via load_credentials."
},
"branches": {
"type": "array",
"items": { "type": "string" },
"description": "Branch refs to track. Empty => default branch."
},
"class": { "$ref": "#/$defs/class" },
"slo": { "$ref": "#/$defs/slo_ref" },
"aba_sensitive": {
"type": "boolean",
"default": false,
"description": "If true, this source needs an event-stream/webhook rather than poll-only (intra-poll ABA mitigation, §2.4 / gate G5). Poll-only remains a mitigation, not elimination."
}
}
}
},
"board_files": {
"type": "array",
"description": "Board / decision files whose edits must be caught (repo-section/anchor-scoped hashing, §1.1). Human-decision file edits, not just API-visible state.",
"items": {
"type": "object",
"required": ["id", "path"],
"additionalProperties": false,
"properties": {
"id": { "type": "string" },
"repo": {
"type": "string",
"description": "Optional reference to a repos[].id this file lives in."
},
"path": {
"type": "string",
"description": "File path (operator-supplied). Locators are hard: repo/issue#/SHA/file:anchor (§2.1)."
},
"class": { "$ref": "#/$defs/class" },
"slo": { "$ref": "#/$defs/slo_ref" }
}
}
},
"lane_anchors": {
"type": "array",
"description": "In-file anchors (headings/markers) scoping a lane's obligations, so a file edit outside the lane's anchor does not wake it.",
"items": {
"type": "object",
"required": ["id", "anchor"],
"additionalProperties": false,
"properties": {
"id": { "type": "string" },
"board_file": {
"type": "string",
"description": "Optional reference to a board_files[].id this anchor lives in."
},
"anchor": {
"type": "string",
"description": "Anchor text/marker delimiting the lane's section within the file."
},
"class": { "$ref": "#/$defs/class" },
"slo": { "$ref": "#/$defs/slo_ref" }
}
}
},
"slos": {
"type": "object",
"description": "Named per-class urgency SLO tiers. SYMBOLIC — the operator sets concrete durations; the schema only fixes the shape and the class ordering intent (§4: security/lease/CI = tight; board = tens of minutes; routine = hours). No numeric parameters are baked into the framework.",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"class": { "$ref": "#/$defs/class" },
"fallback_bound": {
"type": "string",
"description": "Operator-supplied duration (e.g. '5m', '30m', '4h'). Symbolic tier is set by the operator, not the framework."
},
"fallback_cadence": {
"type": "string",
"description": "OPTIONAL, additive (schema_version 1, backward-compatible — omitting it is valid). The per-class cadence bound for the framework-shipped canon FALLBACK WAKE (F7 replacement-before-retirement, EPIC #892): the low-frequency SAFETY-wake timer (mosaic-wake-fallback.timer) that fires the canon drain INDEPENDENT of the event-driven detector, so a stalled detector cannot silently starve delivery. The A10 installer reads this per-class value and writes it as the fallback timer's OnUnitActiveSec via the blank-reset drop-in (exactly one effective OnUnitActiveUSec). SYMBOLIC — an operator-supplied duration (e.g. '30m', '1h', '4h'); the framework bakes in no numeric. Config, not code. Should be no tighter than this tier's `fallback_bound` (the safety wake is a floor, never the primary mechanism)."
},
"quiet_hours_may_suppress": {
"type": "boolean",
"default": false,
"description": "If true, quiet-hours may suppress the cold fallback for this tier. MUST remain false for actionable/critical classes (§3: quiet-hours never gate an actionable/critical class)."
},
"measure_to": {
"type": "string",
"enum": ["consumed", "qualified-action-or-handoff"],
"description": "Terminal the SLO is measured to (§4/G8): CONSUMED measures reading; qualified-action-or-handoff measures doing. Actionable/critical classes measure to the action terminal."
}
}
}
},
"watches": {
"type": "array",
"description": "The declared source-coverage inventory: a lane-by-lane list of every operational source the lane depends on, so an omitted source cannot make the retirement vector pass vacuously (§4/G3 parity inventory). Each entry references a source declared above by kind+id.",
"items": {
"type": "object",
"required": ["lane", "sources"],
"additionalProperties": false,
"properties": {
"lane": {
"type": "string",
"description": "Operator lane identifier this watch serves."
},
"sources": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["kind", "id"],
"additionalProperties": false,
"properties": {
"kind": {
"type": "string",
"enum": ["repo", "board_file", "lane_anchor"],
"description": "Which top-level collection the id refers to."
},
"id": {
"type": "string",
"description": "Reference to repos[].id / board_files[].id / lane_anchors[].id."
}
}
}
}
}
}
}
},
"$defs": {
"class": {
"type": "string",
"enum": ["digest", "actionable", "human", "terminal-log", "reaction"],
"description": "Wake class (§2.3). Only `digest` coalesces (cumulative-state replace); actionable/human APPEND. ALL classes are durable. Absent class => the consumer treats it as `actionable` (fail-safe)."
},
"slo_ref": {
"type": "string",
"description": "Name of an entry in the top-level `slos` map to apply to this source."
}
}
}
@@ -13,7 +13,14 @@ It is a **gate** role: the one and only merge path.
2. **Use the wrapped scripts as the ONLY merge path** — the merge-gate merges
**exclusively** by calling **`pr-merge.sh`** (the merge action, which carries the
authoritative forbidden-path guard) and **`pr-ci-wait.sh`** (to wait for green
CI before merging). These two scripts are the _only_ sanctioned merge path.
CI before merging). Before issuing a verdict, scan the full JSON/API child-step
record (including `clone`) with **`verify-terminal-green.py --expect-commit
<current-provider-PR-head>`** and record the equal expected/observed full-40
commits, exact step count, anomalies, and named exemptions. Missing or mismatched
commit binding is a hard refusal. The verifier's sole interim
exemption is `WP-K8S-1000-CI-POSTGRES-TEARDOWN`; it is signature-scoped, tracked
by #1000, and retires when #1000 is fixed. These scripts are the _only_
sanctioned merge path.
3. **Never call the raw API** — the merge-gate **does NOT** call `tea`, the raw
Gitea/forge HTTP API, or any other merge mechanism directly. Only `pr-merge.sh`
and `pr-ci-wait.sh`.
@@ -868,6 +868,38 @@ steps:
7. **Test on a short-lived non-main branch first** — open a PR and verify quality gates before merging to `main`
8. **Verify images appear** in Gitea Packages tab after successful pipeline
## Terminal-Green Full-Step Contract
A successful pipeline summary is not sufficient: verification MUST consume the full JSON/API child-step record, including `clone`.
```bash
PR_HEAD=<full-40-hex-provider-head>
~/.config/mosaic/tools/woodpecker/pipeline-status.sh \
-r mosaicstack/stack -n <pipeline-number> -f json \
| ~/.config/mosaic/tools/woodpecker/verify-terminal-green.py \
--expect-commit "$PR_HEAD" -
```
`PR_HEAD` MUST come from the current provider PR metadata and MUST be the full 40-hex head, not a local branch guess. The verifier fails if the argument is missing, malformed, absent from the pipeline record, or differs from that record.
The verifier reports the expected and observed commits, total step count, state counts, anomalies, and any applied exemption. Exit `0` means the record satisfies the contract; exit `1` means the commit binding or at least one pipeline, workflow, or child-step state blocks terminal-green; exit `2` means the invocation or JSON input could not be verified.
### Named interim exemption: `WP-K8S-1000-CI-POSTGRES-TEARDOWN`
Only this exact conjunction is exempted:
- pipeline and workflow state are `success`;
- exactly one non-success child exists;
- its name is `ci-postgres` and type is `service`;
- its state is `failure`, exit code is the JSON integer `0` (not boolean, float, string, or null); and
- its error exactly matches `pods "wp-svc-<ULID>-ci-postgres" not found`.
Every near miss remains blocking, including non-zero service exits, startup failures, post-readiness crashes, connection errors, image-pull errors, skipped steps, another failed child, malformed pod names, duplicate matches, or a non-success pipeline/workflow.
**Boundary in both directions:** this exemption recognizes the observed Woodpecker Kubernetes reconciliation miss after an otherwise-successful run. It does not prove that every future PostgreSQL or Kubernetes failure is distinguishable. It does prove, through provider controls, that a deterministic startup failure (`exit_code=1`) and an armed post-readiness postmaster crash (`exit_code=137`, dependent probe `Connection refused`) do not match and remain red.
**Tracking and retirement:** [mosaicstack/stack#1000](https://git.mosaicstack.dev/mosaicstack/stack/issues/1000) owns the provider-seam fix. This exemption MUST be removed when #1000 is fixed. It is not authority to retry or re-trigger a pipeline, and no per-PR re-roll is part of the contract.
## Post-Merge CI Monitoring (Hard Rule)
For source-code delivery, completion is not allowed at "PR opened" stage.
@@ -893,14 +925,16 @@ Woodpecker note:
Before pushing a branch or merging a PR, guard against overlapping project pipelines:
```bash
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>
```
Behavior:
- If pipeline state is running/queued/pending, wait until queue clears.
- If timeout or API/auth failure occurs, treat as `blocked`, report exact failed wrapper command, and stop.
- If pipeline state is running/queued/pending, wait until queue clears; timeout is `ASSERTED_NOT_READY` and exits nonzero.
- Failure, missing status, malformed status, or any other provider-asserted non-green state is `ASSERTED_NOT_READY` and exits nonzero.
- Credential, transport, or provider unavailability is `CANNOT_ASSERT`: the guard emits a loud diagnostic and durable JSONL audit record. For push it exits 0 so recovery work is not bricked. For merge it returns distinct retryable exit 75 and holds until provider recovery; rerunning then self-clears without manual reset. This result is never evidence that CI was clear. If the audit cannot be written, the guard exits nonzero.
- `pr-merge.sh` resolves and guards the exact PR head repository and full SHA automatically, including fork PRs.
## Gitea as Unified Platform
@@ -13,7 +13,7 @@ Merge strategy enforcement (HARD RULE):
- PR target for delivery is `main`.
- Direct pushes to `main` are prohibited.
- Merge to `main` MUST be squash-only.
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash` (or PowerShell equivalent).
- Use `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}` (or PowerShell equivalent).
## Review Checklist
@@ -79,7 +79,7 @@ For implementation work, you MUST run this cycle in order:
8. `pre-push queue guard` - before pushing, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. `push` - push immediately after queue guard passes.
10. `PR integration` - if external git provider is available, create/update PR to `main` and merge with required strategy via Mosaic wrappers.
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge`.
11. `pre-merge queue guard` - before merging PR, wait for running/queued project pipelines on the exact PR head to clear: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
12. `CI/pipeline verification` - wait for terminal CI status and require green before completion (`~/.config/mosaic/tools/git/pr-ci-wait.sh` for PR-based workflow).
13. `issue closure` - close linked external issue (or close internal `docs/TASKS.md` task ref when provider is unavailable).
14. `greenfield situational test` - validate required user flows in a clean environment/startup path (post-merge for trunk workflow changes).
@@ -93,8 +93,8 @@ For implementation work, you MUST run this cycle in order:
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
> without asking.
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash --expect-head <APPROVED_FULL_SHA>`
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
4. `~/.config/mosaic/tools/git/issue-close.sh -i <ISSUE_NUMBER>` (or close internal `docs/TASKS.md` ref when no provider exists)
5. If any step fails: set status `blocked`, report the exact failed wrapper command, and stop.
@@ -425,11 +425,11 @@ git push
and checklist completed (`~/.config/mosaic/templates/docs/DOCUMENTATION-CHECKLIST.md`) when applicable.
13. **PR + CI + Issue Closure Gate** (HARD RULE for source-code tasks):
- Before merging, run queue guard:
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
`~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`
- Ensure PR exists for the task branch (create/update via wrappers if needed):
`~/.config/mosaic/tools/git/pr-create.sh ... -B main`
- Merge via wrapper:
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
`~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
- Wait for terminal CI status:
`~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
- Close linked issue after merge + green CI:
@@ -630,7 +630,7 @@ Construct this from the task row and pass to worker via Task tool:
**MANDATORY:** This ALWAYS includes linting. If the project has a linter configured
(ESLint, Biome, ruff, etc.), you MUST run it and fix ALL violations in files you touched.
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
Do NOT leave lint warnings or errors for someone else to clean up. 6. Run REQUIRED situational tests based on changed surfaces (see `~/.config/mosaic/guides/E2E-DELIVERY.md` and `~/.config/mosaic/guides/QA-TESTING.md`). 7. If task is bug fix/security/auth/critical business logic, apply REQUIRED TDD discipline per `~/.config/mosaic/guides/QA-TESTING.md`. 8. If gates or required situational tests fail: Fix and retry. Do NOT report success with failures. 9. Commit: `git commit -m "fix({finding_id}): brief description"` 10. Before push, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {branch}` 11. Push: `git push origin {branch}` 12. Report result as JSON (see format below)
## Git Scripts
@@ -638,8 +638,9 @@ For issue/PR/milestone operations, use scripts (NOT raw tea/gh):
- `~/.config/mosaic/tools/git/issue-view.sh -i {N}`
- `~/.config/mosaic/tools/git/pr-create.sh -t "Title" -b "Desc" -B main`
- `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash`
- Push: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B {task_branch}`
- Merge: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B {pr_head_branch} -R {pr_head_owner/repo} --sha {pr_head_full_sha}`
- `~/.config/mosaic/tools/git/pr-merge.sh -n {PR_NUMBER} -m squash --expect-head {approved_full_sha}`
- `~/.config/mosaic/tools/git/pr-ci-wait.sh -n {PR_NUMBER}`
- `~/.config/mosaic/tools/git/issue-close.sh -i {N}`
@@ -23,10 +23,12 @@ Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection a
# Milestones
~/.config/mosaic/tools/git/milestone-create.sh
# CI queue guard (required before push/merge)
# CI queue guard (required before push/merge; defaults to the checked-out branch)
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
```
The guard exits nonzero for any provider-asserted non-green, missing, or malformed CI state. If credentials or the provider are unavailable, it emits `CANNOT_ASSERT` and writes a JSONL audit record. Push degrades to exit 0 so recovery work is not bricked; merge holds with retryable exit 75 until the provider recovers, then self-clears without manual reset. Neither outcome is evidence that CI was clear. `pr-merge.sh` automatically inspects the exact PR head repository and full commit SHA rather than its `main` base; this also handles fork PRs without branch-name ambiguity. Pass `--expect-head <approved-full-sha>` to bind a commit-specific review or merge-gate verdict; Gitea uses atomic `head_commit_id` and GitHub uses `--match-head-commit`.
### Code Review (Codex)
```bash
@@ -0,0 +1,31 @@
# Wake Doctrine
This is the canonical fleet wake/heartbeat doctrine, extracted verbatim from the ratified
converged wake/heartbeat design (`docs/scratchpads/heartbeat-planning/CONVERGED-DESIGN.md`). It
governs when agents wake and how a wake is delivered, consumed, and retired.
**Wake only on a real, un-consumed, lane-relevant obligation.** Fixed-interval heartbeats are
forbidden as the primary wake mechanism; they survive only as a **per-class fallback cadence**
bounded by urgency SLO, never as the steady state.
**A digest is cumulative state since the last CONSUMED ack**, not an event delta. It is
self-orienting (who / lane / board-head) and decides the no-op case with **zero tool calls**.
Actionable facts are **claims-to-verify** carrying a **hard locator** (repo/issue#/SHA/file);
self-sufficiency never exempts a consequential action from its live gate.
**Consumption is a consumer act, not a delivery act.** Split RECEIVED (delivery; `wake_id`-deduped)
from CONSUMED (durable capture of a contiguous prefix). Never ack-then-crash-before-capture. Acks
are local-write-only and cumulative; a turn never blocks on the network to ack.
**Durability is unconditional; coalescing is optional.** Every delivered class is durably stored
and acked; only machine `digest` wakes coalesce. A parked or absent pane must never lose a human
or peer message.
**Park is two-phase:** flush-and-checkpoint (recording the CONSUMED cursor) _before_ `/clear`.
**Liveness is independent of work-triggering:** an off-host dead-man beacon, alarming on absence —
never a same-host sibling, never a pane scrape.
**Retire the old net LAST:** run new alongside old, compare ledgers, and cut over only when the
per-host safety vector (no-op-rate ↓ AND canary-FN=0 AND source-parity-inventory-complete AND
reconcile=0 AND p95 event→CONSUMED≤SLO AND p95 event→qualified-action≤SLO) passes.
+81 -2
View File
@@ -18,12 +18,42 @@ set -Eeuo pipefail
# MOSAIC_INSTALL_MODE — prompt|keep|overwrite (default: prompt)
# MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING — 1 to bypass MCP check
# MOSAIC_SKIP_SKILLS_SYNC — 1 to skip skill sync
#
# Flags (CLI args, NOT environment variables — see #869 Point-1 C2):
# --allow-inactive-enforcement Explicit, per-invocation opt-out that lets the
# lease-enforcement hooks (mutator-gate.py,
# receipt-observer-client.py) be wired into
# ~/.claude/settings.json even when this host
# cannot confirm it can ACTIVATE them. Loud on
# use (see mosaic-link-runtime-assets). Default
# (flag absent) is fail-loud: the enforcement
# hooks are NOT wired and the framework's
# runtime-asset-link step reports a failure.
# ──────────────────────────────────────────────────────────────────────────────
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# Deliberately parsed from "$@" (a real, explicit, per-invocation argument) —
# never an environment variable — so this opt-out can never sit silently
# inherited in a shell profile. See #869 Point-1 C2.
ALLOW_INACTIVE_ENFORCEMENT=0
# Component-scoped install (#892 W7): `install.sh --component <name>` runs an
# additive, self-contained component installer and EXITS — it never enters the
# full-framework sync below and never modifies framework-manifest ownership
# behavior (#869: the diff is ADDITIVE). Parsed as a two-token flag here.
COMPONENT=""
_prev_arg=""
for _arg in "$@"; do
case "$_arg" in
--allow-inactive-enforcement) ALLOW_INACTIVE_ENFORCEMENT=1 ;;
--component=*) COMPONENT="${_arg#--component=}" ;;
esac
[[ "$_prev_arg" == "--component" ]] && COMPONENT="$_arg"
_prev_arg="$_arg"
done
# Shared framework path-ownership manifest reader (#791). Parity with
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
@@ -584,6 +614,46 @@ run_migrations() {
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
# Component-scoped install (#892 W7) — additive early dispatch.
# ═══════════════════════════════════════════════════════════════════════════════
# `install.sh --component <name>` delegates to the component's own idempotent,
# fail-closed installer and EXITS. This path is ADDITIVE (#869): it does NOT run
# the full-framework sync, does NOT alter framework-manifest ownership behavior,
# and touches NOTHING the #869 install-ordering-guard covers (no runtime-asset
# linking, no lease-enforcement hook wiring). Each component installer is
# INTERSECTED-AND-VALIDATED against the single SSOT framework-manifest.txt, so a
# component manifest can never authorize a write outside framework ownership.
run_component_install() {
local name="$1"
case "$name" in
wake)
local wi="$SOURCE_DIR/tools/wake/wake-install.sh"
if [[ ! -x "$wi" && ! -f "$wi" ]]; then
fail "Component 'wake' installer not found at $wi"
exit 1
fi
step "Installing Mosaic component: wake"
WAKE_INSTALL_SOURCE="$SOURCE_DIR" WAKE_INSTALL_TARGET="$TARGET_DIR" \
bash "$wi" install
;;
"")
fail "--component requires a name (e.g. --component wake)."
exit 1
;;
*)
fail "Unknown component '$name'. Supported: wake."
exit 1
;;
esac
}
if [[ -n "$COMPONENT" ]]; then
mkdir -p "$TARGET_DIR"
run_component_install "$COMPONENT"
exit 0
fi
# ═══════════════════════════════════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════════════════════════════════
@@ -639,6 +709,10 @@ reconcile_framework_files
# Ensure tool scripts are executable
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
# suffix — git resolves credential helpers by exact name/path, not extension —
# so the *.sh glob above does not cover it; chmod it explicitly.
[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true
ok "Framework synced to $TARGET_DIR"
@@ -666,10 +740,15 @@ step "Post-install tasks"
SCRIPTS="$TARGET_DIR/tools/_scripts"
if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
if "$SCRIPTS/mosaic-link-runtime-assets" >/dev/null 2>&1; then
link_args=()
[[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
# stdout is suppressed as before, but stderr is left connected: the
# install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach
# the operator, not be swallowed silently.
if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then
ok "Runtime assets linked"
else
warn "Runtime asset linking failed (non-fatal)"
warn "Runtime asset linking failed (non-fatal) — see message above for details."
fi
fi
@@ -0,0 +1,22 @@
[Unit]
Description=Mosaic lease broker daemon (framework tools/lease-broker/daemon.py)
Documentation=https://git.mosaicstack.dev/mosaicstack/stack
After=default.target
[Service]
Type=simple
# The broker socket lives under the runtime directory so it disappears with
# the user session instead of surviving as stale state across logins.
# daemon.py's secure_parent() fails closed unless this directory is exactly
# 0700, so RuntimeDirectoryMode is not cosmetic.
RuntimeDirectory=mosaic-lease
RuntimeDirectoryMode=0700
# Remove loader and noninteractive-shell controls before ExecStart loads env,
# matching the tmux fleet units in this same directory.
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
ExecStart=/usr/bin/env -i HOME=%h PATH=/usr/bin:/bin XDG_RUNTIME_DIR=%t /bin/bash --noprofile --norc %h/.config/mosaic/tools/lease-broker/start-lease-broker.sh
Restart=on-failure
RestartSec=1
[Install]
WantedBy=default.target
@@ -0,0 +1,41 @@
[Unit]
# Mosaic wake FALLBACK safety drain (F7 replacement-before-retirement, EPIC #892,
# W7). This is the framework-shipped canon-side FALLBACK WAKE: a LOW-FREQUENCY
# SAFETY drain that fires the canon drain (digest.sh render --from-store) on a
# per-class cadence bound, INDEPENDENT of the event-driven detector daemon
# (mosaic-wake.service). Its whole reason to exist is that a stalled/dead detector
# or daemon can never SILENTLY STARVE delivery: even with nothing pushing, this
# oneshot periodically drains the durable pending-inbox so the cumulative unacked
# set still reaches the consumer. It is the §5 retirement precondition (F7) — it
# must be live + proven-firing BEFORE the legacy mosaic-heartbeat@ timer is reaped,
# so there is never a coverage gap. Fixed-interval heartbeats are forbidden as the
# PRIMARY wake mechanism (WAKE-DOCTRINE); they survive ONLY as this per-class
# fallback cadence, bounded by urgency SLO, never as the steady state.
#
# This is a oneshot SERVICE activated by mosaic-wake-fallback.timer; the cadence
# lives on the TIMER (OnUnitActiveSec), set per-class by the A10 installer via the
# blank-reset drop-in — never here. The service therefore carries NO [Install]
# section (the TIMER is what is enabled/wanted); it is triggered, not wanted.
Description=Mosaic wake fallback safety drain (canon drain: digest.sh render --from-store)
After=default.target
[Service]
Type=oneshot
# Strip loader / noninteractive-shell controls before ExecStart, matching the
# detector, lease-broker and tmux fleet units in this same directory (defense-in-
# depth against an injected BASH_ENV/ENV/LD_PRELOAD in the user manager environment).
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
# Operator-owned runtime configuration. This EnvironmentFile carries only WAKE_*
# NAMES (the per-agent namespace WAKE_AGENT, the lane WAKE_LANE, and — reused from
# the detector — the pluggable adapter COMMANDS resolved BY NAME at runtime). It
# carries NEVER any secret and NEVER any endpoint value. The '-' prefix keeps a
# missing file from masking the installer's dedicated fail-closed install-validate.
EnvironmentFile=-%h/.config/mosaic/wake/fallback.env
# THE CANON DRAIN. digest.sh render --from-store drains the durable pending-inbox
# (store.sh drain) and renders the cumulative-state digest. Running it here, on the
# timer cadence, is the safety net: it is the SAME drain the delivery path uses, so
# a stalled detector cannot starve it. Delivery/paste of the rendered digest is the
# same operator-wired send seam the detector path uses (out of framework scope);
# this unit guarantees the DRAIN fires on a bounded cadence regardless of detector
# health. digest render exits 0 on an empty inbox, so a quiet cycle is a clean no-op.
ExecStart=/bin/bash --noprofile --norc %h/.config/mosaic/tools/wake/digest.sh render --from-store
@@ -0,0 +1,30 @@
[Unit]
# Cadence timer for the Mosaic wake FALLBACK safety drain (F7, EPIC #892, W7).
# Drives mosaic-wake-fallback.service on a LOW-FREQUENCY per-class cadence bound,
# INDEPENDENT of the event-driven detector daemon, so a stalled detector can never
# silently starve delivery. This is the framework-shipped canon-side FALLBACK WAKE:
# a heartbeat-shaped timer that survives ONLY as the per-class fallback cadence
# (WAKE-DOCTRINE) bounded by urgency SLO — never the steady-state wake mechanism.
Description=Mosaic wake fallback cadence timer (per-class safety wake)
After=default.target
[Timer]
# BASE cadence placeholder. The A10 installer OVERRIDES this per-class from the
# watch-list schema's per-class `fallback_cadence` bound, via a BLANK-RESET drop-in
# (an empty OnUnitActiveSec= reset line, then the new value) written under
# mosaic-wake-fallback.timer.d/. systemd merges base + drop-ins so exactly ONE
# effective OnUnitActiveUSec results (wake-install.sh verify-single). The base value
# here is a conservative safety floor for a host installed before any per-class
# drop-in is written — it is deliberately low-frequency (never the primary wake).
OnUnitActiveSec=1h
# Also fire shortly after boot so a freshly-booted host does not wait a full cadence
# for its first safety drain. OnBootSec is a distinct key from OnUnitActiveSec and
# does NOT count toward the exactly-one-OnUnitActiveUSec blank-reset invariant.
OnBootSec=15min
# Catch up a missed elapse (host asleep/off) rather than silently skipping it — a
# fallback that silently skips is exactly the starvation this unit exists to prevent.
Persistent=true
Unit=mosaic-wake-fallback.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,31 @@
[Unit]
# Mosaic wake DETECTOR daemon (A1/W7 of the wake canon, EPIC #892). A LONG-LIVED
# single-instance detector: tools/wake/detector.sh run. This is a SERVICE, not a
# timer — the per-class SLO lives INSIDE the daemon's run-loop (WAKE_DETECTOR_INTERVAL
# poll cadence + the per-cycle off-host beacon emit), never as a systemd
# OnUnitActiveSec interval. The blank-reset cadence idiom therefore does NOT apply
# to this unit; it applies only to the legacy mosaic-heartbeat@ timer during retire.
Description=Mosaic wake detector daemon (framework tools/wake/detector.sh run)
After=default.target
[Service]
Type=simple
# Strip loader / noninteractive-shell controls before ExecStart, matching the
# lease-broker and tmux fleet units in this same directory (defense-in-depth
# against an injected BASH_ENV/ENV/LD_PRELOAD in the user manager environment).
UnsetEnvironment=LD_PRELOAD BASH_ENV ENV
# Operator-owned runtime configuration. This EnvironmentFile carries only the
# WAKE_* NAMES and the pluggable adapter COMMANDS (the off-host beacon/alarm sink
# and the HMAC key NAME) — NEVER the HMAC key material and NEVER the alarm
# endpoint value. Both are resolved BY NAME at runtime via load_credentials, so
# no secret and no endpoint is ever written into this unit. The installer's
# fail-closed install-validate (wake-install.sh validate-targets) is what proves
# the required names are configured + reachable BEFORE this unit is enabled; the
# '-' prefix keeps a missing file from masking that dedicated validation.
EnvironmentFile=-%h/.config/mosaic/wake/detector.env
ExecStart=/bin/bash --noprofile --norc %h/.config/mosaic/tools/wake/detector.sh run
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -88,7 +88,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable)
@@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -97,7 +97,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable)
@@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -101,7 +101,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable)
@@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -87,7 +87,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable)
@@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -84,7 +84,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable)
@@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -9,7 +9,7 @@
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
3. Completion is forbidden at PR-open stage.
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
5. Before push or merge, run the queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
@@ -85,7 +85,7 @@ Reference:
5. Do not mark implementation complete until PR is merged.
6. Do not mark implementation complete until CI/pipeline status is terminal green.
7. Close linked issues/tasks only after merge + green CI.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run the CI queue guard against the push branch or the merge PR's exact head repository/SHA (`ci-queue-wait.sh --help`); `pr-merge.sh` supplies exact merge metadata automatically.
## Container Release Strategy (When Applicable)
@@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push`.
9. Open PR to `main` for delivery changes (no direct push to `main`).
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B <PR_HEAD_BRANCH> -R <PR_HEAD_OWNER/REPO> --sha <PR_HEAD_FULL_SHA>`.
11. Merge PRs that pass required checks and review gates with squash strategy only.
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
@@ -4,6 +4,22 @@ set -euo pipefail
MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}"
backup_stamp="$(date +%Y%m%d%H%M%S)"
# ─── Install-ordering guard opt-out (#869 Point-1 C2) ───────────────────────
# Explicit, per-invocation CLI flag ONLY — deliberately NOT read from an
# environment variable, so it can never sit as a silently-inherited default in
# a shell profile or CI env. Absent (the default) => hard fail-loud path.
allow_inactive_enforcement=0
for arg in "$@"; do
case "$arg" in
--allow-inactive-enforcement) allow_inactive_enforcement=1 ;;
esac
done
# Tracks whether the Claude settings install-ordering guard (below) reported a
# degraded (enforcement-not-wired) outcome, so this script's own exit status
# reflects it even though the rest of the runtime-asset sync must still run.
guard_degraded=0
copy_file_managed() {
local src="$1"
local dst="$2"
@@ -24,6 +40,103 @@ copy_file_managed() {
cp "$src" "$dst"
}
# ─── Install-ordering guard for settings.json (#869 Point-1 C2) ─────────────
#
# settings.json is where #828's enforcement hooks (PreToolUse mutator-gate.py,
# Stop receipt-observer-client.py) get wired unconditionally. Before copying
# it, delegate to `mosaic __link-claude-settings` (packages/mosaic/src/commands/
# install-ordering-guard.ts) so the wiring decision is made by importing the
# C1 activation probe (`leaseEnforcementActivatable()`) directly, rather than
# re-implementing the capability/supervisor checks in shell. That subcommand:
# - activatable -> writes settings.json with hooks intact, exits 0
# - NOT activatable -> writes settings.json with hooks STRIPPED,
# prints an actionable message, exits 1
# - NOT activatable + opt-out -> writes settings.json with hooks intact,
# prints a loud warning, exits 0
# The `mosaic` CLI is expected on PATH at this point ("No executables are
# placed on PATH — the mosaic npm CLI is the only binary", per install.sh).
# If it is not resolvable at all, that is itself strong evidence the
# activation half is absent, so the same fail-loud default applies via a
# minimal python3 fallback (this repo already depends on python3 for the
# lease broker itself).
copy_claude_settings_guarded() {
local src="$1"
local dst="$2"
local guard_args=(__link-claude-settings "$src" "$dst")
if [[ "$allow_inactive_enforcement" == "1" ]]; then
guard_args+=(--allow-inactive-enforcement)
fi
if command -v mosaic >/dev/null 2>&1; then
if mosaic "${guard_args[@]}"; then
return 0
fi
echo "[mosaic-link] Enforcement hooks were NOT wired into $dst (see message above)." >&2
guard_degraded=1
return 0
fi
echo "[mosaic-link] ERROR: 'mosaic' CLI not found on PATH — cannot confirm lease-enforcement" >&2
echo "[mosaic-link] activation capability. enforcement requested but activation half absent —" >&2
echo "[mosaic-link] needs a published CLI carrying launch-runtime activation + a broker" >&2
echo "[mosaic-link] supervisor; refusing to wire a dead gate (see #869)." >&2
if [[ "$allow_inactive_enforcement" == "1" ]]; then
echo "[mosaic-link] WARNING: --allow-inactive-enforcement set — wiring $dst AS-IS (with" >&2
echo "[mosaic-link] enforcement hooks) despite being unable to confirm activation." >&2
copy_file_managed "$src" "$dst"
return 0
fi
mkdir -p "$(dirname "$dst")"
if command -v python3 >/dev/null 2>&1; then
python3 - "$src" "$dst" <<'PYEOF'
import json, sys
src, dest = sys.argv[1], sys.argv[2]
with open(src) as f:
data = json.load(f)
hooks = data.get("hooks", {})
pre = hooks.get("PreToolUse", [])
hooks["PreToolUse"] = [
t for t in pre
if not any("mutator-gate.py" in h.get("command", "") for h in t.get("hooks", []))
]
if not hooks["PreToolUse"]:
del hooks["PreToolUse"]
stop = hooks.get("Stop", [])
new_stop = []
for t in stop:
kept = [h for h in t.get("hooks", []) if "receipt-observer-client.py" not in h.get("command", "")]
if kept:
t = dict(t)
t["hooks"] = kept
new_stop.append(t)
if new_stop:
hooks["Stop"] = new_stop
elif "Stop" in hooks:
del hooks["Stop"]
if hooks:
data["hooks"] = hooks
else:
data.pop("hooks", None)
with open(dest, "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
PYEOF
else
cp "$src" "$dst"
fi
guard_degraded=1
return 0
}
remove_legacy_path() {
local p="$1"
@@ -110,6 +223,13 @@ for runtime_file in \
fi
src="$MOSAIC_HOME/runtime/claude/$runtime_file"
[[ -f "$src" ]] || continue
if [[ "$runtime_file" == "settings.json" ]]; then
# Install-ordering guard (#869 Point-1 C2): gate enforcement-hook wiring
# on confirmed activation instead of the plain copy_file_managed used for
# every other runtime file. See copy_claude_settings_guarded() above.
copy_claude_settings_guarded "$src" "$HOME/.claude/$runtime_file"
continue
fi
copy_file_managed "$src" "$HOME/.claude/$runtime_file"
done
@@ -167,3 +287,12 @@ fi
echo "[mosaic-link] Runtime assets synced (non-symlink mode)"
echo "[mosaic-link] Canonical source: $MOSAIC_HOME"
# Propagate the install-ordering guard's outcome (#869 Point-1 C2): every
# other runtime asset above is best-effort/non-fatal, but a degraded
# (enforcement-not-wired) settings.json must make THIS script's own exit
# status non-zero so callers (framework/install.sh, finalize.ts) can surface
# it — never silently.
if [[ "$guard_degraded" == "1" ]]; then
exit 1
fi
@@ -0,0 +1,180 @@
#!/usr/bin/env bash
# Regression harness for issue #869 Point-1 C2 — the install-ordering guard
# wired into mosaic-link-runtime-assets.
#
# Root cause under test: mosaic-link-runtime-assets copies
# runtime/claude/settings.json (which embeds the PreToolUse mutator-gate.py
# hook and the Stop receipt-observer-client.py hook) straight into
# ~/.claude/settings.json, unconditionally. If the lease-broker activation
# half cannot be confirmed on this host, wiring those hooks bricks it with a
# fail-closed gate that can never be satisfied.
#
# This harness never invokes a real `mosaic` CLI build — it stubs the
# `__link-claude-settings` contract with a fake `mosaic` on PATH so the shell
# WIRING (does mosaic-link-runtime-assets call out correctly? does it
# propagate a degraded outcome? does it still copy every other runtime file?
# does --allow-inactive-enforcement forward through?) is exercised
# independently of the TS guard's own logic (already covered by
# install-ordering-guard.spec.ts). It also exercises the no-mosaic-on-PATH
# python3 fallback directly.
#
# Scenarios:
# 1. probe=true (fake mosaic exits 0) -> settings.json copied, script exits 0.
# 2. probe=false (fake mosaic exits 1) -> script exits 1 (guard_degraded
# propagated), but every OTHER runtime file is still copied.
# 3. probe=false + --allow-inactive-enforcement -> the flag is forwarded to
# the fake mosaic stub.
# 4. No `mosaic` on PATH at all (activation unconfirmable) -> the python3
# fallback strips the enforcement hooks itself and the script exits 1.
# 5. No `mosaic` on PATH + --allow-inactive-enforcement -> the python3
# fallback wires the hooks AS-IS and the script exits 0.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LINK_SCRIPT="$SCRIPT_DIR/mosaic-link-runtime-assets"
TMP_ROOT=$(mktemp -d)
trap 'rm -rf "$TMP_ROOT"' EXIT
fail=0
fail_msg() {
echo "FAIL: $*" >&2
fail=1
}
FIXTURE_SETTINGS='{
"model": "opus",
"hooks": {
"PreToolUse": [
{ "matcher": ".*", "hooks": [ { "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/mutator-gate.py --runtime claude" } ] },
{ "matcher": "Write|Edit|MultiEdit", "hooks": [ { "type": "command", "command": "~/.config/mosaic/tools/qa/prevent-memory-write.sh" } ] }
],
"Stop": [
{ "hooks": [
{ "type": "command", "command": "python3 ~/.config/mosaic/tools/lease-broker/receipt-observer-client.py --runtime claude" },
{ "type": "command", "command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh" }
] }
]
}
}'
# Sets up a fresh $MOSAIC_HOME/runtime/claude/{settings.json,CLAUDE.md,
# hooks-config.json,context7-integration.md} + fresh $HOME, echoes both paths
# space-separated for the caller to `read`.
new_scenario_dirs() {
local scenario="$1"
local base="$TMP_ROOT/$scenario"
local mosaic_home="$base/mosaic-home"
local home="$base/home"
mkdir -p "$mosaic_home/runtime/claude" "$home"
printf '%s' "$FIXTURE_SETTINGS" > "$mosaic_home/runtime/claude/settings.json"
echo "claude.md fixture" > "$mosaic_home/runtime/claude/CLAUDE.md"
echo '{"hooks":{}}' > "$mosaic_home/runtime/claude/hooks-config.json"
echo "context7 fixture" > "$mosaic_home/runtime/claude/context7-integration.md"
echo "$mosaic_home" "$home"
}
settings_has_marker() {
local file="$1" marker="$2"
[[ -f "$file" ]] && grep -q "$marker" "$file"
}
# A fake `mosaic` binary implementing only the __link-claude-settings contract
# this harness needs: writes dest verbatim (fixture is unmodified either way —
# this stub only exercises the CALL CONTRACT, not the TS strip logic, which
# has its own vitest coverage) and exits with the code the scenario wants.
# Records the args it was called with so the harness can assert forwarding.
make_fake_mosaic() {
local bin_dir="$1" exit_code="$2"
mkdir -p "$bin_dir"
cat > "$bin_dir/mosaic" <<EOF
#!/usr/bin/env bash
set -euo pipefail
echo "\$@" > "$bin_dir/mosaic.args"
if [[ "\$1" == "__link-claude-settings" ]]; then
cp "\$2" "\$3"
exit $exit_code
fi
exit 0
EOF
chmod +x "$bin_dir/mosaic"
}
# --- Scenario 1: probe=true (fake mosaic exits 0) ---------------------------
read -r MOSAIC_HOME_1 HOME_1 < <(new_scenario_dirs scenario1)
BIN_1="$TMP_ROOT/scenario1/bin"
make_fake_mosaic "$BIN_1" 0
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_1" HOME="$HOME_1" PATH="$BIN_1:$PATH" "$LINK_SCRIPT" 2>&1)
STATUS=$?
[[ "$STATUS" -eq 0 ]] || fail_msg "scenario1 (probe=true): expected exit 0, got $STATUS. Output: $OUTPUT"
[[ -f "$HOME_1/.claude/settings.json" ]] || fail_msg "scenario1: settings.json was not copied"
# --- Scenario 2: probe=false (fake mosaic exits 1) --------------------------
read -r MOSAIC_HOME_2 HOME_2 < <(new_scenario_dirs scenario2)
BIN_2="$TMP_ROOT/scenario2/bin"
make_fake_mosaic "$BIN_2" 1
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_2" HOME="$HOME_2" PATH="$BIN_2:$PATH" "$LINK_SCRIPT" 2>&1)
STATUS=$?
[[ "$STATUS" -ne 0 ]] || fail_msg "scenario2 (probe=false, default): expected non-zero exit, got 0. Output: $OUTPUT"
[[ -f "$HOME_2/.claude/CLAUDE.md" ]] || fail_msg "scenario2: CLAUDE.md was NOT copied even though it is independent of the settings.json guard"
[[ -f "$HOME_2/.claude/hooks-config.json" ]] || fail_msg "scenario2: hooks-config.json was NOT copied"
[[ -f "$HOME_2/.claude/context7-integration.md" ]] || fail_msg "scenario2: context7-integration.md was NOT copied"
case "$OUTPUT" in
*"NOT be wired"*|*"NOT wired"*) ;;
*) fail_msg "scenario2: expected an actionable degraded-wiring message in output, got: $OUTPUT" ;;
esac
# --- Scenario 3: probe=false + --allow-inactive-enforcement forwards the flag
read -r MOSAIC_HOME_3 HOME_3 < <(new_scenario_dirs scenario3)
BIN_3="$TMP_ROOT/scenario3/bin"
make_fake_mosaic "$BIN_3" 0
MOSAIC_HOME="$MOSAIC_HOME_3" HOME="$HOME_3" PATH="$BIN_3:$PATH" "$LINK_SCRIPT" --allow-inactive-enforcement >/dev/null 2>&1
RECORDED_ARGS="$(cat "$BIN_3/mosaic.args" 2>/dev/null || true)"
case "$RECORDED_ARGS" in
*"--allow-inactive-enforcement"*) ;;
*) fail_msg "scenario3: --allow-inactive-enforcement was not forwarded to the mosaic CLI invocation (got: '$RECORDED_ARGS')" ;;
esac
# --- Scenario 4: no `mosaic` on PATH at all -> python3 fallback strips hooks
read -r MOSAIC_HOME_4 HOME_4 < <(new_scenario_dirs scenario4)
EMPTY_BIN="$TMP_ROOT/scenario4/empty-bin"
mkdir -p "$EMPTY_BIN"
# A PATH containing only python3 (for the fallback) + core utils, no mosaic.
FALLBACK_PATH="$EMPTY_BIN:/usr/bin:/bin"
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_4" HOME="$HOME_4" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" 2>&1)
STATUS=$?
[[ "$STATUS" -ne 0 ]] || fail_msg "scenario4 (no mosaic on PATH, default): expected non-zero exit, got 0. Output: $OUTPUT"
if settings_has_marker "$HOME_4/.claude/settings.json" "mutator-gate.py"; then
fail_msg "scenario4: mutator-gate.py hook was wired even though mosaic could not be resolved (activation unconfirmable)"
fi
if settings_has_marker "$HOME_4/.claude/settings.json" "receipt-observer-client.py"; then
fail_msg "scenario4: receipt-observer-client.py hook was wired even though mosaic could not be resolved"
fi
if ! settings_has_marker "$HOME_4/.claude/settings.json" "prevent-memory-write.sh"; then
fail_msg "scenario4: the unrelated prevent-memory-write.sh hook was incorrectly dropped too"
fi
# --- Scenario 5: no `mosaic` on PATH + --allow-inactive-enforcement --------
read -r MOSAIC_HOME_5 HOME_5 < <(new_scenario_dirs scenario5)
OUTPUT=$(MOSAIC_HOME="$MOSAIC_HOME_5" HOME="$HOME_5" PATH="$FALLBACK_PATH" "$LINK_SCRIPT" --allow-inactive-enforcement 2>&1)
STATUS=$?
[[ "$STATUS" -eq 0 ]] || fail_msg "scenario5 (no mosaic, opt-out): expected exit 0, got $STATUS. Output: $OUTPUT"
if ! settings_has_marker "$HOME_5/.claude/settings.json" "mutator-gate.py"; then
fail_msg "scenario5: mutator-gate.py hook should have been wired (explicit opt-out set)"
fi
case "$OUTPUT" in
*"WARNING"*"--allow-inactive-enforcement"*) ;;
*) fail_msg "scenario5: expected a loud WARNING mentioning --allow-inactive-enforcement, got: $OUTPUT" ;;
esac
if [[ "$fail" -eq 0 ]]; then
echo "install-ordering-guard regression passed (5/5 scenarios)"
fi
exit "$fail"
+89 -2
View File
@@ -4,6 +4,93 @@ These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone
## Durable review provenance
A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments count as durable provenance only after the wrapper reads the created provider record back and verifies that it belongs to the intended repository and pull request and contains the exact submitted body (or verifies the provider-returned record ID).
A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments, approvals, and change requests count as durable provenance only after the wrapper reads the created provider record back and verifies that it was created by _this_ write.
`pr-review.sh` therefore fails closed when a Gitea comment cannot be written, its created comment ID cannot be identified, or provider read-back does not match. It reports comment success only after that read-back verification passes.
**The write is a direct Gitea REST `POST` that returns the created record's id.** Neither wrapper writes through `tea` — tea 0.11.1 can silently no-op while exiting 0 and cannot emit the id of a record it creates, so its exit code is worthless as proof of a durable write (#865). Instead:
- Comments (`issue-comment.sh`, and the `comment` action of `pr-review.sh`) `POST /api/v1/repos/{owner}/{repo}/issues/{index}/comments`, requiring a `201` and parsing the created comment's `id` from the response body.
- Reviews (`approve` / `request-changes`) `POST /api/v1/repos/{owner}/{repo}/pulls/{index}/reviews` with the `event` (`APPROVED` / `REQUEST_CHANGES`), the review `body`, and `commit_id` pinned to the PR's current head, then parse the created review's `id`. The review body travels _in the review submit itself_ — there is no separate detached comment to reconcile (a Gitea `REQUEST_CHANGES` review requires a non-empty body, which the submit carries).
**Verification keys on that exact provider-returned id.** The wrapper then `GET`s that one record directly — `GET /issues/comments/{id}` or `GET /pulls/{n}/reviews/{id}` — and requires that its `id` equals the created id, its **author login equals the acting identity** (resolved via `GET /api/v1/user` for the token in use), and, for comments, its body exactly matches what was submitted **and its returned web URL belongs to this exact provider and repository** (the `issue_url` / `pull_request_url` origin — scheme, host, and effective port — and full path, i.e. deployment prefix + exact `owner/repo` + kind + number, must match; a suffix/`endsWith` test would accept a look-alike host or a decoy path prefix, so the whole normalized URL is compared). The `comment` action of `pr-review.sh` additionally requires the returned resource be a **pull request** (a populated `pull_request_url`); a bare `issue_url` is rejected, so if issue `#N` exists but PR `#N` does not, an issue comment cannot be reported as a verified PR comment. (`issue-comment.sh` legitimately keeps the broader issue-or-PR acceptance.) For reviews, its state matches the requested action, its reviewed `commit_id` equals the PR head, **and its persisted body equals the submitted body** — an exact, presence- and type-checked equality (a missing/`null` persisted body no longer counts as an empty match), because Gitea can finalize/reuse a pending review id whose stored content was authored elsewhere, so the body is bound too. The write, the `/user` identity lookup, and the read-back all use the **same** credential — the effective login's token, or the host credential when no login is named — so the write is verified against the identity that actually performed it.
**A review's pinned head is re-checked after verification (current-head TOCTOU).** The `commit_id` is pinned to the PR head read _before_ the submit; between that read and the read-back the branch could advance (a force-push or a new commit), leaving a verified review attached to a now-superseded commit while the live tip carries unreviewed code. After the exact-id read-back succeeds, the wrapper re-reads the live PR head (`GET …/pulls/{n}`) and requires it still equals the submitted SHA; if the head advanced it fails closed (non-zero, no success line) rather than reporting a review that no longer covers the PR's current commit.
**This closes the concurrency window rather than documenting it.** Because verification keys on the id the create returned, a no-op create yields no id and fails closed with no list-scan fallback, and a _concurrent_ record — even one written by the _same_ identity with an identical body/state — has a _different_ id and cannot be mistaken for this write. There is no residual same-identity window: the earlier boundary-and-author heuristic (accept any `id > pre-write-max` with a matching author) is replaced entirely by exact-id attribution.
**Exact-id read-back is the sole authority.** Verification is a direct `GET` of the one record the create returned; there is no follow-up list enumeration. An earlier redundant pass that re-listed the record's page (`?limit=&page=1,2,…`) was removed: server-capped page sizes and list-pagination quirks made it a false-failure source (a durable, exact-id-verified record could be missed by a non-exhaustive enumeration), and it added nothing over the authoritative exact-id `GET`.
## Credential handling
The Gitea API token is **never passed on a curl command line.** An `Authorization: token <value>` argument would be visible to any local process that can read the process table (`ps` / `/proc/<pid>/cmdline`) for the lifetime of the request. Instead, every authenticated curl call writes the header into a private, mode-`0600` config file under `$TMPDIR` and passes it with `curl --config <file>` (`gitea_write_auth_config`), so only the file _path_ — never the token — appears in argv. Each such file is unlinked on every exit path (success and failure) by the caller's `RETURN` trap.
## `tea` invocation notes (Gitea)
- tea v0.11.1 has **no `comment` subcommand under `tea pr` or `tea issue`** — the `tea pr comment` / `tea issue comment` forms don't error, they silently fall through to a no-op and still exit 0, producing a false-success write (#865). tea's write subcommands (`tea comment`, `tea pr approve`/`reject`) also cannot report the id of the record they create, so their exit code cannot prove a durable write. These wrappers therefore do **not** write reviews or comments through `tea` at all; they use direct Gitea REST `POST`s that return the created record's id (see "Durable review provenance" above). `tea` is consulted only to enumerate the login list for host→login resolution.
- Because the review body is carried in the `POST …/reviews` submit itself, there is no separate detached review comment, and the historical `tea pr approve`/`reject` trailing-positional-argument vs. nonexistent `--comment`/`-comment` flag hazard (#835) no longer applies to these wrappers — no review comment is ever passed to `tea`.
### `--login` override
Both `pr-review.sh` and `issue-comment.sh` accept an optional `--login <name>` flag that overrides the automatically detected Gitea login for that single invocation. The override selects **which credential the REST write, the `/user` identity lookup, and the read-back all use** — its token is resolved from the tea config for that login name (`get_gitea_token_for_login`), falling back to the repo host's credential when no login is named. The resolved login is **host- and port-bound**: the login's configured URL host **and effective port** (the scheme's default port — 80 for `http`, 443 for `https` — applies when a port is omitted, symmetrically on both sides) must match the repo remote's, so a login name shared across hosts (or an override configured for a different Gitea, including one on a different port of the same host) can never send one host's credential to another — a host or port mismatch fails closed rather than leaking a cross-host token. Resolving the acting identity and the read-back from the _same_ login that performs the write is essential: a write performed under an overridden login must be verified against that login's identity, not the host default's. Callers who need a different login than the host default should pass `--login <reviewer-login>`.
As a durable successor to this mechanism, consider giving each reviewer/approver slot its own dedicated Gitea login credential, so that author≠reviewer holds at the credential level rather than relying on wrapper-level `--login` bookkeeping. This is a recommendation for future hardening, not something implemented by this flag.
## Per-agent Gitea identity (Gate-16 author≠reviewer)
By default, git push/fetch (via `git-credential-mosaic`) and the API wrappers above (via
`detect-platform.sh`'s `get_gitea_token`) all authenticate as the single shared Gitea
account/token configured through `tools/_lib/credentials.sh`. That means every agent in a
fleet commits, pushes, and opens PRs under one identity — with no cryptographic
separation between an author and a reviewer.
Both `git-credential-mosaic` and `get_gitea_token()` resolve an optional **per-agent
identity** before falling back to the shared account:
1. `MOSAIC_GIT_IDENTITY` environment variable, or
2. `git config --get mosaic.gitIdentity` (set per-worktree; persists on disk across
non-persistent shells — `git config mosaic.gitIdentity <agent-id>`), or
3. (git-credential-mosaic only) the username git itself supplies for the credential
request.
If the resolved identity has a token file at
`~/.config/mosaic/secrets/gitea-tokens/gitea-{usc,mosaicstack}-<agent-id>.token`, that
identity + token is used. **Nothing configured → nothing changes**: with no per-slot
token file present, both tools fall through to the existing shared-account path
unchanged, so this feature is a no-op on any host that hasn't provisioned per-slot
tokens.
### Enabling it for a clone
The framework installer syncs `git-credential-mosaic` to
`~/.config/mosaic/tools/git/git-credential-mosaic` (executable) on every install/update,
but does **not** register it as git's credential helper automatically. Registration is a
one-time, explicit step:
```bash
# Per-repo (recommended — scopes the helper to this clone only):
git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
# Per-worktree identity pin (Gate-16 separation):
git config mosaic.gitIdentity <agent-id>
```
This is deliberately **not** auto-registered on install/update: `credential.helper` is
global, order-sensitive git config (`~/.gitconfig`) that can already hold an
operator-chosen credential manager (keychain, `store`, `manager-core`, …) for
repositories unrelated to Mosaic. Silently inserting an entry on every framework
install/upgrade risks reordering or shadowing that operator-owned surface across the
whole host — the same operator-owned config the installer's manifest system is
otherwise careful never to touch. Because identity is already resolved per-worktree
(`mosaic.gitIdentity`), the correct granularity for registering the helper is per-clone
too, so a documented manual step is the right shape here, not a global auto-write.
### PowerShell parity
`detect-platform.ps1`'s Gitea wrappers authenticate through `tea` CLI logins
(`Get-GiteaLoginForHost`), not a raw-token `get_gitea_token`-equivalent function — there
is nothing to prepend the identity-resolution block to on the PowerShell side. A native
PowerShell git-credential helper is also unnecessary: `git-credential-mosaic` is invoked
by git's credential-helper protocol (stdin/stdout), which works identically under Git for
Windows' bundled `bash`/`sh` when configured via `credential.helper`, without a `.ps1`
counterpart. A `tea`-login-based per-agent identity for the PowerShell wrappers is a
separate, larger design (mapping identities to `tea login` profiles) and is out of scope
here.
@@ -7,7 +7,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/detect-platform.sh"
BRANCH="main"
BRANCH=""
TARGET_REPO=""
HEAD_SHA=""
TIMEOUT_SEC=900
INTERVAL_SEC=15
PURPOSE="merge"
@@ -15,10 +17,12 @@ REQUIRE_STATUS=0
usage() {
cat <<EOF
Usage: $(basename "$0") [-B branch] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
Usage: $(basename "$0") [-B branch] [-R owner/repo] [--sha full-40] [-t timeout_sec] [-i interval_sec] [--purpose push|merge] [--require-status]
Options:
-B, --branch BRANCH Branch head to inspect (default: main)
-B, --branch BRANCH Branch head to inspect (default: current branch)
-R, --repo OWNER/REPO Repository containing the branch (default: origin repo)
--sha FULL_SHA Inspect this exact 40-character commit instead of resolving the branch
-t, --timeout SECONDS Max wait time in seconds (default: 900)
-i, --interval SECONDS Poll interval in seconds (default: 15)
--purpose VALUE Log context: push|merge (default: merge)
@@ -27,63 +31,65 @@ Options:
Examples:
$(basename "$0")
$(basename "$0") --purpose push -B main -t 600 -i 10
$(basename "$0") --purpose push -t 600 -i 10
EOF
}
# get_remote_host and get_gitea_token are provided by detect-platform.sh
get_state_from_status_json() {
python3 - <<'PY'
# Python source comes from -c so the provider payload remains on stdin.
# Never move the payload to argv: commit-status responses can exceed ARG_MAX.
python3 -c '
import json
import sys
try:
payload = json.load(sys.stdin)
if not isinstance(payload, dict):
raise ValueError("status payload is not an object")
except Exception:
print("unknown")
print("malformed")
raise SystemExit(0)
statuses = payload.get("statuses") or []
state = (payload.get("state") or "").lower()
raw_statuses = payload.get("statuses", [])
raw_state = payload.get("state", "")
if not isinstance(raw_statuses, list) or not isinstance(raw_state, str):
print("malformed")
raise SystemExit(0)
statuses = raw_statuses
state = raw_state.lower()
pending_values = {"pending", "queued", "running", "waiting"}
failure_values = {"failure", "error", "failed"}
success_values = {"success"}
if state in pending_values:
print("pending")
raise SystemExit(0)
if state in failure_values:
print("terminal-failure")
raise SystemExit(0)
if state in success_values:
print("terminal-success")
raise SystemExit(0)
values = []
for item in statuses:
if not isinstance(item, dict):
continue
value = (item.get("status") or item.get("state") or "").lower()
if value:
values.append(value)
print("malformed")
raise SystemExit(0)
raw_value = item.get("status") or item.get("state")
if not isinstance(raw_value, str) or not raw_value:
print("malformed")
raise SystemExit(0)
values.append(raw_value.lower())
if not values and not state:
print("no-status")
elif any(v in pending_values for v in values):
if any(value in pending_values for value in values) or state in pending_values:
print("pending")
elif any(v in failure_values for v in values):
elif any(value in failure_values for value in values) or state in failure_values:
print("terminal-failure")
elif values and all(v in success_values for v in values):
elif values and all(value in success_values for value in values) and state in {"", "success"}:
print("terminal-success")
elif not values:
print("no-status")
else:
print("unknown")
PY
'
}
print_pending_contexts() {
python3 - <<'PY'
python3 -c '
import json
import sys
@@ -104,17 +110,61 @@ for item in statuses:
if not isinstance(item, dict):
continue
name = item.get("context") or item.get("name") or "unknown-context"
value = (item.get("status") or item.get("state") or "unknown").lower()
value = str(item.get("status") or item.get("state") or "unknown").lower()
target = item.get("target_url") or item.get("url") or ""
if value in pending_values:
found = True
if target:
print(f"[ci-queue-wait] pending: {name}={value} ({target})")
else:
print(f"[ci-queue-wait] pending: {name}={value}")
suffix = f" ({target})" if target else ""
print(f"[ci-queue-wait] pending: {name}={value}{suffix}")
if not found:
print("[ci-queue-wait] no pending contexts")
'
}
record_cannot_assert() {
local reason="$1"
local audit_log="${MOSAIC_CI_QUEUE_AUDIT_LOG:-${XDG_STATE_HOME:-${HOME:-}/.local/state}/mosaic/audit/ci-queue-wait.jsonl}"
if [[ -z "$audit_log" ]] || ! mkdir -p "$(dirname "$audit_log")"; then
echo "Error: CANNOT_ASSERT and audit directory is unavailable; refusing degraded pass." >&2
return 70
fi
if ! python3 - "$audit_log" "$reason" "${PLATFORM:-unknown}" "$PURPOSE" "${BRANCH:-unknown}" "${OWNER:-unknown}/${REPO:-unknown}" <<'PY'
import datetime
import json
import os
import sys
path, reason, platform, purpose, branch, repo = sys.argv[1:]
record = {
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"outcome": "CANNOT_ASSERT",
"reason": reason,
"platform": platform,
"purpose": purpose,
"disposition": "hold" if purpose == "merge" else "degraded-pass",
"branch": branch,
"repo": repo,
}
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
try:
os.write(fd, (json.dumps(record, separators=(",", ":")) + "\n").encode())
finally:
os.close(fd)
PY
then
echo "Error: CANNOT_ASSERT and audit write failed at ${audit_log}; refusing degraded pass." >&2
return 70
fi
if [[ "$PURPOSE" == "merge" ]]; then
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=merge branch=${BRANCH:-unknown}; audited=${audit_log}; HOLD (exit 75). Retry after provider recovery; no manual reset is required." >&2
return 75
fi
echo "[ci-queue-wait] CANNOT_ASSERT reason=${reason} purpose=push branch=${BRANCH:-unknown}; audited=${audit_log}; push may proceed in degraded mode." >&2
return 0
}
github_get_branch_head_sha() {
@@ -128,7 +178,87 @@ github_get_commit_status_json() {
local owner="$1"
local repo="$2"
local sha="$3"
gh api "repos/${owner}/${repo}/commits/${sha}/status"
local work_root status_file checks_file
work_root="${AGENT_WORK_ROOT:-${HOME:-}/.cache/mosaic/ci-queue-wait}"
mkdir -p "$work_root" || return 1
status_file=$(mktemp "$work_root/github-status.XXXXXX") || return 1
checks_file=$(mktemp "$work_root/github-checks.XXXXXX") || {
rm -f "$status_file"
return 1
}
if ! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/statuses?per_page=100" > "$status_file" ||
! gh api --paginate --slurp "repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100&filter=latest" > "$checks_file"; then
rm -f "$status_file" "$checks_file"
return 1
fi
python3 - "$status_file" "$checks_file" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as handle:
status_pages = json.load(handle)
with open(sys.argv[2], encoding="utf-8") as handle:
check_pages = json.load(handle)
if not isinstance(status_pages, list) or not isinstance(check_pages, list):
raise SystemExit(1)
# The statuses endpoint is newest-first and can contain retries for one context.
# Keep only the newest entry per context after flattening every page.
combined = []
seen_contexts = set()
for page in status_pages:
if not isinstance(page, list):
raise SystemExit(1)
for status in page:
if not isinstance(status, dict):
raise SystemExit(1)
context = status.get("context")
if not isinstance(context, str) or not context or context in seen_contexts:
continue
seen_contexts.add(context)
combined.append(status)
check_runs = []
reported_total = 0
for page in check_pages:
if not isinstance(page, dict):
raise SystemExit(1)
page_runs = page.get("check_runs") or []
total_count = page.get("total_count")
if not isinstance(page_runs, list) or not isinstance(total_count, int):
raise SystemExit(1)
reported_total = max(reported_total, total_count)
check_runs.extend(page_runs)
if len(check_runs) < reported_total:
raise SystemExit(1)
for run in check_runs:
if not isinstance(run, dict):
raise SystemExit(1)
status = run.get("status")
conclusion = run.get("conclusion")
if status != "completed":
value = "pending"
elif conclusion == "success":
value = "success"
elif conclusion in {"failure", "cancelled", "timed_out", "action_required", "startup_failure", "stale"}:
value = "failure"
else:
value = "unknown"
combined.append({
"context": run.get("name") or "github-check",
"status": value,
"target_url": run.get("html_url") or run.get("details_url") or "",
})
json.dump({"state": "", "statuses": combined}, sys.stdout)
PY
local status=$?
rm -f "$status_file" "$checks_file"
return "$status"
}
gitea_get_branch_head_sha() {
@@ -174,6 +304,14 @@ while [[ $# -gt 0 ]]; do
BRANCH="$2"
shift 2
;;
-R|--repo)
TARGET_REPO="$2"
shift 2
;;
--sha)
HEAD_SHA="$2"
shift 2
;;
-t|--timeout)
TIMEOUT_SEC="$2"
shift 2
@@ -206,45 +344,89 @@ if ! [[ "$TIMEOUT_SEC" =~ ^[0-9]+$ ]] || ! [[ "$INTERVAL_SEC" =~ ^[0-9]+$ ]]; th
echo "Error: timeout and interval must be integer seconds." >&2
exit 1
fi
if [[ -n "$HEAD_SHA" && ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: --sha must be a full 40-character hexadecimal commit SHA." >&2
exit 1
fi
if [[ -n "$TARGET_REPO" && ! "$TARGET_REPO" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]]; then
echo "Error: --repo must be OWNER/REPO." >&2
exit 1
fi
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
detect_platform > /dev/null
if [[ "$PURPOSE" != "push" && "$PURPOSE" != "merge" ]]; then
echo "Error: --purpose must be push or merge." >&2
exit 1
fi
OWNER="unknown"
REPO="unknown"
PLATFORM="unknown"
if ! OWNER=$(get_repo_owner) || [[ -z "$OWNER" ]]; then
record_cannot_assert "repository-owner-unresolvable"
exit $?
fi
if ! REPO=$(get_repo_name) || [[ -z "$REPO" ]]; then
record_cannot_assert "repository-name-unresolvable"
exit $?
fi
if ! detect_platform > /dev/null; then
PLATFORM="${PLATFORM:-unknown}"
record_cannot_assert "unsupported-platform"
exit $?
fi
PLATFORM="${PLATFORM:-unknown}"
if [[ -n "$TARGET_REPO" ]]; then
OWNER="${TARGET_REPO%%/*}"
REPO="${TARGET_REPO##*/}"
fi
if [[ -z "$BRANCH" ]]; then
if ! BRANCH=$(git symbolic-ref --quiet --short HEAD) || [[ -z "$BRANCH" ]]; then
record_cannot_assert "current-branch-unresolvable"
exit $?
fi
fi
if [[ "$PLATFORM" == "github" ]]; then
if ! command -v gh >/dev/null 2>&1; then
echo "Error: gh CLI is required for GitHub CI queue guard." >&2
exit 1
record_cannot_assert "github-cli-unavailable"
exit $?
fi
HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH")
if [[ -z "$HEAD_SHA" ]]; then
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
exit 1
if ! HEAD_SHA=$(github_get_branch_head_sha "$OWNER" "$REPO" "$BRANCH") || [[ -z "$HEAD_SHA" ]]; then
record_cannot_assert "branch-head-unavailable"
exit $?
fi
fi
echo "[ci-queue-wait] platform=github purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
elif [[ "$PLATFORM" == "gitea" ]]; then
HOST=$(get_remote_host) || {
echo "Error: Could not determine remote host." >&2
exit 1
}
TOKEN=$(get_gitea_token "$HOST") || {
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
exit 1
}
HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN")
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
exit 0
if ! HOST=$(get_remote_host) || [[ -z "$HOST" ]]; then
record_cannot_assert "remote-host-unresolvable"
exit $?
fi
if ! TOKEN=$(get_gitea_token "$HOST") || [[ -z "$TOKEN" ]]; then
record_cannot_assert "credential-unresolvable"
exit $?
fi
if [[ -z "$HEAD_SHA" ]]; then
echo "Error: Could not resolve ${BRANCH} head SHA." >&2
exit 1
if ! HEAD_SHA=$(gitea_get_branch_head_sha "$HOST" "$OWNER/$REPO" "$BRANCH" "$TOKEN"); then
record_cannot_assert "branch-head-unavailable"
exit $?
fi
if [[ "$HEAD_SHA" == "__BRANCH_ABSENT__" ]]; then
echo "[ci-queue-wait] branch ${BRANCH} not yet on remote — no in-flight pipeline; queue clear."
exit 0
fi
if [[ -z "$HEAD_SHA" ]]; then
record_cannot_assert "branch-head-unavailable"
exit $?
fi
fi
echo "[ci-queue-wait] platform=gitea purpose=${PURPOSE} branch=${BRANCH} sha=${HEAD_SHA}"
else
echo "Error: Unsupported platform '${PLATFORM}'." >&2
exit 1
record_cannot_assert "unsupported-platform"
exit $?
fi
START_TS=$(date +%s)
@@ -253,14 +435,20 @@ DEADLINE_TS=$((START_TS + TIMEOUT_SEC))
while true; do
NOW_TS=$(date +%s)
if (( NOW_TS > DEADLINE_TS )); then
echo "Error: Timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
echo "Error: ASSERTED_NOT_READY state=pending; timed out waiting for CI queue to clear on ${BRANCH} after ${TIMEOUT_SEC}s." >&2
exit 124
fi
if [[ "$PLATFORM" == "github" ]]; then
STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA")
if ! STATUS_JSON=$(github_get_commit_status_json "$OWNER" "$REPO" "$HEAD_SHA"); then
record_cannot_assert "status-provider-unreachable"
exit $?
fi
else
STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN")
if ! STATUS_JSON=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$HEAD_SHA" "$TOKEN"); then
record_cannot_assert "status-provider-unreachable"
exit $?
fi
fi
STATE=$(printf '%s' "$STATUS_JSON" | get_state_from_status_json)
@@ -271,21 +459,24 @@ while true; do
printf '%s' "$STATUS_JSON" | print_pending_contexts
sleep "$INTERVAL_SEC"
;;
terminal-success)
exit 0
;;
no-status)
if [[ "$REQUIRE_STATUS" -eq 1 ]]; then
echo "Error: No CI status contexts found for ${BRANCH} while --require-status is set." >&2
exit 1
echo "Error: ASSERTED_NOT_READY state=no-status; --require-status was set for ${BRANCH}." >&2
else
echo "Error: ASSERTED_NOT_READY state=no-status purpose=${PURPOSE} branch=${BRANCH}." >&2
fi
echo "[ci-queue-wait] no status contexts present; proceeding."
exit 0
exit 3
;;
terminal-success|terminal-failure|unknown)
# Queue guard only blocks on pending/running/queued states.
exit 0
terminal-failure|malformed|unknown)
echo "Error: ASSERTED_NOT_READY state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3
;;
*)
echo "[ci-queue-wait] unrecognized state '${STATE}', proceeding conservatively."
exit 0
echo "Error: ASSERTED_NOT_READY unrecognized-state=${STATE} purpose=${PURPOSE} branch=${BRANCH}." >&2
exit 3
;;
esac
done
@@ -505,6 +505,41 @@ get_gitea_token() {
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
local cred_loader="$script_dir/../_lib/credentials.sh"
# 0. Per-agent identity (Gate-16 author≠reviewer). If MOSAIC_GIT_IDENTITY, or the
# per-worktree `git config mosaic.gitIdentity`, resolves to an agent that has a
# stored per-slot token for this host, act AS that agent so API tooling
# (pr-create, issue-create, …) authors under the right identity — matching the
# git credential helper. Backward-compatible: nothing resolvable → shared logic below.
local _ident="${MOSAIC_GIT_IDENTITY:-}"
local _ident_src="MOSAIC_GIT_IDENTITY"
if [[ -z "$_ident" ]]; then
_ident="$(git config --get mosaic.gitIdentity 2>/dev/null || true)"
_ident_src="git config mosaic.gitIdentity"
fi
if [[ -n "$_ident" ]]; then
local _idpfx=""
case "$host" in
git.uscllc.com) _idpfx=gitea-usc ;;
git.mosaicstack.dev) _idpfx=gitea-mosaicstack ;;
esac
if [[ -n "$_idpfx" ]]; then
local _idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${_idpfx}-${_ident}.token"
if [[ -r "$_idtok" ]]; then
cat "$_idtok"
return 0
fi
# FAIL LOUD: an explicit git identity was requested for a recognized Gitea host,
# but no per-slot token exists for THAT identity. Refuse to fall through to the
# shared/default credential loader below — silently borrowing another slot's token
# would post PRs/issues/reviews under the WRONG agent (e.g. rev2's review attributed
# to coder3), corrupting Gate-16 author≠reviewer separation. Hard-stop instead so the
# caller aborts loudly rather than acting as the wrong identity.
echo "Error: git identity '$_ident' requested (via $_ident_src) for host '$host', but no per-slot token at $_idtok." >&2
echo " Refusing to borrow another slot's token. Provision the per-slot token, or unset the identity to use shared credentials." >&2
return 1
fi
fi
# 1. Mosaic credential loader (host → service mapping, run in subshell to avoid polluting env)
if [[ -f "$cred_loader" ]]; then
local token
@@ -563,6 +598,873 @@ get_gitea_token() {
return 1
}
# Stage the Gitea bearer credential for curl OUTSIDE the process argument vector.
# Passing "-H 'Authorization: token <value>'" on the curl command line exposes
# the token to anyone who can read the process table (ps / /proc/<pid>/cmdline)
# for the lifetime of the request. Instead, write the header into a private
# (mode 0600) curl config file and have callers pass it with `curl --config`, so
# only the FILE PATH — never the token — appears in argv. Prints the temp file
# path on success; the caller OWNS the file and MUST remove it on every exit
# path (success and failure). $1 = bearer token. Callers must not log the token.
gitea_write_auth_config() {
local token="$1" auth_file
auth_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-gitea-auth.XXXXXX") || return 1
# mktemp already creates the file with 0600; be explicit in case of an
# unusual umask so the credential is never briefly group/other readable.
chmod 600 "$auth_file" 2>/dev/null || true
# `header = "..."` is curl's config syntax for an extra request header. Only
# this filename reaches curl's argv; the token stays on disk, readable solely
# by this user, and is unlinked by the caller's trap after the request.
if ! printf 'header = "Authorization: token %s"\n' "$token" > "$auth_file"; then
rm -f "$auth_file"
return 1
fi
printf '%s' "$auth_file"
}
# Resolve the API token for a SPECIFIC tea login name from tea's own config
# (the same store tea itself writes/reads for `--login <name>`). This is what
# lets a REST write be performed AS the selected --login identity: tea keys its
# per-login tokens by `name` in $XDG_CONFIG_HOME/tea/config.yml (default
# ~/.config/tea/config.yml), exactly as the `tea` CLI resolves them, so a
# --login override and its REST read-back bind to the SAME credential/identity.
#
# $2 (repo host) binds the selected credential to the TARGET host: a tea login
# also records the `url` it authenticates against, and the matched login's URL
# host MUST equal the repo host. This fails closed when an override login is
# configured for a DIFFERENT host than the repo remote, so a login name shared
# across hosts (or a mistargeted override) can never send one host's credential
# to another host (cross-host credential leak). When $2 is empty the host bind
# is skipped (host-agnostic lookup) — callers that write should always pass it.
#
# Prints the token on success; returns non-zero (no output) if the config, a
# matching login token, or the host bind cannot be satisfied. Callers must not
# log the result.
get_gitea_token_for_login() {
local login_name="$1" repo_host="${2:-}" config_file
[[ -n "$login_name" ]] || return 1
config_file="${XDG_CONFIG_HOME:-$HOME/.config}/tea/config.yml"
[[ -f "$config_file" ]] || return 1
LOGIN_NAME="$login_name" REPO_HOST="$repo_host" python3 - "$config_file" <<'PY'
import datetime
import os
import re
import sys
from urllib.parse import urlparse
wanted = os.environ["LOGIN_NAME"]
repo_host = os.environ.get("REPO_HOST", "").strip().lower()
config_path = sys.argv[1]
# PyYAML 6.0.3 SafeLoader implicit resolver patterns (YAML 1.1). An UNQUOTED
# plain scalar matching any of these is resolved by PyYAML to a NON-string type
# (null->None, bool, int, float, timestamp->date/datetime); a quoted scalar is
# ALWAYS a string. These mirror yaml/resolver.py so the PyYAML-absent fallback
# types unquoted scalars exactly as PyYAML would (see _implicit_nonstring).
_IMPLICIT_NULL = re.compile(r"^(?:~|null|Null|NULL|)$")
_IMPLICIT_BOOL = re.compile(
r"^(?:yes|Yes|YES|no|No|NO|true|True|TRUE|false|False|FALSE"
r"|on|On|ON|off|Off|OFF)$"
)
_IMPLICIT_INT = re.compile(
r"^(?:[-+]?0b[0-1_]+"
r"|[-+]?0[0-7_]+"
r"|[-+]?(?:0|[1-9][0-9_]*)"
r"|[-+]?0x[0-9a-fA-F_]+"
r"|[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+)$"
)
# NOTE: the exponent group requires an EXPLICIT sign ([eE][-+][0-9]+), matching
# PyYAML 6.0.3's resolver.py float regex exactly. An unsigned exponent (e.g.
# "1.0e10", ".5e10", "4.e8") is NOT matched by PyYAML's implicit float resolver
# -- PyYAML resolves those as plain strings -- so this pattern must not match
# them either, or the fallback over-rejects a token PyYAML would emit verbatim.
_IMPLICIT_FLOAT = re.compile(
r"^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+][0-9]+)?"
r"|\.[0-9][0-9_]*(?:[eE][-+][0-9]+)?"
r"|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*"
r"|[-+]?\.(?:inf|Inf|INF)"
r"|\.(?:nan|NaN|NAN))$"
)
_IMPLICIT_TIMESTAMP = re.compile(
r"^(?:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]"
r"|[0-9][0-9][0-9][0-9]-[0-9][0-9]?-[0-9][0-9]?"
r"(?:[Tt]|[ \t]+)[0-9][0-9]?"
r":[0-9][0-9]:[0-9][0-9](?:\.[0-9]*)?"
r"(?:[ \t]*(?:Z|[-+][0-9][0-9]?(?::[0-9][0-9])?))?)$"
)
def _implicit_nonstring(text):
# True when an UNQUOTED plain scalar would be resolved by PyYAML's SafeLoader
# to a non-string type (null/bool/int/float/timestamp). Fuzzed against real
# PyYAML 6.0.3: it never returns False where PyYAML types the scalar as a
# non-string (i.e. never fail-open), and (post exponent-sign fix) no longer
# over-rejects unsigned-exponent spellings like "4.e8" or "1.0e10" -- those
# match PyYAML's own implicit float resolver exactly (explicit sign only),
# so PyYAML keeps them as strings and this function now agrees.
return bool(
_IMPLICIT_NULL.match(text)
or _IMPLICIT_BOOL.match(text)
or _IMPLICIT_INT.match(text)
or _IMPLICIT_FLOAT.match(text)
or _IMPLICIT_TIMESTAMP.match(text)
)
# PyYAML 6.0.3 SafeConstructor timestamp regexp (yaml/constructor.py). The
# constructor RE-parses a timestamp-tagged scalar with THIS pattern and then
# builds a datetime.date/datetime, which raises ValueError for an out-of-range
# calendar field (e.g. month 99, hour 25). _IMPLICIT_TIMESTAMP (the RESOLVER
# pattern) is byte-identical to PyYAML's resolver, so anything it tags is also
# tagged by PyYAML and re-matched here.
_TIMESTAMP_CONSTRUCT = re.compile(
r"""^(?P<year>[0-9][0-9][0-9][0-9])
-(?P<month>[0-9][0-9]?)
-(?P<day>[0-9][0-9]?)
(?:(?:[Tt]|[ \t]+)
(?P<hour>[0-9][0-9]?)
:(?P<minute>[0-9][0-9])
:(?P<second>[0-9][0-9])
(?:\.(?P<fraction>[0-9]*))?
(?:[ \t]*(?P<tz>Z|(?P<tz_sign>[-+])(?P<tz_hour>[0-9][0-9]?)
(?::(?P<tz_minute>[0-9][0-9]))?))?)?$""",
re.X,
)
def _int_constructible(text):
# Replicate PyYAML SafeConstructor.construct_yaml_int and report whether it
# would succeed. A resolver-tagged int whose radix body is empty after
# underscore removal (e.g. "0b_", "0x_", "0x__") makes int(base) raise, so
# PyYAML fails the WHOLE document -> the fallback must fail closed too.
value = text.replace("_", "")
if value[:1] in ("+", "-"):
value = value[1:]
if value == "0":
return True
try:
if value.startswith("0b"):
int(value[2:], 2)
elif value.startswith("0x"):
int(value[2:], 16)
elif value[:1] == "0":
int(value, 8)
elif ":" in value:
[int(part) for part in value.split(":")]
else:
int(value)
except ValueError:
return False
return True
def _float_constructible(text):
# Replicate PyYAML SafeConstructor.construct_yaml_float. Retained for the
# WHOLE-document invariant: _IMPLICIT_FLOAT now matches PyYAML's resolver
# pattern exactly (explicit-sign exponent only), and every scalar it tags
# is float()-constructible, so this never fails closed where PyYAML would
# emit a token.
value = text.replace("_", "").lower()
if value[:1] in ("+", "-"):
value = value[1:]
if value in (".inf", ".nan"):
return True
try:
if ":" in value:
[float(part) for part in value.split(":")]
else:
float(value)
except ValueError:
return False
return True
def _timestamp_constructible(text):
# Replicate PyYAML SafeConstructor.construct_yaml_timestamp: build the same
# datetime.date/datetime and report whether it raises. Returns False for an
# out-of-range calendar field (month/day/hour/...), matching PyYAML's
# whole-document ValueError.
match = _TIMESTAMP_CONSTRUCT.match(text)
if not match:
return False
values = match.groupdict()
try:
year = int(values["year"])
month = int(values["month"])
day = int(values["day"])
if not values["hour"]:
datetime.date(year, month, day)
return True
hour = int(values["hour"])
minute = int(values["minute"])
second = int(values["second"])
fraction = 0
if values["fraction"]:
frac = values["fraction"][:6]
frac += "0" * (6 - len(frac))
fraction = int(frac)
tzinfo = None
if values["tz_sign"]:
tz_hour = int(values["tz_hour"])
tz_minute = int(values["tz_minute"] or 0)
delta = datetime.timedelta(hours=tz_hour, minutes=tz_minute)
if values["tz_sign"] == "-":
delta = -delta
tzinfo = datetime.timezone(delta)
elif values["tz"]:
tzinfo = datetime.timezone.utc
datetime.datetime(
year, month, day, hour, minute, second, fraction, tzinfo=tzinfo
)
except (ValueError, OverflowError):
return False
return True
def _constructible(text):
# WHOLE-DOCUMENT INVARIANT: the fallback resolves the SAME login token as
# PyYAML safe_load or fails closed -- never less conservative -- INCLUDING
# when PyYAML raises a CONSTRUCTOR error anywhere in the document. A plain
# scalar can match a typed implicit resolver (int/float/timestamp) yet NOT be
# constructible (e.g. 2023-99-99, 0b_, 0x_); PyYAML then raises on the whole
# load and yields no token, so the fallback MUST fail closed for the whole
# document too. This returns True only when PyYAML's constructor would build
# the scalar (null/bool token sets are always constructible), else False so
# the caller fails closed. `text` is assumed to satisfy _implicit_nonstring.
if _IMPLICIT_NULL.match(text) or _IMPLICIT_BOOL.match(text):
return True
if _IMPLICIT_TIMESTAMP.match(text):
return _timestamp_constructible(text)
if _IMPLICIT_INT.match(text):
return _int_constructible(text)
if _IMPLICIT_FLOAT.match(text):
return _float_constructible(text)
return True
# PyYAML's Reader.check_printable scans the ENTIRE raw input stream (not just
# scalar contents, and NOT scoped by quoting) for code points outside its
# printable set and raises ReaderError -- a whole-document reject -- the instant
# one is found, no matter where it sits (an unrelated field, a comment, inside or
# outside quotes). To guarantee parity WITHOUT a hand-rolled subset (which missed
# the C1 block and BMP noncharacters), this is PyYAML 6.0.3's EXACT Reader
# printable definition, negated verbatim:
# Reader.NON_PRINTABLE =
# re.compile('[^\x09\x0A\x0D\x20-\x7E\x85\xA0-퟿-'
# '\U00010000-\U0010FFFF]')
# i.e. PRINTABLE = {TAB(0x09), LF(0x0A), CR(0x0D), 0x20-0x7E, NEL(0x85),
# 0xA0-0xD7FF, 0xE000-0xFFFD, 0x10000-0x10FFFF}; EVERYTHING else (all other C0
# controls, DEL 0x7F, the entire C1 block 0x80-0x84/0x86-0x9F, the surrogate
# range 0xD800-0xDFFF, and the BMP noncharacters 0xFFFE/0xFFFF) is non-printable
# -> whole-document fail closed. Verified empirically against real PyYAML 6.0.3
# in both directions: the C1 bytes and 0xFFFE/0xFFFF reject; NEL(0x85), 0xA0, and
# astral code points (e.g. U+1F600) are accepted and resolve the token. The prior
# C0/DEL/tab behavior is a strict subset of this. (A surrogate code point cannot
# occur in valid UTF-8, so it also trips the UTF-8 decode on read and fails
# closed; it is included here for exact definitional parity.)
_FORBIDDEN_CONTROL = re.compile(
"[^\x09\x0a\x0d\x20-\x7e\x85\xa0-퟿-\U00010000-\U0010ffff]"
)
# Sentinel: "outside the supported subset -> fail closed". Distinct from a
# genuine null (None), which is a valid resolved value.
_FAIL = object()
# Separators are SPACE-only: PyYAML rejects a tab used as key/value whitespace
# (before or after the ':') with a scanner error, so a tab there must NOT be
# treated as a benign separator. Space before the colon and one space after it
# stay valid (PyYAML strips a plain key's trailing spaces); a tab in either
# position makes the whole line fail to match -> the caller fails closed.
_KEY_RE = re.compile(r"^([A-Za-z0-9_][A-Za-z0-9_.\-]*) *:(?:[ ](.*)|)$")
_FLOW = set("[]{}*&!")
class _Bail(Exception):
# Raised the instant the document leaves the narrow tea-config subset this
# recognizer can prove it resolves IDENTICALLY to PyYAML. Caught by
# _safe_parse, which then fails closed (returns _FAIL) rather than guess.
pass
def _strip_properties(value):
# Consume a leading YAML node non-specific tag ('!' + space) and return the
# remaining node text for normal resolution. PyYAML's SafeLoader applies
# this TRANSPARENT property and then resolves the following node with its
# ordinary implicit typing, so (verified against real PyYAML 6.0.3)
# '! x' -> 'x', '! ' -> null, exactly as the bare node would resolve.
# Returns _FAIL for every form we do NOT reproduce identically, so the
# caller fails closed: a tag shorthand ('!x' -> ConstructorError), an
# explicit or verbatim tag ('!!str' / '!!int' / '!<...>' / '!foo') whose
# type coercion we do not emulate, a tab separator (PyYAML scanner error),
# a duplicate tag ('! ! x' -> ParserError).
#
# ANCHORS ('&name'): deliberately rejected in FULL, not just duplicates.
# Real PyYAML tracks anchor NAMES in a document-scoped composer registry
# and raises ComposerError ("found duplicate anchor") the instant the
# SAME anchor name is declared on a SECOND node anywhere in the document
# -- including on a totally unrelated node far from the token field. This
# line-oriented fallback has no such document-wide registry (and building
# one reliably, across every node shape this recognizer does not even
# parse, risks missing a scope and re-opening the fail-open). So instead
# of emulating the registry, every '&'-anchor property fails closed here,
# unconditionally. This is a conservative OVER-reject relative to PyYAML:
# a single, non-duplicated '&a x' is valid YAML that real PyYAML resolves
# to 'x', but we refuse it too. That is intentional and safe -- fail
# closed can only cost an emitted token PyYAML would have allowed, never
# emit one PyYAML would reject.
seen_tag = False
while value[:1] in ("!", "&"):
if value[0] == "!":
# Non-specific tag: '!' then AT LEAST ONE SPACE. A tab is a PyYAML
# scanner error; '!x' / '!!type' / '!<verbatim>' are typed/short tags.
if seen_tag or len(value) < 2 or value[1] != " ":
return _FAIL
seen_tag = True
value = value[1:].lstrip(" ")
else: # anchor '&name' -- always fail closed, see comment above
return _FAIL
return value
def _scalar(raw):
# Resolve a single flow scalar (quoted or plain) the way PyYAML would for
# tea's simple values, or _FAIL when it is outside the supported subset so
# the caller fails closed instead of guessing.
#
# A quoted scalar is ALWAYS a string (its contents returned verbatim); a '#'
# inside quotes is data. An UNQUOTED scalar ends at the first whitespace
# -preceded '#' (a YAML comment must be preceded by whitespace or line start,
# so "abc#def" stays literal while "abc # note" becomes "abc"), and is then
# subject to PyYAML's implicit typing: forms like 12345 / null / ~ / yes /
# 3.14 / a timestamp resolve to a NON-string (int/None/bool/float/date), so
# they return None here (PyYAML's path rejects a non-str token via _accept).
# Strip SPACES only, never tabs: PyYAML raises a scanner error on a tab in a
# plain/leading/trailing scalar position, so a tab must be PRESERVED here to
# trip the fail-closed guard below rather than be silently normalized away.
value = raw.strip(" ")
if not value:
return None # empty plain scalar -> null
if value[0] in ("'", '"'):
quote = value[0]
end = value.find(quote, 1)
if end == -1:
return _FAIL # unterminated quote: PyYAML would error / continue
rest = value[end + 1:].strip(" ")
if rest and not rest.startswith("#"):
return _FAIL # trailing junk after a quoted scalar
inner = value[1:end]
# Single-quote '' escaping and double-quote backslash escapes are NOT
# interpreted here; reject any scalar that uses them so we never diverge
# from PyYAML on escape handling.
if quote == "'" and "'" in inner:
return _FAIL
if quote == '"' and "\\" in inner:
return _FAIL
return inner
for i, ch in enumerate(value):
if ch == "#" and (i == 0 or value[i - 1] in (" ", "\t")):
value = value[:i]
break
value = value.strip(" ") # spaces only; a tab must survive to fail closed
if not value:
return None
if value[0] in ("!", "&"):
# Leading node property (non-specific tag '! ' / plain anchor '&name '):
# PyYAML applies it to the FOLLOWING node and resolves THAT, so strip the
# transparent forms and re-resolve the remainder EXACTLY (implicit typing
# and all). _strip_properties returns _FAIL for the tag/anchor forms
# PyYAML rejects or type-coerces, which fail closed here.
remainder = _strip_properties(value)
if remainder is _FAIL:
return _FAIL
return _scalar(remainder)
if value[0] in _FLOW or value[0] in ("|", ">", "@", "`", '"', "'", "%", ","):
return _FAIL # flow / block-scalar / reserved / directive / quote
# NOTE: "?" is deliberately NOT in the blanket-reject set above. Empirically
# verified against real PyYAML 6.0.3: "?x" (indicator immediately followed by
# a non-space) is a plain scalar string "?x" like "-x" and ":x" are -- only
# "?" alone or "? " (followed by space/EOL) opens a complex mapping key and
# is illegal in a value position. Blanket-rejecting every leading "?" would
# over-reject a token PyYAML accepts verbatim; the narrower check below
# (mirroring "-" and ":") handles exactly the illegal bare-indicator forms.
if value[0] in ("-", "?", ":") and (len(value) == 1 or value[1] in (" ", "\t")):
# A bare block indicator, not a plain scalar: '-'/'- ' opens a sequence
# entry, '?'/'? ' a complex mapping key, ':'/': ' a mapping value -- all
# illegal in a value position, where PyYAML raises a scanner error on the
# whole document. "-x"/"-1"/"?x"/":x" (indicator NOT followed by space)
# remain valid plain scalars and fall through. Fail closed on the bare
# indicator so the fallback never emits a token PyYAML would refuse.
return _FAIL
# NO blanket internal-indicator reject here. In BLOCK context (where a tea
# config value always sits) PyYAML treats ',[]{}' as ordinary plain-scalar
# content -- they are flow indicators ONLY inside a flow collection -- and
# treats '!&*|>#%@`' plus quotes as significant ONLY at the FIRST non-space
# character of a node (a leading one starts a tag/anchor/alias/block-scalar/
# comment/directive/reserved/quote), which the leading-char guard above
# (value[0] ...) already rejects. Verified empirically against real PyYAML
# 6.0.3: an INTERNAL '!' ',' '[' ']' '{' '}' '&' '*' '#'(not space-preceded)
# ':'(not space-followed) all resolve as a plain-string token. The only
# internal positions PyYAML treats as significant in block context are ' #'
# (whitespace-preceded '#' -> comment; stripped by the loop above) and ': '
# or a trailing ':' (-> mapping; rejected by the ": "/endswith(":") guard
# below). A blanket any(ch in _FLOW ...) scan over-rejected those internal
# indicators, dropping a token PyYAML emits verbatim; removing it restores
# parity while the position-specific guards keep the fail-open direction shut.
if "\t" in value:
# A tab anywhere in a plain scalar (leading, trailing, or embedded) is a
# PyYAML scanner error on the whole document -- it accepts tabs ONLY
# inside quoted scalars (handled above, returned verbatim). Fail closed
# so the fallback never emits a token PyYAML would refuse over a tab.
return _FAIL
if ": " in value or value.endswith(":"):
return _FAIL # nested-mapping-in-scalar / ambiguous
if _implicit_nonstring(value):
# A plain scalar PyYAML would tag as a non-string (null/bool/int/float/
# timestamp). If PyYAML's CONSTRUCTOR would build it, the value is a
# non-string -> null-equivalent for a token field: return None and keep
# parsing (as before). But if it matches a typed implicit resolver yet is
# NOT constructible (e.g. 2023-99-99, 0b_, 0x_), PyYAML raises on the
# WHOLE document and yields no token, so the fallback MUST fail closed
# for the whole document too -> _FAIL (which the caller turns into _Bail).
if not _constructible(value):
return _FAIL
return None
return value
class _Parser:
# A deliberately NARROW, conservative recognizer for the block-style YAML
# subset tea writes (mappings of scalar fields; a `logins:` block SEQUENCE of
# such mappings; optional shallow nested mappings for e.g. preferences). It
# reconstructs the SAME Python object PyYAML's SafeLoader would, but the
# instant it meets anything it cannot prove it handles identically -- a
# document marker (--- / ...), a block scalar (| / >), a flow collection, a
# duplicate mapping key, inconsistent indentation, an escape, or any line
# outside the grammar -- it raises _Bail so the whole resolution fails
# closed. This guarantees the module invariant (only ever MORE conservative
# than PyYAML, never less) at the DOCUMENT level, closing the structural
# fail-open classes (nested-logins shadow, block-scalar shadow, duplicate
# root key, malformed-after-valid, and extra-document) that a line scan that
# does not validate whole-document structure would miss.
def __init__(self, lines):
self.toks = []
for raw in lines:
if not raw.strip():
continue
lead = raw[: len(raw) - len(raw.lstrip(" \t"))]
if "\t" in lead:
raise _Bail() # tab in indentation: PyYAML scanner error
indent = len(lead)
body = raw[indent:]
if body.lstrip().startswith("#"):
continue
if re.match(r"^(---|\.\.\.)(\s|$)", body) or body in ("---", "..."):
raise _Bail() # document / end marker -> multi-doc -> fail closed
# rstrip SPACES only: a trailing tab is a PyYAML scanner error, so it
# must be kept on the token body to reach the fail-closed guards
# (rstrip() would swallow it and let a bad line resolve a token).
self.toks.append((indent, body.rstrip(" ")))
self.i = 0
def peek(self):
return self.toks[self.i] if self.i < len(self.toks) else None
def parse_document(self):
if not self.toks:
return None
node = self.parse_node(0)
if self.i != len(self.toks):
raise _Bail() # trailing unconsumed content -> malformed
return node
def parse_node(self, min_indent):
tok = self.peek()
if tok is None:
return None
indent, body = tok
if indent < min_indent:
return None
if body.startswith("-") and (len(body) == 1 or body[1] in (" ", "\t")):
return self.parse_seq(indent)
return self.parse_map(indent)
def parse_map(self, indent):
result = {}
while True:
tok = self.peek()
if tok is None:
break
cur_indent, body = tok
if cur_indent < indent:
break
if cur_indent > indent:
raise _Bail() # unexpected deeper line (bad indentation)
if body.startswith("-") and (len(body) == 1 or body[1] in (" ", "\t")):
raise _Bail() # sequence item where a mapping entry was expected
m = _KEY_RE.match(body)
if not m:
raise _Bail()
key = m.group(1)
inline = m.group(2)
self.i += 1
if key in result:
raise _Bail() # duplicate mapping key (PyYAML last-wins; we bail)
if inline is not None and inline.strip(" ") != "":
val = _scalar(inline)
if val is _FAIL:
raise _Bail()
result[key] = val
else:
result[key] = self.parse_block_value(indent)
return result
def parse_block_value(self, key_indent):
# The value after a "key:" with no inline scalar. A block SEQUENCE may sit
# at the same indent as the key (YAML permits `- ` aligned with the key --
# tea's own on-disk shape) or deeper; a block MAPPING must be strictly
# deeper; otherwise the value is null.
nxt = self.peek()
if nxt is None:
return None
ni, nb = nxt
is_item = nb.startswith("-") and (len(nb) == 1 or nb[1] in (" ", "\t"))
if is_item and ni >= key_indent:
return self.parse_seq(ni)
if ni > key_indent:
return self.parse_node(ni)
return None
def parse_seq(self, indent):
result = []
while True:
tok = self.peek()
if tok is None:
break
cur_indent, body = tok
if cur_indent < indent:
break
if cur_indent > indent:
raise _Bail()
if not (body.startswith("-") and (len(body) == 1 or body[1] in (" ", "\t"))):
break # a mapping entry at this indent ends the sequence
rest = body[1:].strip(" ") # spaces only; a tab must survive to bail
self.i += 1
if rest == "":
nxt = self.peek()
if nxt is not None and nxt[0] > indent:
result.append(self.parse_node(indent + 1))
else:
result.append(None)
continue
km = _KEY_RE.match(rest)
if km:
# "- key: value" opens a mapping whose fields continue at the
# column where the content after the dash began.
field_indent = indent + (len(body) - len(body[1:].lstrip()))
result.append(self.parse_inline_map(field_indent, km))
else:
val = _scalar(rest)
if val is _FAIL:
raise _Bail()
result.append(val)
return result
def parse_inline_map(self, field_indent, first_match):
result = {}
key = first_match.group(1)
inline = first_match.group(2)
if inline is not None and inline.strip(" ") != "":
val = _scalar(inline)
if val is _FAIL:
raise _Bail()
result[key] = val
else:
result[key] = self.parse_block_value(field_indent)
while True:
tok = self.peek()
if tok is None:
break
cur_indent, body = tok
if cur_indent != field_indent:
if cur_indent > field_indent:
raise _Bail()
break
if body.startswith("-") and (len(body) == 1 or body[1] in (" ", "\t")):
raise _Bail()
m = _KEY_RE.match(body)
if not m:
raise _Bail()
k = m.group(1)
iv = m.group(2)
self.i += 1
if k in result:
raise _Bail()
if iv is not None and iv.strip(" ") != "":
v = _scalar(iv)
if v is _FAIL:
raise _Bail()
result[k] = v
else:
result[k] = self.parse_block_value(field_indent)
return result
def _safe_parse(lines):
# Return the parsed root object (dict/list/scalar/None) when the WHOLE
# document is inside the supported subset, else _FAIL (fail closed).
try:
return _Parser(lines).parse_document()
except _Bail:
return _FAIL
except Exception:
return _FAIL
def _url_matches_repo_host(url):
# Mirror gitea_url_matches_host (detect-platform.sh): the login's recorded
# URL must name the SAME host AND the SAME effective port as the repo remote
# host, not merely the same hostname. A login configured for an explicit,
# non-default provider port (e.g. :9443) must NOT satisfy a portless (default
# -port) repo host, and a login on the matching port (e.g. :8443) must NOT be
# rejected. Ports are normalized by applying the login URL's scheme default
# (80 for http, else 443) to whichever side omits the port, symmetrically, so
# an implicit port and its explicit default-port form compare equal.
if not isinstance(url, str) or not url:
return False
configured = urlparse(url if "//" in url else f"//{url}")
remote = urlparse(f"//{repo_host}")
configured_host = configured.hostname
if not configured_host or configured_host.lower() != (remote.hostname or "").lower():
return False
default_port = 80 if configured.scheme == "http" else 443
configured_port = configured.port if configured.port is not None else default_port
remote_port = remote.port if remote.port is not None else default_port
return configured_port == remote_port
def _accept(token, url):
# Enforce the host bind before surfacing a token. When a repo host is given,
# the login's recorded URL host AND port must match it; a login with no
# usable URL (or a mismatched host/port) is rejected (fail closed) so a
# cross-host (or cross-port) credential is never emitted.
if not isinstance(token, str) or not token:
return None
if repo_host:
if not _url_matches_repo_host(url):
return None
return token
def _token_via_pyyaml():
# Preferred, fully general path when PyYAML is installed. Raises ImportError
# (caught by the caller) when the module is unavailable so the environment
# -robust fallback can take over instead of failing closed on every host
# that lacks PyYAML.
import yaml
with open(config_path, encoding="utf-8") as handle:
config = yaml.safe_load(handle)
logins = config.get("logins") if isinstance(config, dict) else None
if not isinstance(logins, list):
return None
for login in logins:
if isinstance(login, dict) and str(login.get("name") or "") == wanted:
return _accept(login.get("token"), login.get("url"))
return None
_BREAKS = "\r\n\x85

"
_VALUE_OPEN_RE = re.compile(r"^\s*(?:-\s+)*[A-Za-z0-9_][A-Za-z0-9_.\-]*:$")
_SEQ_OPEN_RE = re.compile(r"^\s*(?:-\s+)*-$")
def _value_open_before(prefix):
# True when a quote appearing at the END of `prefix` (the current logical
# line so far) begins a flow SCALAR value -- the only position where a quote
# opens a quoted scalar whose embedded line breaks PyYAML folds instead of
# splitting. That is: the prefix is empty/all-indent (root or block scalar
# start), or ends with a mapping ': ' or a sequence '- ' indicator that is
# SPACE-separated from the quote. A quote glued to the previous char (e.g.
# 'key:"x') is NOT a value opener (PyYAML needs the space), and a quote mid
# -content is literal -- both fail this test, so their breaks split the line
# exactly as before (fail closed / plain-scalar behavior preserved).
stripped = prefix.rstrip(" ")
if stripped == "":
return True # start of line (after any indentation) -> node value start
if prefix == stripped:
return False # no space before the quote -> not a value opener
return bool(_VALUE_OPEN_RE.match(stripped) or _SEQ_OPEN_RE.match(stripped))
def _fold_quoted_break_run(run):
# Reproduce PyYAML's flow-scalar folding (scan_flow_scalar_spaces +
# scan_flow_scalar_breaks) for the maximal ' \t' + line-break run inside a
# quoted scalar. A run with NO break is literal whitespace (verbatim). With a
# break: surrounding spaces/tabs are dropped; a single \n-class break
# (\n / \r / \r\n / \x85 NEL) folds to ONE space; (LS) / (PS)
# are kept verbatim; each ADDITIONAL break (a blank line) contributes its own
# break char (\n for the \n-class). Verified against real PyYAML 6.0.3 for
# both double- and single-quoted scalars (they fold identically here).
def _lb(s, j):
if s[j] == "\r" and j + 1 < len(s) and s[j + 1] == "\n":
return "\n", 2
if s[j] in "\r\n\x85":
return "\n", 1
return s[j], 1 # / preserved verbatim
n = len(run)
j = 0
while j < n and run[j] in " \t":
j += 1
if j >= n:
return run # pure whitespace, no break -> literal content
line_break, adv = _lb(run, j)
j += adv
breaks = []
while True:
while j < n and run[j] in " \t":
j += 1
if j < n and run[j] in _BREAKS:
b, adv = _lb(run, j)
j += adv
breaks.append(b)
else:
break
out = ""
if line_break != "\n":
out += line_break
elif not breaks:
out += " "
return out + "".join(breaks)
def _split_logical_lines(raw_text):
# Split like str.splitlines() EXCEPT a line break INSIDE a value-opening
# quoted scalar does NOT end the line: PyYAML keeps the quoted scalar together
# and folds the break (flow folding above), so we fold it and continue the
# same logical line. Only the break chars that survive the _FORBIDDEN_CONTROL
# gate reach here: \n, \r, \x85 (NEL), (LS), (PS). OUTSIDE
# quotes every one ends the line (matching PyYAML's block-level line breaks
# and str.splitlines), so a NEL/LS/PS used as the document's line-terminator
# style, and an UNQUOTED plain scalar carrying an embedded break, split and
# fail closed exactly as before. A quoted scalar left unterminated at EOF
# stays on the final line and fails closed in _scalar (PyYAML errors too).
out = []
cur = []
i = 0
n = len(raw_text)
quote = None
while i < n:
ch = raw_text[i]
if quote is None:
if ch in _BREAKS:
out.append("".join(cur))
cur = []
i += 2 if (ch == "\r" and i + 1 < n and raw_text[i + 1] == "\n") else 1
continue
if ch in ("'", '"') and _value_open_before("".join(cur)):
quote = ch
cur.append(ch)
i += 1
continue
cur.append(ch)
i += 1
continue
# inside a value-opening quoted scalar
if ch == quote:
cur.append(ch)
quote = None
i += 1
continue
if ch in " \t" or ch in _BREAKS:
j = i
has_break = False
while j < n and (raw_text[j] in " \t" or raw_text[j] in _BREAKS):
if raw_text[j] in _BREAKS:
has_break = True
j += 1
run = raw_text[i:j]
if has_break:
# A continuation line beginning (at column 0, no indent) with a
# document marker is a PyYAML scanner error even inside a quoted
# scalar; when the run ends on a break the continuation content at
# j is at column 0, so guard it and fail closed. (An INDENTED
# '---' is literal content to PyYAML and folds normally.)
if run[-1] in _BREAKS and raw_text[j:j + 3] in ("---", "...") and (
j + 3 >= n or raw_text[j + 3] in "\0 \t" + _BREAKS
):
raise _Bail()
cur.append(_fold_quoted_break_run(run))
else:
cur.append(run)
i = j
continue
cur.append(ch)
i += 1
if cur or not out:
out.append("".join(cur))
return out
def _token_via_lines():
# Conservative fallback for hosts without PyYAML. It parses config.yml with a
# strict recognizer (_safe_parse) of the narrow block-style subset tea writes,
# which reconstructs the SAME object PyYAML would OR fails closed (_FAIL) on
# ANYTHING it cannot prove it resolves identically -- document markers, block
# scalars, flow collections, duplicate keys, inconsistent indentation, or any
# line outside the grammar. On a recognized document it then resolves the
# login EXACTLY as the PyYAML path does (root `logins` list -> first entry
# whose `name` equals the request -> host/port-bound token), so the fallback
# can only ever be MORE conservative than PyYAML, never less.
with open(config_path, encoding="utf-8") as handle:
raw_text = handle.read()
# Whole-document, position-independent reject: PyYAML's Reader rejects the
# ENTIRE document (ReaderError) if a NON-printable code point (per its exact
# printable definition -- see _FORBIDDEN_CONTROL: C0 controls, DEL, the C1
# block, surrogates, and 0xFFFE/0xFFFF) appears ANYWHERE in the raw stream --
# in a plain scalar, inside single- or double-quoted scalars, in a comment,
# or in a field this recognizer never even looks at. Checking the raw text
# (before line splitting, which would otherwise split on some of these same
# code points -- e.g. \x0b, \x0c, \x1c-\x1e -- and obscure them) mirrors that
# exactly: fail closed for the whole document. (NEL 0x85, LS 0x2028 and PS
# 0x2029 are PRINTABLE to PyYAML's Reader, so they are NOT in
# _FORBIDDEN_CONTROL; _split_logical_lines below reproduces PyYAML's line-break
# semantics for them -- a structural break outside quotes, a FOLDED break
# inside a quoted scalar -- rather than str.splitlines(), which would wrongly
# split them mid-quote and fail-close a token PyYAML resolves.)
if _FORBIDDEN_CONTROL.search(raw_text):
return None
lines = _split_logical_lines(raw_text)
config = _safe_parse(lines)
if config is _FAIL:
return None
logins = config.get("logins") if isinstance(config, dict) else None
if not isinstance(logins, list):
return None
for login in logins:
if isinstance(login, dict) and str(login.get("name") or "") == wanted:
return _accept(login.get("token"), login.get("url"))
return None
try:
try:
token = _token_via_pyyaml()
except ImportError:
token = _token_via_lines()
except Exception:
raise SystemExit(1)
if isinstance(token, str) and token:
print(token)
raise SystemExit(0)
raise SystemExit(1)
PY
}
# Resolve HTTPS basic auth credentials for a Gitea host from ~/.git-credentials.
# Prints "username:password" for direct curl -u consumption. Callers must not log it.
get_gitea_basic_auth() {
+69
View File
@@ -0,0 +1,69 @@
#!/bin/bash
# git-credential-mosaic — git credential helper — resolves Gitea tokens from
# the Mosaic credential store at runtime so remote URLs never embed secrets.
#
# Install (one-time, per clone or globally):
# git config credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
# # or, fleet-wide: git config --global credential.helper "$HOME/.config/mosaic/tools/git/git-credential-mosaic"
#
# Per-agent Gate-16 identity (author != reviewer separation):
# git config mosaic.gitIdentity <agent-id> # per-worktree, persists on disk
# # or: export MOSAIC_GIT_IDENTITY=<agent-id>
#
# Resolution priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity
# (per-worktree, survives across non-persistent shells) > git-supplied username
# (credential.username / URL). When the resolved identity has a matching
# per-agent token file, use it instead of the shared account. Backward
# compatible: nothing resolvable -> shared token (unchanged behavior).
[ "$1" = "get" ] || exit 0
host=""; username_in=""
while IFS= read -r line; do
[ -z "$line" ] && break
case "$line" in
host=*) host=${line#host=};;
username=*) username_in=${line#username=};;
esac
done
# Per-agent identity resolution (Gate-16 author≠reviewer separation).
# Priority: MOSAIC_GIT_IDENTITY env > git config mosaic.gitIdentity (per-worktree,
# survives across non-persistent shells) > git-supplied username (credential.username
# / URL). When the resolved identity has a matching per-agent token, use it instead of
# the shared account. Backward-compatible: nothing resolvable → shared token.
ident="$MOSAIC_GIT_IDENTITY"
[ -z "$ident" ] && ident=$(git config --get mosaic.gitIdentity 2>/dev/null)
[ -z "$ident" ] && ident="$username_in"
if [ -n "$ident" ]; then
case "$host" in
git.uscllc.com) idpfx=gitea-usc;;
git.mosaicstack.dev) idpfx=gitea-mosaicstack;;
*) idpfx="";;
esac
if [ -n "$idpfx" ]; then
idtok="$HOME/.config/mosaic/secrets/gitea-tokens/${idpfx}-${ident}.token"
if [ -r "$idtok" ]; then
echo "username=${ident}"
echo "password=$(cat "$idtok")"
exit 0
fi
fi
fi
case "$host" in
git.uscllc.com) svc=gitea-usc;;
git.mosaicstack.dev) svc=gitea-mosaicstack;;
*) exit 0;;
esac
# Script-relative (not $HOME-absolute) so this resolves correctly regardless
# of where the framework installer places tools/ under $HOME — mirrors
# detect-platform.sh's own cred_loader resolution in this same directory.
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=../_lib/credentials.sh
source "$script_dir/../_lib/credentials.sh"
load_credentials "$svc" >/dev/null 2>&1 || exit 0
# GITEA_USER is not populated by load_credentials (it only exports
# GITEA_URL/GITEA_TOKEN for gitea-*), so this fallback is normally taken. Gitea's
# git-over-HTTP auth authenticates from the token itself (the password field),
# not from the username string, so any non-empty placeholder works here — this
# is deliberately NOT a real account name (framework files must stay
# operator-agnostic; see tools/quality/scripts/verify-sanitized.sh).
echo "username=${GITEA_USER:-git}"
echo "password=$GITEA_TOKEN"
@@ -1,6 +1,26 @@
#!/bin/bash
# issue-comment.sh - Add a comment to an issue on GitHub or Gitea
# Usage: issue-comment.sh -i <issue_number> -c <comment>
# Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]
#
# tea v0.11.1 defines no `comment` subcommand under `tea issue` (or `tea pr`);
# the non-existent `tea issue comment ...` form does not error — tea silently
# no-ops and still exits 0, so a caller trusting the exit code believes a
# comment was posted when it was not (#865). tea 0.11.1 also cannot reliably
# emit the id of a record it created, so an exit code is the ONLY signal it
# offers — and that signal is untrustworthy. This script therefore does not
# write via tea at all: it POSTs the comment through the Gitea REST API (which
# returns the created comment object, including its id), then GETs that exact
# id back and fails closed unless it matches. Keying verification to the
# provider-returned created id means a concurrent comment cannot masquerade as
# this write and a no-op create simply yields no id to verify.
#
# --login override: the default login is resolved from the local `tea` login
# list for this repo's host (get_gitea_login). Pass --login <name> to override
# it for this invocation only. The REST write, the /user identity read, and the
# read-back are ALL performed with the token of the EFFECTIVE login (the
# override when given), so the write and its verification bind to the same
# identity — a --login override is never written under one credential and
# verified under a different default one.
set -e
@@ -10,6 +30,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
# Parse arguments
ISSUE_NUMBER=""
COMMENT=""
LOGIN_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case $1 in
@@ -21,12 +42,17 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2"
shift 2
;;
-l|--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-h|--help)
echo "Usage: issue-comment.sh -i <issue_number> -c <comment>"
echo "Usage: issue-comment.sh -i <issue_number> -c <comment> [--login <name>]"
echo ""
echo "Options:"
echo " -i, --issue Issue number (required)"
echo " -c, --comment Comment text (required)"
echo " -l, --login Override the detected Gitea tea login for this call"
echo " -h, --help Show this help"
exit 0
;;
@@ -49,20 +75,273 @@ fi
detect_platform >/dev/null
# Resolve and cache the Gitea REST endpoint + token for the current remote,
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
#
# The token is resolved for the EFFECTIVE login (the --login override when
# given, otherwise the detected default) so that the single credential used for
# the write ALSO drives the /user identity read and the read-back — write token
# and read-back token are the same identity by construction (this is the
# credential-ordering fix: a --login override is no longer written under one
# credential and verified under a different default one). Falls back to the
# host-scoped credential ONLY when NO --login override was supplied (the
# best-effort default path). When $2 is "explicit" the login came from a
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
# CLOSED rather than silently downgrading the write to the host default
# identity — otherwise a caller relying on a dedicated per-role credential would
# be told the write succeeded as requested while it was attributed to the shared
# default. Returns non-zero (clear stderr) on any resolution failure.
gitea_resolve_api_for_login() {
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
host=$(get_remote_host)
if [[ -n "$override_explicit" ]]; then
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (comment write/read-back)" >&2
return 1
}
else
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for login '$effective_login' (comment write/read-back)" >&2
return 1
}
fi
configured_url=$(get_gitea_url_for_host "$host") || {
echo "Error: Configured Gitea URL not found for comment read-back verification" >&2
return 1
}
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
return 1
}
GITEA_API_ROOT="${configured_url%/}/api/v1"
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
# The provider WEB base (scheme + host + effective port + any deployment path
# prefix) that Gitea uses to build a comment's html issue_url/pull_request_url.
# Read-back verification pins the returned URL's origin + path prefix to THIS,
# not just a repo/issue suffix.
GITEA_WEB_BASE="${configured_url%/}"
return 0
}
# Resolve the login of the identity the API token authenticates as (GET
# /user). Used to attribute a read-back record to THIS invocation's writer so
# a concurrent write from a DIFFERENT identity cannot satisfy verification.
# Prints the login on success.
gitea_authenticated_login() {
local response_file auth_config status
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-whoami.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$response_file"
echo "Error: could not stage Gitea credential for identity read" >&2
return 1
}
trap 'rm -f "$response_file" "$auth_config"' RETURN
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
--config "$auth_config" \
"$GITEA_API_ROOT/user"); then
echo "Error: Gitea authenticated-identity read transport failed" >&2
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea authenticated-identity read failed with HTTP $status" >&2
return 1
fi
python3 - "$response_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
user = json.load(response)
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("missing authenticated login")
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr)
raise SystemExit(1)
print(login)
PY
}
# Post a comment to a Gitea issue via the supported REST API and verify it
# durably against a PROVIDER-RETURNED created id — never trust an exit code
# (#865 defect class: tea's non-existent `tea issue comment` no-ops yet exits
# 0). The write is a direct POST that returns the created comment object, so we
# learn the exact id of THIS write; we then GET that exact id and require
# id == created id AND author == acting identity AND exact body AND that it
# belongs to this issue. Because verification is keyed to the id the create
# returned, a concurrent comment (even same identity, same body) CANNOT
# masquerade as this write, and a suppressed/no-op write yields no created id
# and fails closed — there is no fallback list scan that a concurrent record
# could satisfy. Prints the created comment id on success.
#
# Args: $1 = issue number, $2 = comment body, $3 = acting identity login.
gitea_create_comment_verified() {
local issue_number="$1" comment_body="$2" acting_login="$3"
local payload write_file readback_file auth_config write_status readback_status created_id
payload=$(COMMENT_BODY="$comment_body" python3 -c '
import json
import os
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
')
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-write.XXXXXX")
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-issue-comment-getid.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$write_file" "$readback_file"
echo "Error: could not stage Gitea credential for comment write" >&2
return 1
}
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
-X POST \
--config "$auth_config" \
-H 'Content-Type: application/json' \
-d "$payload" \
"$GITEA_API_BASE/issues/$issue_number/comments"); then
echo "Error: Gitea comment write transport failed" >&2
return 1
fi
if [[ "$write_status" != "201" ]]; then
echo "Error: Gitea comment write failed with HTTP $write_status (#865: no durable comment created)" >&2
return 1
fi
created_id=$(python3 - "$write_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
comment = json.load(response)
created_id = comment.get("id") if isinstance(comment, dict) else None
if not isinstance(created_id, int) or created_id <= 0:
raise ValueError("create response carried no positive comment id")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
raise SystemExit(1)
print(created_id)
PY
) || return 1
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
--config "$auth_config" \
"$GITEA_API_BASE/issues/comments/$created_id"); then
echo "Error: Gitea comment read-back transport failed" >&2
return 1
fi
if [[ "$readback_status" != "200" ]]; then
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
return 1
fi
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
EXPECTED_NUMBER="$issue_number" EXPECTED_WEB_BASE="$GITEA_WEB_BASE" \
python3 - "$readback_file" <<'PY' || return 1
import json
import os
import sys
from urllib.parse import urlparse
def _origin_and_path(url):
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
# port and its explicit default form compare equal.
parsed = urlparse(url or "")
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
default_port = 80 if scheme == "http" else 443
port = parsed.port if parsed.port is not None else default_port
return (scheme, host, port), parsed.path.rstrip("/")
try:
with open(sys.argv[1], encoding="utf-8") as response:
comment = json.load(response)
if not isinstance(comment, dict):
raise ValueError("response is not a comment object")
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
acting_login = os.environ["ACTING_LOGIN"]
slug = os.environ["EXPECTED_REPO_SLUG"]
number = os.environ["EXPECTED_NUMBER"]
web_base = os.environ["EXPECTED_WEB_BASE"]
# Gitea populates WEB (html) URLs here, not API paths. A plain issue comment
# carries issue_url = <web_base>/<owner>/<repo>/issues/<n> (pull_request_url
# empty); a comment posted to a PR's conversation carries
# pull_request_url = <web_base>/<owner>/<repo>/pulls/<n> (issue_url empty).
# Pin the returned URL's ORIGIN (scheme+host+port) and its FULL path to this
# provider + repo + kind + number — an endswith/suffix test would accept a
# look-alike host (evil.example/deceptive/<slug>/issues/N) or a same-host
# decoy prefix (/other/<slug>/issues/N), so compare the whole thing.
base_origin, base_path = _origin_and_path(web_base)
expected_issue_path = f"{base_path}/{slug}/issues/{number}"
expected_pr_path = f"{base_path}/{slug}/pulls/{number}"
def _belongs(url, expected_path):
if not url:
return False
origin, path = _origin_and_path(url)
return origin == base_origin and path == expected_path
if comment.get("id") != expected_id:
raise ValueError("read-back id does not match the created id")
if (comment.get("user") or {}).get("login") != acting_login:
raise ValueError("created comment is not authored by the acting identity")
if comment.get("body") != expected_body:
raise ValueError("created comment body does not match")
if not (
_belongs(comment.get("issue_url"), expected_issue_path)
or _belongs(comment.get("pull_request_url"), expected_pr_path)
):
raise ValueError("created comment does not belong to this issue on this provider/repo")
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
PY
echo "$created_id"
return 0
}
if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
# Build the invocation as an argv array (not unquoted $(get_gitea_repo_args)
# word-splitting) so the comment body — including Markdown backticks, $(...),
# and quotes — is passed verbatim and never re-split or shell-evaluated.
REPO_SLUG=$(get_repo_slug)
GITEA_LOGIN_NAME=$(get_gitea_login) || {
echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2
# Resolve the login this comment should be attributed to: the --login
# override when given, otherwise the detected default for this repo's host.
# A --login override always wins. Otherwise name this repo host's login only
# as a best effort: the login name merely selects a per-login token, and
# gitea_resolve_api_for_login falls back to the host credential
# (get_gitea_token) when no tea login is named, so the default credential
# still resolves even when the host tea has no matching login entry.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login 2>/dev/null || true)
# Bind the REST endpoint + token to the effective login, then derive the
# acting identity from that SAME credential (GET /user). The write below and
# its read-back both use this credential, so the write is verified against
# the identity that actually performed it. Passing "explicit" when --login
# was supplied forbids the host-default fallback: an unresolvable explicit
# override fails closed instead of writing under the default identity.
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
comment_id=$(gitea_create_comment_verified "$ISSUE_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
echo "Error: could not create and verify a comment on Gitea issue #$ISSUE_NUMBER via a provider-returned created id (#865)." >&2
exit 1
}
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME"
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
echo "Added and verified comment on Gitea issue #$ISSUE_NUMBER (comment ID $comment_id)"
else
echo "Error: Unknown platform"
exit 1
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env bash
# mutate-push-guard.sh -- regenerate the README's mutation table from MEASUREMENT.
#
# WHY THIS EXISTS. The README carried a hand-written mutation table quoting
# "32/32" style results. Those numbers were true when typed and went stale in
# silence as the suite grew. A README is what a reader trusts when the tool
# misbehaves, so a confidently-wrong one is worse than none. Every number the
# README prints about mutation now comes out of this script.
#
# ============================ WHAT THIS TOOL GOT WRONG ========================
# Three defects, all found by review, all of the same shape: A TOOL WHOSE ENTIRE
# OUTPUT IS A COVERAGE CLAIM MUST BE HARDER TO FOOL THAN THE CODE IT MEASURES.
#
# 1. IT REPORTED FULL COVERAGE ON A RED BASELINE. A mutant was "killed" whenever
# the suite reported any failure at all, and the baseline was run only at the
# END and never required to be green. So ONE pre-existing suite failure --
# changing no guard behaviour whatsoever -- satisfied EVERY mutant: 13 killed,
# 0 survived, a confident table generated and pasted into the README, exit 0.
# Now: the baseline runs FIRST and must be exit-0 with zero failures, and a
# kill requires the mutant to break a case THE BASELINE PASSED, recorded BY
# NAME. A tally is not evidence; a named delta is.
#
# 2. IT MUTATED THE REVIEWED SOURCE IN PLACE. Restoration leaned on an EXIT trap.
# A TRAP IS CLEANUP, NOT ISOLATION -- SIGKILL cannot run it. An interrupted run
# left push-guard.sh mutated in the working tree, and the reviewer's NEXT
# suite run silently inherited it. A verification tool that alters its subject
# can leave the subject wrong in a way the next measurement believes.
# Now: the subject is copied into a temp dir and only the COPY is ever
# written to. The source is untouched BY CONSTRUCTION rather than by cleanup,
# which is the only version of this that survives kill -9.
#
# 3. ITS WORK DIR WAS SHARED. Concurrent runs interfered through the suite's
# default .work directory. Each run now gets its own.
#
# (Note the deliberate asymmetry with verify-clean-clone.sh, which forbids cp:
# there the copy LAUNDERED the property under measurement, so measuring a copy
# was the defect. Here mutation is destructive by design, so copying is what
# PROTECTS the subject. The rule is not "never copy" -- it is "know whether the
# copy preserves the property you are about to measure.")
#
# ================== THREE WAYS A MUTATION RUN LIES, AND THE GUARD FOR EACH ====
# A. THE ANCHOR NO LONGER MATCHES. The mutant is never applied, the suite is
# green, and the report says SURVIVED -- the same word a real coverage gap
# gets. Guarded: ANCHOR MISSING is a loud failure.
# B. THE ANCHOR MATCHES PROSE. This one landed on the first run: a mutant aimed
# at a branch matched inside the usage() heredoc, edited a help string,
# changed no behaviour, and duly reported SURVIVED. A documentation edit was
# one step from being recorded as an uncovered branch. A MUTATION THAT CANNOT
# CHANGE BEHAVIOUR IS NOT A SURVIVING MUTANT, IT IS A NON-MEASUREMENT.
# Guarded: anchors resolving inside usage() are refused.
# C. THE ANCHOR IS AMBIGUOUS. Two unrelated branches here are both the line
# `if (( status != 0 )); then`; a first-match replace would credit the kill
# to the wrong branch. Guarded: a match count != 1 refuses rather than guesses.
set -uo pipefail
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
while (( $# )); do
case "$1" in
# --dir exists so this tool can be pointed at a FIXTURE copy and tested.
--dir) SRC_DIR="$(cd "$2" && pwd)"; shift 2 ;;
*) printf 'usage error: unknown argument: %s\n' "$1" >&2; exit 64 ;;
esac
done
SRC_TARGET="$SRC_DIR/push-guard.sh"
SRC_SUITE="$SRC_DIR/test-push-guard.sh"
for f in "$SRC_TARGET" "$SRC_SUITE"; do
[[ -r "$f" ]] || { printf 'REFUSING: cannot read %s\n' "$f" >&2; exit 1; }
done
# --- ISOLATION, NOT CLEANUP --------------------------------------------------
# Everything below writes only inside WORK. The trap is a courtesy for disk
# space; correctness does not depend on it running.
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
install -m 755 "$SRC_TARGET" "$WORK/push-guard.sh"
install -m 755 "$SRC_SUITE" "$WORK/test-push-guard.sh"
TARGET="$WORK/push-guard.sh"
SUITE="$WORK/test-push-guard.sh"
BAK="$WORK/push-guard.sh.orig"
cp "$TARGET" "$BAK"
# Per-run work dir: the suite otherwise defaults to a shared .work beside itself,
# and two concurrent runs corrupt each other's fixtures.
export MOSAIC_TEST_WORK_DIR="$WORK/.work"
# --- where the prose lives: usage() { ... EOF ---------------------------------
PROSE_LO="$(grep -n '^usage() {' "$BAK" | head -1 | cut -d: -f1)"
PROSE_HI="$(awk -v lo="$PROSE_LO" 'NR > lo && /^EOF$/ { print NR; exit }' "$BAK")"
if [[ -z "$PROSE_LO" || -z "$PROSE_HI" ]]; then
echo "!! cannot locate the usage() heredoc -- the prose guard would be inert; refusing" >&2
exit 1
fi
# passing_cases <output> -- names of cases that PASSED, one per line
passing_cases() { printf '%s\n' "$1" | sed -n 's/^ PASS \[[^]]*\] \(.*\) (exit [0-9]*)$/\1/p'; }
tally() { printf '%s\n' "$1" | grep -E 'needles: [0-9]+ passed' | tail -1; }
# --- THE BASELINE MUST BE GREEN, AND IT IS ESTABLISHED FIRST ------------------
printf '=== baseline (must be exit 0 with zero failures) ===\n'
BASE_OUT="$("$SUITE" 2>&1)"; BASE_RC=$?
BASE_LINE="$(tally "$BASE_OUT")"
BASE_FAILED="$(printf '%s\n' "$BASE_LINE" | sed -n 's/.*, \([0-9]*\) failed.*/\1/p')"
if (( BASE_RC != 0 )) || [[ -z "$BASE_LINE" || "$BASE_FAILED" != "0" ]]; then
printf 'REFUSING: baseline is not green -- exit %s, tally: %s\n' \
"$BASE_RC" "${BASE_LINE:-<no tally emitted>}" >&2
printf '\nEvery mutant would be scored KILLED by the pre-existing failure, and this\n' >&2
printf 'tool would publish a confident coverage table that measured nothing. Fix the\n' >&2
printf 'suite first. NO TABLE IS EMITTED.\n' >&2
exit 1
fi
printf ' %s\n' "$BASE_LINE"
mapfile -t BASE_PASSING < <(passing_cases "$BASE_OUT")
printf ' %d named cases passing at baseline\n' "${#BASE_PASSING[@]}"
if (( ${#BASE_PASSING[@]} == 0 )); then
printf 'REFUSING: could not parse any case names -- kills could not be attributed.\n' >&2
exit 1
fi
printf ' prose (usage heredoc) is lines %s-%s -- anchors there are refused, not scored\n\n' \
"$PROSE_LO" "$PROSE_HI"
rc_all=0
KILLED=0; SURVIVED=0
declare -a ROWS=()
mutate() {
local name="$1" find="$2" repl="$3"
# Count and locate in python so MULTI-LINE anchors work. They are required:
# two unrelated branches in this file are both the single line
# `if (( status != 0 )); then`, and a one-line anchor cannot say which one a
# result belongs to. Guessing would attribute a kill to the wrong branch.
local loc; loc="$(python3 - "$BAK" "$find" <<'LOCPY'
import sys
s = open(sys.argv[1]).read(); find = sys.argv[2]
n = s.count(find)
print(n, (s[:s.index(find)].count("\n") + 1) if n else 0)
LOCPY
)"
local n="${loc%% *}" ln="${loc##* }"
if (( n == 0 )); then
printf ' !! ANCHOR MISSING %-46s NOT APPLIED -- result would be meaningless\n' "$name"
rc_all=1; return
fi
if (( n != 1 )); then
printf ' !! ANCHOR AMBIGUOUS %-46s %d matches -- refusing to guess which branch\n' "$name" "$n"
rc_all=1; return
fi
if (( ln >= PROSE_LO && ln <= PROSE_HI )); then
printf ' !! ANCHOR IS PROSE %-46s line %d is inside usage() -- not a branch\n' "$name" "$ln"
rc_all=1; return
fi
python3 - "$BAK" "$TARGET" "$find" "$repl" <<'MUTPY'
import sys
src, dst, find, repl = sys.argv[1:5]
open(dst, "w").write(open(src).read().replace(find, repl, 1))
MUTPY
local out; out="$("$SUITE" 2>&1)"
cp "$BAK" "$TARGET"
local line; line="$(tally "$out")"
if [[ -z "$line" ]]; then
printf ' !! NO TALLY %-46s suite produced no needle count\n' "$name"
rc_all=1; return
fi
# A KILL IS A NAMED DELTA, NOT A TALLY. Cases that passed at baseline and no
# longer pass are the evidence; anything else (a case that was already
# failing, a suite that died early) cannot be credited to this mutant.
local now; now="$(passing_cases "$out")"
local -a broke=()
local c
for c in "${BASE_PASSING[@]}"; do
grep -qxF -- "$c" <<<"$now" || broke+=("$c")
done
local total="${#BASE_PASSING[@]}"
if (( ${#broke[@]} > 0 )); then
printf ' KILLED L%-5s %-46s %d/%d fail\n' "$ln" "$name" "${#broke[@]}" "$total"
printf ' by: %s\n' "${broke[0]}"
(( ${#broke[@]} > 1 )) && printf ' +%d more\n' "$(( ${#broke[@]} - 1 ))"
ROWS+=("| \`$name\` (L$ln) | ${#broke[@]}/$total fail | killed |")
KILLED=$(( KILLED + 1 ))
else
printf ' SURVIVED L%-5s %-46s 0/%d fail <-- UNCOVERED BRANCH\n' "$ln" "$name" "$total"
ROWS+=("| \`$name\` (L$ln) | 0/$total fail | **SURVIVED** |")
SURVIVED=$(( SURVIVED + 1 )); rc_all=1
fi
}
echo "=== push-guard mutation run ==="
# EVERY ANCHOR BELOW IS VERBATIM SOURCE TEXT OF THE GUARD, so the single quotes
# are load-bearing: these strings must reach `mutate` as the CHARACTERS that
# appear in push-guard.sh. Expanding them would search for THIS shell's (unset)
# $rel, $cmode, $EX_CONFIG and match nothing -- which the anchor guards would
# report as ANCHOR MISSING rather than silently, but the intent is still to
# forbid expansion. The directive is scoped to this function so it cannot mask a
# genuine unintended-literal anywhere else in the file.
# shellcheck disable=SC2016
run_mutants() {
mutate "json decision requirement bypassed" \
' if (( ${#JSON_PATHS[@]} == 0 )); then' ' if false; then'
mutate "opt-out accepted with no written reason" \
'if not isinstance(reason, str) or not reason.strip():' 'if False:'
mutate "committed re-read of the opt-out skipped" \
' [[ "$CFG_MODE" == "none" ]] || return 0' ' return 0'
mutate "untracked config honoured as an opt-out" \
' if [[ -z "$rel" ]]; then' ' if false; then'
mutate "staged-but-uncommitted opt-out honoured" \
' if [[ -z "$cmode" ]]; then' ' if false; then'
mutate "committed SYMLINK config honoured" \
' if [[ "$cmode" == "120000" ]]; then' ' if false; then'
mutate "unparseable committed config ignored" \
' if (( cstatus != 0 )); then' ' if false; then'
mutate "local-only opt-out (HEAD says ON) honoured" \
' if [[ "$cmode_val" != "none" ]]; then' ' if false; then'
mutate "empty MERGE exempted" \
' if [[ "$all_same" == yes ]]; then' ' if false; then'
mutate "empty ROOT exempted" \
'if [[ -z "$(git diff-tree --root -r --name-only --no-commit-id HEAD)" ]]; then' \
'if false; then'
mutate "--since-head ancestry check removed" \
'if ! git merge-base --is-ancestor "$since_head" "$head"; then' 'if false; then'
# guard's own source text, matched verbatim. Expanding them here would search for
# this shell's (empty) $EX_CONFIG instead of the characters in the file.
mutate "staged-file enumeration ignores git failure" \
"$(printf 'if (( status != 0 )); then\n local msg')" \
"$(printf 'if false; then\n local msg')"
mutate "malformed config degrades to absent instead of refusing" \
"$(printf 'if (( status != 0 )); then\n fail "$EX_CONFIG"')" \
"$(printf 'if false; then\n fail "$EX_CONFIG"')"
}
run_mutants
printf '\nbaseline: %s\n' "$BASE_LINE"
printf '%d killed, %d survived\n' "$KILLED" "$SURVIVED"
printf '\n--- README TABLE (paste verbatim) ---\n'
printf '| mutation | suite result | verdict |\n|---|---|---|\n'
printf '| *unmodified* | %s | baseline |\n' "$(printf '%s' "$BASE_LINE" | sed 's/push-guard needles: //')"
printf '%s\n' "${ROWS[@]}"
exit "$rc_all"
+42 -58
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# pr-merge.sh - Merge pull requests on Gitea or GitHub
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d] [--skip-queue-guard]
# Usage: pr-merge.sh -n PR_NUMBER [-m squash] [-d]
set -euo pipefail
@@ -12,8 +12,8 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
MERGE_METHOD="squash"
DELETE_BRANCH=false
SKIP_QUEUE_GUARD=false
DRY_RUN=false
EXPECT_HEAD=""
usage() {
cat <<EOF
@@ -25,15 +25,14 @@ Options:
-n, --number NUMBER PR number to merge (required)
-m, --method METHOD Merge method: squash only (default: squash)
-d, --delete-branch Delete the head branch after merge
--skip-queue-guard Skip CI queue guard wait before merge
--dry-run Run metadata/login preflight without merging
--expect-head SHA Refuse unless the PR head matches this full commit SHA
-h, --help Show this help message
Examples:
$(basename "$0") -n 42 # Merge PR #42
$(basename "$0") -n 42 -m squash # Squash merge
$(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
EOF
exit "${1:-1}"
}
@@ -53,15 +52,14 @@ while [[ $# -gt 0 ]]; do
DELETE_BRANCH=true
shift
;;
--skip-queue-guard)
SKIP_QUEUE_GUARD=true
shift
;;
--dry-run)
DRY_RUN=true
SKIP_QUEUE_GUARD=true
shift
;;
--expect-head)
EXPECT_HEAD="$2"
shift 2
;;
-h|--help)
usage 0
;;
@@ -86,18 +84,36 @@ if [[ "$MERGE_METHOD" != "squash" ]]; then
echo "Error: Mosaic policy enforces squash merge only. Received '$MERGE_METHOD'." >&2
exit 1
fi
if [[ -n "$EXPECT_HEAD" && ! "$EXPECT_HEAD" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: --expect-head must be a full 40-character hexadecimal commit SHA." >&2
exit 1
fi
PR_METADATA="$("$SCRIPT_DIR/pr-metadata.sh" -n "$PR_NUMBER")"
BASE_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("baseRefName") or "").strip())')"
HEAD_BRANCH="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefName") or "").strip())')"
HEAD_SHA="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; print((json.load(sys.stdin).get("headRefOid") or "").strip())')"
HEAD_REPO="$(printf '%s' "$PR_METADATA" | python3 -c 'import json, sys; value=json.load(sys.stdin).get("headRepository") or ""; print((value.get("nameWithOwner") or value.get("full_name") or "") if isinstance(value, dict) else str(value).strip())')"
if [[ "$BASE_BRANCH" != "main" ]]; then
echo "Error: Mosaic policy allows merges only for PRs targeting 'main' (found '$BASE_BRANCH')." >&2
exit 1
fi
if [[ "$SKIP_QUEUE_GUARD" != true ]]; then
if [[ -z "$HEAD_BRANCH" || -z "$HEAD_REPO" || ! "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Error: Could not resolve the PR head branch, repository, and full commit SHA for queue inspection." >&2
exit 1
fi
if [[ -n "$EXPECT_HEAD" && "$HEAD_SHA" != "$EXPECT_HEAD" ]]; then
echo "Error: PR head moved: expected $EXPECT_HEAD, found $HEAD_SHA." >&2
exit 1
fi
if [[ "$DRY_RUN" != true ]]; then
"$SCRIPT_DIR/ci-queue-wait.sh" \
--purpose merge \
-B "$BASE_BRANCH" \
-B "$HEAD_BRANCH" \
-R "$HEAD_REPO" \
--sha "$HEAD_SHA" \
-t "${MOSAIC_CI_QUEUE_TIMEOUT_SEC:-900}" \
-i "${MOSAIC_CI_QUEUE_POLL_SEC:-15}"
fi
@@ -106,31 +122,22 @@ PLATFORM=$(detect_platform)
OWNER=$(get_repo_owner)
REPO=$(get_repo_name)
is_known_tea_empty_identity_failure() {
local error_file="$1"
python3 - "$error_file" <<'PY'
import re
import sys
with open(sys.argv[1], encoding="utf-8", errors="replace") as handle:
error = handle.read()
known_empty_identity = re.search(
r"user does not exist.*\[.*uid:\s*0,\s*name:\s*\]",
error,
flags=re.IGNORECASE | re.DOTALL,
)
raise SystemExit(0 if known_empty_identity else 1)
PY
}
merge_gitea_with_api() {
local host="$1" api_url token basic_auth body_file raw_code payload
api_url="https://${host}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR_NUMBER}/merge"
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
body_file=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-api-response.XXXXXX")
payload='{"Do":"squash"}'
payload=$(python3 - "$HEAD_SHA" "$DELETE_BRANCH" <<'PY'
import json
import sys
head_sha, delete_branch = sys.argv[1:]
payload = {"Do": "squash", "head_commit_id": head_sha}
if delete_branch == "true":
payload["delete_branch_after_merge"] = True
print(json.dumps(payload, separators=(",", ":")))
PY
)
token=$(get_gitea_token "$host" || true)
if [[ -n "$token" ]]; then
@@ -202,7 +209,7 @@ fi
case "$PLATFORM" in
github)
cmd=(gh pr merge "$PR_NUMBER" --squash)
cmd=(gh pr merge "$PR_NUMBER" --squash --match-head-commit "$HEAD_SHA")
[[ "$DELETE_BRANCH" == true ]] && cmd+=(--delete-branch)
"${cmd[@]}"
;;
@@ -211,32 +218,9 @@ case "$PLATFORM" in
echo "Error: Cannot determine host from origin remote URL" >&2
exit 1
}
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
if [[ -n "$TEA_LOGIN" ]]; then
mkdir -p "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}"
TEA_ERROR_FILE=$(mktemp "${AGENT_WORK_ROOT:-${HOME:-/tmp}/mosaic/agent-work}/pr-merge-tea-error.XXXXXX")
if tea pr merge "$PR_NUMBER" --style squash --repo "$OWNER/$REPO" --login "$TEA_LOGIN" 2> "$TEA_ERROR_FILE"; then
rm -f "$TEA_ERROR_FILE"
elif is_known_tea_empty_identity_failure "$TEA_ERROR_FILE"; then
cat "$TEA_ERROR_FILE" >&2
echo "Known tea empty identity failure detected; using authenticated Gitea API merge fallback." >&2
rm -f "$TEA_ERROR_FILE"
merge_gitea_with_api "$HOST"
else
cat "$TEA_ERROR_FILE" >&2
rm -f "$TEA_ERROR_FILE"
exit 1
fi
else
echo "No tea login configured for $HOST; using authenticated Gitea API merge fallback." >&2
merge_gitea_with_api "$HOST"
fi
# Delete branch after merge if requested
if [[ "$DELETE_BRANCH" == true ]]; then
echo "Note: Branch deletion after merge may need to be done separately with tea" >&2
fi
# Gitea's API head_commit_id is an atomic compare-and-merge precondition.
# tea cannot express it, so exact-head merges use the authenticated API path.
merge_gitea_with_api "$HOST"
;;
*)
echo "Error: Could not detect git platform" >&2
@@ -109,7 +109,7 @@ PY
detect_platform > /dev/null
if [[ "$PLATFORM" == "github" ]]; then
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
METADATA=$(gh pr view "$PR_NUMBER" --json number,title,body,state,author,headRefName,headRefOid,headRepository,baseRefName,files,labels,assignees,milestone,createdAt,updatedAt,url,isDraft)
write_metadata "$METADATA"
elif [[ "$PLATFORM" == "gitea" ]]; then
OWNER=$(get_repo_owner)
@@ -182,6 +182,25 @@ if isinstance(head_ref, str) and head_ref.startswith('refs/pull/'):
data.get('head_ref'),
head_ref,
)
head_sha = first_non_empty(
nested(data, 'head', 'sha'),
nested(data, 'head', 'id'),
data.get('head_sha'),
)
head_repo = first_non_empty(
nested(data, 'head', 'repo', 'full_name'),
nested(data, 'head', 'repo', 'name_with_owner'),
)
if not head_repo:
head_repo_owner = first_non_empty(
nested(data, 'head', 'repo', 'owner', 'login'),
nested(data, 'head', 'repo', 'owner', 'username'),
nested(data, 'head', 'repo', 'owner_name'),
)
head_repo_name = first_non_empty(nested(data, 'head', 'repo', 'name'))
if head_repo_owner and head_repo_name:
head_repo = f'{head_repo_owner}/{head_repo_name}'
base_ref = first_non_empty(
nested(data, 'base', 'ref'),
nested(data, 'base', 'name'),
@@ -207,6 +226,8 @@ normalized = {
'state': data.get('state'),
'author': nested(data, 'user', 'login') or '',
'headRefName': head_ref,
'headRefOid': head_sha,
'headRepository': head_repo,
'baseRefName': base_ref,
'labels': [l.get('name', '') for l in data.get('labels', []) if isinstance(l, dict)],
'assignees': [a.get('login', '') for a in data.get('assignees', []) if isinstance(a, dict)],
+611 -81
View File
@@ -1,6 +1,33 @@
#!/bin/bash
# pr-review.sh - Review a pull request on GitHub or Gitea
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]
# Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>] [-r owner/repo] [-H host]
#
# Gitea reviews and comments are written through the supported REST API, not
# `tea`: tea 0.11.1 cannot emit the id of a record it creates and can silently
# no-op while exiting 0 (#865 defect class), so an exit code is the only — and
# untrustworthy — signal it offers. approve/request-changes POST to
# /pulls/{n}/reviews (returns the created review with its id); the `comment`
# action POSTs to /issues/{n}/comments (returns the created comment with its
# id). Each write is then verified by GETting that exact returned id, so a
# concurrent record cannot masquerade as this write and a no-op fails closed.
#
# --login override: the default login is resolved from the local tea login list
# for this repo's host (get_gitea_login_for_host). Pass --login <name> to
# override it for this invocation only. The REST write, the /user identity read,
# and every read-back are ALL performed with the token of the EFFECTIVE login,
# so the write and its verification bind to the same identity.
#
# -r/--repo override: explicit owner/repo slug, skipping git-remote slug
# inference — mirrors the -r convention of the sibling wrappers (pr-view.sh,
# pr-diff.sh, pr-ci-wait.sh; mosaicstack/stack #867) for reviewer worktrees
# whose origin is nonstandard or missing. -H/--host makes the target Gitea
# instance explicit too (skips remote-host inference), so ambient CWD/remote
# state can no longer cross-wire the review to the wrong instance. With -r, the
# resolved repo is preflighted (GET .../repos/<slug>) BEFORE any write so a
# wrong-host cross-wire surfaces as a clear preflight error instead of an opaque
# write-404. Every Gitea curl (write, read-back, preflight) carries a
# `User-Agent: mosaic-pr-review` header, since some Cloudflare-fronted Gitea
# hosts intermittently reject curl's default User-Agent.
set -e
@@ -12,6 +39,9 @@ source "$SCRIPT_DIR/detect-platform.sh"
PR_NUMBER=""
ACTION=""
COMMENT=""
LOGIN_OVERRIDE=""
REPO_OVERRIDE=""
HOST_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case $1 in
@@ -27,13 +57,28 @@ while [[ $# -gt 0 ]]; do
COMMENT="$2"
shift 2
;;
-l|--login)
LOGIN_OVERRIDE="$2"
shift 2
;;
-r|--repo)
REPO_OVERRIDE="$2"
shift 2
;;
-H|--host)
HOST_OVERRIDE="$2"
shift 2
;;
-h|--help)
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>]"
echo "Usage: pr-review.sh -n <pr_number> -a <action> [-c <comment>] [--login <name>] [-r owner/repo] [-H host]"
echo ""
echo "Options:"
echo " -n, --number PR number (required)"
echo " -a, --action Review action: approve, request-changes, comment (required)"
echo " -c, --comment Review comment (required for request-changes)"
echo " -l, --login Override the detected Gitea tea login (approve/request-changes only)"
echo " -r, --repo Explicit owner/repo slug (skips git-remote slug inference)"
echo " -H, --host Explicit Gitea host (skips remote-host inference)"
echo " -h, --help Show this help"
exit 0
;;
@@ -54,96 +99,161 @@ if [[ -z "$ACTION" ]]; then
exit 1
fi
detect_platform >/dev/null
if [[ -n "$REPO_OVERRIDE" ]]; then
# An explicit --repo is the whole point of a reviewer worktree whose origin
# is nonstandard or missing (#867 convention, mirrored from pr-view.sh /
# pr-diff.sh): do not hard-fail platform detection on a missing/foreign
# origin — assume gitea, the only platform --repo/--host target.
detect_platform >/dev/null 2>&1 || PLATFORM="gitea"
else
detect_platform >/dev/null
fi
# Post a review comment body to a Gitea PR via the supported comments REST API
# and verify it durably via provider read-back (see docs on durable review
# provenance in README.md). Used by the `comment` action and, since `tea`
# v0.11.1 defines no `--comment`/`-comment` flag on `pr approve`/`pr reject`,
# also by the `approve` and `request-changes` actions to carry an optional
# review body that `tea` itself cannot attach.
# Render the provider's own explanation for a failed request, for appending to
# an error message (#1004). Every HTTP arm in this file already has the response
# body on disk; without this it was discarded unread at exactly the moment the
# caller needed it, which pushes an operator toward re-issuing the request by
# hand to find out what the server said. Gitea returns {"message": "..."} on a
# refusal; anything unparseable falls back to a truncated raw first line so a
# proxy's HTML error page still says something. Prints "" when there is nothing
# to add, so callers can interpolate unconditionally.
#
# Args: $1 = PR number, $2 = comment body
# On success: prints only the created comment ID to stdout, returns 0.
# On failure: prints an error to stderr, returns 1.
gitea_post_verified_comment() {
local pr_number="$1" comment_body="$2"
local host token configured_url repo api_base payload
local write_response_file readback_response_file comment_id
# Args: $1 = path to the response body file.
gitea_error_detail() {
local body_file="$1"
[[ -s "$body_file" ]] || return 0
python3 - "$body_file" <<'PY' 2>/dev/null || true
import json
import sys
LIMIT = 300
try:
with open(sys.argv[1], encoding="utf-8", errors="replace") as response:
raw = response.read().strip()
except OSError:
raise SystemExit(0)
if not raw:
raise SystemExit(0)
detail = ""
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
for key in ("message", "error", "errors"):
value = parsed.get(key)
if isinstance(value, str) and value.strip():
detail = value.strip()
break
if isinstance(value, list) and value:
detail = "; ".join(str(item) for item in value).strip()
break
except ValueError:
pass
if not detail:
detail = raw.splitlines()[0].strip()
if not detail:
raise SystemExit(0)
if len(detail) > LIMIT:
detail = detail[:LIMIT] + "..."
print(f" — provider said: {detail}")
PY
}
# Post a comment to a Gitea PR (PR comments ARE issue comments) via the
# supported REST API and verify it against a PROVIDER-RETURNED created id. The
# write is a direct POST that returns the created comment object, so we learn
# the exact id of THIS write; we GET that exact id and require id == created id
# AND author == acting identity AND exact body AND that it belongs to this PR.
# Keying to the returned id means no concurrent comment (even same identity /
# body) can masquerade as this write, and a no-op create yields no id and fails
# closed. Requires GITEA_API_BASE / GITEA_API_TOKEN to be resolved first (via
# gitea_resolve_api_for_login). Prints the created comment id on success.
#
# Args: $1 = PR number, $2 = comment body, $3 = acting identity login.
gitea_create_comment_verified() {
local pr_number="$1" comment_body="$2" acting_login="$3"
local payload write_file readback_file auth_config write_status readback_status created_id
host=$(get_remote_host)
token=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for comment persistence" >&2
return 1
}
configured_url=$(get_gitea_url_for_host "$host") || {
echo "Error: Configured Gitea URL not found for comment persistence" >&2
return 1
}
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
return 1
}
api_base="${configured_url%/}/api/v1/repos/$repo"
payload=$(COMMENT_BODY="$comment_body" python3 -c '
import json
import os
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
')
write_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
readback_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-readback.XXXXXX")
trap 'rm -f "$write_response_file" "$readback_response_file"' RETURN
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$write_file" "$readback_file"
echo "Error: could not stage Gitea credential for comment write" >&2
return 1
}
trap 'rm -f "$write_file" "$readback_file" "$auth_config"' RETURN
if ! write_status=$(curl -sS -o "$write_response_file" -w '%{http_code}' \
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
-X POST \
-H "Authorization: token $token" \
--config "$auth_config" \
-H 'User-Agent: mosaic-pr-review' \
-H 'Content-Type: application/json' \
-d "$payload" \
"$api_base/issues/$pr_number/comments"); then
"$GITEA_API_BASE/issues/$pr_number/comments"); then
echo "Error: Gitea comment write transport failed" >&2
return 1
fi
if [[ "$write_status" != "201" ]]; then
echo "Error: Gitea comment write failed with HTTP $write_status" >&2
echo "Error: Gitea comment write failed with HTTP $write_status$(gitea_error_detail "$write_file")" >&2
return 1
fi
comment_id=$(python3 - "$write_response_file" <<'PY'
created_id=$(python3 - "$write_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
comment = json.load(response)
comment_id = comment.get("id") if isinstance(comment, dict) else None
if not isinstance(comment_id, int) or comment_id <= 0:
raise ValueError("missing positive comment id")
created_id = comment.get("id") if isinstance(comment, dict) else None
if not isinstance(created_id, int) or created_id <= 0:
raise ValueError("create response carried no positive comment id")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
raise SystemExit(1)
print(comment_id)
print(created_id)
PY
) || return 1
if ! readback_status=$(curl -sS -o "$readback_response_file" -w '%{http_code}' \
-H "Authorization: token $token" \
"$api_base/issues/comments/$comment_id"); then
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
--config "$auth_config" \
-H 'User-Agent: mosaic-pr-review' \
"$GITEA_API_BASE/issues/comments/$created_id"); then
echo "Error: Gitea comment read-back transport failed" >&2
return 1
fi
if [[ "$readback_status" != "200" ]]; then
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
echo "Error: Gitea comment read-back failed with HTTP $readback_status$(gitea_error_detail "$readback_file")" >&2
return 1
fi
if EXPECTED_COMMENT_ID="$comment_id" EXPECTED_COMMENT_BODY="$comment_body" EXPECTED_REPO="$repo" EXPECTED_PR_NUMBER="$pr_number" \
python3 - "$readback_response_file" <<'PY'
EXPECTED_COMMENT_ID="$created_id" EXPECTED_COMMENT_BODY="$comment_body" \
ACTING_LOGIN="$acting_login" EXPECTED_REPO_SLUG="${GITEA_API_BASE##*/repos/}" \
EXPECTED_NUMBER="$pr_number" EXPECTED_WEB_BASE="$GITEA_WEB_BASE" \
python3 - "$readback_file" <<'PY' || return 1
import json
import os
import sys
from urllib.parse import urlparse
def _origin_and_path(url):
# Normalize a URL to (scheme, host, effective-port) + comment path. The port
# defaults to the scheme's default (80 http / 443 otherwise) so an implicit
# port and its explicit default form compare equal.
parsed = urlparse(url or "")
scheme = (parsed.scheme or "").lower()
host = (parsed.hostname or "").lower()
default_port = 80 if scheme == "http" else 443
port = parsed.port if parsed.port is not None else default_port
return (scheme, host, port), parsed.path.rstrip("/")
try:
with open(sys.argv[1], encoding="utf-8") as response:
comment = json.load(response)
@@ -151,27 +261,390 @@ try:
raise ValueError("response is not a comment object")
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
expected_repo = os.environ["EXPECTED_REPO"]
expected_pr = os.environ["EXPECTED_PR_NUMBER"]
issue_path = urlparse(comment.get("issue_url", "")).path.rstrip("/")
expected_suffix = f"/repos/{expected_repo}/issues/{expected_pr}"
acting_login = os.environ["ACTING_LOGIN"]
slug = os.environ["EXPECTED_REPO_SLUG"]
number = os.environ["EXPECTED_NUMBER"]
web_base = os.environ["EXPECTED_WEB_BASE"]
# Gitea populates WEB (html) URLs here, not API paths. A PR-conversation
# comment carries pull_request_url = <web_base>/<owner>/<repo>/pulls/<n> (with
# issue_url empty), while a plain issue comment carries
# issue_url = <web_base>/<owner>/<repo>/issues/<n> (with pull_request_url empty).
# This is the pr-review `comment` action, so the comment MUST land on a pull
# request: require pull_request_url. A plain issue_url is REJECTED — if issue
# #N exists but PR #N does not, POST /issues/N/comments creates an issue
# comment, and accepting that issue_url would let the wrapper falsely report a
# verified PR comment (issue-comment.sh legitimately keeps the broader
# issue-or-PR acceptance; a PR review does not).
# Pin the returned URL's ORIGIN (scheme+host+port) and its FULL path to this
# provider + repo + kind + number — an endswith/suffix test would accept a
# look-alike host (evil.example/deceptive/<slug>/pulls/N) or a same-host
# decoy prefix (/other/<slug>/pulls/N), so compare the whole thing.
base_origin, base_path = _origin_and_path(web_base)
expected_pr_path = f"{base_path}/{slug}/pulls/{number}"
def _belongs(url, expected_path):
if not url:
return False
origin, path = _origin_and_path(url)
# Repo owner/repo slugs are case-insensitive (Gitea canonicalizes the
# pull_request_url slug to lowercase on return), while EXPECTED_REPO_SLUG
# is taken verbatim from GITEA_API_BASE and may be mixed-case. The
# remainder of the path (".../pulls/<number>") is numeric, so lowercasing
# the whole path for this comparison only relaxes case, not identity: the
# origin tuple (scheme+host+port) above still pins the provider host, and
# the path is still compared in FULL (no endswith/suffix match), so the
# look-alike-host and same-host decoy-prefix protections are unchanged.
return origin == base_origin and path.lower() == expected_path.lower()
if comment.get("id") != expected_id:
raise ValueError("comment id mismatch")
raise ValueError("read-back id does not match the created id")
if (comment.get("user") or {}).get("login") != acting_login:
raise ValueError("created comment is not authored by the acting identity")
if comment.get("body") != expected_body:
raise ValueError("comment body mismatch")
if not issue_path.endswith(expected_suffix):
raise ValueError("repository or PR mismatch")
raise ValueError("created comment body does not match")
if not _belongs(comment.get("pull_request_url"), expected_pr_path):
raise ValueError("claimed PR comment did not land on a pull request (kind=pulls) on this provider/repo")
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
PY
then
true
echo "$created_id"
return 0
}
# Resolve and cache the Gitea REST endpoint + token for the current remote,
# bound to a SPECIFIC login identity ($1). Populates GITEA_API_ROOT (…/api/v1),
# GITEA_API_BASE (…/api/v1/repos/<slug>), and GITEA_API_TOKEN.
#
# The token is resolved for the EFFECTIVE login (the --login override when
# given, otherwise the detected default), so the one credential used to submit
# the review/comment ALSO drives the /user identity read and every read-back —
# write token and read-back token are the same identity by construction. This
# is the credential-ordering fix: a --login override is no longer submitted
# under one credential and verified under a different default one. Falls back to
# the host-scoped credential ONLY when NO --login override was supplied (the
# best-effort default path). When $2 is "explicit" the login came from a
# caller-supplied --login: that exact login's token MUST resolve, and we FAIL
# CLOSED rather than silently downgrading the review/comment to the host default
# identity. Returns non-zero (clear stderr) on any resolution failure.
#
# Honors the module-level REPO_OVERRIDE / HOST_OVERRIDE (-r/--repo, -H/--host):
# when set, they skip git-remote slug/host inference entirely — for reviewer
# worktrees whose origin is nonstandard or missing, and to make the target
# instance fully deterministic (an ambient CWD/remote can otherwise cross-wire
# a review to the wrong Gitea host). When -r/--repo is used, the resolved repo
# is preflighted (GET .../repos/<slug>) BEFORE any write: a wrong-host
# cross-wire would otherwise surface only as an opaque write-404 with zero
# residue.
gitea_resolve_api_for_login() {
local effective_login="$1" override_explicit="${2:-}" host configured_url repo
local preflight_auth_config preflight_status
if [[ -n "$HOST_OVERRIDE" ]]; then
host="$HOST_OVERRIDE"
else
host=$(get_remote_host)
fi
if [[ -n "$override_explicit" ]]; then
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") || {
echo "Error: could not resolve a host-matched Gitea token for --login '$effective_login' on host '$host'; refusing to fall back to the host default identity or a cross-host credential (review write/read-back)" >&2
return 1
}
else
GITEA_API_TOKEN=$(get_gitea_token_for_login "$effective_login" "$host") \
|| GITEA_API_TOKEN=$(get_gitea_token "$host") || {
echo "Error: Gitea token not found for login '$effective_login' (review write/read-back)" >&2
return 1
}
fi
configured_url=$(get_gitea_url_for_host "$host") || {
echo "Error: Configured Gitea URL not found for review read-back verification" >&2
return 1
}
if [[ -n "$REPO_OVERRIDE" ]]; then
repo="$REPO_OVERRIDE"
else
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
return 1
}
fi
GITEA_API_ROOT="${configured_url%/}/api/v1"
GITEA_API_BASE="$GITEA_API_ROOT/repos/$repo"
# The provider WEB base (scheme + host + effective port + any deployment path
# prefix) that Gitea uses to build a comment's html issue_url/pull_request_url.
# Read-back verification pins the returned URL's origin + path prefix to THIS,
# not just a repo/PR suffix.
GITEA_WEB_BASE="${configured_url%/}"
if [[ -n "$REPO_OVERRIDE" ]]; then
preflight_auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
echo "Error: could not stage Gitea credential for --repo preflight" >&2
return 1
}
preflight_status=$(curl -sS -o /dev/null -w '%{http_code}' \
--config "$preflight_auth_config" \
-H 'User-Agent: mosaic-pr-review' \
"$GITEA_API_BASE") || preflight_status="000"
rm -f "$preflight_auth_config"
if [[ "$preflight_status" != "200" ]]; then
echo "Error: repo '$repo' not reachable at $configured_url (HTTP $preflight_status) — wrong host? pass -H/--host <gitea-host> or cd into the target checkout" >&2
return 1
fi
fi
return 0
}
# Resolve the login of the identity the API token authenticates as (GET
# /user). Used to attribute a read-back review to THIS action's reviewer so a
# concurrent review from a DIFFERENT identity cannot satisfy verification.
# Prints the login on success.
gitea_authenticated_login() {
local response_file auth_config status
response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-whoami.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$response_file"
echo "Error: could not stage Gitea credential for identity read" >&2
return 1
}
trap 'rm -f "$response_file" "$auth_config"' RETURN
if ! status=$(curl -sS -o "$response_file" -w '%{http_code}' \
--config "$auth_config" \
-H 'User-Agent: mosaic-pr-review' \
"$GITEA_API_ROOT/user"); then
echo "Error: Gitea authenticated-identity read transport failed" >&2
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea authenticated-identity read failed with HTTP $status$(gitea_error_detail "$response_file")" >&2
return 1
fi
echo "$comment_id"
python3 - "$response_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
user = json.load(response)
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
raise ValueError("missing authenticated login")
except (OSError, json.JSONDecodeError, TypeError, ValueError) as error:
print(f"Error: could not resolve authenticated Gitea identity: {error}", file=sys.stderr)
raise SystemExit(1)
print(login)
PY
}
# GET /pulls/{n} into a caller-owned response file and print its head commit
# SHA. This core sets NO RETURN trap and reuses a caller-provided auth config +
# response file, so it is safe to call from INSIDE another trapped function
# (the post-verify re-read below) without clobbering that function's cleanup
# trap. $1 = PR number, $2 = response file, $3 = curl auth config file.
gitea_read_pr_head_into() {
local pr_number="$1" pr_file="$2" auth_config="$3" status
if ! status=$(curl -sS -o "$pr_file" -w '%{http_code}' \
--config "$auth_config" \
-H 'User-Agent: mosaic-pr-review' \
"$GITEA_API_BASE/pulls/$pr_number"); then
echo "Error: Gitea PR head read transport failed" >&2
return 1
fi
if [[ "$status" != "200" ]]; then
echo "Error: Gitea PR head read failed with HTTP $status$(gitea_error_detail "$pr_file")" >&2
return 1
fi
python3 - "$pr_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
pr = json.load(response)
head_sha = pr.get("head", {}).get("sha") if isinstance(pr, dict) else None
if not isinstance(head_sha, str) or not head_sha:
raise ValueError("missing PR head sha")
except (OSError, json.JSONDecodeError, AttributeError, TypeError, ValueError) as error:
print(f"Error: could not resolve PR head commit: {error}", file=sys.stderr)
raise SystemExit(1)
print(head_sha)
PY
}
# Resolve the PR's current head commit SHA (GET /pulls/{n}). The review is
# submitted against — and later verified as pinned to — this exact commit, so a
# stale review left over from an earlier push cannot be mistaken for this one.
# Prints the head SHA on success.
gitea_pr_head_sha() {
local pr_number="$1" pr_file auth_config
pr_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-head.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$pr_file"
echo "Error: could not stage Gitea credential for PR head read" >&2
return 1
}
trap 'rm -f "$pr_file" "$auth_config"' RETURN
gitea_read_pr_head_into "$pr_number" "$pr_file" "$auth_config"
}
# Submit a review to a Gitea PR via the supported REST API and verify it against
# a PROVIDER-RETURNED created id. tea 0.11.1's `pr approve`/`reject` cannot emit
# the id of the review it created and can silently no-op while exiting 0 (#865
# defect class), so this does NOT shell out to tea: it POSTs to
# /pulls/{n}/reviews with the event (APPROVED / REQUEST_CHANGES), the PR head
# commit_id, and the review body, which returns the created review object
# including its id. It then GETs that exact review id and requires
# id == created id AND author == acting identity AND state == expected AND
# commit_id == PR head. Keying to the returned id means no concurrent review
# (even same identity/state/head) can masquerade as this one, and a no-op
# submit yields no id and fails closed. Prints the created review id on success.
#
# Args: $1 = PR number, $2 = event (APPROVED|REQUEST_CHANGES),
# $3 = review body (may be empty for APPROVED), $4 = acting login,
# $5 = PR head sha.
gitea_submit_review_verified() {
local pr_number="$1" event="$2" review_body="$3" acting_login="$4" head_sha="$5"
local payload write_file readback_file recheck_file auth_config
local write_status readback_status created_id live_head
payload=$(REVIEW_EVENT="$event" REVIEW_BODY="$review_body" REVIEW_COMMIT="$head_sha" python3 -c '
import json
import os
print(json.dumps({
"event": os.environ["REVIEW_EVENT"],
"body": os.environ["REVIEW_BODY"],
"commit_id": os.environ["REVIEW_COMMIT"],
}))
')
write_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-submit.XXXXXX")
readback_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-getid.XXXXXX")
recheck_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-recheck.XXXXXX")
auth_config=$(gitea_write_auth_config "$GITEA_API_TOKEN") || {
rm -f "$write_file" "$readback_file" "$recheck_file"
echo "Error: could not stage Gitea credential for review submit" >&2
return 1
}
trap 'rm -f "$write_file" "$readback_file" "$recheck_file" "$auth_config"' RETURN
if ! write_status=$(curl -sS -o "$write_file" -w '%{http_code}' \
-X POST \
--config "$auth_config" \
-H 'User-Agent: mosaic-pr-review' \
-H 'Content-Type: application/json' \
-d "$payload" \
"$GITEA_API_BASE/pulls/$pr_number/reviews"); then
echo "Error: Gitea review submit transport failed" >&2
return 1
fi
# Gitea returns 200 (occasionally 201) with the created review object.
if [[ "$write_status" != "200" && "$write_status" != "201" ]]; then
echo "Error: Gitea review submit failed with HTTP $write_status$(gitea_error_detail "$write_file")" >&2
return 1
fi
created_id=$(python3 - "$write_file" <<'PY'
import json
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
review = json.load(response)
created_id = review.get("id") if isinstance(review, dict) else None
if not isinstance(created_id, int) or created_id <= 0:
raise ValueError("submit response carried no positive review id")
except (OSError, json.JSONDecodeError, ValueError) as error:
print(f"Error: could not identify created Gitea review: {error}", file=sys.stderr)
raise SystemExit(1)
print(created_id)
PY
) || return 1
if ! readback_status=$(curl -sS -o "$readback_file" -w '%{http_code}' \
--config "$auth_config" \
-H 'User-Agent: mosaic-pr-review' \
"$GITEA_API_BASE/pulls/$pr_number/reviews/$created_id"); then
echo "Error: Gitea review read-back transport failed" >&2
return 1
fi
if [[ "$readback_status" != "200" ]]; then
echo "Error: Gitea review read-back failed with HTTP $readback_status$(gitea_error_detail "$readback_file")" >&2
return 1
fi
EXPECTED_REVIEW_ID="$created_id" EXPECTED_STATE="$event" ACTING_LOGIN="$acting_login" \
EXPECTED_HEAD_SHA="$head_sha" EXPECTED_REVIEW_BODY="$review_body" \
python3 - "$readback_file" <<'PY' || return 1
import json
import os
import sys
try:
with open(sys.argv[1], encoding="utf-8") as response:
review = json.load(response)
if not isinstance(review, dict):
raise ValueError("response is not a review object")
expected_id = int(os.environ["EXPECTED_REVIEW_ID"])
expected_state = os.environ["EXPECTED_STATE"]
acting_login = os.environ["ACTING_LOGIN"]
expected_head = os.environ["EXPECTED_HEAD_SHA"]
expected_body = os.environ["EXPECTED_REVIEW_BODY"]
if review.get("id") != expected_id:
raise ValueError("read-back id does not match the created id")
if (review.get("user") or {}).get("login") != acting_login:
raise ValueError("created review is not authored by the acting identity")
if review.get("state") != expected_state:
raise ValueError("created review is not in the expected state")
if review.get("commit_id") != expected_head:
raise ValueError("created review is not pinned to the PR head commit")
# Bind to the exact submitted body. On Gitea v1.25.4 SubmitReview may
# finalize/reuse a pending review id whose Content was authored elsewhere;
# the exact GET exposes the persisted body, so a mismatch (a reused/foreign
# review carrying different Content) fails closed even when id/author/state/
# head all line up. Require presence + string TYPE + exact equality rather
# than `(body or "")`: the old coalesce treated a missing/null persisted body
# as equal to an empty submitted one, so a non-empty submitted body that
# persisted as null (a suppressed/lost body) would have passed. When a
# non-empty body was submitted the persisted value MUST be that exact string;
# when an empty body was submitted the persisted value must be empty or
# absent (a non-empty persisted body is likewise a divergence — vice-versa).
persisted_body = review.get("body")
if expected_body == "":
if persisted_body not in (None, ""):
raise ValueError("created review carries a body but none was submitted")
elif not isinstance(persisted_body, str) or persisted_body != expected_body:
raise ValueError("created review body does not match the submitted body")
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
print(f"Error: Gitea review persistence verification failed: {error}", file=sys.stderr)
raise SystemExit(1)
PY
# Current-head TOCTOU close-out: the review verified above is pinned to
# head_sha, but that head was read BEFORE the submit. Between then and now
# the PR branch may have advanced (a force-push or a new commit), which would
# leave this verified review attached to a now-superseded commit while the
# live tip carries unreviewed code — yet the wrapper would still report
# success. Re-read the LIVE PR head and require it STILL equals the submitted
# SHA; if it advanced, fail closed (nonzero, no created id emitted, no
# success line). This reuses the submit-scoped auth config + recheck file so
# it neither leaks the token to argv nor clobbers this function's cleanup.
live_head=$(gitea_read_pr_head_into "$pr_number" "$recheck_file" "$auth_config") || {
echo "Error: could not re-read Gitea PR head after review verification" >&2
return 1
}
if [[ "$live_head" != "$head_sha" ]]; then
echo "Error: Gitea PR head advanced from $head_sha to $live_head between review submit and verification; refusing to report a review pinned to a superseded commit (#865 current-head TOCTOU)" >&2
return 1
fi
echo "$created_id"
return 0
}
@@ -205,40 +678,97 @@ if [[ "$PLATFORM" == "github" ]]; then
elif [[ "$PLATFORM" == "gitea" ]]; then
case $ACTION in
approve)
repo=$(get_repo_slug)
host=$(get_remote_host)
login=$(get_gitea_login_for_host "$host")
# tea v0.11.1 defines no --comment/-comment flag on `pr approve`;
# route any review body via the durable comment API instead (#835).
tea pr approve "$PR_NUMBER" --repo "$repo" --login "$login"
echo "Approved Gitea PR #$PR_NUMBER"
if [[ -n "$COMMENT" ]]; then
comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1
echo "Added and verified review comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
fi
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
# under `set -e`, with no origin and no -H, previously killed the script
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
# Bind the REST endpoint + token to the effective login, then derive
# the acting identity from that SAME credential so the review submit
# and its read-back verify against the identity that performed them.
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
# The review body (if any) travels with the review itself in the REST
# submit — the created review record carries it — so there is no
# separate detached comment to reconcile.
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "APPROVED" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
echo "Error: could not submit and verify an APPROVED review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
exit 1
}
echo "Approved and verified Gitea PR #$PR_NUMBER (review ID $review_id)"
;;
request-changes)
if [[ -z "$COMMENT" ]]; then
echo "Error: Comment required for request-changes"
exit 1
fi
repo=$(get_repo_slug)
host=$(get_remote_host)
login=$(get_gitea_login_for_host "$host")
# tea v0.11.1 defines no --comment/-comment flag on `pr reject`;
# route the review body via the durable comment API instead (#835).
tea pr reject "$PR_NUMBER" --repo "$repo" --login "$login"
echo "Requested changes on Gitea PR #$PR_NUMBER"
comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1
echo "Added and verified review comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
# under `set -e`, with no origin and no -H, previously killed the script
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
head_sha=$(gitea_pr_head_sha "$PR_NUMBER") || exit 1
review_id=$(gitea_submit_review_verified "$PR_NUMBER" "REQUEST_CHANGES" "$COMMENT" "$ACTING_LOGIN" "$head_sha") || {
echo "Error: could not submit and verify a REQUEST_CHANGES review on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
exit 1
}
echo "Requested changes and verified on Gitea PR #$PR_NUMBER (review ID $review_id)"
;;
comment)
if [[ -z "$COMMENT" ]]; then
echo "Error: Comment required"
exit 1
fi
comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1
# Best-effort host for the tea-login GUESS only (gitea_resolve_api_for_login
# below re-derives the real host from HOST_OVERRIDE/remote independently and
# is authoritative). Prefer an explicit -H/--host; otherwise best-effort
# git-remote inference, tolerating its ABSENCE (a bare `get_remote_host` here
# under `set -e`, with no origin and no -H, previously killed the script
# SILENTLY — exit 1, zero output — even though -r/-H are exactly the flags
# that support running with no usable origin at all).
host="${HOST_OVERRIDE:-$(get_remote_host 2>/dev/null || true)}"
# A --login override always wins. Otherwise name this host's login
# only as a best effort: the login name merely selects a per-login
# token, and gitea_resolve_api_for_login falls back to the host
# credential (get_gitea_token) when no tea login is named — so a host
# tea's login list need not enumerate exotic (e.g. ported) hosts for
# the default credential to resolve. The single resolved token is
# then used for the write, the /user identity, and the read-back.
EFFECTIVE_LOGIN="$LOGIN_OVERRIDE"
[[ -n "$EFFECTIVE_LOGIN" ]] || EFFECTIVE_LOGIN=$(get_gitea_login_for_host "$host" 2>/dev/null || true)
gitea_resolve_api_for_login "$EFFECTIVE_LOGIN" "${LOGIN_OVERRIDE:+explicit}" || exit 1
ACTING_LOGIN=$(gitea_authenticated_login) || exit 1
comment_id=$(gitea_create_comment_verified "$PR_NUMBER" "$COMMENT" "$ACTING_LOGIN") || {
echo "Error: could not create and verify a comment on Gitea PR #$PR_NUMBER via a provider-returned created id (#865)." >&2
exit 1
}
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
;;
*)
@@ -0,0 +1,204 @@
# push-guard
Mechanical closure of one defect: **a verification that passes when the thing it verifies never happened.**
Three incidents in one evening, three agents, no coordination, same shape:
| # | Incident | Why the check passed |
| --- | ----------------------------------------------------------------- | ------------------------------------------------------------------- |
| 1 | `PUSH VERIFIED` reported after nothing was pushed | Commit had aborted, so local HEAD trivially equalled the remote ref |
| 2 | An **empty commit** carrying another commit's message was pushed | A push was chained after a _failed_ commit and ran on stale state |
| 3 | Conflict markers + invalid JSON committed into 70 generated files | Nothing asserted the generated output still parsed |
Each is an assertion satisfied by the null case. `push-guard.sh` asserts the **positive** fact instead: a new object exists, the remote _moved_, the content _parses_.
## Checks
| Sub-command | Asserts | Exit on failure |
| -------------- | -------------------------------------------- | --------------- |
| `check-staged` | index has no unmerged paths | `2` |
| | no conflict markers in staged content | `2` |
| | the conflict scan actually _completed_ | `2` |
| | staged JSON under the nominated paths parses | `3` |
| | **something is actually staged** | `5` |
| | **an explicit JSON decision exists** | `6` |
| `push` | HEAD advanced past `--since-head` | `5` |
| | HEAD is a non-empty commit | `5` |
| | remote **moved**, and now equals local HEAD | `4` |
## Usage
```bash
push-guard.sh check-staged --json-path 'data/**/*.json'
BEFORE=$(git rev-parse HEAD)
git commit -m "..."
push-guard.sh push --remote origin --branch main --since-head "$BEFORE"
```
`--since-head` is what distinguishes "committed nothing" from "committed something", and capturing the remote ref _before_ pushing is what makes `remote == local` mean anything. Both were missing from the guard that produced incident 1.
## Design decisions that measurement forced
These are the interesting part; each one was wrong in the first draft.
**A bare `=======` is deliberately NOT treated as a conflict marker.** It collides with reStructuredText underlines and ASCII rules. Every genuine conflict git writes also contains `<<<<<<<` and `>>>>>>>`, so requiring those loses no real detection. Measured against a real repository: **zero** false positives across the entire tracked tree.
**The JSON check is opt-in by path, not on-by-default.** The first draft checked every staged `*.json`, on the reasoning that broader is strictly stronger. Measured against the same repository: **17 of 119** tracked `.json` files fail a strict parse, _all legitimately_ — every `tsconfig*.json` is JSONC (comments are legal there) and the CA templates are Go templates that merely carry a `.json` suffix. An on-by-default check fires on ~14% of the repo's JSON, and a guard that cries wolf gets routed around until the bypass is habitual — at which point the bypass covers the true positives too. Broader was **weaker**.
**`--json-path` values are normalized to `:(glob)` magic.** Git's default pathspec matching makes `data/**/*.json` require at least one intermediate directory: it matches `data/sub/b.json` and _silently skips_ `data/a.json`. The obvious spelling would have delivered partial coverage with no warning.
## Found by independent review, after the needles were already green
An adversarial review (author ≠ reviewer) found two genuine fail-opens that 17 self-written needles, five mutation runs and a repo-wide false-positive measurement had all missed. Both were reproduced before being fixed, and both now have needles.
**Renamed files were invisible to every content check.** Git detects renames by default (`diff.renames=true` since 2.9), so `git mv` plus a small edit is reported as a single `R` entry — which `--diff-filter=ACM` does not match. Reproduced at 97% similarity: the guard printed `staged content OK` and exited 0 with conflict markers in the staged blob. Fixed with `--no-renames`.
There is a trap inside this one worth recording. With _only_ the renamed file staged, the guard exits 5 — the nothing-staged check fires because the file list came back empty. That looks like a catch and is pure accident; co-stage one ordinary file and the fail-open is total. The needle deliberately co-stages a clean file so it cannot pass for the wrong reason.
**`-I` skipped files marked binary in `.gitattributes`,** while the summary still counted them as scanned — a clean pass _and_ a false count. Fixed with `-a`.
The review also correctly identified one **vacuous control**: the positive `--since-head` case asserted only exit 0, which the push produces anyway, so deleting the entire `--since-head` block left it passing. It now asserts on output.
Two reported findings did **not** reproduce and were not acted on: `:(glob)` does still match a bare directory pathspec, and my first rename repro failed only because the edit dropped similarity below the detection threshold — a badly built test, not an absent bug. Re-testing properly is what confirmed it.
## The JSON decision is mandatory (`.push-guard.json`)
The first release announced `JSON check NOT REQUESTED` and continued. That was _visible_ rather than silent, which is better — but it is still an **absence**, and an absence is not reviewable. Nobody reads a line that has printed correctly ten thousand times. A repo that needed the check and never wired it up stayed unprotected forever, and nothing ever failed.
The decision is now required, from one of exactly two places:
```jsonc
// nominate generated JSON for parse-checking
{"json_paths": ["data/**/*.json"], "allow_invalid_json": ["fixtures/*"]}
// or opt out — the reason is REQUIRED and is printed on every run
{"json_check": "none", "reason": "no generated JSON in this repo"}
```
`--json-path` on the command line also satisfies it. Saying nothing is refused (exit `6`).
**The asymmetry is deliberate.** Turning the check _on_ is safe from anywhere, so a CLI flag suffices. Turning it _off_ is confined to a committed file, because that is the only form a human can review: you can read a reason in a diff, and you cannot review the fact that nobody typed a flag. An opt-out living in an ad-hoc command line is the old fail-open with extra steps.
A **malformed** config is refused outright rather than treated as absent — that fallback would mean a typo silently disables the check the file was written to enable. A config that parses but _states nothing_ (`{}`) is refused too: valid JSON that says nothing is the original defect wearing a config file as a disguise.
**Migration is a hard cutover, on purpose.** The tempting path is "warn for one release, then enforce" — but that warning phase _is_ the degrade-to-a-warning this tool forbids, and it leaves the fail-open open for exactly as long as the warning is ignored, which is indefinitely. Consumers add a config or the guard refuses. It breaks loudly, once. Two pre-existing cases in this repo's own harness were the first to pay that cost, which is the correct place to feel it.
## Known limitations — stated, not hidden
- `check-staged` inspects the index. Content added to the working tree _after_ it runs is not covered — it belongs in a `pre-commit` hook, where the window is smallest.
- `push` hardcodes `HEAD:refs/heads/$branch`, so it cannot verify a commit built via plumbing on a different base. A `--sha` option would close this; it is deliberately not added without a needle.
## Test harness
`test-push-guard.sh` — 46 cases, each check in **both polarities**:
- **NEEDLE** — a deliberately broken fixture that must trip the guard.
- **CONTROL** — a clean fixture that must pass.
Controls are not decoration. A guard that failed unconditionally would satisfy every needle and look fully covered. Several controls additionally assert on the guard's _output_ (`--out`), because exit 0 cannot distinguish "checked the files and they were fine" from "matched no files and had nothing to check" — two controls in an earlier revision were passing vacuously for exactly that reason.
### Mutation results — the harness was itself tested by breaking the guard
**Everything in this section is regenerated by `./mutate-push-guard.sh`, not typed.** The previous version of this table was hand-maintained: it was true when written, went stale as the suite grew, and ended up asserting `unmodified | 32/32 pass` against a 46-case suite. A README is what a reader consults when the tool misbehaves, so a confidently-wrong one is worse than none. Re-run the script and paste; do not edit the numbers.
| mutation | suite result | verdict |
| ---------------------------------------------------------------- | ------------------- | -------- |
| _unmodified_ | 46 passed, 0 failed | baseline |
| `json decision requirement bypassed` (L482) | 3/43 fail | killed |
| `opt-out accepted with no written reason` (L334) | 1/43 fail | killed |
| `committed re-read of the opt-out skipped` (L405) | 5/43 fail | killed |
| `untracked config honoured as an opt-out` (L409) | 1/43 fail | killed |
| `staged-but-uncommitted opt-out honoured` (L425) | 1/43 fail | killed |
| `committed SYMLINK config honoured` (L433) | 1/43 fail | killed |
| `unparseable committed config ignored` (L444) | 1/43 fail | killed |
| `local-only opt-out (HEAD says ON) honoured` (L459) | 1/43 fail | killed |
| `empty MERGE exempted` (L704) | 1/43 fail | killed |
| `empty ROOT exempted` (L715) | 1/43 fail | killed |
| `--since-head ancestry check removed` (L665) | 1/43 fail | killed |
| `staged-file enumeration ignores git failure` (L173) | 1/43 fail | killed |
| `malformed config degrades to absent instead of refusing` (L375) | 4/43 fail | killed |
13 mutants, 0 survived.
**Why the denominator is 43 while the suite reports 46.** The generator attributes kills only to cases it can parse by name, which is the `PASS [KIND] <name> (exit N)` form. Of the 46 passing assertions, 45 print `PASS` and 43 of those match that form. The three excluded lines are:
```
PASS [CONTROL] guard runs without emitting any interpreter warning
PASS [NEEDLE ] the warning detector fires on a known warning string
ok [e9-fixture ] fixture is a merge (3 fields) with tree identical to both parents
```
The two `z1` warning assertions assert on a whole _class_ of output rather than an exit code, so they carry no `(exit N)`; `e9-fixture` is a fixture precondition and prints in the `ok` form. All three still run and still gate the suite — they are excluded from _attribution_, not from _execution_.
An earlier revision of this paragraph named the excluded set as `w2-fixture`, `e9-fixture` and `g1-fixture`. **That was written from memory instead of from the output, and two of the three names were wrong:** `w2-fixture` belongs to `test-verify-clean-clone.sh` and `g1-fixture` to `test-mutate-push-guard.sh`, so neither runs in this suite at all and neither could contribute to its tally. A reader auditing the denominator would have gone looking for them in the wrong files. It is recorded rather than quietly corrected because a confidently-wrong provenance inside the section that exists to make a generated number auditable is the same defect as everything else on this page: **a claim that reads as measured and is not.** The set above was produced by running the suite and applying the generator's own parser to its output.
Earlier mutants, run at the suite size of the day and kept as history rather than as a live claim: fail-open (9/16), always-fail (16/16 — controls catch it), revert `:(glob)` normalization (3/16, caught **only** by the `--out` assertions), drop the remote-did-not-move assertion (1/16), restore blanket `|| true` on the scan (1/17), revert `--no-renames` (1/19), revert `-a` to `-I` (1/19), delete the `--since-head` block (2/19, incl. the control that _was_ vacuous), stray-warning emission (1/32).
A guard nobody has watched fail is not a guard. The same applies to the harness: the `:(glob)` mutant would have passed silently without the output assertions, so the assertions are load-bearing rather than ornamental.
### Two branches were uncovered, and writing this table is what found them
Building the generator turned up two mutants that survived a **46-case suite reporting 46/46 green**: `staged-but-uncommitted opt-out honoured` and `unparseable committed config ignored`. Neither branch had any case at all. Both are now needled, which is why they read _killed_ above.
Both survivors share a shape worth naming: the mutant still exits `6`, because control falls through to a _sibling_ refusal that rejects for a different reason. **An exit-code-only assertion would have been satisfied by the wrong branch.** The new cases anchor on the distinguishing clause instead.
The same audit found a live instance of that defect already in the suite. Three separate branches print the headline `OPT-OUT IS NOT REVIEWABLE` — untracked, staged-not-committed, and symlink — and the untracked needle was anchored on the shared headline. Delete the untracked branch and control reaches a sibling printing those same words, so **the needle would not fail; it would re-point.** A substring anchor does not fail when its subject is removed. It is now anchored on `is not tracked in git`.
### The generator refuses three ways a mutation run can lie
1. **The anchor no longer matches.** The mutant is never applied, the suite is green, and a naive report says _survived_ — the same word a real coverage gap gets. `ANCHOR MISSING` is a loud failure instead.
2. **The anchor matches prose.** This one landed on the first run: a mutant aimed at the refuse-on-missing-config branch matched inside the `usage()` heredoc, edited a help string, changed no behaviour, and duly reported _survived_. A documentation edit was one step from being recorded as an uncovered branch. **A mutation that cannot change behaviour is not a surviving mutant, it is a non-measurement** — and a non-measurement reported as a result is the same defect as the vacuous test the harness exists to hunt. Anchors resolving inside `usage()` are now refused, and the heredoc's bounds are located at runtime rather than hardcoded.
3. **The anchor is ambiguous.** Two unrelated branches here are both the single line `if (( status != 0 )); then`; a first-match replace would silently attribute the kill to whichever came first. Multi-line anchors are supported and a match count `!= 1` refuses rather than guesses.
**A blanket `|| true` on the scan was a fail-open in the guard's own error handling.** `git grep` exits `1` for "no match" but `>=2` for a real failure. `|| true` collapsed the two, so a malformed pathspec or unreadable index would have been reported as "ok, no conflict markers" — a clean pass from a scan that never ran. The status is now discriminated, and a PATH-shim fault-injection needle proves the refusal.
**A `<<'PY'` heredoc inside `$( )` made bash print a warning on every run — and 30 needles plus a clean linter all missed it.** The config parser started life as an inline heredoc inside a command substitution, so every invocation emitted `warning: command substitution: 1 unterminated here-document` to stderr. It executed correctly, every needle stayed green, and shellcheck reported nothing. The harness missed it because `expect` asserts _substrings it was told to look for_ — and nobody tells you to look for output you did not know existed. It surfaced only when the guard was run against a real repository.
The fix moves the parser to a top-level constant. The lesson is encoded as case `z1`, which asserts the **absence of a whole output class** rather than the presence of an expected string, and is paired with a needle proving the detector can fire. A tool built to refuse quiet failures was quietly polluting stderr for its entire existence.
**The `-E` flag on `git grep` is load-bearing and was caught by a needle, not by review.** `git grep` defaults to _basic_ regex, in which `(`, `|` and `{7}` are literal characters. Without `-E` the patterns match nothing and the check reports a clean pass over a file full of conflict markers — the exact defect this tool exists to prevent, shipped inside the tool itself.
### Then the review turned on the harness, and found three more
A second independent review ran everything from a fresh clone and reproduced three defects — **all of them in the tools written to prevent defects.** None was in `push-guard.sh`.
**1. The clean-clone verifier could not verify the tree that ships it.** `verify-clean-clone.sh` resolved the repository top level correctly but then passed **bare basenames** to `git ls-tree`, which is a root-relative pathspec. These files really live at `packages/mosaic/framework/tools/git/`, so in place every artifact came back `NOT TRACKED at HEAD` and the verifier exited 1 without ever running. The tool built to stop packaging false-greens was unusable against its own packaging.
Its suite could not see it, because **every fixture installed the artifacts at the fixture repository root.** 6/6 green proved flat-layout operation and said nothing about the deployed path. That is the third time on this tool that a control validated a _model_ instead of the _subject_: v1 of the verifier measured a `cp`'d scratch repo, and then v2's own tests measured a layout that does not exist. **A fixture is a claim about the world; an untested fixture is an unreviewed one.** The committed prefix now comes from `git rev-parse --show-prefix` and is threaded through `ls-tree`, the cloned `stat`, and the suite's working directory; `w6-nested` builds the real nested layout, `w6-prefix` asserts the verifier _reports_ that prefix (so a green `w6` cannot mean the prefix was harmlessly ignored), and `w7-nested-mode` proves the mode needle still bites down there.
**2. Any pre-existing suite failure satisfied every mutant.** The generator called a mutant `KILLED` whenever `failed > 0`, and never required a green baseline. Inject one always-failing case that changes no guard behaviour whatsoever and the run reports _13 killed, 0 survived_, emits the table above, and exits 0. **A tally is not evidence; a named delta is.** The generator now refuses outright unless the unmodified baseline is exit-0 with zero failures — and emits no table when it refuses — then scores each mutant by the _named cases_ that stopped passing, printing the first one (`by: <case>`) beside every kill.
**3. An interrupted run stranded a mutated `push-guard.sh` in the reviewed tree.** Restoration leaned on an `EXIT` trap. **A trap is cleanup, not isolation, and SIGKILL cannot run it.** It happened to the reviewer twice and contaminated the following suite run until the clone was discarded. Isolation is now by construction: the guard and suite are `install`ed into a temp dir and every mutation is applied to _that_ copy, so the reviewed file is never opened for writing at all.
Note the deliberate asymmetry with `verify-clean-clone.sh`, which forbids `cp` anywhere in the file. The rule is not "never copy" — it is **know whether the copy preserves the property you are about to measure.** `cp` launders mode, so the verifier must not copy; mutation is destructive by design, so the generator must.
`test-mutate-push-guard.sh` (8 cases) now covers all three: `g1-*` proves a red baseline is refused with no table, `g2-*` is the positive control plus an assertion that kills are attributed by name, and `g3-*` kills the generator mid-mutation and asserts the subject is byte-identical afterwards.
That last one was **vacuous on its first attempt.** `timeout -s KILL 3` looked convincing and proved nothing: at three seconds the generator is still running its baseline, so no mutation has been applied and the subject is trivially unchanged — for the _unfixed_ in-place generator too, which I confirmed by rebuilding it and running it. The kill is now driven from inside the run (the fixture's suite counts its own invocations and kills the generator on the second, when mutant #1 is applied), and `g3-needle-bites` puts the reconstructed pre-fix mechanism through the identical kill to prove it _does_ strand a mutated file. A control written to close a blocker was itself a member of the vacuous family.
Two smaller things fell out of building those cases, both worth recording because both read as the opposite of what they were:
- **`grep -q` under `pipefail` turns a successful match into a failed assertion.** `grep -q` exits at the first match, the producer dies of SIGPIPE, and `pipefail` reports 141. This cost a red `w6-prefix` against a verifier that was printing the right prefix all along. Capture into a variable and test the variable.
- **`$PPID` inside `$( )` is the subshell, not the caller.** Killing it merely ends the command substitution; the parent carries on and exits 0. The fixture uses `kill -9 0` (the process group) with the generator launched under `setsid --wait`.
**Linting is measured at default severity, and the earlier claim was not.** "shellcheck clean on all five" was published on the strength of `shellcheck -S warning`, which exited 0 — while the default severity exited 1 with twelve `SC2016` findings. A filtered measurement reported as an unfiltered claim is the same shape as everything else on this page. Those literals genuinely must not expand, so `run_mutants()` carries one scoped, documented `SC2016` suppression; all six files are now clean at **default** severity. (A documented "this literal is intentionally unexpanded" is a different thing from a comment asserting a safety property nobody rechecks.)
**Not independently reproduced here:** blocker 1's original repro ran against the real PR checkout, and this session has no credential for that remote. The `w6`/`w7` fixtures replicate the layout at the exact deployed prefix instead, which is a reconstruction, not the original observation. Stated rather than glossed.
## Proposed framework path
```
framework/tools/git/push-guard.sh # the guard
framework/tools/git/test-push-guard.sh # 46 needles and controls
framework/tools/git/mutate-push-guard.sh # regenerates the mutation table above
framework/tools/git/test-mutate-push-guard.sh # 8 needles for the generator
framework/tools/git/verify-clean-clone.sh # proves the COMMITTED artifact runs
framework/tools/git/test-verify-clean-clone.sh # 9 needles for the verifier
```
Matches the existing `tools/git/test-*.sh` convention. Dependencies: bash 4.4+, git, python3 — `python3` is already an accepted dependency of `ci-queue-wait.sh`.
**All six must be committed mode `100755`.** They were once delivered `100644`, so a clone exited `126 Permission denied` for everyone who was not the author; `verify-clean-clone.sh` exists to make that unshippable and asserts the mode from `git ls-tree` of the source commit, never from the filesystem.
Operator-agnostic: no hostnames, credentials, remotes, or operator-specific paths. Clean under the framework-PR firewall.
+793
View File
@@ -0,0 +1,793 @@
#!/usr/bin/env bash
# push-guard.sh - Mechanical guards against verifications that PASS when the
# thing they verify never happened.
#
# WHY THIS EXISTS
# ---------------
# Three independent incidents, one shape:
# 1. An agent's "PUSH VERIFIED" step compared local HEAD to the remote ref and
# reported success. The commit had ABORTED, so local trivially equalled
# remote. The guard could not distinguish "pushed" from "committed nothing".
# 2. An agent chained a push after a FAILED commit, ran on stale state, and
# pushed an EMPTY commit carrying an unrelated commit's message.
# 3. An agent committed unresolved conflict markers into 70 generated files and
# pushed invalid JSON to a default branch.
#
# All three are the same defect: a check whose success condition is satisfied by
# the null case. This script asserts the POSITIVE fact (a new object exists, the
# remote MOVED, the content parses) rather than the absence of an error.
#
# DESIGN RULES (do not relax these)
# * Every check exits NON-ZERO and names what failed. There is deliberately no
# --warn-only / --soft flag: a guard that can degrade to a warning is the
# fail-open we are trying to remove.
# * Checks are fail-CLOSED. `set -euo pipefail` means an unexpected git or
# network error aborts non-zero rather than falling through to "OK".
# * Nothing is skipped silently. A check with no applicable inputs says so on
# stdout instead of contributing a quiet green.
#
# EXIT CODES
# 0 all requested checks passed
# 2 conflict markers present, or the index has unmerged paths
# 3 staged JSON does not parse
# 4 push verification failed (remote did not move, or moved elsewhere)
# 5 nothing staged / empty commit — the null case, refused
# 6 NO JSON DECISION — no --json-path and no usable .push-guard.json
# 64 usage error
#
# Dependencies: bash 4.4+, git, python3.
set -euo pipefail
readonly EX_OK=0
readonly EX_CONFLICT=2
readonly EX_JSON=3
readonly EX_PUSH=4
readonly EX_NULL=5
readonly EX_CONFIG=6
readonly EX_USAGE=64
# Repo-level configuration. Its ABSENCE is refused, not defaulted — see
# resolve_json_config() for why.
readonly CONFIG_FILE=".push-guard.json"
# Conflict-marker detection.
#
# We match `<<<<<<<`, `>>>>>>>` and the diff3 `|||||||` marker, each anchored at
# column 0 and required to be followed by a space or end-of-line (real markers
# are exactly seven characters plus an optional ref label).
#
# We deliberately do NOT match a bare `=======` line. It is the one marker that
# collides with ordinary prose — reStructuredText section underlines and ASCII
# rules use it constantly — and a guard that fires on documentation gets switched
# off, which costs more than the miss. Every genuine conflict git writes contains
# `<<<<<<<` and `>>>>>>>` as well, so requiring those loses no real detection.
readonly MARKER_PATTERNS=(
-e '^<<<<<<<( |$)'
-e '^>>>>>>>( |$)'
-e '^[|]{7}( |$)'
)
log() { printf '%s\n' "$*"; }
fail() {
local code="$1"; shift
printf '\n' >&2
printf 'PUSH-GUARD FAILED: %s\n' "$1" >&2
shift
local line
for line in "$@"; do printf ' %s\n' "$line" >&2; done
printf '\n' >&2
exit "$code"
}
usage() {
cat <<'EOF'
Usage:
push-guard.sh check-staged [--json-path <pathspec>]...
[--allow-invalid-json <pathspec>]...
push-guard.sh push --remote <remote> --branch <branch> [--since-head <sha>]
[-- <extra git push args>...]
push-guard.sh --help
Subcommands:
check-staged Run pre-commit content guards against the STAGED index:
(a) no unmerged index paths, no conflict markers
(c) staged JSON under the nominated paths parses
Refuses to pass when nothing is staged (exit 5).
Check (c) requires an EXPLICIT decision — either --json-path,
or .push-guard.json. Saying nothing is refused (exit 6).
push Perform a push and prove it HAPPENED:
- HEAD is a non-empty commit (differs from its parent)
- with --since-head: HEAD advanced past that sha
- the remote ref MOVED, and now equals local HEAD
Capturing the remote ref BEFORE the push is what makes
"remote == local" meaningful; without it the assertion is
satisfied by having pushed nothing.
Options:
--json-path <pathspec> Git pathspec of generated/serialized JSON to
parse-check, e.g. 'data/**/*.json'. Repeatable.
Opt-in on purpose: many real .json files are
JSONC (tsconfig) or templates and do NOT parse
strictly. Nominate the generated ones. Satisfies
the decision requirement on its own; overrides a
config opt-out (loudly).
--allow-invalid-json <pathspec> Exempt a path from the JSON parse check
(for deliberate malformed-input fixtures).
Exemptions are printed, never silent.
--since-head <sha> HEAD before your commit step. Asserts a new
commit object was actually created.
--remote <name> Remote to push to.
--branch <name> Branch to push.
Repo config (.push-guard.json, at the repository root):
{"json_paths": ["data/**/*.json"], "allow_invalid_json": ["fixtures/*"]}
— nominate generated JSON for parse-checking.
{"json_check": "none", "reason": "no generated JSON in this repo"}
— opt out. The reason is REQUIRED and is printed on every run.
An opt-out is only accepted from this file, never from a command-line flag: a
committed reason can be read in a diff, an absent flag cannot be reviewed at all.
An invalid config is refused outright rather than treated as absent.
There is no flag to downgrade a failure to a warning. That is intentional.
EOF
}
require_repo() {
git rev-parse --show-toplevel >/dev/null 2>&1 \
|| fail "$EX_USAGE" "not inside a git repository"
cd "$(git rev-parse --show-toplevel)"
}
# ---------------------------------------------------------------- check-staged
# staged_files_z <array-name> [pathspec...]
#
# THE FAIL-OPEN THIS FUNCTION EXISTS TO REMOVE:
# mapfile -d '' -t files < <(git diff ...)
# observes MAPFILE's exit status, never the producer's. When git diff dies -- a
# bad pathspec, a corrupt index -- mapfile cheerfully reads zero bytes and
# succeeds, and the caller then reports that silence as "nothing to check".
#
# `set -o pipefail` does NOT cover this. pipefail applies to PIPELINES; a process
# substitution is an asynchronous child whose status is never collected. That is
# the precise reason every `cmd | cmd` in this file is safe and both `< <(cmd)`
# constructs were not -- a distinction worth stating, because "we set pipefail"
# reads like whole-file coverage and is not.
#
# Both callers now route through here, so the category is gone rather than the
# two known instances being patched.
staged_files_z() {
local -n _out="$1"; shift
local tmp err status=0
tmp="$(mktemp)"; err="$(mktemp)"
if (( $# )); then
git diff --cached --name-only --diff-filter=ACM --no-renames -z \
-- "$@" >"$tmp" 2>"$err" || status=$?
else
git diff --cached --name-only --diff-filter=ACM --no-renames -z \
>"$tmp" 2>"$err" || status=$?
fi
if (( status != 0 )); then
local msg; msg="$(tr '\n' ' ' <"$err")"
rm -f "$tmp" "$err"
fail "$EX_CONFIG" \
"CANNOT ENUMERATE STAGED FILES — git diff exited $status" \
"git said: ${msg:-(no message)}" \
"" \
"Refusing to report a result. An enumeration that failed returns no" \
"files, which is indistinguishable from a clean tree — reporting that" \
"as OK would be a silent pass on an unexamined index."
fi
_out=()
mapfile -d '' -t _out <"$tmp"
rm -f "$tmp" "$err"
}
check_unmerged() {
local unmerged
unmerged="$(git ls-files --unmerged | awk '{print $4}' | sort -u)"
if [[ -n "$unmerged" ]]; then
mapfile -t paths <<<"$unmerged"
fail "$EX_CONFLICT" \
"the index has UNMERGED paths — a merge/rebase is still in progress" \
"${paths[@]}" \
"" \
"Resolve the conflict and 'git add' the result. Do not commit past this."
fi
log " ok index has no unmerged paths"
}
check_conflict_markers() {
local -a files=("$@")
local hits
# git grep --cached searches the INDEX (what will actually be committed),
# not the working tree.
#
# -a (treat every blob as text), NOT -I (skip binary). -I honours git's
# binary classification, which includes an explicit `.gitattributes` `binary`
# attribute. A repo that marks a path binary would have its staged conflict
# markers silently skipped while the summary still counted the file as
# scanned — a clean pass AND a false count. Verified reproducible. The cost
# of -a is that a genuine binary blob could theoretically emit a noisy match
# line; a false alarm is recoverable, a silent miss is not.
# -E is load-bearing: git grep defaults to BASIC regex, in which '(', '|'
# and '{7}' are literal characters. Without it these patterns match nothing
# and the check reports a clean pass over a file full of markers. That
# failure was caught by the needle, not by review.
#
# Exit status is discriminated, NOT swallowed. git grep returns 1 for "no
# match" and >=2 for a real error (bad pathspec, unreadable index). A blanket
# '|| true' would turn a broken scan into "ok, no conflict markers" — the
# same fail-open in the guard's own error handling.
local status=0
hits="$(git grep --cached -a -n -E "${MARKER_PATTERNS[@]}" -- "${files[@]}")" || status=$?
if (( status > 1 )); then
fail "$EX_CONFLICT" \
"git grep FAILED (exit $status) — the conflict scan did not run" \
"Refusing to report a clean result from a scan that did not complete."
fi
if [[ -n "$hits" ]]; then
mapfile -t lines <<<"$hits"
fail "$EX_CONFLICT" \
"staged content contains unresolved CONFLICT MARKERS" \
"${lines[@]}" \
"" \
"These are staged and would be committed verbatim."
fi
log " ok no conflict markers in ${#files[@]} staged file(s)"
}
# JSON parse check.
#
# SCOPE IS OPT-IN, AND THAT IS A MEASURED DECISION, NOT LAZINESS.
# The first draft checked every staged *.json. Run against a real repository that
# turned out to be 17 of 119 tracked .json files failing a strict parse — all of
# them legitimate: every tsconfig*.json is JSONC (comments are legal there) and
# the CA templates are Go templates that merely carry a .json suffix. An
# on-by-default check would have fired on ~14% of this repo's JSON, and a guard
# that cries wolf gets routed around until the bypass is habitual — which is
# worse than no guard, because the bypass then also covers the true positives.
#
# So the caller nominates the generated/serialized paths this check is FOR, as
# git pathspecs (git, not bash, expands them — '**' works correctly).
#
# WHAT SAYING NOTHING NOW COSTS YOU
# ---------------------------------
# The first release announced "JSON check NOT REQUESTED" and continued. That was
# visible rather than silent, which is better — but it is still an ABSENCE, and
# an absence is not reviewable. Nobody reads a line that has printed correctly
# ten thousand times. A repo that needed the check and never wired it up stayed
# unprotected forever, and nothing ever failed.
#
# The decision is now MANDATORY and must come from one of two places:
# * turning the check ON — --json-path on the command line, or "json_paths"
# in .push-guard.json
# * turning the check OFF — ONLY "json_check": "none" in .push-guard.json,
# which REQUIRES a non-empty "reason"
# Saying nothing at all is refused (exit 6).
#
# The asymmetry is deliberate. Turning a check on is safe from anywhere. Turning
# one OFF is confined to a committed file because that is the only form a human
# can review: you can read a reason in a diff, and you cannot review the fact
# that nobody typed a flag. An opt-out that lives in an ad-hoc command line is
# just the old fail-open with extra steps.
# The config parser, held as a string rather than an inline heredoc.
#
# A `<<'PY'` heredoc INSIDE a $( ) command substitution makes bash emit
# warning: command substitution: 1 unterminated here-document
# on every single run. It still executed, every needle stayed green and the
# static linter stayed clean — the warning goes to stderr and broke no
# assertion. It surfaced only when the guard was run against a real
# repository. A tool built to refuse quiet output was quietly polluting
# stderr; needle 'z1' now asserts the guard emits no warnings at all.
#
# `read -d ''` returns non-zero at EOF without finding a NUL, which is the
# NORMAL path when slurping a heredoc — hence `|| true`. That is the one
# shape where it does not mask a real error, unlike the `git grep || true`
# this codebase already removed once.
IFS='' read -r -d '' CONFIG_PARSER <<'PY' || true
import json, sys
path = sys.argv[1]
try:
with open(path) as fh:
cfg = json.load(fh)
except Exception as exc:
sys.stderr.write("does not parse: %s" % exc)
sys.exit(2)
if not isinstance(cfg, dict):
sys.stderr.write("must contain a JSON object, got %s" % type(cfg).__name__)
sys.exit(2)
def clean_list(name, value):
if not isinstance(value, list):
sys.stderr.write('"%s" must be an array' % name)
sys.exit(2)
for item in value:
if not isinstance(item, str) or not item.strip():
sys.stderr.write('"%s" must contain only non-empty strings' % name)
sys.exit(2)
# Tabs/newlines would be mangled by the tab-delimited handoff below.
# Reject them explicitly rather than silently truncating a pathspec.
if "\t" in item or "\n" in item:
sys.stderr.write('"%s" entry contains a tab or newline: %r' % (name, item))
sys.exit(2)
return value
mode = cfg.get("json_check")
paths = cfg.get("json_paths")
allow = cfg.get("allow_invalid_json", [])
if mode is not None and mode != "none":
sys.stderr.write('"json_check" must be "none" if present, got %r' % (mode,))
sys.exit(2)
if mode == "none":
if paths:
sys.stderr.write('"json_check": "none" and "json_paths" are mutually exclusive')
sys.exit(2)
reason = cfg.get("reason")
if not isinstance(reason, str) or not reason.strip():
sys.stderr.write('"json_check": "none" REQUIRES a non-empty "reason"')
sys.exit(2)
print("MODE\tnone")
print("REASON\t%s" % reason.strip().replace("\t", " ").replace("\n", " "))
sys.exit(0)
if paths is None:
sys.stderr.write(
'states no decision — needs "json_paths", '
'or "json_check": "none" with a "reason"'
)
sys.exit(2)
clean_list("json_paths", paths)
if not paths:
sys.stderr.write('"json_paths" must not be empty')
sys.exit(2)
clean_list("allow_invalid_json", allow)
print("MODE\tcheck")
for item in paths:
print("JPATH\t%s" % item)
for item in allow:
print("ALLOW\t%s" % item)
PY
resolve_json_config() {
local cfg out status=0
cfg="$(git rev-parse --show-toplevel)/$CONFIG_FILE"
if [[ ! -f "$cfg" ]]; then
CFG_MODE="absent"
return 0
fi
# A malformed config must REFUSE, never degrade to "treat as absent". That
# fallback would rebuild precisely the fail-open this gate closes: a typo in
# the config would silently disable the check it was written to enable.
out="$(python3 -c "$CONFIG_PARSER" "$cfg" 2>&1)" || status=$?
if (( status != 0 )); then
fail "$EX_CONFIG" \
"$CONFIG_FILE is INVALID — $out" \
"" \
"Refusing to run. A config that does not parse is not the same as no" \
"config: treating it as absent would silently disable the very check" \
"this file was written to enable."
fi
local key val
while IFS=$'\t' read -r key val; do
case "$key" in
MODE) CFG_MODE="$val" ;;
REASON) CFG_REASON="$val" ;;
JPATH) CFG_PATHS+=("$val") ;;
ALLOW) CFG_ALLOW+=("$val") ;;
esac
done <<<"$out"
# ------------------------------------------------------------------
# THE ASYMMETRY, ENFORCED RATHER THAN INTENDED.
# ON may come from anywhere: turning a check on needs no review.
# OFF must come from a COMMITTED object, because the entire argument for
# requiring a written reason was that a reason is reviewable in a diff
# while an absence is not. An opt-out honoured from an untracked
# working-tree file is not reviewable either -- it is an ad-hoc local
# bypass wearing a config file's clothes, and it defeats the design.
# So the OFF decision is re-read from HEAD and the working tree gets no
# vote in it. A committed ON flipped to OFF locally is likewise refused.
# ------------------------------------------------------------------
[[ "$CFG_MODE" == "none" ]] || return 0
local rel cmode cblob cout cstatus=0
rel="$(git ls-files --full-name --error-unmatch -- "$cfg" 2>/dev/null)" || rel=""
if [[ -z "$rel" ]]; then
fail "$EX_CONFIG" \
"OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is not tracked in git" \
"recorded reason was: ${CFG_REASON:-(none)}" \
"" \
"The opt-out requires a written reason precisely so it shows up in a" \
"diff. An untracked file shows up in nobody's review, so honouring it" \
"would rebuild the unreviewable bypass this design exists to prevent." \
"" \
"Commit $CONFIG_FILE, then run again. Turning the check ON needs no" \
"commit; only turning it OFF does."
fi
# A COMMITTED SYMLINK (mode 120000) is a mutable target: the reviewed blob
# is a path, and what it points at can change without any diff at all.
cmode="$(git ls-tree HEAD -- "$rel" 2>/dev/null | awk '{print $1}')"
if [[ -z "$cmode" ]]; then
fail "$EX_CONFIG" \
"OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is staged but not yet in HEAD" \
"recorded reason was: ${CFG_REASON:-(none)}" \
"" \
"A staged-but-uncommitted opt-out has not been through review either." \
"Commit it first."
fi
if [[ "$cmode" == "120000" ]]; then
fail "$EX_CONFIG" \
"OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is a committed SYMLINK" \
"" \
"The reviewed object would be a path, not a decision: what it points" \
"at can be changed later without producing any diff. Commit the" \
"config as a regular file."
fi
cblob="$(git show "HEAD:$rel" 2>/dev/null)" || cblob=""
cout="$(printf '%s' "$cblob" | python3 -c "$CONFIG_PARSER" /dev/stdin 2>&1)" || cstatus=$?
if (( cstatus != 0 )); then
fail "$EX_CONFIG" \
"COMMITTED $CONFIG_FILE is INVALID — $cout" \
"" \
"The working tree opts out, but the committed version does not parse," \
"so there is no reviewable decision to honour."
fi
local cmode_val="" creason=""
while IFS=$'\t' read -r key val; do
case "$key" in
MODE) cmode_val="$val" ;;
REASON) creason="$val" ;;
esac
done <<<"$cout"
if [[ "$cmode_val" != "none" ]]; then
fail "$EX_CONFIG" \
"LOCAL-ONLY OPT-OUT — the working tree turns the JSON check OFF but" \
"HEAD does not (committed decision: $cmode_val)" \
"working-tree reason: ${CFG_REASON:-(none)}" \
"" \
"An uncommitted edit that disables a committed check is exactly the" \
"unreviewable bypass this guard refuses. Commit the change if it is" \
"real; revert it if it was a local convenience."
fi
# Print the COMMITTED reason, never the working tree's: the reason anyone
# can actually review is the one in the object.
CFG_REASON="$creason"
}
check_staged_json() {
local -a checked=() skipped=()
local f err
local -a pathspec=()
resolve_json_config
if (( ${#JSON_PATHS[@]} == 0 )); then
case "$CFG_MODE" in
absent)
fail "$EX_CONFIG" \
"NO JSON DECISION — no --json-path, and no $CONFIG_FILE" \
"" \
"This guard will not run without an explicit decision about" \
"staged-JSON validation. Create $CONFIG_FILE with EITHER:" \
"" \
' {"json_paths": ["data/**/*.json"]}' \
"" \
"or, if this repo genuinely has no generated JSON:" \
"" \
' {"json_check": "none", "reason": "<why>"}' \
"" \
"The reason is mandatory so the opt-out is recorded and" \
"reviewable in the diff, instead of being an absence nobody sees."
;;
none)
log " -- JSON check OPTED OUT in $CONFIG_FILE — recorded reason: $CFG_REASON"
return 0
;;
check)
JSON_PATHS=(${CFG_PATHS[@]+"${CFG_PATHS[@]}"})
ALLOW_INVALID_JSON+=(${CFG_ALLOW[@]+"${CFG_ALLOW[@]}"})
log " .. JSON paths from $CONFIG_FILE: ${JSON_PATHS[*]}"
;;
esac
else
# An explicit --json-path turns the check ON, so it satisfies the decision
# requirement by itself. If the config opted out, the nomination WINS and
# says so loudly — resolving a contradiction toward more checking is the
# only safe direction, but silently ignoring a committed opt-out would
# hide a real disagreement.
case "$CFG_MODE" in
none)
log " !! $CONFIG_FILE opts out of the JSON check, but --json-path was"
log " !! given — honouring the nomination and checking anyway."
;;
check)
JSON_PATHS+=(${CFG_PATHS[@]+"${CFG_PATHS[@]}"})
ALLOW_INVALID_JSON+=(${CFG_ALLOW[@]+"${CFG_ALLOW[@]}"})
;;
esac
fi
# Normalize to :(glob) magic. WITHOUT it, git's default pathspec matching
# makes 'data/**/*.json' require at least one intermediate directory, so it
# matches data/sub/b.json but SILENTLY SKIPS data/a.json. The obvious
# spelling would then deliver partial coverage with no warning — a
# quiet-underscan, which is the same family of defect as a quiet pass.
# Pathspecs that already carry explicit magic (leading ':') are left alone.
for f in "${JSON_PATHS[@]}"; do
[[ "$f" == :* ]] && pathspec+=("$f") || pathspec+=(":(glob)$f")
done
for f in ${ALLOW_INVALID_JSON[@]+"${ALLOW_INVALID_JSON[@]}"}; do
[[ "$f" == :* ]] && pathspec+=("$f") || pathspec+=(":(exclude,glob)$f")
skipped+=("$f")
done
local -a files=()
staged_files_z files "${pathspec[@]}"
for f in ${files[@]+"${files[@]}"}; do
[[ "$f" == *.json ]] || continue
if ! err="$(git show ":$f" | python3 -c '
import json, sys
try:
json.load(sys.stdin)
except Exception as exc:
sys.stderr.write(str(exc))
sys.exit(1)
' 2>&1)"; then
fail "$EX_JSON" \
"staged JSON does not parse: $f" \
"$err" \
"" \
"A serialization error from a generator is a STOP, not something to commit past."
fi
checked+=("$f")
done
for f in ${skipped[@]+"${skipped[@]}"}; do
log " -- JSON check EXEMPTED by --allow-invalid-json: $f"
done
if (( ${#checked[@]} == 0 )); then
log " -- no staged .json under ${JSON_PATHS[*]} — JSON check not applicable"
else
log " ok ${#checked[@]} staged .json file(s) under ${JSON_PATHS[*]} parse"
fi
}
cmd_check_staged() {
require_repo
log "push-guard: checking staged content"
check_unmerged
# --no-renames is load-bearing, NOT cosmetic. Git detects renames by default
# (diff.renames=true since 2.9), so a `git mv` plus a small edit is reported
# as ONE 'R' entry — which --diff-filter=ACM DOES NOT MATCH. The renamed file
# becomes invisible to every content check: staged conflict markers sail
# through and the guard prints "staged content OK". Verified reproducible at
# 97% similarity with an ordinary co-staged file present. --no-renames
# decomposes each rename back into D + A so the new path is always seen.
# (Adding R to the filter also works, but --name-only -z emits two paths for
# a rename and is ambiguous to parse — this removes the category instead.)
#
# SECOND INSTANCE OF THE SAME DEFECT, found by sweeping for the construct
# rather than fixing only the one that was reported. This one failed CLOSED
# (zero files hit "NOTHING IS STAGED"), so it was never a security hole --
# but it reported a confident WRONG DIAGNOSIS, sending anyone debugging it
# at their index instead of at the failing git invocation.
local -a files=()
staged_files_z files
if (( ${#files[@]} == 0 )); then
fail "$EX_NULL" \
"NOTHING IS STAGED" \
"About to commit, but the index is identical to HEAD." \
"" \
"This is the null case: a commit here is empty, and any later" \
"'verified' step would be asserting against content that does not exist."
fi
check_conflict_markers "${files[@]}"
check_staged_json "${files[@]}"
log "push-guard: staged content OK"
}
# ------------------------------------------------------------------------ push
remote_sha() {
# Empty output means the branch does not exist on the remote yet.
# A network/auth failure makes ls-remote non-zero, which set -e turns into an
# abort — the guard never treats an unreachable remote as "unchanged".
git ls-remote "$1" "refs/heads/$2" | awk 'NR==1{print $1}'
}
cmd_push() {
require_repo
local remote="" branch="" since_head=""
local -a extra=()
while (( $# )); do
case "$1" in
--remote) remote="${2:-}"; shift 2 ;;
--branch) branch="${2:-}"; shift 2 ;;
--since-head) since_head="${2:-}"; shift 2 ;;
--) shift; extra=("$@"); break ;;
*) fail "$EX_USAGE" "unknown argument to push: $1" ;;
esac
done
[[ -n "$remote" && -n "$branch" ]] \
|| fail "$EX_USAGE" "push requires --remote and --branch"
local head
head="$(git rev-parse HEAD)"
log "push-guard: verifying push of $head -> $remote/$branch"
# (1) Did a new commit actually get created?
if [[ -n "$since_head" ]]; then
git rev-parse --verify --quiet "${since_head}^{commit}" >/dev/null \
|| fail "$EX_USAGE" \
"--since-head is not a commit in this repository: $since_head" \
"" \
"Cannot verify advancement against a start point that does not exist."
if [[ "$head" == "$since_head" ]]; then
fail "$EX_NULL" \
"HEAD DID NOT ADVANCE — no commit was created" \
"HEAD is still $head" \
"" \
"Your commit step failed and execution continued on stale state." \
"Pushing now would publish something you did not just build."
fi
# INEQUALITY IS NOT ADVANCEMENT. "different from where I started" is
# satisfied by checking out any unrelated commit -- including one that
# existed long before this session -- which would let pre-existing work
# be published as something just built. The claim being made is that new
# commits were created ON TOP OF the recorded start point, and only
# ancestry states that.
if ! git merge-base --is-ancestor "$since_head" "$head"; then
fail "$EX_NULL" \
"HEAD IS NOT A DESCENDANT of the recorded start point" \
"start: $since_head" \
"head : $head" \
"" \
"HEAD differs from the start point but does not build on it, so" \
"this is a checkout of other history rather than work you just" \
"created. Being different is not the same as having advanced."
fi
log " ok HEAD advanced ${since_head:0:9} -> ${head:0:9} (descendant)"
fi
# (2) Is that commit non-empty?
# EXEMPTING BY PARENT COUNT WAS A FAIL-OPEN. An empty root commit sailed
# through "emptiness check not applicable" and was then reported as a
# CONFIRMED PUSH -- directly contradicting the non-empty requirement this
# step exists to enforce. Root and merge commits do have well-defined
# emptiness; the original code declined to define it, which is not the same
# as it being undefined.
local parents
parents="$(git rev-list --parents -n 1 HEAD | wc -w)"
if (( parents == 2 )); then # sha + exactly one parent
if git diff --quiet HEAD^ HEAD; then
fail "$EX_NULL" \
"HEAD IS AN EMPTY COMMIT — it changes nothing against its parent" \
"commit $head" \
"" \
"An empty commit carrying a real message is indistinguishable" \
"from real work in the log. Refusing to push it."
fi
log " ok HEAD is a non-empty commit"
elif (( parents > 2 )); then
# A merge is empty when its tree matches EVERY parent: it then carries
# no content of its own and no integration either.
local p all_same=yes
for p in $(git rev-list --parents -n 1 HEAD | cut -d' ' -f2-); do
git diff --quiet "$p" HEAD || { all_same=no; break; }
done
if [[ "$all_same" == yes ]]; then
fail "$EX_NULL" \
"HEAD IS AN EMPTY MERGE — its tree is identical to every parent" \
"commit $head" \
"" \
"It integrates nothing and introduces nothing. Refusing to push it."
fi
log " ok HEAD is a non-empty merge commit"
else
# A root commit has no parent, so emptiness is measured against the
# empty tree: it is empty when it adds no paths at all.
if [[ -z "$(git diff-tree --root -r --name-only --no-commit-id HEAD)" ]]; then
fail "$EX_NULL" \
"HEAD IS AN EMPTY ROOT COMMIT — it introduces no files" \
"commit $head" \
"" \
"A root commit is empty when it adds nothing against the empty" \
"tree. Refusing to push it."
fi
log " ok HEAD is a non-empty root commit"
fi
# (3) Capture the remote BEFORE. This is the step whose absence made the
# original "PUSH VERIFIED" a false positive.
local before after
before="$(remote_sha "$remote" "$branch")"
log " .. remote before: ${before:-<branch does not exist>}"
git push "$remote" "HEAD:refs/heads/$branch" ${extra[@]+"${extra[@]}"}
after="$(remote_sha "$remote" "$branch")"
log " .. remote after: ${after:-<absent>}"
# (4) The remote must now equal local HEAD...
if [[ "$after" != "$head" ]]; then
fail "$EX_PUSH" \
"REMOTE DOES NOT MATCH LOCAL HEAD after push" \
"local HEAD: $head" \
"remote $branch: ${after:-<absent>}" \
"" \
"The push did not land what you are holding."
fi
# (5) ...AND it must have MOVED to get there. Without this, a push that
# transferred nothing passes step (4) trivially.
if [[ "$after" == "$before" ]]; then
fail "$EX_PUSH" \
"REMOTE DID NOT MOVE — nothing was actually pushed" \
"remote was already at $after before this push ran" \
"" \
"'remote == local' is satisfied by having pushed nothing. That is" \
"the false positive this guard exists to catch: the work you think" \
"you just published was already there, or was never committed."
fi
log "push-guard: PUSH CONFIRMED ${before:0:9}${before:+ }-> ${after:0:9}"
}
# ------------------------------------------------------------------------ main
ALLOW_INVALID_JSON=()
JSON_PATHS=()
CFG_MODE="absent"
CFG_REASON=""
CFG_PATHS=()
CFG_ALLOW=()
main() {
(( $# )) || { usage; exit "$EX_USAGE"; }
local sub="$1"; shift
case "$sub" in
check-staged)
while (( $# )); do
case "$1" in
--json-path) JSON_PATHS+=("${2:-}"); shift 2 ;;
--allow-invalid-json) ALLOW_INVALID_JSON+=("${2:-}"); shift 2 ;;
*) fail "$EX_USAGE" "unknown argument to check-staged: $1" ;;
esac
done
cmd_check_staged
;;
push) cmd_push "$@" ;;
-h|--help) usage ;;
*) usage; exit "$EX_USAGE" ;;
esac
# Explicit: the only way to reach here is with every requested check passed.
exit "$EX_OK"
}
main "$@"
@@ -13,7 +13,7 @@
# Covers:
# (a) 404 branch-absent -> exit 0, "queue clear" message.
# (b) 200 existing branch + a terminal CI state -> unchanged behavior.
# (c) genuine API error (500) -> still fail-closed (nonzero exit).
# (c) genuine API error (500) -> loud, audited CANNOT_ASSERT; degraded exit 0.
set -euo pipefail
@@ -62,7 +62,7 @@ case "$mode" in
200) code=200; body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}' ;;
500) code=500; body='{"message":"internal server error"}' ;;
no-status) code=200; body='{}' ;;
terminal-success) code=200; body='{"state":"success"}' ;;
terminal-success) code=200; body='{"state":"success","statuses":[{"status":"success"}]}' ;;
*)
echo "curl stub: unknown mode=$mode" >&2
exit 2
@@ -91,6 +91,7 @@ run_ci_queue_wait() {
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export GITEA_TOKEN="stub-token"
export GITEA_URL="https://git.example.test"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
"$SCRIPT_DIR/ci-queue-wait.sh" -B "$branch" --purpose push -t 5 -i 1
)
}
@@ -131,19 +132,26 @@ elif [[ "$out_b" == *"queue clear"* ]]; then
fail=1
fi
# (c) genuine API error (500) -> still fail-closed, exit nonzero.
# (c) genuine API error (500) -> CANNOT_ASSERT is loud and audited, but does not brick delivery.
set +e
out_c=$(MOSAIC_STUB_BRANCH_MODE=500 run_ci_queue_wait "feat/some-branch" 2>&1)
status_c=$?
set -e
if [[ "$status_c" -eq 0 ]]; then
echo "FAIL(c): expected a nonzero exit for a genuine 500 API error, got 0" >&2
if [[ "$status_c" -ne 0 ]]; then
echo "FAIL(c): expected degraded exit 0 for provider unavailability, got $status_c" >&2
echo "$out_c" >&2
fail=1
elif [[ "$out_c" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL(c): expected a loud CANNOT_ASSERT diagnostic" >&2
echo "$out_c" >&2
fail=1
elif [[ "$out_c" == *"queue clear"* ]]; then
echo "FAIL(c): a genuine API error must not be reported as queue-clear" >&2
echo "$out_c" >&2
fail=1
elif [[ ! -s "$WORK_DIR/audit/ci-queue-wait.jsonl" ]]; then
echo "FAIL(c): expected a durable CANNOT_ASSERT audit record" >&2
fail=1
fi
if [[ "$fail" -eq 0 ]]; then
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# GitHub Actions uses Checks API check-runs, not only legacy commit statuses.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-github-checks}"
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" checkout -q -b fix/github-checks
git -C "$REPO_DIR" remote add origin https://github.com/acme/widgets.git
cat > "$STUB_DIR/gh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
endpoint=""
for arg in "$@"; do
[[ "$arg" == repos/* ]] && endpoint="$arg"
done
printf '%s\n' "$*" >> "${MOSAIC_GH_CALL_LOG:?}"
case "$endpoint" in
repos/acme/widgets/branches/fix/github-checks)
printf '%s\n' '0123456789abcdef0123456789abcdef01234567'
;;
repos/acme/widgets/commits/*/statuses?per_page=100)
printf '%s\n' '[[]]'
;;
repos/acme/widgets/commits/*/check-runs?per_page=100\&filter=latest)
case "${MOSAIC_GH_CHECK_MODE:?}" in
success) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"success"}]}]' ;;
pending) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"in_progress","conclusion":null}]}]' ;;
failure) printf '%s\n' '[{"total_count":1,"check_runs":[{"name":"ci","status":"completed","conclusion":"failure"}]}]' ;;
late-failure) printf '%s\n' '[{"total_count":2,"check_runs":[{"name":"first-page","status":"completed","conclusion":"success"}]},{"total_count":2,"check_runs":[{"name":"later-page","status":"completed","conclusion":"failure"}]}]' ;;
*) exit 2 ;;
esac
;;
*) echo "unexpected gh endpoint: $endpoint" >&2; exit 2 ;;
esac
SH
chmod +x "$STUB_DIR/gh"
run_guard() {
local mode="$1"
(
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_GH_CHECK_MODE="$mode"
export MOSAIC_GH_CALL_LOG="$WORK_DIR/gh-calls.log"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$WORK_DIR/audit.jsonl"
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose push -t 0 -i 0
)
}
failures=0
assert_case() {
local mode="$1" expected_rc="$2" expected_state="$3" output rc
set +e
output=$(run_guard "$mode" 2>&1)
rc=$?
set -e
if [[ "$expected_rc" == zero && "$rc" -ne 0 ]]; then
echo "FAIL github-$mode: expected rc=0, got $rc" >&2
failures=$((failures + 1))
elif [[ "$expected_rc" == nonzero && "$rc" -eq 0 ]]; then
echo "FAIL github-$mode: expected rc!=0, got 0" >&2
failures=$((failures + 1))
fi
if [[ "$output" != *"state=$expected_state"* ]]; then
echo "FAIL github-$mode: expected state=$expected_state, got:" >&2
printf '%s\n' "$output" >&2
failures=$((failures + 1))
fi
}
set -e
: > "$WORK_DIR/gh-calls.log"
assert_case success zero terminal-success
assert_case pending nonzero pending
assert_case failure nonzero terminal-failure
assert_case late-failure nonzero terminal-failure
if [[ $(grep -c 'check-runs?per_page=100&filter=latest' "$WORK_DIR/gh-calls.log") -lt 4 ]]; then
echo "FAIL: expected every case to query all Checks API pages" >&2
failures=$((failures + 1))
fi
if [[ "$failures" -ne 0 ]]; then
echo "GitHub check-runs regression failed ($failures assertions)" >&2
exit 1
fi
echo "GitHub check-runs regression passed (4/4 cases, including later-page failure)"
@@ -0,0 +1,232 @@
#!/usr/bin/env bash
# Exit-asserting RM-03 regression harness for ci-queue-wait.sh.
# Every case is a process-level assertion: a classifier-only green cannot satisfy it.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-queue-wait-tristate}"
REPO_DIR="$WORK_DIR/repo"
STUB_DIR="$WORK_DIR/stubs"
AUDIT_LOG="$WORK_DIR/audit/ci-queue-wait.jsonl"
FEATURE_BRANCH="fix/rm-03-fixture"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$STUB_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" checkout -q -b "$FEATURE_BRANCH"
git -C "$REPO_DIR" remote add origin https://git.example.test/acme/widgets.git
cat > "$STUB_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
url=""
has_write_out=0
for arg in "$@"; do
case "$arg" in
-w) has_write_out=1 ;;
http://*|https://*) url="$arg" ;;
esac
done
printf '%s\n' "$url" >> "${MOSAIC_STUB_URL_LOG:?}"
case "$url" in
*/branches/*)
if [[ "${MOSAIC_STUB_BRANCH_MODE:-ok}" == "unreachable" ]]; then
exit 7
fi
body='{"commit":{"id":"deadbeefcafef00d0123456789abcdef01234567"}}'
if [[ "$has_write_out" -eq 1 ]]; then
printf '%s\n200' "$body"
else
printf '%s' "$body"
fi
;;
*/status)
case "${MOSAIC_STUB_STATUS_MODE:?}" in
success) printf '%s' '{"state":"success","statuses":[{"status":"success"}]}' ;;
pending) printf '%s' '{"state":"pending","statuses":[{"status":"pending","context":"ci/test"}]}' ;;
failure) printf '%s' '{"state":"failure","statuses":[{"status":"failure"}]}' ;;
no-status) printf '%s' '{"state":"","statuses":[]}' ;;
aggregate-success-no-status) printf '%s' '{"state":"success","statuses":[]}' ;;
malformed) printf '%s' 'not-json' ;;
malformed-statuses-type) printf '%s' '{"state":"success","statuses":"corrupt"}' ;;
malformed-status-entry) printf '%s' '{"state":"success","statuses":[null]}' ;;
large-success)
python3 -c 'import json; print(json.dumps({"state":"success", "statuses":[{"status":"success"}], "padding":"x" * (160 * 1024)}), end="")'
;;
unreachable) exit 7 ;;
*) echo "unknown status mode" >&2; exit 2 ;;
esac
;;
*) echo "unexpected curl URL: $url" >&2; exit 2 ;;
esac
SH
chmod +x "$STUB_DIR/curl"
run_guard() {
local status_mode="$1"
local audit_log="${2:-$AUDIT_LOG}"
shift 2 || true
(
cd "$REPO_DIR" || exit
export PATH="$STUB_DIR:$PATH"
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
if [[ "$status_mode" == "credential-unresolvable" ]]; then
export HOME="$WORK_DIR/empty-home"
mkdir -p "$HOME"
unset GITEA_TOKEN GITEA_URL MOSAIC_GIT_IDENTITY
export MOSAIC_STUB_STATUS_MODE=success
else
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_STUB_STATUS_MODE="$status_mode"
fi
export MOSAIC_STUB_URL_LOG="$WORK_DIR/urls.log"
export MOSAIC_CI_QUEUE_AUDIT_LOG="$audit_log"
"$SCRIPT_DIR/ci-queue-wait.sh" --purpose "${MOSAIC_TEST_PURPOSE:-push}" -t 0 -i 0 "$@"
)
}
failures=0
run_assertion() {
local name="$1" expected_rc="$2" status_mode="$3" required_text="$4"
local output rc
shift 4
set +e
output=$(run_guard "$status_mode" "$AUDIT_LOG" "$@" 2>&1)
rc=$?
set -e
case "$expected_rc" in
zero)
if [[ "$rc" -ne 0 ]]; then
echo "FAIL $name: expected rc=0, got rc=$rc" >&2
failures=$((failures + 1))
fi
;;
nonzero)
if [[ "$rc" -eq 0 ]]; then
echo "FAIL $name: expected rc!=0, got rc=0" >&2
failures=$((failures + 1))
fi
;;
not126)
if [[ "$rc" -eq 126 ]]; then
echo "FAIL $name: payload transport hit ARG_MAX (rc=126)" >&2
failures=$((failures + 1))
fi
;;
esac
if [[ "$output" != *"$required_text"* ]]; then
echo "FAIL $name: output missing '$required_text' (rc=$rc)" >&2
printf '%s\n' "$output" >&2
failures=$((failures + 1))
fi
}
set -e
: > "$WORK_DIR/urls.log"
run_assertion success zero success 'state=terminal-success'
run_assertion pending nonzero pending 'ASSERTED_NOT_READY'
run_assertion failure nonzero failure 'ASSERTED_NOT_READY'
run_assertion no-status nonzero no-status 'ASSERTED_NOT_READY'
run_assertion aggregate-success-no-status nonzero aggregate-success-no-status 'ASSERTED_NOT_READY'
run_assertion malformed nonzero malformed 'ASSERTED_NOT_READY'
run_assertion malformed-statuses-type nonzero malformed-statuses-type 'ASSERTED_NOT_READY'
run_assertion malformed-status-entry nonzero malformed-status-entry 'ASSERTED_NOT_READY'
run_assertion large-payload not126 large-success 'state=terminal-success'
run_assertion credential-unresolvable zero credential-unresolvable 'CANNOT_ASSERT'
run_assertion provider-unreachable zero unreachable 'CANNOT_ASSERT'
if [[ ! -s "$AUDIT_LOG" ]] || ! grep -q '"outcome":"CANNOT_ASSERT"' "$AUDIT_LOG"; then
echo "FAIL provider-unreachable-audit: expected durable CANNOT_ASSERT JSONL record" >&2
failures=$((failures + 1))
fi
# Merge cannot proceed without exact-head evidence. CANNOT_ASSERT is retryable exit 75,
# distinct from ASSERTED_NOT_READY (3/124), and still writes its audit record.
merge_audit_lines_before=$(wc -l < "$AUDIT_LOG")
set +e
merge_unreachable_output=$(MOSAIC_TEST_PURPOSE=merge run_guard unreachable "$AUDIT_LOG" 2>&1)
merge_unreachable_rc=$?
set -e
if [[ "$merge_unreachable_rc" -ne 75 ]]; then
echo "FAIL merge-provider-unreachable: expected rc=75, got rc=$merge_unreachable_rc" >&2
failures=$((failures + 1))
fi
if [[ "$merge_unreachable_output" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL merge-provider-unreachable: expected loud CANNOT_ASSERT diagnostic" >&2
failures=$((failures + 1))
fi
merge_audit_lines_after=$(wc -l < "$AUDIT_LOG")
if [[ "$merge_audit_lines_after" -le "$merge_audit_lines_before" ]]; then
echo "FAIL merge-provider-unreachable: expected an additional audit record" >&2
failures=$((failures + 1))
fi
# A feature-branch push with no -B must inspect the checked-out feature branch.
if ! grep -q "/branches/$FEATURE_BRANCH" "$WORK_DIR/urls.log"; then
echo "FAIL implicit-branch: provider was not queried for $FEATURE_BRANCH" >&2
failures=$((failures + 1))
fi
# Merge callers can pin both a fork repository and the exact reviewed head SHA.
exact_sha=0123456789abcdef0123456789abcdef01234567
: > "$WORK_DIR/urls.log"
run_assertion exact-fork-head zero success 'state=terminal-success' \
-B fix/rm-03-fixture -R contributor/widgets-fork --sha "$exact_sha"
if ! grep -q "/repos/contributor/widgets-fork/commits/$exact_sha/status" "$WORK_DIR/urls.log"; then
echo "FAIL exact-fork-head: status URL did not bind fork repository and exact SHA" >&2
failures=$((failures + 1))
fi
if grep -q '/branches/' "$WORK_DIR/urls.log"; then
echo "FAIL exact-fork-head: explicit SHA must not be re-resolved through a branch" >&2
failures=$((failures + 1))
fi
# Platform/repository discovery failures use the same audited CANNOT_ASSERT path.
audit_lines_before=$(wc -l < "$AUDIT_LOG")
git -C "$REPO_DIR" remote set-url origin https://gitlab.com/acme/widgets.git
set +e
unsupported_output=$(run_guard success "$AUDIT_LOG" 2>&1)
unsupported_rc=$?
set -e
git -C "$REPO_DIR" remote set-url origin https://git.example.test/acme/widgets.git
if [[ "$unsupported_rc" -ne 0 ]]; then
echo "FAIL unsupported-platform: expected degraded rc=0, got rc=$unsupported_rc" >&2
failures=$((failures + 1))
fi
if [[ "$unsupported_output" != *"CANNOT_ASSERT"* ]]; then
echo "FAIL unsupported-platform: expected loud CANNOT_ASSERT diagnostic" >&2
failures=$((failures + 1))
fi
audit_lines_after=$(wc -l < "$AUDIT_LOG")
if [[ "$audit_lines_after" -le "$audit_lines_before" ]]; then
echo "FAIL unsupported-platform: expected an additional audit record" >&2
failures=$((failures + 1))
fi
# A degraded pass is forbidden if the audit receipt cannot be written.
mkdir -p "$WORK_DIR/not-a-directory"
printf 'file' > "$WORK_DIR/not-a-directory/parent"
set +e
audit_failure_output=$(run_guard unreachable "$WORK_DIR/not-a-directory/parent/audit.jsonl" 2>&1)
audit_failure_rc=$?
set -e
if [[ "$audit_failure_rc" -eq 0 ]]; then
echo "FAIL audit-unavailable: expected rc!=0, got rc=0" >&2
failures=$((failures + 1))
fi
if [[ "$audit_failure_output" != *"audit"* ]]; then
echo "FAIL audit-unavailable: expected loud audit failure diagnostic" >&2
failures=$((failures + 1))
fi
if [[ "$failures" -ne 0 ]]; then
echo "ci-queue-wait tri-state regression failed ($failures assertions)" >&2
exit 1
fi
echo "ci-queue-wait tri-state regression passed (all outcome classes)"
@@ -0,0 +1,161 @@
#!/usr/bin/env bash
# Regression harness for `git-credential-mosaic` — per-agent Gitea identity
# resolution (Gate-16 author≠reviewer separation).
#
# Covers:
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
# mosaic.gitIdentity (per-worktree) > git-supplied username.
# 2. Correct per-slot token file path chosen per host
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
# 3. Per-slot token present -> emits that identity + token.
# 4. Per-slot token absent -> falls back to the shared account
# (backward-compat / no-op for hosts without per-slot tokens).
# 5. Unknown/unrelated host -> exits 0 with no output (passthrough).
#
# Uses stubbed token files under a fake HOME + a real (throwaway) git repo.
# NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/git-credential-mosaic}"
FAKE_HOME="$WORK_DIR/home"
REPO_DIR="$WORK_DIR/repo"
# Mirror the real deployed layout (~/.config/mosaic/tools/{git,_lib}/) under the
# fake HOME: git-credential-mosaic resolves its credentials.sh sibling via a
# script-relative path (BASH_SOURCE), so the copy must live next to a stubbed
# _lib/credentials.sh, not the real one, to keep this test hermetic.
HELPER="$FAKE_HOME/.config/mosaic/tools/git/git-credential-mosaic"
rm -rf "$WORK_DIR"
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" \
"$FAKE_HOME/.config/mosaic/tools/git" \
"$FAKE_HOME/.config/mosaic/tools/_lib" \
"$REPO_DIR"
cp "$SCRIPT_DIR/git-credential-mosaic" "$HELPER"
chmod +x "$HELPER"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" config user.email "[email protected]"
git -C "$REPO_DIR" config user.name "Test"
# Fake shared-account credential loader — stands in for
# tools/_lib/credentials.sh's load_credentials(), scoped to this test only.
cat > "$FAKE_HOME/.config/mosaic/tools/_lib/credentials.sh" <<'SH'
load_credentials() {
case "$1" in
gitea-mosaicstack) GITEA_URL="https://git.mosaicstack.dev"; GITEA_TOKEN="shared-mosaicstack-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
gitea-usc) GITEA_URL="https://git.uscllc.com"; GITEA_TOKEN="shared-usc-token"; export GITEA_URL GITEA_TOKEN; return 0 ;;
*) return 1 ;;
esac
}
SH
fail=0
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" != "$actual" ]]; then
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
fail=1
fi
}
# Feed "host=<h>\nusername=<u>\n\n" on stdin (mirrors git's credential protocol)
# and run the helper with the fake HOME, inside REPO_DIR (so `git config
# mosaic.gitIdentity` resolves per-worktree), plus any extra env passed in $@.
run_helper() {
local host="$1" username_in="$2"; shift 2
(
cd "$REPO_DIR"
env -i HOME="$FAKE_HOME" PATH="$PATH" "$@" bash "$HELPER" get <<EOF
host=$host
username=$username_in
EOF
)
}
# ---------------------------------------------------------------------------
# 1. No identity resolvable anywhere, no per-slot token -> shared fallback
# (backward-compat: unchanged behavior when nothing is configured).
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
out=$(run_helper "git.mosaicstack.dev" "")
assert_eq "shared fallback: username" "username=git" "$(echo "$out" | grep '^username=')"
assert_eq "shared fallback: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
# ---------------------------------------------------------------------------
# 2. git-supplied username resolves to an identity WITH a per-slot token ->
# that identity + token wins over the shared account.
# ---------------------------------------------------------------------------
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
out=$(run_helper "git.mosaicstack.dev" "agentA")
assert_eq "username-resolved identity: username" "username=agentA" "$(echo "$out" | grep '^username=')"
assert_eq "username-resolved identity: password" "password=agentA-mosaicstack-token" "$(echo "$out" | grep '^password=')"
# ---------------------------------------------------------------------------
# 3. git config mosaic.gitIdentity (per-worktree) beats git-supplied username.
# ---------------------------------------------------------------------------
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
git -C "$REPO_DIR" config mosaic.gitIdentity agentB
out=$(run_helper "git.mosaicstack.dev" "agentA")
assert_eq "git-config beats username: username" "username=agentB" "$(echo "$out" | grep '^username=')"
assert_eq "git-config beats username: password" "password=agentB-mosaicstack-token" "$(echo "$out" | grep '^password=')"
# ---------------------------------------------------------------------------
# 4. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
# ---------------------------------------------------------------------------
echo -n "agentC-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentC.token"
out=$(run_helper "git.mosaicstack.dev" "agentA" MOSAIC_GIT_IDENTITY=agentC)
assert_eq "env beats git-config: username" "username=agentC" "$(echo "$out" | grep '^username=')"
assert_eq "env beats git-config: password" "password=agentC-mosaicstack-token" "$(echo "$out" | grep '^password=')"
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
# ---------------------------------------------------------------------------
# 5. Identity resolves, but no matching per-slot token file -> falls back to
# the shared account (per-agent identity is opt-in, not a hard requirement).
# ---------------------------------------------------------------------------
out=$(run_helper "git.mosaicstack.dev" "no-such-agent")
assert_eq "no per-slot token: username" "username=git" "$(echo "$out" | grep '^username=')"
assert_eq "no per-slot token: password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
# ---------------------------------------------------------------------------
# 6. Correct per-slot token PATH is chosen per host: same agent id, different
# host prefix (gitea-usc- vs gitea-mosaicstack-).
# ---------------------------------------------------------------------------
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
out=$(run_helper "git.uscllc.com" "agentD")
assert_eq "host-scoped token path (usc): username" "username=agentD" "$(echo "$out" | grep '^username=')"
assert_eq "host-scoped token path (usc): password" "password=agentD-usc-token" "$(echo "$out" | grep '^password=')"
# agentD has NO mosaicstack token -> must fall back to shared mosaicstack, not
# leak the usc token across hosts.
out=$(run_helper "git.mosaicstack.dev" "agentD")
assert_eq "host-scoped token path (cross-host must not leak): username" "username=git" "$(echo "$out" | grep '^username=')"
assert_eq "host-scoped token path (cross-host must not leak): password" "password=shared-mosaicstack-token" "$(echo "$out" | grep '^password=')"
# ---------------------------------------------------------------------------
# 7. Unrelated/unknown host -> exit 0, no output (passthrough for non-Gitea
# remotes, e.g. github.com via a different credential helper).
# ---------------------------------------------------------------------------
out=$(run_helper "github.com" "agentA")
assert_eq "unknown host: no output" "" "$out"
# ---------------------------------------------------------------------------
# 8. Non-"get" verb (store/erase) -> exit 0, no output (git-credential
# protocol: this helper only implements get).
# ---------------------------------------------------------------------------
store_out=$(cd "$REPO_DIR" && env -i HOME="$FAKE_HOME" PATH="$PATH" bash "$HELPER" store <<EOF
host=git.mosaicstack.dev
username=agentA
password=whatever
EOF
)
assert_eq "store verb: no output" "" "$store_out"
if [[ "$fail" -eq 0 ]]; then
echo "git-credential-mosaic identity resolution regression passed"
fi
exit "$fail"
@@ -312,4 +312,901 @@ if [[ "$override_wins" != "mosaicstack" ]]; then
fi
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
# ---------------------------------------------------------------------------
# #865 Blocker 1 & 2: get_gitea_token_for_login must resolve the SAME token as
# PyYAML would (or fail closed identically) even when PyYAML is ABSENT, and must
# bind the credential to the repo host's scheme + host + EFFECTIVE PORT — not the
# hostname alone. These fixtures probe the ImportError-dispatched line-parser
# fallback under FORCED PyYAML absence with adversarial YAML shapes, asserting it
# NEVER misattributes a token from a nested sub-map or a mis-indented line, strips
# inline comments like PyYAML, fails closed where PyYAML errors, and rejects a
# port mismatch while accepting an exact / default-port match. When PyYAML is
# available the same fixtures also assert the PyYAML path agrees (equivalence).
# ---------------------------------------------------------------------------
FIXTURE_XDG="$WORK_DIR/tokenfix"
NOYAML_DIR="$WORK_DIR/noyaml"
mkdir -p "$FIXTURE_XDG/tea" "$NOYAML_DIR"
# A shadow `yaml` module that raises ImportError, forcing the fallback path.
printf 'raise ImportError("forced-absent for #865 fallback regression")\n' > "$NOYAML_DIR/yaml.py"
if python3 -c 'import yaml' >/dev/null 2>&1; then HAVE_PYYAML=true; else HAVE_PYYAML=false; fi
# Confirm the shim really does force ImportError, so the fallback is exercised.
if python3 -c 'import yaml' >/dev/null 2>&1; then
if PYTHONPATH="$NOYAML_DIR" python3 -c 'import yaml' >/dev/null 2>&1; then
echo "FAIL: PyYAML-absence shim did not force ImportError (fallback not exercised)" >&2
exit 1
fi
fi
write_fixture() { printf '%s' "$1" > "$FIXTURE_XDG/tea/config.yml"; }
# Resolve a token via the FORCED-fallback path (PyYAML shimmed to ImportError).
token_fallback() {
(
cd "$REPO_DIR"
XDG_CONFIG_HOME="$FIXTURE_XDG" PYTHONPATH="$NOYAML_DIR" bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_token_for_login "$1" "$2"
' _ "$1" "$2"
) 2>/dev/null || true
}
# Resolve a token via the normal path (uses PyYAML when installed).
token_pyyaml() {
(
cd "$REPO_DIR"
XDG_CONFIG_HOME="$FIXTURE_XDG" bash -c '
source "'"$SCRIPT_DIR"'/detect-platform.sh"
get_gitea_token_for_login "$1" "$2"
' _ "$1" "$2"
) 2>/dev/null || true
}
assert_token() {
local desc="$1" expected="$2" login="$3" host="$4" got
got=$(token_fallback "$login" "$host")
if [[ "$got" != "$expected" ]]; then
echo "FAIL fallback [$desc]: expected [$expected] got [$got]" >&2
exit 1
fi
if [[ "$HAVE_PYYAML" == true ]]; then
got=$(token_pyyaml "$login" "$host")
if [[ "$got" != "$expected" ]]; then
echo "FAIL pyyaml [$desc]: expected [$expected] got [$got]" >&2
exit 1
fi
fi
}
# 1. Plain, well-formed entry resolves its token.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
'
assert_token "plain scalar" "TOK_PLAIN" primary git.example
# 2. A token nested inside a deeper SUB-MAP must NOT attach to the entry — PyYAML
# resolves the entry's own token to None here, so the fallback must too.
write_fixture 'logins:
- name: primary
url: https://git.example
extra:
token: TOK_NESTED_ATTACKER
- name: other
url: https://git.example
token: TOK_OTHER
'
assert_token "nested sub-map token is not attributed" "" primary git.example
assert_token "sibling entry still resolves its own token" "TOK_OTHER" other git.example
# 3. A MIS-INDENTED token line (deeper than the entry's fields) must not attach;
# PyYAML errors on this shape, so both fail closed.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_MISINDENT
'
assert_token "mis-indented token fails closed" "" primary git.example
# 4. A trailing inline comment on a scalar is stripped, exactly as PyYAML does.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_INLINE # trailing note
'
assert_token "inline comment stripped" "TOK_INLINE" primary git.example
# 5. A PyYAML-fail-closed case: tab indentation. PyYAML raises a scanner error;
# the fallback resolves no token. Both fail closed identically.
write_fixture "$(printf 'logins:\n - name: primary\n url: https://git.example\n\ttoken: TOK_TAB\n')"
assert_token "tab-indent fails closed like PyYAML" "" primary git.example
# 6. Host binding is scheme + host + EFFECTIVE PORT, not hostname alone.
write_fixture 'logins:
- name: ported
url: https://git.example:8443
token: TOK_PORTED
'
assert_token "explicit port exact match accepted" "TOK_PORTED" ported git.example:8443
assert_token "portless repo host rejects :8443 login" "" ported git.example
assert_token "wrong explicit port rejected" "" ported git.example:9443
# 7. An implicit (portless) login URL equals the scheme's explicit default port.
write_fixture 'logins:
- name: defported
url: https://git.example
token: TOK_DEFPORT
'
assert_token "implicit https vs explicit :443 match" "TOK_DEFPORT" defported git.example:443
assert_token "implicit https vs :8443 rejected" "" defported git.example:8443
# 8. An UNQUOTED token whose raw text PyYAML's implicit resolver types as a
# NON-string (int / null / bool / float) must fail closed: PyYAML yields a
# non-str value that _accept rejects, so the fallback must NOT surface the
# stringified scalar as a credential. Each raw form fails closed IDENTICALLY
# to PyYAML (a prior residual emitted "12345"/"null"/"true"/etc. here).
assert_nonstring_token_fails_closed() {
local desc="$1" raw="$2"
write_fixture "logins:
- name: primary
url: https://git.example
token: ${raw}
"
assert_token "$desc" "" primary git.example
}
assert_nonstring_token_fails_closed "unquoted int token fails closed" "12345"
assert_nonstring_token_fails_closed "unquoted null token fails closed" "null"
assert_nonstring_token_fails_closed "unquoted tilde-null token fails closed" "~"
assert_nonstring_token_fails_closed "unquoted yes(bool) token fails closed" "yes"
assert_nonstring_token_fails_closed "unquoted true(bool) token fails closed" "true"
assert_nonstring_token_fails_closed "unquoted float token fails closed" "3.14"
# 9. A QUOTED scalar is ALWAYS a string, even when its contents look like a
# non-string implicit form. The quotes force str typing in PyYAML, so the
# fallback must accept the literal (quote-stripped) contents as the token.
write_fixture 'logins:
- name: primary
url: https://git.example
token: "12345"
'
assert_token "double-quoted digit token is a literal string" "12345" primary git.example
write_fixture "logins:
- name: primary
url: https://git.example
token: 'abc'
"
assert_token "single-quoted token is a literal string" "abc" primary git.example
# assert_fallback_fails_closed: the forced-fallback path MUST resolve no token
# (fail closed). Used for STRUCTURAL cases where PyYAML would resolve a DIFFERENT
# token (e.g. duplicate-key last-wins) — the fallback must never surface the
# wrong/stale token, so it fails closed instead; when PyYAML is present we also
# confirm it really does resolve a (divergent) token, proving the fallback is the
# strictly-more-conservative side and the case is a genuine fail-open guard.
assert_fallback_fails_closed() {
local desc="$1" login="$2" host="$3" got
got=$(token_fallback "$login" "$host")
if [[ -n "$got" ]]; then
echo "FAIL fallback [$desc]: expected fail-closed, got a token" >&2
exit 1
fi
if [[ "$HAVE_PYYAML" == true ]]; then
got=$(token_pyyaml "$login" "$host")
if [[ -z "$got" ]]; then
echo "FAIL [$desc]: expected PyYAML to resolve a divergent token" >&2
exit 1
fi
fi
}
# 10. tea's REAL on-disk shape: the `logins:` block SEQUENCE items sit at the
# SAME indentation as the key (dash at column 0), with extra scalar fields.
# The recognizer must resolve this exactly like PyYAML (regression guard so
# the stricter whole-document recognizer does not fail closed on real input).
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_REAL
default: false
ssh_host: ""
- name: other
url: https://other.example
token: TOK_REAL_OTHER
preferences:
editor: false
flags: null
'
assert_token "tea dash-at-column-0 real shape resolves" "TOK_REAL" primary git.example
assert_token "tea real shape sibling resolves own token" "TOK_REAL_OTHER" other other.example
# 11. NESTED-SHADOW: a nested `logins:` (NOT at root scope) must not be mistaken
# for the real root logins. The recognizer parses whole-document structure,
# so it selects the ROOT logins token exactly as PyYAML does — never the
# nested attacker token. (A prior line scan matched the FIRST logins at ANY
# indent and returned ATTACKER.)
write_fixture 'outer:
logins:
- name: primary
url: https://git.example
token: ATTACKER_NESTED
logins:
- name: primary
url: https://git.example
token: ROOT_TOK
'
assert_token "nested logins shadow selects ROOT token" "ROOT_TOK" primary git.example
# 12. BLOCK-SCALAR-SHADOW: text inside a YAML literal/folded block ( | or > ) is
# an OPAQUE scalar to PyYAML (so `logins` is a string, not a list) and must
# not be scanned as live logins entries. Both fail closed.
write_fixture 'logins: |
- name: primary
url: https://git.example
token: ATTACKER_BLOCK
'
assert_token "block-scalar logins value fails closed" "" primary git.example
# A folded/literal block scalar anywhere is outside the recognizer's subset, so
# the fallback fails closed (conservative) even though PyYAML can still resolve
# the real root token past the opaque scalar. Fail-closed is the safe side.
write_fixture 'note: >
logins:
- name: primary
token: ATTACKER_FOLDED
logins:
- name: primary
url: https://git.example
token: ROOT_OK
'
assert_fallback_fails_closed "folded block scalar present fails closed" primary git.example
# 13. DUPLICATE-ROOT / DUPLICATE-FIELD: a duplicated `logins:` root key (PyYAML
# last-wins) or a duplicated field within a login must fail closed rather
# than take the FIRST (stale) value. PyYAML resolves the LAST; the fallback
# refuses to guess.
write_fixture 'logins:
- name: primary
url: https://git.example
token: FIRST_DUP
logins:
- name: primary
url: https://git.example
token: LAST_DUP
'
assert_fallback_fails_closed "duplicate root logins key fails closed" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: FIRST_FIELD
token: SECOND_FIELD
'
assert_fallback_fails_closed "duplicate token field fails closed" primary git.example
# 14. MALFORMED-AFTER-VALID: a syntax error LATER in the file makes PyYAML reject
# the WHOLE document; the recognizer must too (not emit the earlier token).
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
broken: a: b: c
'
assert_token "malformed line after valid login fails closed" "" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
broken: [unclosed
'
assert_token "unclosed flow after valid login fails closed" "" primary git.example
# 15. EXTRA-DOCUMENT: a multi-document file (--- separator, or ... end marker)
# makes PyYAML safe_load reject multi-document input; the recognizer fails
# closed on ANY document marker rather than emit the first doc's token.
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
---
logins:
- name: primary
url: https://git.example
token: SECOND_DOC
'
assert_token "second document (--- separator) fails closed" "" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
...
trailing: 1
'
assert_token "end marker then more content fails closed" "" primary git.example
# 16. CONSTRUCTOR-VALIDITY / INVALID-INDICATOR: a plain scalar can match a typed
# implicit resolver (int/float/timestamp) yet be NON-constructible, or begin
# with an indicator a plain scalar may not start with. PyYAML then RAISES on
# the WHOLE document (constructor error / scanner error) and yields NO token,
# so the fallback must ALSO fail closed for the whole document -- even though
# the (unrelated) malformed key sits alongside an otherwise-valid logins
# block whose token is itself well-formed. A prior residual proved STRUCTURE
# and implicit TYPE but not constructor validity, so it ignored the malformed
# key and still emitted the valid login token (fail-open in the dangerous
# direction). assert_both_fail_closed asserts fallback == PyYAML == no token.
assert_both_fail_closed() {
local desc="$1" login="$2" host="$3" got
got=$(token_fallback "$login" "$host")
if [[ -n "$got" ]]; then
echo "FAIL fallback [$desc]: expected fail-closed, got a token" >&2
exit 1
fi
if [[ "$HAVE_PYYAML" == true ]]; then
got=$(token_pyyaml "$login" "$host")
if [[ -n "$got" ]]; then
echo "FAIL pyyaml [$desc]: expected PyYAML to also fail closed (raise/no token), got a token" >&2
exit 1
fi
fi
}
# write_bad_key_fixture: an unrelated root key carrying $1 as its plain scalar,
# followed by an otherwise-valid logins block whose token is well-formed.
write_bad_key_fixture() {
write_fixture "bad: $1
logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
"
}
# Non-constructible TIMESTAMP-tagged scalars: match the resolver, but the
# calendar field is out of range so PyYAML's datetime construction raises.
write_bad_key_fixture '2023-99-99' # month 99 / day 99 invalid
assert_both_fail_closed "bad-date 2023-99-99 fails closed like PyYAML" primary git.example
write_bad_key_fixture '2023-13-01' # month 13 invalid
assert_both_fail_closed "bad-month 2023-13-01 fails closed like PyYAML" primary git.example
write_bad_key_fixture '2023-01-15T25:00:00' # hour 25 invalid
assert_both_fail_closed "bad-hour timestamp fails closed like PyYAML" primary git.example
# Non-constructible INT-tagged scalars: match the int resolver, but the radix
# body is empty after underscore removal so int(base) raises.
write_bad_key_fixture '0b_'
assert_both_fail_closed "empty-binary 0b_ fails closed like PyYAML" primary git.example
write_bad_key_fixture '0x_'
assert_both_fail_closed "empty-hex 0x_ fails closed like PyYAML" primary git.example
write_bad_key_fixture '0x__'
assert_both_fail_closed "empty-hex 0x__ (multi-underscore) fails closed" primary git.example
# Invalid plain-scalar INDICATOR forms: a plain scalar may not begin with '%'
# (directive) or ',' (flow) -- PyYAML raises a scanner/parser error on the whole
# document, so the fallback fails closed on the leading indicator.
write_bad_key_fixture '%broken'
assert_both_fail_closed "leading-%% directive indicator fails closed" primary git.example
write_bad_key_fixture ',bad'
assert_both_fail_closed "leading-comma flow indicator fails closed" primary git.example
# Bare block indicators in a value position ('-'/'- ', '?'/'? ', ':'/': '):
# PyYAML raises a scanner error on the whole document, so the fallback must fail
# closed rather than accept the indicator as a plain-scalar string.
write_bad_key_fixture '-'
assert_both_fail_closed "bare dash (seq indicator) fails closed" primary git.example
write_bad_key_fixture '- x'
assert_both_fail_closed "dash-space (seq entry) fails closed" primary git.example
write_bad_key_fixture '? key'
assert_both_fail_closed "question-space (complex key) fails closed" primary git.example
# ...but an indicator NOT followed by whitespace is a valid plain scalar string,
# so the token still resolves (no over-broad fail-close).
write_bad_key_fixture '-x'
assert_token "dash-not-space is a plain string, token resolves" "TOK_PLAIN" primary git.example
write_bad_key_fixture ':x'
assert_token "colon-not-space is a plain string, token resolves" "TOK_PLAIN" primary git.example
# NOT over-broad: a genuinely CONSTRUCTIBLE typed scalar (or a look-alike PyYAML
# keeps as a plain string) leaves the document valid, so BOTH still resolve the
# login token -- the fix must not fail closed on these.
write_bad_key_fixture '2023-01-15'
assert_token "valid date unrelated key still resolves token" "TOK_PLAIN" primary git.example
write_bad_key_fixture '2023-01-15 10:00:00'
assert_token "valid datetime unrelated key still resolves token" "TOK_PLAIN" primary git.example
# '0o_' is NOT matched by PyYAML's int resolver (YAML 1.1 octal is 0[0-7]+, not
# 0o...), so PyYAML keeps it a STRING and resolves the token; the fallback must
# agree (no spurious fail-close).
write_bad_key_fixture '0o_'
assert_token "0o_ is a plain string in PyYAML, token still resolves" "TOK_PLAIN" primary git.example
# '4.e8' matches the fallback's (superset) float pattern but PyYAML keeps it a
# string; either way it is constructible, so the token still resolves in both.
write_bad_key_fixture '4.e8'
assert_token "4.e8 float look-alike still resolves token" "TOK_PLAIN" primary git.example
# A valid radix int as an unrelated key must not fail closed.
write_bad_key_fixture '0x1f'
assert_token "valid hex int unrelated key still resolves token" "TOK_PLAIN" primary git.example
# 17. TAB / SCANNER PARITY: PyYAML raises a ScannerError on a tab used anywhere
# outside a quoted scalar -- leading, trailing, or embedded in a plain value,
# immediately after a key colon, before a key colon, or as indentation -- and
# yields NO token, accepting tabs ONLY inside single/double-quoted scalars
# (where the tab is preserved as string content). A prior fallback swallowed
# those tabs (via .strip()/.rstrip() normalization and [ \t] key separators)
# and still emitted the login token -- a fail-open in the dangerous direction.
# The recognizer now fails CLOSED for the whole document on any tab PyYAML
# rejects, while preserving the tabs PyYAML keeps (inside quotes). All tab
# positions were verified empirically against PyYAML 6.0.3 (ScannerError for
# each rejected position; string-preserved for quoted inner tabs).
TAB=$'\t'
# Fail-close: a tab in a plain value position (trailing / leading / embedded).
write_bad_key_fixture "l4o${TAB}"
assert_both_fail_closed "trailing tab in plain value fails closed" primary git.example
write_bad_key_fixture "${TAB}9"
assert_both_fail_closed "leading tab in plain value fails closed" primary git.example
write_bad_key_fixture "a${TAB}b"
assert_both_fail_closed "embedded tab in plain value fails closed" primary git.example
# Fail-close: a tab immediately after the key colon (no separating space).
write_fixture "bad:${TAB}9
logins:
- name: primary
url: https://git.example
token: TOK_PLAIN
"
assert_both_fail_closed "tab immediately after key colon fails closed" primary git.example
# Fail-close: a tab used as indentation (before a sequence dash).
write_fixture "logins:
${TAB}- name: primary
url: https://git.example
token: TOK_PLAIN
"
assert_both_fail_closed "tab used as indentation fails closed" primary git.example
# Fail-close: a tab trailing a sequence-mapping field value.
write_fixture "logins:
- name: primary
url: https://git.example
token: TOK_PLAIN${TAB}
"
assert_both_fail_closed "tab trailing a seq field value fails closed" primary git.example
# NOT over-broad: a tab strictly INSIDE a quoted scalar is valid YAML (PyYAML
# keeps it as string content), so the document parses and the login token still
# resolves in BOTH paths -- double-quoted and single-quoted.
write_bad_key_fixture "\"a${TAB}b\""
assert_token "tab inside a double-quoted value still resolves token" "TOK_PLAIN" primary git.example
write_bad_key_fixture "'a${TAB}b'"
assert_token "tab inside a single-quoted value still resolves token" "TOK_PLAIN" primary git.example
# ...and a quoted token value carrying an inner tab resolves to the exact string
# (tab preserved), identical to PyYAML's construction.
write_fixture "logins:
- name: primary
url: https://git.example
token: \"T${TAB}OK\"
"
assert_token "quoted token with inner tab resolves verbatim" "T${TAB}OK" primary git.example
# 18. CONTROL-CHARACTER FAIL-CLOSE (#865 round-9 blocker 1): PyYAML's Reader
# scans the ENTIRE raw document stream (not merely scalar contents, and NOT
# scoped by quoting) for bytes outside its printable set and raises
# ReaderError -- a WHOLE-DOCUMENT reject -- the instant one is found,
# regardless of where it sits: an unrelated field's plain scalar, inside a
# double- or single-quoted scalar, or a comment. Verified empirically against
# real installed PyYAML 6.0.3 (see detect-platform.sh's _FORBIDDEN_CONTROL
# comment): every C0 control byte {0x00-0x08, 0x0B, 0x0C, 0x0E-0x1F} plus DEL
# (0x7F) rejects in ALL THREE contexts (plain / double-quoted / single-quoted);
# only TAB(0x09), LF(0x0A), CR(0x0D) are accepted among the low byte range
# (TAB has its own narrower, position-aware coverage in section 17 above; LF/CR
# are line separators). A prior fallback ONLY guarded tabs and emitted the
# login token from documents PyYAML rejects over an UNRELATED field's control
# byte -- a dangerous fail-open (credential emission from a document PyYAML
# refuses). write_control_char_fixture writes the raw byte directly via
# printf's octal escape (never through a bash string/variable, which cannot
# hold an embedded NUL) so 0x00 is exercised faithfully alongside the rest.
write_control_char_fixture() {
local octal="$1" quote="${2:-}"
{
if [[ -n "$quote" ]]; then
printf 'bad: %sx' "$quote"
# shellcheck disable=SC2059 # deliberate: $octal supplies printf's
# own \NNN octal escape so the raw control byte reaches the file
# directly, never passing through a bash string (which truncates
# at an embedded NUL and so cannot represent byte 0x00 otherwise).
printf "\\${octal}"
printf 'y%s\n' "$quote"
else
printf 'bad: x'
# shellcheck disable=SC2059 # deliberate: $octal supplies printf's
# own \NNN octal escape so the raw control byte reaches the file
# directly, never passing through a bash string (which truncates
# at an embedded NUL and so cannot represent byte 0x00 otherwise).
printf "\\${octal}"
printf 'y\n'
fi
printf 'logins:\n - name: primary\n url: https://git.example\n token: TOK_PLAIN\n'
} > "$FIXTURE_XDG/tea/config.yml"
}
# Plain (unquoted) unrelated-field placement: the full empirically-confirmed
# forbidden C0/DEL set.
for octal in 000 001 002 003 004 005 006 007 010 013 014 \
016 017 020 021 022 023 024 025 026 027 \
030 031 032 033 034 035 036 037 177; do
write_control_char_fixture "$octal"
assert_both_fail_closed "control byte \\$octal in unrelated plain field fails closed" primary git.example
done
# Inside-quote variants (double and single) for the six bytes called out
# explicitly in the round-9 blocker report: 0x00,0x01,0x07,0x0e,0x1f,0x7f.
for octal in 000 001 007 016 037 177; do
write_control_char_fixture "$octal" '"'
assert_both_fail_closed "control byte \\$octal inside double-quoted unrelated field fails closed" primary git.example
write_control_char_fixture "$octal" "'"
assert_both_fail_closed "control byte \\$octal inside single-quoted unrelated field fails closed" primary git.example
done
# Same forbidden byte inside a comment line -- PyYAML's Reader check is
# stream-wide, so it rejects here too, not merely inside live scalar content.
{
printf '# note'
printf '\007'
printf 'here\nlogins:\n - name: primary\n url: https://git.example\n token: TOK_PLAIN\n'
} > "$FIXTURE_XDG/tea/config.yml"
assert_both_fail_closed "control byte in a comment line fails closed" primary git.example
# NOT over-broad: TAB/LF/CR remain accepted where PyYAML already accepts them
# (covered by section 17's tab fixtures and the ordinary newline-delimited
# fixtures used throughout this file), so no additional assertion is needed
# here beyond confirming the forbidden-control guard does not fire on them.
# 19. UNSIGNED-EXPONENT FLOAT OVER-REJECTION (#865 round-9 blocker 2): PyYAML
# 6.0.3's implicit float resolver requires an EXPLICIT SIGN on the exponent
# ([eE][-+][0-9]+); an unsigned exponent is NOT matched, so PyYAML resolves
# the scalar as a plain STRING, not a float. A prior fallback's float
# recognizer accepted an OPTIONAL sign ([eE][-+]?[0-9]+), over-matching these
# spellings as floats and dropping the token PyYAML would emit verbatim
# (over-rejection). Verified empirically against real PyYAML 6.0.3.
for form in '1.0e10' '+1.0e10' '-1.0e10' '1.0E10' '.5e10' '4.e8'; do
write_fixture "logins:
- name: primary
url: https://git.example
token: ${form}
"
assert_token "unsigned-exponent form '$form' is a PyYAML string, token resolves" "$form" primary git.example
done
# Parity guard: a genuine SIGNED-exponent float is still typed as a non-string
# float by PyYAML and must still fail closed (not regress into over-acceptance).
write_fixture 'logins:
- name: primary
url: https://git.example
token: 1.0e+10
'
assert_token "signed-exponent genuine float still fails closed" "" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: 1.0e-10
'
assert_token "signed-exponent (negative) genuine float still fails closed" "" primary git.example
# 20. RESIDUAL OVER-REJECTION found via round-9 differential fuzzing (folded into
# this round, not split off): a plain scalar starting with "?" NOT followed by
# whitespace (e.g. "?x") is a valid PyYAML string -- only a bare "?" or "? "
# (question mark followed by space/EOL) opens a complex mapping key and is
# illegal in a value position. A prior blanket-reject set treated EVERY
# leading "?" as illegal, over-rejecting a token PyYAML accepts verbatim.
write_fixture 'logins:
- name: primary
url: https://git.example
token: ?x
'
assert_token "question-mark-not-space is a plain string, token resolves" "?x" primary git.example
# 21. PRINTABLE-BOUNDARY FAIL-CLOSE (#865 round-10 blocker 1): the round-9 guard
# used a hand-rolled C0/DEL subset that MISSED code points PyYAML's Reader
# also rejects -- the C1 block (U+0080-0084, U+0086-009F) and the BMP
# noncharacters U+FFFE/U+FFFF -- so the fallback still emitted the token from
# documents PyYAML rejects whole (fail-open). The guard now uses PyYAML
# 6.0.3's EXACT Reader.NON_PRINTABLE character class (see detect-platform.sh
# _FORBIDDEN_CONTROL). Verified empirically against real PyYAML 6.0.3:
# PRINTABLE = {0x09,0x0A,0x0D, 0x20-0x7E, 0x85(NEL), 0xA0-0xD7FF,
# 0xE000-0xFFFD, 0x10000-0x10FFFF}; everything else fails the whole document
# closed. write_codepoint_fixture emits a chosen Unicode code point's real
# UTF-8 bytes (via python3, since bash strings cannot faithfully carry many
# of these) into a selectable position, then the login block follows.
write_codepoint_fixture() {
# $1 = hex code point (e.g. 0x80); $2 = position: field|comment|token|nelterm
CP_HEX="$1" CP_POS="$2" python3 - "$FIXTURE_XDG/tea/config.yml" <<'PY'
import sys
cp = int(__import__("os").environ["CP_HEX"], 16)
pos = __import__("os").environ["CP_POS"]
ch = chr(cp)
head = "logins:\n - name: primary\n url: https://git.example\n token: TOK_PLAIN\n"
if pos == "field":
doc = head + "other: x" + ch + "y\n"
elif pos == "comment":
doc = head + "# note x" + ch + "y here\n"
elif pos == "token":
doc = "logins:\n - name: primary\n url: https://git.example\n token: T" + ch + "K\n"
elif pos == "nelterm":
# NEL (U+0085) used as the line terminator throughout: PyYAML treats it as a
# line break (printable, NOT a ReaderError) and resolves the token; the
# fallback's _split_logical_lines splits on NEL identically OUTSIDE a quote
# -> parity, token resolves. (Round 23 covers NEL/LS/PS INSIDE a quote, where
# PyYAML folds rather than breaks and a naive splitlines() would over-split.)
doc = ("logins:" + ch + " - name: primary" + ch
+ " url: https://git.example" + ch + " token: TOK_NEL" + ch)
else:
raise SystemExit("bad pos")
with open(sys.argv[1], "w", encoding="utf-8") as f:
f.write(doc)
PY
}
# C1-block + BMP-noncharacter code points fail the WHOLE document closed in an
# unrelated field and in a comment, exactly as PyYAML's ReaderError does.
for cphex in 0x80 0x81 0x84 0x86 0x9f 0xfffe 0xffff; do
write_codepoint_fixture "$cphex" field
assert_both_fail_closed "code point $cphex in unrelated field fails closed" primary git.example
write_codepoint_fixture "$cphex" comment
assert_both_fail_closed "code point $cphex in a comment fails closed" primary git.example
done
# NOT over-broad: printable code points PyYAML ACCEPTS must still resolve the
# token in BOTH paths -- NEL(0x85) as a line separator, U+00A0 (NBSP) inside a
# value, and an astral code point (U+1F600) inside the token value.
write_codepoint_fixture 0x85 nelterm
assert_token "NEL (U+0085) line-terminator resolves token" "TOK_NEL" primary git.example
write_codepoint_fixture 0xa0 field
assert_token "U+00A0 in unrelated value still resolves token" "TOK_PLAIN" primary git.example
write_codepoint_fixture 0x1f600 token
assert_token "astral U+1F600 inside token resolves verbatim" "$(printf 'T\360\237\230\200K')" primary git.example
# 22. INTERNAL-INDICATOR OVER-REJECTION (#865 round-10 blocker 2): the round-9
# recognizer blanket-rejected any plain scalar CONTAINING a flow indicator
# ([]{}*&!), but in BLOCK context PyYAML treats ',[]{}' as ordinary content
# and treats '!&*#...' as significant ONLY at the FIRST non-space char. So an
# INTERNAL indicator is legal plain-string content and PyYAML emits the token
# verbatim; the fallback dropped it (over-rejection). Verified empirically
# against real PyYAML 6.0.3. The fix removes the blanket internal scan while
# the leading-char guard and the ' #'/': '/trailing-':' guards keep the
# fail-OPEN direction shut.
for tv in 'a!b' 'a,b' 'a[b' 'a]b' 'a{b' 'a}b' 'a&b' 'a*b' 'a[b]c' 'a{b}c' 'a,b,c' 'a#b' 'a:b'; do
write_fixture "logins:
- name: primary
url: https://git.example
token: ${tv}
"
assert_token "internal-indicator token '$tv' resolves verbatim" "$tv" primary git.example
done
# Fail-OPEN direction stays shut: a LEADING indicator, a ' #' comment tail, an
# internal ': ' (colon-space) inline map, and a trailing ':' each make PyYAML
# resolve NO usable string token (tag/flow/anchor reject or None, comment strip,
# or a mapping), so BOTH must fail closed. (Leading tag/anchor/flow are the
# documented, round-9-approved structural fail-closed class; kept intact here.)
write_fixture 'logins:
- name: primary
url: https://git.example
token: !x
'
assert_both_fail_closed "leading '!' tag token fails closed" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: [a]
'
assert_both_fail_closed "leading '[' flow-seq token fails closed" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: a # trailing comment
'
# ' #' comment tail: PyYAML strips the comment -> token is the string 'a', which
# still resolves. This is the NOT-over-broad boundary partner of the guard.
assert_token "space-hash comment tail strips to plain token" "a" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: a: b
'
assert_both_fail_closed "internal colon-space (inline map) token fails closed" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: ab:
'
assert_both_fail_closed "trailing colon (map indicator) token fails closed" primary git.example
# 23. EMBEDDED LINE-BREAK INSIDE A QUOTED SCALAR (#865 round-11 blocker A): the
# round-10 fallback split the raw document with str.splitlines(), which breaks
# at NEL(U+0085), LS(U+2028) and PS(U+2029) -- code points that are PRINTABLE
# to PyYAML's Reader. Inside a flow (quoted) scalar PyYAML does NOT break at
# these: it LINE-FOLDS a double/single-quoted scalar (NEL/LF/CR -> a single
# space; LS/PS -> the char verbatim), so it resolves ONE token, while
# splitlines() cut the value mid-quote and failed the whole document closed
# (over-rejection). The fallback now uses _split_logical_lines, which
# reproduces PyYAML's flow-folding. Verified empirically vs real PyYAML 6.0.3.
# write_break_fixture emits a chosen break code point in a selectable context.
write_break_fixture() {
# $1 = hex code point of the break; $2 = context: dq|sq|plain|comment|dq2
CP_HEX="$1" Q_STYLE="$2" python3 - "$FIXTURE_XDG/tea/config.yml" <<'PY'
import sys, os
cp = int(os.environ["CP_HEX"], 16)
q = os.environ["Q_STYLE"]
ch = chr(cp)
head = "logins:\n - name: primary\n url: https://git.example\n token: "
if q == "dq":
doc = head + '"tok' + ch + 'en"'
elif q == "sq":
doc = head + "'tok" + ch + "en'"
elif q == "plain":
doc = head + "tok" + ch + "en"
elif q == "dq2":
# blank line inside a quoted scalar: PyYAML folds a two-break run to a literal
# newline, which the recognizer's key regex cannot carry -> endorsed
# fail-closed over-reject (see assert_fallback_fails_closed below).
doc = head + '"tok' + ch + ch + 'en"'
elif q == "comment":
doc = ("logins:\n - name: primary\n url: https://git.example\n"
" token: TOK_PLAIN\n# c" + ch + "x")
else:
raise SystemExit("bad q")
with open(sys.argv[1], "w", encoding="utf-8") as f:
f.write(doc + "\n")
PY
}
# NEL folds to a single space inside double- AND single-quoted scalars: the token
# resolves identically in both paths (fallback no longer over-splits).
write_break_fixture 0x85 dq
assert_token "NEL inside double-quote folds to space, token resolves" "tok en" primary git.example
write_break_fixture 0x85 sq
assert_token "NEL inside single-quote folds to space, token resolves" "tok en" primary git.example
# LS(U+2028)/PS(U+2029) are preserved VERBATIM by PyYAML's flow fold (they are
# not \n-class breaks); the fallback must surface them byte-for-byte.
write_break_fixture 0x2028 dq
assert_token "LS inside double-quote is verbatim" "$(printf 'tok\342\200\250en')" primary git.example
write_break_fixture 0x2028 sq
assert_token "LS inside single-quote is verbatim" "$(printf 'tok\342\200\250en')" primary git.example
write_break_fixture 0x2029 dq
assert_token "PS inside double-quote is verbatim" "$(printf 'tok\342\200\251en')" primary git.example
# Direction-sensitivity: the SAME code points UNQUOTED (a plain scalar) or in a
# COMMENT make PyYAML raise a scanner error, so both paths must fail closed. The
# fold rule applies ONLY inside a quoted scalar.
write_break_fixture 0x85 plain
assert_token "NEL in an unquoted plain scalar fails closed" "" primary git.example
write_break_fixture 0x2028 plain
assert_token "LS in an unquoted plain scalar fails closed" "" primary git.example
write_break_fixture 0x85 comment
assert_token "NEL in a comment fails closed" "" primary git.example
# Endorsed fail-closed over-reject: a blank line inside a quoted scalar folds to a
# literal newline that the recognizer cannot carry -- PyYAML resolves a
# (newline-bearing) token, the fallback fails closed (strictly safer).
write_break_fixture 0x85 dq2
assert_fallback_fails_closed "blank-line-in-quote (NEL run) fails closed" primary git.example
# 24. LEADING NON-SPECIFIC TAG / ANCHOR PROPERTY (#865 round-11 blocker B, revised
# in round 12): the round-10 recognizer blanket-rejected any scalar beginning
# with '!' or '&'. Round 11 taught it to strip a transparent NON-SPECIFIC tag
# ('! ' bang + SPACE) and a transparent plain ANCHOR ('&name ') so '! x' /
# '&a x' resolve the STRING 'x', matching PyYAML. Round 12 discovered that the
# anchor half of that was a HIGH fail-open: PyYAML's Composer tracks anchor
# NAMES in a document-scoped registry and raises ComposerError ("found
# duplicate anchor") the instant the SAME name is declared on a SECOND node
# ANYWHERE in the document (even an unrelated one) -- the fallback's
# per-scalar-only view has no such registry and would emit the later token.
# Round 12's fix: reject EVERY '&'-anchor property, unconditionally. The
# transparent NON-SPECIFIC TAG behavior ('! x' -> 'x') is unchanged and still
# verified below; only the anchor half now fails closed (deliberate
# conservative over-reject, verified safe both ways against real PyYAML 6.0.3).
for pair in '! x=x' '! x y=x y' '! "q"=q' "! 'q'=q"; do
tv="${pair%%=*}"; want="${pair#*=}"
write_fixture "logins:
- name: primary
url: https://git.example
token: ${tv}
"
assert_token "tag property token '$tv' resolves node string" "$want" primary git.example
done
# Fail-OPEN direction stays shut. '!x' (bang + NON-space) is a tag HANDLE ->
# ConstructorError; '!foo x'/'* a'/'! !x' raise; a property over a NON-string node
# ('! 123'/'! true'/'! null') types non-str -> no usable token. All fail closed in
# BOTH paths.
for tv in '!x' '!foo x' '* a' '! !x' '! 123' '! true' '! null' '&a &b x'; do
write_fixture "logins:
- name: primary
url: https://git.example
token: ${tv}
"
assert_token "non-resolving property token '$tv' fails closed" "" primary git.example
done
# Round 12: a SINGLE, non-duplicated '&a x' is valid YAML that real PyYAML
# resolves to the string 'x' (round-11 behavior, and still true of the oracle).
# The fallback now rejects it anyway -- a deliberate, endorsed CONSERVATIVE
# over-reject (see the round-12 comment block above): fail-closed can only cost
# an emitted token PyYAML would have allowed, never emit one PyYAML rejects, and
# a per-scalar recognizer cannot safely prove document-wide anchor-name
# uniqueness. assert_fallback_fails_closed also confirms PyYAML really does
# resolve a token here, proving this is a genuine (safe-direction) divergence
# and not an accidental parity loss.
write_fixture 'logins:
- name: primary
url: https://git.example
token: &a x
'
assert_fallback_fails_closed "round-12: single non-duplicated anchor '&a x' now fails closed (conservative over-reject; PyYAML resolves x)" primary git.example
# Same over-reject for the combined tag+anchor forms round 11 used to resolve.
write_fixture 'logins:
- name: primary
url: https://git.example
token: ! &b x
'
assert_fallback_fails_closed "round-12: '! &b x' (tag+anchor) now fails closed (conservative over-reject)" primary git.example
write_fixture 'logins:
- name: primary
url: https://git.example
token: &b ! x
'
assert_fallback_fails_closed "round-12: '&b ! x' (anchor+tag) now fails closed (conservative over-reject)" primary git.example
# Endorsed fail-closed over-reject: an EXPLICIT tag ('!!str x', verbose
# '!<tag:yaml.org,2002:str> x') forces a string PyYAML resolves, but the fallback
# recognizes only the transparent non-specific tag and fails closed (safer).
for tv in '!!str x' '!<tag:yaml.org,2002:str> x'; do
write_fixture "logins:
- name: primary
url: https://git.example
token: ${tv}
"
assert_fallback_fails_closed "explicit-tag token '$tv' fails closed" primary git.example
done
# 25. #865 round 12 HIGH fail-open closure: DUPLICATE ANCHOR NAME across separate
# nodes. Real PyYAML's Composer tracks anchor names in a DOCUMENT-SCOPED
# registry and raises ComposerError ("found duplicate anchor ... first
# occurrence") the instant the SAME anchor name is declared a second time
# ANYWHERE in the document -- failing the WHOLE document closed, no token,
# regardless of how far the duplicate sits from the logins block. The round-11
# fallback tracked anchors only WITHIN a single scalar's `_strip_properties`
# call, so it had no visibility into a duplicate declared on an unrelated
# node and would still emit the (later) token: fail-open. The round-12 fix
# (reject every '&'-anchor property, unconditionally -- see section 24 above)
# closes this as a strict superset: since NO anchor is ever accepted, a
# duplicate anchor can never slip through. These cases exercise that
# document-wide duplicate-anchor invariant specifically (as opposed to
# section 24's single-anchor-on-the-token-field cases) and pair each fallback
# assertion with confirmation that real PyYAML also fails closed here (via
# ComposerError), proving this was a genuine fail-open, not a hypothetical.
write_fixture 'first: &same one
second: &same two
logins:
- name: primary
url: https://git.example
token: TOK_DUP_ROOT
'
assert_both_fail_closed "round-12: duplicate anchor name on two unrelated root nodes fails closed" primary git.example
write_fixture 'outer:
nested: &dup x
dup_root: &dup y
logins:
- name: primary
url: https://git.example
token: TOK_DUP_NESTED
'
assert_both_fail_closed "round-12: duplicate anchor name across a nested node and a root node fails closed" primary git.example
write_fixture 'first: &dup one
logins:
- name: primary
url: https://git.example
token: &dup TOK_DUP_TOKEN_NODE
'
assert_both_fail_closed "round-12: anchor name declared earlier and repeated on the token-bearing node fails closed" primary git.example
# Regression guard: the non-specific TAG half of section 24 ('! x' -> 'x') is
# UNCHANGED by the round-12 anchor fix and must still resolve.
write_fixture 'logins:
- name: primary
url: https://git.example
token: ! x
'
assert_token "round-12 regression: '! x' (tag, no anchor) still resolves 'x'" "x" primary git.example
echo "Gitea login resolution regression harness passed"
@@ -0,0 +1,217 @@
#!/usr/bin/env bash
# Regression harness for detect-platform.sh's get_gitea_token() per-agent
# identity resolution (Gate-16 author≠reviewer separation) — the API-tooling
# counterpart to git-credential-mosaic, so pr-create.sh/issue-create.sh/etc.
# open records under the resolved agent identity, not the shared account.
#
# Covers:
# 1. Identity resolution priority: MOSAIC_GIT_IDENTITY env > git config
# mosaic.gitIdentity (per-worktree).
# 2. Correct per-slot token file path chosen per host
# (gitea-usc-<id>.token vs gitea-mosaicstack-<id>.token).
# 3. Per-slot token present -> that token is returned (agent-authored calls).
# 4. No identity requested -> shared credential-loader token (backward
# compat, unchanged).
# 5. Patch 2b — explicit identity + recognized Gitea host + ABSENT per-slot
# token for that identity -> FAIL LOUD (nonzero return, empty stdout, a
# stderr diagnostic naming identity/source/host/expected path). Must NOT
# fall through to the shared/default token (Gate-16 author≠reviewer
# integrity — never silently borrow another slot's credentials). Covered
# for both identity sources (git config, MOSAIC_GIT_IDENTITY env) and
# for a same-identity cross-host case (token exists for one host, not
# the other).
# 6. Scope containment: identity requested + an UNRECOGNIZED Gitea host (no
# per-slot token scheme) -> Patch 2b does not apply; existing
# fall-through behavior is unchanged.
#
# Uses a stubbed credentials.json + stubbed per-slot token files under a fake
# HOME. NEVER reads real secrets or touches the real ~/.config/mosaic/secrets.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-token-identity}"
FAKE_HOME="$WORK_DIR/home"
REPO_DIR="$WORK_DIR/repo"
CREDENTIALS_FILE="$FAKE_HOME/.config/mosaic/credentials.json"
rm -rf "$WORK_DIR"
mkdir -p "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens" "$REPO_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
cat > "$CREDENTIALS_FILE" <<'JSON'
{
"gitea": {
"mosaicstack": {
"url": "https://git.mosaicstack.dev",
"token": "shared-mosaicstack-token"
},
"usc": {
"url": "https://git.uscllc.com",
"token": "shared-usc-token"
}
}
}
JSON
fail=0
assert_eq() {
local desc="$1" expected="$2" actual="$3"
if [[ "$expected" != "$actual" ]]; then
echo "FAIL: $desc — expected '$expected', got '$actual'" >&2
fail=1
fi
}
# Runs get_gitea_token for $1=host inside REPO_DIR (per-worktree git config
# resolves there) with a fake HOME + the stub credentials.json, plus any
# extra env passed in $@.
call_get_gitea_token() {
local host="$1"; shift
(
cd "$REPO_DIR"
# shellcheck disable=SC2016 # deliberately deferred: $DETECT_PLATFORM_SH is
# expanded by the INNER bash -c (via the exported env var below), not here.
env -i HOME="$FAKE_HOME" PATH="$PATH" MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
DETECT_PLATFORM_SH="$SCRIPT_DIR/detect-platform.sh" "$@" \
bash -c 'source "$DETECT_PLATFORM_SH"; get_gitea_token "$1"' _ "$host"
)
}
# ---------------------------------------------------------------------------
# 1. No identity resolvable -> shared credential-loader token (unchanged).
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config --unset mosaic.gitIdentity 2>/dev/null || true
out=$(call_get_gitea_token "git.mosaicstack.dev")
assert_eq "shared fallback (no identity)" "shared-mosaicstack-token" "$out"
# ---------------------------------------------------------------------------
# 2. git config mosaic.gitIdentity resolves to an agent WITH a per-slot
# token -> that token wins over the shared account.
# ---------------------------------------------------------------------------
echo -n "agentA-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentA.token"
git -C "$REPO_DIR" config mosaic.gitIdentity agentA
out=$(call_get_gitea_token "git.mosaicstack.dev")
assert_eq "git-config identity token" "agentA-mosaicstack-token" "$out"
# ---------------------------------------------------------------------------
# 3. MOSAIC_GIT_IDENTITY env beats git config mosaic.gitIdentity.
# ---------------------------------------------------------------------------
echo -n "agentB-mosaicstack-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentB.token"
out=$(call_get_gitea_token "git.mosaicstack.dev" MOSAIC_GIT_IDENTITY=agentB)
assert_eq "env beats git-config identity token" "agentB-mosaicstack-token" "$out"
# ---------------------------------------------------------------------------
# 4. FAIL LOUD (Patch 2b): an identity is explicitly requested (via git config
# mosaic.gitIdentity, and separately via MOSAIC_GIT_IDENTITY env) for a
# RECOGNIZED Gitea host, but no per-slot token exists for THAT identity.
# Must NOT fall through to the shared/default token — silently borrowing
# another slot's credentials would post PRs/issues/reviews as the WRONG
# agent (Gate-16 author≠reviewer integrity break). Expect: nonzero return,
# EMPTY stdout (no token — shared or otherwise — leaked), and a stderr
# diagnostic naming the identity, its source, the host, and the expected
# per-slot token path.
# ---------------------------------------------------------------------------
assert_failloud() {
local desc="$1" host="$2" ident="$3" expected_tok_path="$4"; shift 4
local stderr_file="$WORK_DIR/stderr.tmp"
: > "$stderr_file"
set +e
local stdout
stdout=$(call_get_gitea_token "$host" "$@" 2>"$stderr_file")
local rc=$?
set -e
local stderr
stderr=$(cat "$stderr_file")
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: $desc — expected nonzero return, got 0 (stdout='$stdout')" >&2
fail=1
fi
if [[ -n "$stdout" ]]; then
echo "FAIL: $desc — expected empty stdout (no token leaked), got '$stdout'" >&2
fail=1
fi
if [[ "$stderr" != *"$ident"* ]]; then
echo "FAIL: $desc — stderr does not name the requested identity '$ident':" >&2
echo "$stderr" >&2
fail=1
fi
if [[ "$stderr" != *"$host"* ]]; then
echo "FAIL: $desc — stderr does not name the host '$host':" >&2
echo "$stderr" >&2
fail=1
fi
if [[ "$stderr" != *"$expected_tok_path"* ]]; then
echo "FAIL: $desc — stderr does not name the expected per-slot token path '$expected_tok_path':" >&2
echo "$stderr" >&2
fail=1
fi
if [[ "$stderr" == *"shared"*"token"* ]]; then
echo "FAIL: $desc — stderr unexpectedly mentions a shared token value:" >&2
echo "$stderr" >&2
fail=1
fi
}
# 4a. git config mosaic.gitIdentity source, recognized host (mosaicstack),
# shared token IS present but must not be borrowed.
git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent
assert_failloud "fail-loud via git-config identity (recognized host)" \
"git.mosaicstack.dev" "no-such-agent" \
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-no-such-agent.token"
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
# 4b. MOSAIC_GIT_IDENTITY env source (takes priority over git config), same
# recognized-host / absent-token scenario -> also fails loud.
assert_failloud "fail-loud via MOSAIC_GIT_IDENTITY env (recognized host)" \
"git.mosaicstack.dev" "no-such-agent-env" \
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-no-such-agent-env.token" \
MOSAIC_GIT_IDENTITY=no-such-agent-env
# ---------------------------------------------------------------------------
# 5. Correct per-slot token PATH per host: same agent id, only a usc token
# exists. usc host returns it (happy path, unchanged). mosaicstack host
# has NO per-slot token for this identity -> Patch 2b fail-loud applies
# there too (must NOT fall back to the shared mosaicstack token, and must
# NOT leak the agent's usc token either).
# ---------------------------------------------------------------------------
echo -n "agentD-usc-token" > "$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-usc-agentD.token"
git -C "$REPO_DIR" config mosaic.gitIdentity agentD
out=$(call_get_gitea_token "git.uscllc.com")
assert_eq "host-scoped token path (usc)" "agentD-usc-token" "$out"
assert_failloud "fail-loud on cross-host absence (no fallback, no cross-host leak)" \
"git.mosaicstack.dev" "agentD" \
"$FAKE_HOME/.config/mosaic/secrets/gitea-tokens/gitea-mosaicstack-agentD.token"
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
# ---------------------------------------------------------------------------
# 6. Scope containment: identity explicitly requested, but the host is NOT a
# recognized Gitea host (no per-slot token scheme at all) -> Patch 2b does
# NOT apply; existing fall-through behavior is unchanged (ends in the
# pre-existing generic failure since no shared credentials match either,
# NOT the fail-loud diagnostic path).
# ---------------------------------------------------------------------------
git -C "$REPO_DIR" config mosaic.gitIdentity no-such-agent
set +e
out=$(call_get_gitea_token "github.com" 2>"$WORK_DIR/stderr-scope.tmp")
rc=$?
set -e
err=$(cat "$WORK_DIR/stderr-scope.tmp")
if [[ "$rc" -eq 0 ]]; then
echo "FAIL: unrecognized host + identity — expected nonzero (no credentials configured), got 0" >&2
fail=1
fi
if [[ "$err" == *"no per-slot token at"* ]]; then
echo "FAIL: unrecognized host + identity — fail-loud diagnostic must not fire for a host with no per-slot scheme:" >&2
echo "$err" >&2
fail=1
fi
git -C "$REPO_DIR" config --unset mosaic.gitIdentity
if [[ "$fail" -eq 0 ]]; then
echo "get_gitea_token identity resolution regression passed"
fi
exit "$fail"
@@ -0,0 +1,571 @@
#!/usr/bin/env bash
# Regression harness for issue-comment.sh's Gitea comment write + verification
# (#865).
#
# The #865 defect class: tea 0.11.1's `tea issue comment ...` (a nonexistent
# subcommand) silently no-ops yet exits 0, and tea cannot emit the id of a
# record it created — so an exit code is worthless as proof of a durable write.
# The wrapper therefore does NOT write via tea at all. It POSTs the comment to
# the Gitea REST API (which returns the created comment object, including its
# id), then GETs THAT EXACT id back and requires it to match on id, author
# (acting identity), body, and issue. Because verification is keyed to the id
# the create returned, no concurrent comment can masquerade as this write, and a
# suppressed/no-op create yields no id and fails closed.
#
# This harness models a REAL server: the curl stub keeps persistent comment
# state on disk, the POST actually CREATES and PERSISTS a record and returns its
# id, and the read-back GET reads that same state. There is no independently
# fabricated record for the wrapper to "find" — the only way verification
# passes is if the POST genuinely created the record the read-back retrieves.
# It proves the wrapper:
# 1. never shells out to tea to write (no `tea comment` / `tea issue comment`);
# 2. creates the comment via REST POST and learns the provider-returned id;
# 3. verifies THAT EXACT id by direct GET, attributed to the acting identity;
# 4. fails closed when the write is a no-op even though a concurrent
# SAME-IDENTITY comment with the same body already exists (the closed
# concurrency window — no fallback list scan can rescue a no-op);
# 5. fails closed when the created record is not authored by the acting
# identity;
# 6. treats the exact-id GET as the SOLE authority — it performs NO follow-up
# list enumeration (the stub exposes no comment-list endpoint, so any
# residual enumeration attempt would fail the run);
# 7. with a RESOLVABLE --login override, performs the write, the /user identity
# lookup, and the read-back ALL under THAT login's token/identity — never
# the host default;
# 8. with an UNRESOLVABLE --login override, FAILS CLOSED (nonzero, no write, no
# success line) instead of silently downgrading to the host default
# identity — the token seam maps each bearer token to the identity it
# authenticates as, so a misattributed write is caught;
# 9. with a --login override whose tea config URL is a DIFFERENT host than the
# repo remote, FAILS CLOSED (host-bound token selection) rather than sending
# that other host's credential cross-host;
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
# success or the failure path — nested function-scoped RETURN traps do not
# clobber each other and every scratch file is removed on all exit paths.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-readback}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
XDG_DIR="$WORK_DIR/xdg"
TEA_LOG="$WORK_DIR/tea.log"
CURL_LOG="$WORK_DIR/curl.log"
# Full curl argv per invocation — proves the bearer token never rides in argv.
CURL_ARGV_LOG="$WORK_DIR/curl-argv.log"
AUTH_LOG="$WORK_DIR/auth.log"
OUTPUT_FILE="$WORK_DIR/output.log"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
STATE_FILE="$WORK_DIR/comments.json"
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
# check can assert every POST/GET body + metadata temp file is cleaned up.
TMP_SCRATCH="$WORK_DIR/scratch"
cleanup() {
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
ISSUE_NUMBER=7
REPO_SLUG="mosaicstack/stack"
API_BASE="https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack"
API_ROOT="https://git.mosaicstack.dev/api/v1"
BODY='durable "note" -- marker'
ACTING_LOGIN="primary-reviewer"
FOREIGN_LOGIN="other-writer"
# A dedicated per-role --login override identity, with its own token stored in
# tea's config (exactly the author-not-equal-reviewer hardening path).
OVERRIDE_LOGIN="delegated-reviewer"
DEFAULT_TOKEN="test-only-placeholder"
OVERRIDE_TOKEN="override-token-placeholder"
# A --login override whose tea config URL points at a DIFFERENT Gitea host than
# the repo remote (git.mosaicstack.dev). Its token must NEVER be sent to the
# repo host: host-bound selection must fail closed on the host mismatch.
CROSS_HOST_LOGIN="foreign-host-reviewer"
CROSS_HOST_TOKEN="cross-host-token-placeholder"
# tea config: the override login has its own token here (as tea itself stores
# per-login tokens). The default login name ("mosaicstack") is deliberately NOT
# present, so the no-override default path resolves via the host credential
# fallback while an explicit --login must resolve from this file or fail closed.
# A second login is configured for a DIFFERENT host to exercise host-bound
# rejection.
mkdir -p "$XDG_DIR/tea"
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
python3 - "$XDG_DIR/tea/config.yml" <<'PY'
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as handle:
handle.write("logins:\n")
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
handle.write(" url: https://git.mosaicstack.dev\n")
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
handle.write(f" - name: {os.environ['CROSS_HOST_LOGIN']}\n")
handle.write(" url: https://git.uscllc.com\n")
handle.write(f" token: {os.environ['CROSS_HOST_TOKEN']}\n")
PY
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
import json
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
json.dump({
"gitea": {
"mosaicstack": {
"url": os.environ["CONFIGURED_GITEA_URL"],
"token": "test-only-placeholder",
}
}
}, credentials)
PY
# tea stub: only ever answers the login list (used to resolve the default login
# name). It must NEVER be asked to write a comment — the wrapper writes via REST.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$ISSUE_COMMENT_TEA_LOG"
if [[ "$*" == "login list --output json" ]]; then
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
exit 0
fi
echo "Unexpected tea command (wrapper must not write via tea): $*" >&2
exit 92
SH
chmod +x "$BIN_DIR/tea"
# curl stub: a small REST server backed by persistent on-disk comment state.
# GET /user -> acting identity
# POST /issues/7/comments -> CREATE + PERSIST, return created object
# GET /issues/comments/{id} -> read the persisted record by exact id
# There is deliberately NO comment-LIST endpoint: exact-id read-back is the sole
# authority, so any residual list enumeration attempt hits the unexpected-request
# guard and fails the test.
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
# Record the FULL argv exactly as spawned, before consumption. The bearer token
# must NOT appear here — it is delivered via a curl --config file (#865 ITEM 3a),
# so only the config file PATH may show up.
printf '%s\n' "$*" >> "$ISSUE_COMMENT_CURL_ARGV_LOG"
output_file=""
method="GET"
url=""
data=""
auth_token=""
config_file=""
while [[ $# -gt 0 ]]; do
case "$1" in
-o) output_file="$2"; shift 2 ;;
-H)
[[ "$2" == Authorization:* ]] && auth_token="${2##* }"
shift 2 ;;
-K|--config) config_file="$2"; shift 2 ;;
-w) shift 2 ;;
-X) method="$2"; shift 2 ;;
-d|--data) data="$2"; shift 2 ;;
-s|-S|-sS) shift ;;
http://*|https://*) url="$1"; shift ;;
*) shift ;;
esac
done
# Resolve the bearer token from the curl --config file (its real, secure source);
# fall back to an -H header only for defense in depth. The config line is
# `header = "Authorization: token <value>"`.
if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then
config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)"
if [[ "$config_hdr" == *"token "* ]]; then
auth_token="${config_hdr##*token }"
auth_token="${auth_token%\"}"
fi
fi
path="${url%%\?*}"
query="${url#*\?}"
[[ "$query" == "$url" ]] && query=""
printf '%s %s\n' "$method" "$url" >> "$ISSUE_COMMENT_CURL_LOG"
# Map the presented bearer token to the identity it authenticates as — the same
# derivation Gitea's own /user does. The wrapper's write, /user lookup, and
# read-back must all carry the SAME token, so the acting identity recorded here
# reveals which credential actually performed the request.
acting_identity=""
case "$auth_token" in
"$ISSUE_COMMENT_DEFAULT_TOKEN") acting_identity="$ISSUE_COMMENT_ACTING_LOGIN" ;;
"$ISSUE_COMMENT_OVERRIDE_TOKEN") acting_identity="$ISSUE_COMMENT_OVERRIDE_LOGIN" ;;
"$ISSUE_COMMENT_CROSS_HOST_TOKEN") acting_identity="$ISSUE_COMMENT_CROSS_HOST_LOGIN" ;;
esac
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$ISSUE_COMMENT_AUTH_LOG"
write_response() {
local status="$1" body="$2"
[[ -n "$output_file" ]] || exit 96
printf '%s' "$body" > "$output_file"
printf '%s' "$status"
}
if [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_ROOT/user" ]]; then
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
write_response 200 "$(ISSUE_COMMENT_LOGIN="$acting_identity" python3 - <<'PY'
import json
import os
print(json.dumps({"login": os.environ["ISSUE_COMMENT_LOGIN"]}))
PY
)"
elif [[ "$method" == "POST" && "$path" == "$ISSUE_COMMENT_API_BASE/issues/7/comments" ]]; then
result=$(ISSUE_COMMENT_ACTING_LOGIN="${acting_identity:-$ISSUE_COMMENT_ACTING_LOGIN}" ISSUE_COMMENT_DATA="$data" python3 - <<'PY'
import json
import os
state_path = os.environ["ISSUE_COMMENT_STATE"]
mode = os.environ["ISSUE_COMMENT_TEST_MODE"]
acting = os.environ["ISSUE_COMMENT_ACTING_LOGIN"]
foreign = os.environ["ISSUE_COMMENT_FOREIGN_LOGIN"]
repo = os.environ["ISSUE_COMMENT_REPO_SLUG"]
body = json.loads(os.environ["ISSUE_COMMENT_DATA"]).get("body")
with open(state_path, encoding="utf-8") as handle:
comments = json.load(handle)
# no-op-concurrent: the wrapper's own write is SUPPRESSED (returns 200 with no
# created object) even though a concurrent same-identity comment already exists
# in state. Nothing is persisted; there is no created id to verify.
if mode == "no-op-concurrent":
print("200")
print(json.dumps({}))
raise SystemExit(0)
author = foreign if mode == "author-mismatch" else acting
new_id = (max((c["id"] for c in comments), default=0)) + 1
# REAL Gitea comment shape: issue_url is the WEB (html) path, not an API path,
# and a plain issue comment leaves pull_request_url empty. The URL-injection
# modes persist a record whose id/author/body are all correct but whose
# issue_url is forged, so ONLY the origin+path verification can catch them.
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
if mode == "url-wrong-host":
issue_url = f"https://evil.example/{repo}/issues/7"
elif mode == "url-wrong-owner":
issue_url = "https://git.mosaicstack.dev/attacker/stack/issues/7"
elif mode == "url-wrong-repo":
issue_url = "https://git.mosaicstack.dev/mosaicstack/other/issues/7"
elif mode == "url-suffix-injection":
# Prefix-injected: a bare endswith("/<slug>/issues/7") test would ACCEPT this.
issue_url = f"https://git.mosaicstack.dev/deceptive/{repo}/issues/7"
record = {
"id": new_id,
"body": body,
"user": {"login": author},
"issue_url": issue_url,
"pull_request_url": "",
}
comments.append(record)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(comments, handle)
print("201")
print(json.dumps(record))
PY
)
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
import json
import os
state_path = os.environ["ISSUE_COMMENT_STATE"]
wanted = int(os.environ["ISSUE_COMMENT_GET_ID"])
with open(state_path, encoding="utf-8") as handle:
comments = json.load(handle)
match = next((c for c in comments if c["id"] == wanted), None)
if match is None:
print("404")
print(json.dumps({"message": "not found"}))
else:
print("200")
print(json.dumps(match))
PY
)
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
else
echo "Unexpected curl request: $method $url" >&2
exit 97
fi
SH
chmod +x "$BIN_DIR/curl"
# Seed persistent server state for a mode, then run the wrapper against it.
seed_state() {
local mode="$1"
ISSUE_COMMENT_SEED_MODE="$mode" ISSUE_COMMENT_SEED_BODY="$BODY" \
ISSUE_COMMENT_SEED_ACTING="$ACTING_LOGIN" ISSUE_COMMENT_SEED_REPO="$REPO_SLUG" \
python3 - "$STATE_FILE" <<'PY'
import json
import os
import sys
mode = os.environ["ISSUE_COMMENT_SEED_MODE"]
body = os.environ["ISSUE_COMMENT_SEED_BODY"]
acting = os.environ["ISSUE_COMMENT_SEED_ACTING"]
repo = os.environ["ISSUE_COMMENT_SEED_REPO"]
# REAL Gitea comment shape: issue_url is the WEB path, pull_request_url empty.
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
def comment(cid, text, author):
return {
"id": cid,
"body": text,
"user": {"login": author},
"issue_url": issue_url,
"pull_request_url": "",
}
if mode == "fresh-success":
# 50 pre-existing comments already exist; the comment this run creates
# becomes id 51, proving exact-id read-back works regardless of how many
# comments precede it (no list enumeration is involved).
comments = [comment(i, f"prior {i}", acting) for i in range(1, 51)]
elif mode == "no-op-concurrent":
# A concurrent SAME-IDENTITY comment with the IDENTICAL body already exists.
# The wrapper's own write will be a no-op; it must still fail closed because
# no created id is returned — it must not scan and accept this record.
comments = [comment(55, body, acting)]
else: # author-mismatch
comments = []
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump(comments, handle)
PY
}
run_comment() {
local mode="$1"
shift
: > "$TEA_LOG"
: > "$CURL_LOG"
: > "$CURL_ARGV_LOG"
: > "$AUTH_LOG"
: > "$OUTPUT_FILE"
seed_state "$mode"
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
TMPDIR="$TMP_SCRATCH" \
XDG_CONFIG_HOME="$XDG_DIR" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
ISSUE_COMMENT_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
ISSUE_COMMENT_AUTH_LOG="$AUTH_LOG" \
ISSUE_COMMENT_STATE="$STATE_FILE" \
ISSUE_COMMENT_TEST_MODE="$mode" \
ISSUE_COMMENT_ACTING_LOGIN="$ACTING_LOGIN" \
ISSUE_COMMENT_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
ISSUE_COMMENT_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
ISSUE_COMMENT_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
ISSUE_COMMENT_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
ISSUE_COMMENT_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
ISSUE_COMMENT_API_BASE="$API_BASE" \
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
) > "$OUTPUT_FILE" 2>&1
}
# Assert the wrapper left no scratch temp files behind in TMPDIR (POST/GET
# request bodies + metadata). Called after both success and failure paths so a
# clobbered/leaked RETURN trap is caught on every exit route.
assert_no_temp_leak() {
local context="$1" leaked
# Includes the curl auth-config files (mosaic-gitea-auth-*), which carry the
# bearer token and must be unlinked on every exit path.
leaked=$(find "$TMP_SCRATCH" -type f \( -name 'mosaic-issue-comment-*' -o -name 'mosaic-gitea-auth-*' \) 2>/dev/null || true)
if [[ -n "$leaked" ]]; then
echo "FAIL: issue-comment temp files leaked ($context):" >&2
printf '%s\n' "$leaked" >&2
exit 1
fi
}
# Assert the presented bearer token NEVER appeared in curl's argv (it must travel
# via a curl --config file), and that --config auth was actually used. On the
# expected path grep matches nothing, so no token value is ever printed.
assert_token_not_in_argv() {
local context="$1"
if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" -e "$CROSS_HOST_TOKEN" "$CURL_ARGV_LOG"; then
echo "FAIL: a Gitea bearer token leaked into curl argv ($context)" >&2
exit 1
fi
if ! grep -q -- '--config' "$CURL_ARGV_LOG"; then
echo "FAIL: curl was not invoked with --config file auth ($context)" >&2
exit 1
fi
}
# Case 1: a genuine REST create (id 51) is verified end to end via its exact
# provider-returned id — no list enumeration is involved.
run_comment fresh-success
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
# The write is a REST POST, never a tea comment.
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
echo "FAIL: wrapper wrote a comment via tea instead of REST" >&2
exit 1
fi
# Read-back is a DIRECT GET of the exact created id.
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
# Acting identity resolved via GET /user.
grep -q "^GET $API_ROOT/user$" "$CURL_LOG"
# No comment-list enumeration is performed — the exact-id GET is authoritative.
if grep -Eq "^GET $API_BASE/issues/7/comments(\?|$)" "$CURL_LOG"; then
echo "FAIL: wrapper performed a redundant comment-list enumeration" >&2
exit 1
fi
# Default path (no --login): the host credential fallback resolves, and the
# write is performed AND self-verified under the host-default acting identity.
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
grep -q "^GET $API_BASE/issues/comments/51 $ACTING_LOGIN$" "$AUTH_LOG"
# Success path leaves no scratch temp files behind.
assert_no_temp_leak "fresh-success"
# ITEM 3a: the token drove the write/read-back chain but never appeared in curl
# argv — it was passed via a curl --config file.
assert_token_not_in_argv "fresh-success default-token"
# Case 2: a no-op write with a concurrent SAME-IDENTITY, same-body comment
# already present must FAIL CLOSED — the closed concurrency window.
if run_comment no-op-concurrent; then
echo "FAIL: wrapper reported success when its write no-opped but a concurrent same-identity comment existed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: wrapper accepted a concurrent record for a no-op write (window not closed)" >&2
exit 1
fi
# It must NOT have fallen back to a list scan that could find the concurrent id.
if grep -q "^GET $API_BASE/issues/comments/55$" "$CURL_LOG"; then
echo "FAIL: wrapper read back the concurrent comment id 55 (illegitimate fallback)" >&2
exit 1
fi
# Case 3: a created record NOT authored by the acting identity must FAIL CLOSED.
if run_comment author-mismatch; then
echo "FAIL: wrapper accepted a created comment authored by a different identity" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: read-back did not enforce acting-identity authorship" >&2
exit 1
fi
# Failure-after-read-back path must ALSO leave no scratch temp files behind
# (proves the RETURN traps clean up on the error-return route, not just success).
assert_no_temp_leak "author-mismatch"
# Case 4: a RESOLVABLE --login override — the write, the /user identity lookup,
# and the read-back must ALL be performed under THAT login's token/identity, not
# the host default. The override login has id 1 (empty seed).
run_comment override-success --login "$OVERRIDE_LOGIN"
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 1)' "$OUTPUT_FILE"
grep -q "^GET $API_ROOT/user $OVERRIDE_LOGIN$" "$AUTH_LOG"
grep -q "^POST $API_BASE/issues/7/comments $OVERRIDE_LOGIN$" "$AUTH_LOG"
grep -q "^GET $API_BASE/issues/comments/1 $OVERRIDE_LOGIN$" "$AUTH_LOG"
# The host-default identity must NOT have performed ANY request in this run.
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: an explicit --login override request was performed under the host default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
# Case 5: an UNRESOLVABLE --login override (name absent from tea config) must
# FAIL CLOSED — no silent downgrade to the host default identity: nonzero exit,
# no success line, and NO write performed.
if run_comment override-unresolvable --login "nonexistent-typo-login"; then
echo "FAIL: unresolvable --login override did not fail closed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: unresolvable --login override reported success" >&2
exit 1
fi
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
echo "FAIL: unresolvable --login override still performed a write" >&2
exit 1
fi
# And it must not have silently fallen back to the host default identity.
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: unresolvable --login override fell back to the host default identity" >&2
exit 1
fi
# Case 6: a --login override that IS present in tea config but whose URL is a
# DIFFERENT host than the repo remote must FAIL CLOSED (host-bound selection).
# The cross-host token must NEVER be sent to the repo host, and no write occurs.
if run_comment cross-host --login "$CROSS_HOST_LOGIN"; then
echo "FAIL: cross-host --login override did not fail closed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: cross-host --login override reported success" >&2
exit 1
fi
# The cross-host credential must not have performed ANY request against the repo
# host — no request may be attributed to the cross-host identity.
if grep -q " $CROSS_HOST_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: cross-host credential was sent to the repo host (cross-host leak)" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
echo "FAIL: cross-host --login override still performed a write" >&2
exit 1
fi
# It must not have silently downgraded to the host default identity either.
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: cross-host --login override fell back to the host default identity" >&2
exit 1
fi
assert_no_temp_leak "cross-host"
# Cases 7-10 (#865 Blocker 3): the created record's id/author/body are all
# correct, but its provider-returned issue_url is forged. Verification pins the
# URL's ORIGIN (scheme+host+effective-port) and its FULL path (deployment prefix
# + exact owner/repo + kind + number), so each forgery must FAIL CLOSED. A bare
# endswith/suffix test would wrongly accept the look-alike-host and
# prefix-injection variants.
for bad_mode in url-wrong-host url-wrong-owner url-wrong-repo url-suffix-injection; do
if run_comment "$bad_mode"; then
echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: forged comment URL ($bad_mode) passed verification" >&2
exit 1
fi
assert_no_temp_leak "$bad_mode"
done
# Sanity: the exact same verification path still ACCEPTS a legitimate web-shaped
# issue_url (already exercised by Case 1's fresh-success), so the tightened check
# is not rejecting genuine writes.
echo "issue-comment.sh REST create + exact-id read-back regression passed"
@@ -0,0 +1,178 @@
#!/usr/bin/env bash
# test-mutate-push-guard.sh -- needles for the mutation generator.
#
# The generator's entire output is a COVERAGE CLAIM, and the README publishes it.
# That makes it the most dangerous file here: when it is wrong it does not fail,
# it reassures. Two of its three defects were found by review rather than by any
# test, so these are the cases that had to exist.
#
# g1 a RED BASELINE must be refused before any mutant runs. Previously ONE
# pre-existing suite failure -- changing no guard behaviour at all --
# satisfied every mutant: 13 killed, 0 survived, table emitted, exit 0.
# g3 an INTERRUPTED run must leave the subject byte-identical. Restoration
# used to lean on an EXIT trap, and A TRAP IS CLEANUP, NOT ISOLATION:
# SIGKILL cannot run it, so an interrupted run stranded a mutated
# push-guard.sh that the next suite run silently inherited.
# g2 is the positive control. Without it every case here could be passing
# because the generator refuses unconditionally, which is a wall, not a gate.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GEN="$HERE/mutate-push-guard.sh"
PASS=0; FAIL=0
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
ok() { printf ' ok [%-16s] %s\n' "$1" "${2:-}"; PASS=$(( PASS + 1 )); }
bad() { printf ' FAIL [%-16s] %s\n' "$1" "$2"; FAIL=$(( FAIL + 1 )); }
# fixture <name> -- a directory holding an independent copy of guard + suite
fixture() {
local d="$TMP/$1"; mkdir -p "$d"
install -m 755 "$HERE/push-guard.sh" "$d/push-guard.sh"
install -m 755 "$HERE/test-push-guard.sh" "$d/test-push-guard.sh"
printf '%s\n' "$d"
}
echo "== g1: a RED BASELINE must be refused, with NO table emitted =="
# The injected case asserts exit 99 from `true`. It fails always, and it changes
# NO guard behaviour -- which is the whole point: a defect anywhere in the suite
# used to be enough to certify every branch as covered.
g1="$(fixture red)"
python3 - "$g1/test-push-guard.sh" <<'PY'
import sys
p = sys.argv[1]; s = open(p).read()
anchor = 'mkdir -p "$WORK_DIR"\n'
assert s.count(anchor) == 1, "injection anchor not unique -- fixture would not be the red baseline"
s = s.replace(anchor, anchor + '\nexpect NEEDLE 99 "injected always-failing case" -- true\n', 1)
open(p, "w").write(s)
PY
# Prove the fixture really IS red before asserting the generator notices, or g1
# could pass against a green suite and test nothing.
if "$g1/test-push-guard.sh" >/dev/null 2>&1; then
bad g1-fixture "injected suite still passes -- fixture is not a red baseline"
else
ok g1-fixture "fixture suite is red, as required"
fi
out="$("$GEN" --dir "$g1" 2>&1)"; rc=$?
if (( rc != 0 )) && [[ "$out" == *"REFUSING: baseline is not green"* ]]; then
ok g1-refused "exit $rc, refused before mutating"
else
bad g1-refused "wanted nonzero + refusal; got rc=$rc"
fi
if [[ "$out" != *"README TABLE"* && "$out" != *"killed,"* ]]; then
ok g1-no-table "no coverage table emitted from a red baseline"
else
bad g1-no-table "a table or kill count was published despite the red baseline"
fi
echo
echo "== g3: an INTERRUPTED run must not alter the subject =="
# THE KILL MUST LAND INSIDE A MUTATION WINDOW OR THIS CASE PROVES NOTHING.
# First attempt used `timeout -s KILL 3`. At three seconds the generator is still
# running its BASELINE, so no mutation has been applied yet and the subject is
# trivially unchanged -- for the ORIGINAL in-place generator too, which I
# confirmed by running it. The control passed for a reason unrelated to the fix:
# a vacuous control, in the control written for blocker 3.
#
# So the kill is now driven from INSIDE the run. The fixture's suite counts its
# own invocations and SIGKILLs the generator on the second one -- invocation 1 is
# the baseline, invocation 2 happens with mutant #1 APPLIED. That is exactly the
# window where an in-place generator strands a mutated subject.
instrument_kill_at_second_run() {
python3 - "$1" <<'PY'
import sys
p = sys.argv[1]; s = open(p).read()
anchor = 'PASS=0\nFAIL=0\n'
assert s.count(anchor) == 1, "instrumentation anchor not unique"
inject = anchor + '''
if [[ -n "${G3_COUNTER:-}" ]]; then
n=$(( $(cat "$G3_COUNTER" 2>/dev/null || echo 0) + 1 ))
printf '%s' "$n" > "$G3_COUNTER"
# Invocation 2 = first mutant applied. Kill the generator where it hurts.
# `kill -9 0` targets the whole PROCESS GROUP, not $PPID: the generator runs
# the suite inside $( ), which forks, so $PPID is that subshell and killing
# it merely ends the command substitution -- the generator carries on and
# exits 0. The caller puts the generator in its OWN group via setsid, so the
# group is exactly the generator and its children, and this harness is not
# in it.
(( n == 2 )) && kill -9 0
fi
'''
open(p, "w").write(s.replace(anchor, inject, 1))
PY
}
# run_killed <dir> -> echoes "<rc> <before> <after>"
run_killed() {
local d="$1" gen="$2" before after rc
instrument_kill_at_second_run "$d/test-push-guard.sh"
before="$(sha256sum "$d/push-guard.sh" | cut -d' ' -f1)"
G3_COUNTER="$d/.count" setsid --wait "$gen" --dir "$d" >/dev/null 2>&1; rc=$?
after="$(sha256sum "$d/push-guard.sh" | cut -d' ' -f1)"
printf '%s %s %s\n' "$rc" "$before" "$after"
}
g3="$(fixture killed)"
read -r krc before after <<<"$(run_killed "$g3" "$GEN")"
if (( krc != 0 )); then
ok g3-was-killed "generator died mid-mutation (exit $krc)"
else
bad g3-was-killed "generator exited 0 -- the kill never landed, so the check below is vacuous"
fi
if [[ "$before" == "$after" ]]; then
ok g3-subject-intact "push-guard.sh byte-identical after the kill"
else
bad g3-subject-intact "SUBJECT MUTATED AND STRANDED: $before -> $after"
fi
# PROVE THE NEEDLE BITES. Reconstruct the pre-fix mechanism -- mutate the source
# in place, restore from an EXIT trap -- and put it through the identical kill.
# If this does NOT strand a mutated file, g3-subject-intact is measuring nothing
# and the isolation fix is unevidenced.
g3o="$(fixture killed-inplace)"
python3 - "$GEN" "$g3o/gen-inplace.sh" <<'PY'
import sys
s = open(sys.argv[1]).read()
old = '''install -m 755 "$SRC_TARGET" "$WORK/push-guard.sh"
install -m 755 "$SRC_SUITE" "$WORK/test-push-guard.sh"
TARGET="$WORK/push-guard.sh"
SUITE="$WORK/test-push-guard.sh"
BAK="$WORK/push-guard.sh.orig"
cp "$TARGET" "$BAK"'''
new = '''TARGET="$SRC_TARGET"
SUITE="$SRC_SUITE"
BAK="$WORK/push-guard.sh.orig"
cp "$TARGET" "$BAK"
trap 'cp "$BAK" "$TARGET"; rm -rf "$WORK"' EXIT'''
assert s.count(old) == 1, "cannot reconstruct the in-place mechanism -- bite proof would be fake"
open(sys.argv[2], "w").write(s.replace(old, new, 1))
PY
chmod +x "$g3o/gen-inplace.sh"
read -r _orc obefore oafter <<<"$(run_killed "$g3o" "$g3o/gen-inplace.sh")"
if [[ "$obefore" != "$oafter" ]]; then
ok g3-needle-bites "in-place generator strands a mutated subject, as it must"
else
bad g3-needle-bites "the OLD mechanism also left the subject intact -- g3 is vacuous"
fi
echo
echo "== g2: POSITIVE CONTROL -- a clean subject must produce a full green run =="
g2="$(fixture clean)"
out2="$("$GEN" --dir "$g2" 2>&1)"; rc2=$?
if (( rc2 == 0 )) && [[ "$out2" == *"0 survived"* ]]; then
ok g2-control "$(printf '%s' "$out2" | grep -E '^[0-9]+ killed')"
else
bad g2-control "wanted exit 0 with 0 survived; got rc=$rc2"
fi
# A kill must be attributed to a NAMED case, not to a bare tally -- that is the
# fix for blocker 2 and it needs its own assertion.
if [[ "$out2" == *" by: "* ]]; then
ok g2-attributed "kills name the case they broke"
else
bad g2-attributed "no per-mutant case attribution in the output"
fi
echo
printf '%d passed, %d failed\n' "$PASS" "$FAIL"
(( FAIL == 0 ))
@@ -1,5 +1,5 @@
#!/bin/bash
# Regression harness for pr-merge.sh Gitea non-interactive tea empty identity fallback.
# Regression harness for pr-merge.sh Gitea exact-head API path and input safety.
set -euo pipefail
@@ -79,15 +79,24 @@ emit_response() {
printf '200'
fi
}
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/commits/0123456789abcdef0123456789abcdef01234567/status"* ]]; then
emit_response '{"state":"success","statuses":[{"context":"ci/test","status":"success"}]}'
exit 0
fi
if [[ "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123"* && "$args" != *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock"},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
emit_response '{"number":123,"title":"mock","state":"open","user":{"login":"tester"},"head":{"ref":"feature/mock","sha":"0123456789abcdef0123456789abcdef01234567","repo":{"full_name":"mosaicstack/stack"}},"base":{"ref":"main"},"labels":[],"assignees":[],"html_url":"https://git.mosaicstack.dev/mosaicstack/stack/pulls/123","mergeable":true}'
exit 0
fi
if [[ "$args" == *"-X POST"* && "$args" == *"/api/v1/repos/mosaicstack/stack/pulls/123/merge"* ]]; then
if [[ "$post_data" != '{"Do":"squash"}' ]]; then
echo "unexpected merge payload: $post_data" >&2
exit 96
fi
POST_DATA="$post_data" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["POST_DATA"])
assert payload == {
"Do": "squash",
"head_commit_id": "0123456789abcdef0123456789abcdef01234567",
}, payload
PY
emit_response '{"merged":true,"message":"mock merge complete"}'
exit 0
fi
@@ -107,8 +116,8 @@ export GITEA_URL="https://git.mosaicstack.dev"
export GITEA_TOKEN="redacted-test-token"
OUTPUT="$SANDBOX/output.log"
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected pr-merge.sh to recover via Gitea API fallback." >&2
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
echo "Expected pr-merge.sh to use the exact-head Gitea API path." >&2
echo "--- output ---" >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
echo "--- mock log ---" >&2
@@ -127,38 +136,6 @@ if grep -q 'redacted-test-token' "$OUTPUT"; then
exit 1
fi
cat > "$MOCK_BIN/tea" <<'EOF'
#!/bin/bash
set -euo pipefail
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
printf '\n' >> "$PR_MERGE_TEST_LOG"
if [[ "$*" == *"login list"* ]]; then
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
exit 0
fi
if [[ "$*" == *"pr merge"* ]]; then
echo 'tea network timeout' >&2
exit 2
fi
exit 0
EOF
chmod +x "$MOCK_BIN/tea"
: > "$LOG_FILE"
if "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected arbitrary tea failure to remain blocking." >&2
exit 1
fi
if grep -q '/api/v1/repos/mosaicstack/stack/pulls/123/merge' "$LOG_FILE"; then
echo "Arbitrary tea failure unexpectedly used Gitea API merge fallback." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
exit 1
fi
if ! grep -q 'tea network timeout' "$OUTPUT"; then
echo "Expected arbitrary tea error to be preserved in output." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1
fi
cat > "$MOCK_BIN/tea" <<'EOF'
#!/bin/bash
set -euo pipefail
@@ -177,8 +154,8 @@ EOF
chmod +x "$MOCK_BIN/tea"
unset GITEA_LOGIN
: > "$LOG_FILE"
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
echo "Expected missing tea login to use authenticated Gitea API fallback." >&2
if ! "$SCRIPT_DIR/pr-merge.sh" -n 123 -m squash > "$OUTPUT" 2>&1; then
echo "Expected the exact-head API path not to depend on a tea login." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
sed 's/redacted-test-token/***REDACTED***/g' "$LOG_FILE" >&2
exit 1
@@ -215,7 +192,7 @@ cd "$REPO_DIR"
git remote set-url origin https://github.com/mosaicstack/stack.git
: > "$LOG_FILE"
rm -f "$SENTINEL"
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
echo "Expected GitHub metacharacter PR number to be rejected." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1
@@ -240,7 +217,7 @@ git remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
export GITEA_LOGIN="git.mosaicstack.dev"
: > "$LOG_FILE"
rm -f "$SENTINEL"
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash --skip-queue-guard > "$OUTPUT" 2>&1; then
if "$SCRIPT_DIR/pr-merge.sh" -n "$INJECTION" -m squash > "$OUTPUT" 2>&1; then
echo "Expected Gitea metacharacter PR number to be rejected." >&2
sed 's/redacted-test-token/***REDACTED***/g' "$OUTPUT" >&2
exit 1
@@ -260,4 +237,4 @@ if ! grep -q 'Invalid PR number' "$OUTPUT"; then
exit 1
fi
echo "pr-merge.sh Gitea fallback regression passed"
echo "pr-merge.sh Gitea exact-head API regression passed"
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# shellcheck disable=SC2030,SC2031 # Provider arms isolate PATH/credentials in subshells.
# The commit whose CI was guarded must be the commit the provider atomically merges.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-merge-head-pin}"
SHA=0123456789abcdef0123456789abcdef01234567
make_fixture() {
local name="$1" remote="$2"
local root="$WORK_DIR/$name"
local tools="$root/tools/git"
mkdir -p "$tools" "$root/repo"
cp "$SCRIPT_DIR/pr-merge.sh" "$tools/pr-merge.sh"
cp "$SCRIPT_DIR/detect-platform.sh" "$tools/detect-platform.sh"
git -C "$root/repo" init -q
git -C "$root/repo" remote add origin "$remote"
cat > "$tools/pr-metadata.sh" <<SH
#!/usr/bin/env bash
printf '%s\n' '{"baseRefName":"main","headRefName":"fix/pinned","headRefOid":"$SHA","headRepository":"contributor/widgets-fork"}'
SH
cat > "$tools/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
exit 0
SH
chmod +x "$tools"/*.sh
}
rm -rf "$WORK_DIR"
make_fixture gitea https://git.example.test/acme/widgets.git
make_fixture github https://github.com/acme/widgets.git
cat > "$WORK_DIR/gitea/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
payload=""
for ((i=1; i<=$#; i++)); do
if [[ "${!i}" == "-d" ]]; then
j=$((i + 1))
payload="${!j}"
fi
done
printf '%s' "$payload" > "${MOSAIC_MERGE_PAYLOAD_LOG:?}"
printf '200'
SH
chmod +x "$WORK_DIR/gitea/curl"
set +e
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123
) >"$WORK_DIR/gitea.out" 2>&1
gitea_rc=$?
set -e
if [[ "$gitea_rc" -ne 0 ]]; then
echo "FAIL gitea-pin: merge fixture returned $gitea_rc" >&2
cat "$WORK_DIR/gitea.out" >&2
exit 1
fi
python3 - "$WORK_DIR/gitea-payload.json" "$SHA" <<'PY'
import json
import sys
payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert set(payload) <= {"Do", "head_commit_id", "delete_branch_after_merge"}, payload
assert payload.get("Do") == "squash", payload
assert payload.get("head_commit_id") == sys.argv[2], payload
PY
# A merge-gate verdict is commit-bound. A stale expected head must fail before merge.
wrong_sha=ffffffffffffffffffffffffffffffffffffffff
rm -f "$WORK_DIR/gitea-payload-stale.json"
set +e
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-stale.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --expect-head "$wrong_sha"
) >"$WORK_DIR/gitea-stale.out" 2>&1
stale_rc=$?
set -e
if [[ "$stale_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-stale.json" ]]; then
echo "FAIL stale-verdict: moved head was not refused before provider merge" >&2
exit 1
fi
# A merge-capable path cannot bypass the mandatory queue guard. The legacy
# --skip-queue-guard option must be rejected before any provider merge call.
cat > "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh" <<'SH'
#!/usr/bin/env bash
exit 99
SH
chmod +x "$WORK_DIR/gitea/tools/git/ci-queue-wait.sh"
rm -f "$WORK_DIR/gitea-payload-bypass.json"
set +e
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-bypass.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --skip-queue-guard
) >"$WORK_DIR/gitea-bypass.out" 2>&1
bypass_rc=$?
set -e
if [[ "$bypass_rc" -eq 0 ]] || [[ -e "$WORK_DIR/gitea-payload-bypass.json" ]]; then
echo "FAIL merge-bypass: --skip-queue-guard reached the provider merge path" >&2
exit 1
fi
# Dry-run is the only path that may omit the guard because it exits before the
# provider merge dispatch. Prove the exit and absence of a merge payload.
rm -f "$WORK_DIR/gitea-payload-dry-run.json"
(
cd "$WORK_DIR/gitea/repo"
export PATH="$WORK_DIR/gitea:$PATH"
export GITEA_TOKEN=stub-token
export GITEA_URL=https://git.example.test
export MOSAIC_CREDENTIALS_FILE="$WORK_DIR/no-credentials.json"
export MOSAIC_MERGE_PAYLOAD_LOG="$WORK_DIR/gitea-payload-dry-run.json"
env -u MOSAIC_GIT_IDENTITY "$WORK_DIR/gitea/tools/git/pr-merge.sh" -n 123 --dry-run
) >"$WORK_DIR/gitea-dry-run.out" 2>&1
if [[ -e "$WORK_DIR/gitea-payload-dry-run.json" ]]; then
echo "FAIL dry-run: non-merging preflight reached the provider merge path" >&2
exit 1
fi
cat > "$WORK_DIR/github/gh" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" > "${MOSAIC_GH_MERGE_LOG:?}"
SH
chmod +x "$WORK_DIR/github/gh"
(
cd "$WORK_DIR/github/repo"
export PATH="$WORK_DIR/github:$PATH"
export MOSAIC_GH_MERGE_LOG="$WORK_DIR/github-call.log"
"$WORK_DIR/github/tools/git/pr-merge.sh" -n 123
) >"$WORK_DIR/github.out" 2>&1
if ! grep -q -- "--match-head-commit $SHA" "$WORK_DIR/github-call.log"; then
echo "FAIL github-pin: merge command omitted --match-head-commit $SHA" >&2
cat "$WORK_DIR/github-call.log" >&2
exit 1
fi
echo "PR merge exact-head pin regression passed (Gitea + GitHub)"

Some files were not shown because too many files have changed in this diff Show More