Three reviewer blockers, all verified against source and by measurement before
acting on them.
C1 — the fixture's `tsx` was `apps/gateway/node_modules/.bin/tsx`, which is not
the TSX CLI but a pnpm-generated /bin/sh launcher that **exports NODE_PATH**
naming four repository module roots into the process it starts. A fixture whose
stated invariant is that the child receives no Node loader controls was shipping
a `tsx` that reinstated one. Resolving `tsx/dist/cli.mjs` directly deletes the
wrapper, the NODE_PATH injection, three coreutils symlinks and an off-PATH
package-layout symlink at once: PATH is now two names, `node` and `tsx`. Targets
are realpath-resolved and asserted to be executable files at module scope,
before the first child, and the symlinks derive from those checked values.
Observed child key set carries neither NODE_PATH nor NODE_OPTIONS.
C3 — the dev:local contract was a substring check followed by arbitrary shell
execution: the manifest case asserted the script *contained* src/local-dev.ts,
and the helper then handed the whole string to `sh -c`. "curl … | sh; tsx
src/local-dev.ts" satisfied it and ran. Now one module constant spelled once,
asserted with equality in the manifest case and re-asserted in the executing
helper against the value about to run. No metacharacter denylist — equality
already excludes them, and a denylist here would repeat the mistake the PATH
allowlist was built to correct.
C2 — the timeout bound is corrected rather than closed. `exec` removes the outer
shell, but the required route is itself multi-process: TSX spawns an esbuild
service child and a loader/eval child, and the latter is where route code runs.
Measured six out of six times under this exact spawn shape, that child survives
SIGKILL to its TSX parent, is reparented, and completes its work afterward —
while sandbox teardown removes directories it still holds. So exact equality
plus `exec` prohibits *injected* shell content and does not close the gap; the
launcher the contract *permits* is the one that leaks. The timeout control is
named and commented for the single-process property it actually measures and is
not offered as containment evidence.
Containment design is at architecture adjudication and is deliberately not
implemented here: every process in the TSX tree runs in the verifier's own
process group and session, so the obvious repair would signal the test runner.
RED unchanged: 83 failed | 17 passed -> 83 failed | 18 passed (101), the one
added case being the timeout control passing as a harness control. Prettier,
tsc and ESLint clean; zero /tmp/kbn-02a-* residue; casts unchanged at six.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
PATH was the last part of the child environment still built as a denylist:
the two workspace node_modules/.bin directories, dirname(process.execPath)
and the shell's directory. Measured on this host that is 4,093 distinct
executable names, and apps/gateway/node_modules/.bin carries real agent
runtimes this repo depends on directly — pi (@mariozechner/pi-coding-agent),
pi-ai, openai, anthropic-ai-sdk. The comment on that construction claimed it
prevented an unrelated same-name executable from answering, which is the
resolution it permitted.
PATH is now one fixture-owned directory in the per-run sandbox holding only
symlinks: node, tsx, and dirname/sed/uname (the pnpm tsx launcher is a
/bin/sh script that calls all three to locate its own entrypoint). The
launcher's relative ../tsx/dist/cli.mjs lookup is served by one directory
symlink inside the sandbox, off PATH. The shell is invoked absolutely and
needs no entry; env needs none because the shebang invokes it absolutely.
The fixture control now enumerates the directories on the child's observed
PATH and asserts the reachable executable names EQUAL the allowlist. The
seven harness-name assertions are kept and commented redundant-by-design: a
name enumeration finds pi and misses the other three actually present, which
is the empirical argument against the denylist shape. Symlink targets are
asserted to resolve to the exact expected binaries.
Measured: 4,093 -> 5 reachable names; `command -v pi` resolves the real
.bin/pi under the old construction and is not-found under the new one;
`tsx --version` still succeeds on the narrowed PATH.
Bounds recorded rather than overclaimed: this narrows command resolution,
not module resolution — the tsx launcher exports NODE_PATH naming four real
repository module roots, the last exposing 775 packages including the same
agent runtimes. And the shell-arm key-set measurement describes the
control's own arm, not the GREEN route.
RED unchanged: 83 failing / 17 passing (100 cases, 6 files), no Errors row,
zero /tmp/kbn-02a-* residue, dev:local still absent.
Three review rounds found the same defect in this guard: the value validated
and the value used came from two different reads. Each repair narrowed the
window and the next round reopened it -- a Proxy, then a getter replacing
Object.keys mid-enumeration. The single-entries-array form closes both, and a
fourth round showed it still is not a proof: the helper calls Object.fromEntries,
map and filter after the enumeration, and a getter can replace any of them.
That is not closable here. These cases share a closure with the guard, so an
author who can patch a global can patch the guard. The escalation is what was
wrong -- the previous round added a caller-supplied callback to the one spawn
site purely to make a mutation proof read better, and it had to be reverted.
So the claim is bounded rather than narrowed again:
- Freeze ALLOWED_CHILD_ENV, DECLARABLE_CASE_ENV_NAMES and SHELL_GENERATED_ENV.
The name list was mutable; a case could push a forbidden name before
declaring it. Verified: the push now throws and the spawn is still rejected.
- Say what the guard does. It rejects an ambient copy or a stray name passed
as a declaration, which is the mistake a maintainer makes by accident. The
comments claimed it closed the accessor gap; they no longer do.
- Correct the bootstrap() comment: main.ts constructs a logger before the
secret check. The load-bearing claim -- the throw precedes any config read --
is unchanged.
Document corrections, all found by review of claims written in earlier rounds:
- Withdraw "no second call to any replaceable global stands between the check
and the use". False, and stated while correcting a different overclaim.
- Both exploits are rejected, but by different assertions. The ambient copy is
named by the offending-keys message; the Proxy is stopped one assertion
earlier by isProxy, whose message names no keys.
- The sandbox is created in spawnHermetic before the spawn call, not inside
the spawn arguments. main.spec.ts:58 cleans up in a finally rather than by
registration, outside the kbn-02a- prefix the residue count measures.
- The package.json RED does not execute the route yet: the script is absent, so
the case stops on the missing manifest entry before any child is created.
- Three route cases, not two. Four XDG_*_HOME roots plus XDG_RUNTIME_DIR, not
"five XDG home roots". Not every failure is behavioral -- the structural
source guards are counted in the same 83.
RED unchanged: 83 failing / 17 passing (100 cases, 6 files), no Errors row,
zero /tmp/kbn-02a-* residue, fixture control green. Typecheck, lint, Prettier
clean.
The dev:local route case built its child environment as a denylist — copy the
ambient process.env, delete three keys — leaving the real HOME, every XDG root,
MOSAIC_GATEWAY_HOME, service endpoints, auth/credential families, the Node
loader controls and an ambient PATH in place. A faulty GREEN could read real
host state before an assertion caught it, and a refusal could be produced by
the host's own configuration rather than by the route, so the case would pass
while proving nothing about the code under test. The hard timeout bounds how
long a leak runs; it does not prevent one.
The child now receives exactly eight variables built from scratch — a private
HOME under a per-run temporary sandbox, all five XDG roots and TMPDIR beneath
it, and a PATH built from the two workspace .bin directories and the directory
of the running Node binary — plus the per-case declarations. Nothing is
inherited.
Two cases hold that rather than assume it: a fixture control asserting the
constructed key set, that HOME is neither the real home nor an ancestor of it,
and that PATH is the three intended directories (green by construction, a
harness negative control, not coverage); and an executed case that plants a
complete valid local declaration and a forbidden DSN in the parent environment
and requires the undeclared refusal — a leaky fixture would fire the DSN
refusal instead.
RED only. 83 failing / 17 passing (100 cases, 6 files), Errors 0, zero
/tmp/kbn-02a-* residue. No GREEN, no push, no 17th fenced path.
Second AMEND from the verifier. Five blockers, all inside the 16-path fence; no
new path taken and no GREEN implemented.
1. The run carried an out-of-band `process.exit unexpectedly called with "1"`,
raised by main.ts:71 self-invoking on import and rejecting after the importing
case had returned. Vitest attributes that to no case and warns it can produce
false positives; it also falsified the document's "zero non-assertion
failures". The import is now wrapped so `process.exit` is stubbed
non-throwing and the case awaits a settle turn — the behavioral red is
unchanged, the error is owned, and the run reports `Errors 0`.
2. Two inventory cases demanded the raw values 'standalone' and 'postgres',
which the value-free classification unions cannot represent, so no compliant
implementation could have turned them green. They assert 'non-local' and
'non-pglite'.
3. SourceReadability gained `absent`. Without it an absent source has to be
reported `readable` — the one value meaning "read, and clean" — collapsing
the distinction the refusal path is built on. The invariant
`present === false` iff `readability === 'absent'` is now asserted per state
and across all seven rows at once.
4. Acceptance #6 had no positive anchor: an empty `bootstrap()` and an
always-refusing `onModuleInit()` satisfied every case, so "safe" could have
meant "does not work". Two cases now fail against that — bootstrap must reach
NestFactory.create and listen under the frozen policy with zero independent
read, and the sterile local lifecycle must actually run both migrations with
no Postgres handle. Both in-process and substituted at their seams; no port
is bound and nothing connects.
5. Every mkdtemp site in the six files now registers teardown at the point of
creation, including the child route's root created inside the spawn
arguments. The matrix leaves zero /tmp/kbn-02a-* residue, measured.
Focused matrix: 82 failing / 16 passing (98 cases, 6 files), Errors 0.
Typecheck, lint, Prettier, diff --check clean.
Author phase only; still no implementation. Kept as a distinct fifth commit so
the RED baseline, the contract restoration, the span audit and this supplement
remain separately reviewable.
The finding that mattered: every frozen-policy case mocked ./main.js wholesale,
so nothing executed main.ts, AppModule or the ConfigModule provider, and the
three independent reads at main.ts:28, app.module.ts:39 and config.module.ts:12
could all have survived a fully green matrix. Fence revision 15 -> 16 (granted
solely for apps/gateway/src/main.spec.ts) is used to import the real modules
with no ./main.js mock and assert no independent loadConfig, no tier probe and
no NestFactory.create. The existing anchoring case is left red-in-place rather
than deleted: its import-time-exit premise is the opposite of the new contract,
so it must be migrated by whoever implements GREEN.
Also closes: the executed dev:local child-process route, which requires a
LOCAL_START_* code AND the absence of the BETTER_AUTH_SECRET throw, since a
nonzero exit alone is satisfied by a crash; the real database onModuleInit
lifecycle, asserting migrations never run; the full disposable state-root
contract (absent, not-a-directory, symlink, non-private, foreign-owner), with
ownership substituted at lstat because a filesystem-built case passes trivially
wherever CI runs as root; unreadable and malformed sources, which refuse rather
than being skipped, built as EISDIR so they cannot be vacuous under any uid;
per-source non-local tier detection for all seven labels; canary retention and
console cases; post-freeze source-mutation cases; and a three-form static-import
guard.
Two defects of my own are fixed rather than papered over. The non-canonical
state-root case normalized to the valid path before the policy saw it, so it
demanded a refusal on input identical to the accepted one — a red that could
never go green for its stated reason. And the first substitution of @nestjs/core
was wholesale, which dropped APP_GUARD and killed app.module.ts at import, so
two cases failed for a reason they did not state.
Classification types are unions, not strings: a declaredTier?: string is
value-free only by convention, and a two-member union cannot carry a canary.
77 failing / 15 passing (92 cases, 6 files). Typecheck, lint and Prettier clean.
Zero non-assertion failures. Seven of the fifteen greens are vacuous against the
stub, up from three; that rise is recorded in the document so it cannot later be
read as coverage. The first RED report's implication that the GREEN targets were
covered behaviorally is corrected there too.
Refs: KBN-101-02a (Stack #1178)
Scrappy's ruling, verified independently at
docs/scratchpads/771-kbn101-db-role-split.md:98, which records
apps/gateway/Dockerfile explicitly as 'a planned KBN-101-05 artifact'.
The row names something intended to exist, not a mis-naming of
docker/gateway.Dockerfile, so it is not the inverse defect I filed it as.
The correction sharpens the proposed control: a link check failing on any
absent ownership path would have flagged this row and been wrong. The
check needs a planned marker, and an unmarked absent path is the failure.
Negative existence evidence retained; classification corrected.
Commissioned with -02a on the board ruling. Four findings: the -02
local-start hold is carried by eight files not five (AGENTS.md and
README.md among them, so -02a's completion surface is wider than its
record); a candidate defect of the same shape at -05, whose scope row
contains neither 'process-exec' nor 'LoadCredential' though three guides
condition deployment on that interface; a negative result confirming the
-00/-03/-05 activation hold spans correctly; and the structural hole that
makes all of them unverifiable, whose inverse form is the -05 rows naming
apps/gateway/Dockerfile, a path that does not exist.
Additive to section 10 only. No other item's rows altered; the Dockerfile
discrepancy is reported, not corrected — that row is -05's.
b4c468b3 wrote the -02a path-ownership manifest OVER the frozen KBN-101
contract (#774, #902) rather than appending to it, deleting 272 lines
including the KBN-101-05 exclusive-ownership row, the K101-REQ-04
traceability row and the -03 runner/manifest contract.
Sections 1-9 are restored byte-identical to 216cd722. The -02a amendment
is re-added as additive section 10 with its headings demoted. No other
item's ownership rows are altered.
Self-reported to the verifier; the defect class is the same span/scope
failure -02a exists to close.
Author phase only. No implementation: the scaffolding is deliberately
permissive so every refusal assertion fails behaviorally rather than on a
missing module. A `not implemented` throw would have satisfied `.toThrow()`
and produced a green matrix that proved nothing.
Adds the path-ownership manifest and 60 behavioral cases across the nine
mutation->guard rows: inherited env, shared dotenv, daemon JSON, app JSON,
root JSON, MOSAIC_GATEWAY_HOME redirect, exact local tier, exact PGlite plus
disposable state root, and one-shot freeze with dynamic import.
50 red / 10 green. Zero non-assertion failures. The ten greens are itemized
in the manifest, including three that are vacuous until the implementation
exists.
Enforcement sits at the apps/gateway seam. Nothing in packages/db or
packages/config is touched: -02a proves the local entrypoint never reaches
those paths rather than modifying code owned by -01, -03 and -10.
Refs: KBN-101-02a (Stack #1178)
runRuntimeLaunchEntry set MOSAIC_HOME to the shipped framework root, so
launch-runtime.py appended its launch ledger to
framework/fleet/run/sessions/events.ndjson — polluting the tree that
manifest.spec.ts walks and failing its completeness check in CI.
Point MOSAIC_HOME at the per-entry temp root instead. Nothing in the
launch chain resolves tools via MOSAIC_HOME (entry scripts resolve via
SCRIPT_DIR); the ledger is its only consumer here.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
The two install-linker-compatibility tests still asserted the pre-isolation
behavior (mosaic skill links planted in $HOME/.claude/skills). This branch
deliberately moved the link farm into the mosaic-owned harness homes
($MOSAIC_HOME/.claude/skills) and demoted the base-install dirs to
cleanup-only legacy targets, so the tests now assert the new topology:
the skill links appear under the harness home, foreign links in the legacy
dir are preserved, and no new mosaic link is planted in the base install.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
The mosaic wrapper makes pane_current_command=node (RUNTIME_ACCEPTABLE_COMMANDS.claude=['claude','node']); the walk matters precisely in that no-shell-wrapper case. Match reality.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_013SAYFkRhQfhguY7AHfiUC8
The launcher runs the runtime as a spawnSync CHILD of node(mosaic) (deliberate,
per launch.ts:99 — parent survives to propagate signals), so
MOSAIC_LEASE_SESSION_ID lives on the claude child, not the pane's root pid. The
transport read only pane.pid's /proc/environ and returned RESOLVE_FAILED for
every real 'mosaic claude' seat. Now BFS the pane's process subtree (bounded,
injectable children-reader) and read the first descendant that carries a valid
lease id; fail-closed if none. Unit tests now exercise the real walk (pane=node
without lease -> child=claude with lease) rather than mocking the resolution.
Found by scooby greenfield E2E on fomo-lin with proc-level evidence.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_013SAYFkRhQfhguY7AHfiUC8
MAX_LEASE_TTL_SECONDS (daemon cap+default) and DEFAULT_TTL_SECONDS
(lease_promote client) both move to 3600. The 5-minute TTL made
gated-by-default sessions unusable (re-promotion mid-task); 1 hour
matches a working session. Full test:framework-shell RC=0.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013SAYFkRhQfhguY7AHfiUC8
The independent W-0R review of 3592b92e passed but left two PLAUSIBLE
findings: the stderr notice for a legitimately-omitted operator source was
claimed and never asserted (a silent omission is the original defect in
miniature), and the chmod 0o000 unreadable simulations fail spuriously when
euid==0 (CAP_DAC_OVERRIDE). Falsifier for the new assertion: deleting the
notice block turns the suite red (failures=3); restoring returns green.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EHYXhcCQsL3J1Lnm7EraGq
build_construction skipped any normative source it could not read
(`except OSError: continue`) and promoted whatever remained. That is not a
degraded binding, it is a forged smaller one: the broker recomputes h_source /
h_payload from the fragments it is SENT (daemon.py:602-616), so an omitted
fragment is internally consistent and PAYLOAD_BINDING_MISMATCH cannot fire. A
partial law promotes exactly like a complete one and nothing downstream can tell
the difference.
Measured before this change, against a seeded home: with only USER.md readable,
the client produced a one-fragment construction with promotion=True. Removing
CONSTITUTION.md, STANDARDS.md or the runtime contract likewise promoted.
The classification mirrors the framework's own file ownership rather than
inventing one:
* CONSTITUTION.md / AGENTS.md / STANDARDS.md are framework-owned and
reconciled every upgrade (install.sh FRAMEWORK_OWNED,
config/file-adapter.ts FRAMEWORK_OWNED_FILES), as is the per-runtime
RUNTIME.md. Absent => IncompleteBinding. A deployment missing one is broken,
not minimal.
* SOUL.md / USER.md are deliberately not seeded by install.sh ("generated by
`mosaic init`") and TOOLS.md is seeded on first install only, so their
absence is legitimate. It is reported on stderr, never silent.
Unreadable is handled separately from absent for EVERY source, optional ones
included: a file that will not open is not a file that was never configured, and
collapsing the two is what let a permission change quietly shrink the law.
Also corrects this module's own docstring, which asserted that a VERIFIED lease
means "this agent is running THIS law". It does not. Both sides of the broker's
comparison originate in this client, so it detects corruption in transit and
nothing else. That overstatement is where the belief spread from; the stronger
claim needs the broker re-reading on-disk sources against a manifest the agent
cannot rewrite.
Test: promotion_binding_unittest.py, enumerated in test:framework-shell (the
enumeration guard's population is *test*.sh and does not cover Python, so an
unenumerated test here would simply never run). Falsifier executed: defeating the
guard while leaving the module API intact turns the suite red (12 failures);
restoring it returns green.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EHYXhcCQsL3J1Lnm7EraGq
This reverts 939f2e04. Keeping the revert rather than dropping the commit,
because the failed attempt is the most useful record on this branch.
The wiring worked mechanically — verified with a live model on sb-it-1-dt: the
receipt was emitted verbatim as a whole message, and the broker token was minted
AND consumed, so observe_receipt and promote_lease both succeeded and the lease
reached VERIFIED.
It failed as a DESIGN, for reasons that are properties of the protocol rather
than of this wiring:
* It puts control-plane traffic in the user-facing conversation channel. An
operator asking "what model are you?" received a receipt string instead of an
answer — the model tried a tool, was blocked, complied with the receipt
instruction, and in one-shot mode that text turn BECAME the reply. Observed
twice, non-deterministically.
* The lease TTL is hard-capped at 300s (MAX_LEASE_TTL_SECONDS; ttl_seconds >
cap raises INVALID_LEASE_TTL). Measured: allowed at T+0, LEASE_EXPIRED at
T+310. So the visible cost recurs every five minutes of mutator activity.
* Model compliance is not guaranteed — one run retried the command instead of
emitting the receipt.
Any model emission is user-visible, so this is not fixable by better wiring; it
needs a design answer about how promotion is triggered and paid for. That is
under adversarial review (docs/scratchpads/lease-remediation/07-liveness-design-brief.md
in the operator's repo). Promotion triggering will return on its own branch once
that lands.
What remains here is independently sound and unblocked: harness-home isolation,
the immutable launch record, the skills relocation, the promotion client itself
(steps 1/4/5), and the #1087 prefix guard.
prune_stale_links_in_target compared "$resolved" == "$canonical_real/"* while
length-checking only $resolved. If $canonical_real were ever empty the pattern
collapses to == "/"* and matches every absolute path.
The failure is precisely inverted, which is what makes it worth fixing rather
than noting: is_mosaic_skill_name already `continue`s for names that ARE current
mosaic skills, so an empty prefix would delete exactly the FOREIGN symlinks in
every target directory and preserve the mosaic ones. On this host that is 4 base
installs, including codex's own .system entry.
Reported by mos-claude as #1087 after I introduced the same guard in the new
legacy-cleanup path in the previous commit and walked past this instance thirty
lines away. Same defect class, same file, one function apart.
$canonical_real is populated by readlink -f after a mkdir -p, so an empty value
requires readlink to fail — unlikely, but the consequence is deleting operator
symlinks across every harness, which is not a risk worth carrying for one test.
Completes the promotion path: the client landed in the previous commit, but
nothing drove step 2 — the model emitting the receipt. This wires it.
LAZY, not at session start. Promotion costs an entire model turn, because the
receipt must be the whole message (hmac.compare_digest, "not a transcript
substring"). Minting at session start would collide with the Constitution's
first-response mode declaration — the two cannot share a message, so requiring
both would be unsatisfiable. Deferring to the first DENIED MUTATOR means the
mode declaration happens first and the receipt gets its own later turn, so no
governance change is needed. A read-only session never pays for promotion at all.
Mechanism: on a MUTATOR_UNVERIFIED denial the tool_call hook mints a challenge
and returns the receipt in the block `reason`, which pi feeds back to the model
as the tool result — the existing injection path already used by
lease-lifecycle.ts. The model emits the receipt as its next message, message_end
ships it to the observer, and the extension then calls observe_receipt +
promote_lease.
Only MUTATOR_UNVERIFIED triggers minting. Other denials (GATE_UNAVAILABLE,
STALE_GENERATION, LEASE_EXPIRED, ANCESTRY_MISMATCH) describe conditions a
receipt cannot fix, and begin_verification revokes before it mints, so minting
there would thrash the broker.
Completion is gated on an EXACT text match against the minted receipt. This is
load-bearing, not defensive: message_end also fires for the message that
CONTAINED the blocked tool call — one turn BEFORE the model answers. An earlier
version completed there, so observe_receipt compared against the wrong text,
failed, and burned the challenge before the model ever emitted it. Matching the
text mirrors the broker's own compare_digest semantics and waits for the right
turn. Confirmed by instrumenting message_end and watching it fire with
pending=yes one message too early.
It never posts the receipt itself. receipt-observer-client.py accepts any
string, so self-posting would satisfy the broker while proving nothing — the
whole point is that a live model echoes a challenge it was given.
Bounded by MAX_PROMOTION_ATTEMPTS: model compliance is not guaranteed (observed
a run where the model retried the command instead of emitting the receipt), so
a non-complying model degrades to today's behaviour — denied mutators — rather
than looping.
Verified with a live model on sb-it-1-dt: receipt emitted verbatim as a whole
message, and the broker token was minted AND consumed, i.e. observe_receipt and
promote_lease both succeeded and the lease reached VERIFIED.
Known limitation: under `pi -p`, the receipt is a text-only turn, which ends the
one-shot loop — so promotion completes but the blocked tool is not retried in
that same invocation. Interactive and durable fleet sessions continue and retry
normally.
The enforcement half of the lease broker ships and denies; the promotion half
has no production caller anywhere in the package. Verified across 0.0.48, 0.0.49
and 0.0.50-next.2207: begin_verification / observe_receipt / promote_lease are
invoked only by broker-test-client.ts, the acceptance spec, unit tests, and two
probes under docs/.
Consequence: no lease on any host can reach VERIFIED, so mutator-gate denies
every mutator with MUTATOR_UNVERIFIED via a gate that nothing shipped can
satisfy. Runtimes that enforce the gate in-process (pi, via mosaic-extension's
tool_call hook) are bricked for mutators; runtimes whose gate is wired through a
settings hook escape only when that hook is absent — i.e. by being ungated.
This adds the client. It implements protocol steps 1, 4 and 5:
1. begin_verification -> mint a challenge, return the exact receipt text
2. the MODEL emits that text verbatim as its entire latest message
3. the runtime adapter ships that message to the observer socket
4. observe_receipt -> PENDING_PROMOTION
5. promote_lease -> VERIFIED
Step 2 is deliberately NOT implemented here, and that is the point.
is_verbatim_receipt uses hmac.compare_digest against the exact minted string —
explicitly "not a transcript substring" — which makes promotion a LIVENESS
PROOF: it requires a live model that received the challenge in its context and
echoed it exactly.
receipt-observer-client.py will post ANY string as the latest assistant message.
A promotion client that posted its own receipt would satisfy the broker while
proving nothing — a gate-disabler indistinguishable from a working fix unless
someone specifically looks. Emitting the receipt therefore belongs to the runtime
adapter, where a real model turn happens. A local diagnostic that posts its own
receipt exists in the operator's repo and is deliberately NOT shipped here.
The construction binds the exact normative source bytes, so a VERIFIED lease
means "this agent is running THIS law", not merely "this session id is known".
h_source/h_payload are derived by importing the framework's own
normative_fragments.build_payload rather than reimplementing it: the broker
derives them the same way and any divergence yields PAYLOAD_BINDING_MISMATCH.
There must be exactly one implementation.
session_identity() prefers the generation FILE over the env var, matching
lease_generation.py. Sending a generation higher than the broker's would revoke
the session's own authority (daemon.py:342-344), so it never guesses.
Verified end-to-end on sb-it-1-dt under a real lease-gated anchor: a mutator
denied rc=2 MUTATOR_UNVERIFIED, then begin -> observe -> promote -> VERIFIED,
then the same mutator allowed rc=0. Negative controls pass: a fresh session is
still denied, and an unrelated session still reads UNVERIFIED — promotion is
per-session and does not leak.
Still open: adapter wiring for step 2. Lazy promotion on first mutator attempt
avoids colliding with the Constitution's first-response mode declaration, since
compare_digest requires the receipt to be the WHOLE message.
Mosaic wrote into the operator's harness base installs — ~/.claude,
~/.pi/agent, ~/.codex, ~/.config/opencode — for settings, instructions, and a
102-symlink skill farm per harness. Any experiment with hooks or gating
therefore mutated the operator's own tooling, and a broken framework change
could take out the very harness needed to repair it.
Harness home isolation
----------------------
Each runtime now reads config from a dedicated mosaic-owned home via the
harness's own config-dir variable:
claude CLAUDE_CONFIG_DIR ~/.config/mosaic/.claude
pi PI_CODING_AGENT_DIR ~/.config/mosaic/.pi (replaces ~/.pi/agent)
codex CODEX_HOME ~/.config/mosaic/.codex
opencode XDG_CONFIG_HOME ~/.config/mosaic/.opencode
These paths are manifest-UNKNOWN, so rule 3 (#791) resolves them to operator
ownership and a keep-mode upgrade can neither overwrite nor prune them.
A bare `claude` / `pi` keeps its own config AND auth, making it a structural
break-glass rather than one depending on restoring a file under pressure.
opencode is blunter than the rest: it has no dedicated variable and follows XDG,
so isolation also relocates XDG lookups for anything it spawns. Documented in
place.
mosaic-sync-skills now links into those homes and cleans the legacy farms it
previously planted in base installs. Ownership is proven by RESOLUTION, not by
name — only symlinks resolving inside the canonical/local skills dirs are
removed, mirroring the refusal already in commands/skill.js. Verified against a
real install: codex's own .system directory survived while its 102 mosaic links
were removed. Both resolution prefixes are length-checked first; an empty prefix
would make "$resolved" == "$prefix/"* match every absolute path and delete
foreign symlinks.
Immutable launch record
-----------------------
Every launch now appends one record to fleet/run/sessions/events.ndjson before
exec. Mandatory, mechanical, no model involvement.
pi rewrites its own argv to a bare `pi`, so /proc/<pid>/cmdline destroys the
launch evidence — that has already produced a confident wrong diagnosis ("this
agent bypassed the launcher"), disproved only by the parent's argv and only
because the parent had not yet exited. A record written before exec is the only
place this survives.
The path is the #797 Runtime Session Ledger, already operator-classified and
already covered by test-upgrade-manifest-guard.sh, which seeds it and proves a
populated ledger survives keep-mode upgrades — but nothing shipped ever wrote
it. This implements it in the shape that guard already asserts (0600 files under
a 0700 dir).
`mosaic` writes session.launch; launch-runtime.py appends lease.register with
the broker session id and activation capability. They correlate by an explicit
MOSAIC_LAUNCH_ID, never by pid: execRuntime uses spawnSync, so the runtime is a
child with a different pid.
Records normative fragment digests (CONSTITUTION/AGENTS/SOUL/USER/STANDARDS/
TOOLS/RUNTIME) — the same set the broker hashes for promotion, so drift is
mechanically detectable rather than a matter of judgement.
Credential-safe: env is captured as PRESENT NAMES ONLY, and argv values over
256 bytes become a sha256 + length rather than being inlined.
Also fixes CLI_VERSION resolution: '@mosaicstack/mosaic/package.json' is not in
the package exports map and always throws ERR_PACKAGE_PATH_NOT_EXPORTED.
resolveTool() uses that same failing specifier, which is why its documented
preference for bundled tools over the deployed ~/.config/mosaic copy has never
once applied — noted in place, not fixed here.
Verified on sb-it-1-dt: isolated homes written and base installs byte-identical
for all four harnesses; 408 legacy symlinks removed with 1 foreign entry
preserved; launch records paired across the spawn boundary. typecheck shows zero
errors in launch.ts (the @mosaicstack/types failures are pre-existing and
reproduce on a pristine origin/main worktree).
invariant_r_unittest.py (landing with the lease-remediation stack, PR
#1109) hard-requires an installed `pi` binary pinned to the measured
version: it boots Pi's real tool registry and proves the broker's
read-only carve-out resolves to real, unshadowed builtins. Absent
runtime fails loud by design — so CI must provide it.
Install @earendil-works/[email protected].1 (the canonical Pi;
@mariozechner/* is embedded-legacy) at step level in the test step.
Step-level rather than baked into Dockerfile.ci because ci-image
publishes are currently blocked on registry UNAUTHORIZED; baking it in
is the follow-up once registry auth is fixed, at which point this line
degrades to a fast no-op guard like the openssl line above it.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Dtdjx4Gxude9fwyLezCrhh
Add the Slice-Zero catalog and selection HTTP surfaces for P3 Task 3:
GET /api/harnesses, GET /api/harnesses/:harnessId/catalog,
GET+PUT /api/chat/preferences/selection. Scope is always server-derived
via scopeFromUser(CurrentUser); selection tuples are validated against the
live catalog with no fallback substitution and persisted in a transitional
owner-scoped in-memory store. HarnessModule is wired into AppModule.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
The `format` step of .woodpecker/ci.yml:89 (`pnpm format:check`) failed on
pipeline 2111 for this branch. Reproduced on a bench with the lockfile-pinned
[email protected] against the repo .prettierrc and .prettierignore, using CI's
exact glob: WRITING-STYLE.md was the only failing file.
The change is mechanical and semantically null: markdown table cell padding
and `*emphasis*` -> `_emphasis_`. Verified by normalizing both revisions
(whitespace removed, `_`/`*` folded, table rules collapsed) — the results are
byte-identical.
This does not address the prose findings published on #965 (P1-P4); those
await a ruling. The `test` step also failed on 2111, on a base ~40 commits
stale — attribution for that failure needs this rerun, and is not claimed here.
Co-authored-by: mos-dt-0 <[email protected]>
root cause: emitDecoratorMetadata reflected the third constructor parameter as Function and Nest attempted to resolve it
fix: optional HANDOFF_ID_FACTORY injection token, no production provider, preserving undefined -> crypto.randomUUID() default and unchanged positional construction
TDD: real CoordModule red at Function index [2], then green; test overrides only unrelated AuthGuard because its AUTH provider comes from AppModule's global AuthModule context
Closes#1145