Skip to content

feat(worker): authorized task home for compartment child requests - #278

Merged
ruby-dlee merged 13 commits into
mainfrom
fm/compartment-task-home
Aug 20, 2026
Merged

feat(worker): authorized task home for compartment child requests#278
ruby-dlee merged 13 commits into
mainfrom
fm/compartment-task-home

Conversation

@ruby-dlee

@ruby-dlee ruby-dlee commented Aug 20, 2026

Copy link
Copy Markdown
Owner

Unblocks the R2/R3 compartment child lane: a secondmate compartment running in Azure can now actually obtain a crewmate. Built to R2R3-DESIGN.md AMENDMENT 2 section 1, which rewrote B.5 step 3.

The established diagnosis

The controller was never the blocker. --owner-kind is a plain argv flag on request (bin/fm-worker-lifecycle.py, the request subparser) and the controller never inspects FM_HOME to decide it: a request minted with FM_HOME on the controller's own home plus --owner-kind secondmate plus the parent pair is admitted with all four B.1 bounds enforced and children_total incremented.

The pinch point is that FM_HOME does three separable jobs at once:

  1. where the requesting task's local authorities live, because authoritative_request_bindings read env["home"]/state/<task>.meta;
  2. the identity stamped into the request's home_binding;
  3. the identity of the money document.

Jobs 1 and 2 belong to the requester, job 3 belongs to the controller, and the compartment child is the first case where they differ. Moving FM_HOME to the secondmate home therefore aims at a SECOND controller document and refuses at verify_state's home fence. That fence is not weakened here; it is left byte-unchanged and now has its own golden.

What this builds

bin/fm-worker-lifecycle.py

  • request gains --task-home (default None).
  • authoritative_request_bindings(env, task, generation, task_home=None): origin = task_home or env["home"], the metadata read becomes origin/"state"/(task + ".meta"), and the stamped binding becomes home_binding(origin). Every other line is unchanged.
  • command_request accepts --task-home only with a complete parent pair, --role author and --owner-kind secondmate; otherwise it refuses with the exact string task home is owned by compartment child requests only.
  • New authorize_task_home(env, state, item, task_home), called INSIDE the existing controller_lock hold, immediately before the unchanged enforce_child_bounds.

bin/fm-spawn.sh

  • TASK_HOME (default $FM_HOME), set from the new FM_SPAWN_TASK_HOME. STATE/DATA/PROJECTS derive from $TASK_HOME; FM_HOME, CONFIG, the lifecycle invocation and the new PRIMARY_STATE stay on the primary.
  • owner_kind derives from $TASK_HOME/$SUB_HOME_MARKER rather than $FM_HOME's marker.
  • --task-home "$TASK_HOME" is forwarded only when it differs from $FM_HOME, so every ordinary spawn keeps its exact pre-flag argv.
  • spawn_cloud_assignment_generation (re-anchored: it is in the cloud placement helpers block, previously reading $STATE/azure-workers/controller.json) now reads $PRIMARY_STATE/azure-workers/controller.json. Without this the child would be reported durably queued forever.
  • Two more re-anchored reads that the split would have broken: the cloud state-dir guard now compares $STATE against $TASK_HOME/state rather than $FM_HOME/state (it would otherwise refuse every compartment-child spawn outright), and bin/fm-spawn-cloud-monitor.sh now derives CONTROLLER from $FM_HOME rather than from $STATE.
  • FM_SPAWN_TASK_HOME is refused when it is relative, is not an existing directory, is not a seeded secondmate home, or is combined with FM_STATE_OVERRIDE/FM_DATA_OVERRIDE/FM_PROJECTS_OVERRIDE. When it differs from $FM_HOME the spawn additionally takes that home's own lifecycle lock (a dedicated variable, so the --secondmate spawn lane's lock is untouched) and re-validates its marker under the hold.

On PR #273. #273 removed the FM_AZURE_WORKER_STATE_DIR pin, stopped moving FM_HOME in the compartment monitor, and introduced --spawn-home on fm-secondmate-cloud-monitor.py child-relay, documented as "the home the child spawn runs under, which is the controller's own home". That concept is correct and is extended rather than duplicated: --spawn-home stays the primary and remains what the spawn runs under, and the compartment's own home (already carried by that lane as --home) is what must now reach the spawn as FM_SPAWN_TASK_HOME. Nothing in #273 is undone, and no parallel concept is introduced. The monitor files themselves are untouched here (a sibling change owns them), so the monitor-side wiring of FM_SPAWN_TASK_HOME is the one remaining step; the controller and spawn contract it needs is complete and proven end to end below.

The authority chain, and why nothing is self-authorizing

authorize_task_home runs under the one exclusive hold over the one document, before the bounds it anchors. Three independent links, all owned by the primary:

  1. task_home/.fm-secondmate-home must be a regular non-symlink file whose contents equal item["parent_task"] (the same marker file and shape validate_secondmate_home already validates elsewhere);
  2. the primary's own env["home"]/data/secondmates.md must map that secondmate id to exactly that resolved directory - an entry only the primary could have written;
  3. the unchanged enforce_child_bounds must then find that id as an assigned role=secondmate entry in this controller's document.

A directory that plants its own marker fails link 2. A registry entry naming an unmarked or symlink-marked home fails link 1. A marked and registered home whose secondmate is not a live compartment here fails link 3. No link can be satisfied by the task home alone.

Zero-diff proof

verify_state, empty_state and the whole C2 claim/lease/fence discipline are byte-unchanged. Proven by AST-slicing both origin/main's and this head's bin/fm-worker-lifecycle.py and comparing each function's source text:

ZERO-DIFF on 22 of 22 fenced functions
CHANGED: verify_request, create_worker_record, command_request

covering verify_state, empty_state, claim_pending, apply_pending, drain_pending, controller_lock, load_state, save_state, command_abandon_claim, provider_mutate, slot_lease, ensure_unique_bindings, enforce_child_bounds, capacity_admission, outstanding_cost_reservations, home_binding, request_key, release_receipt, verify_release_against_worker, proof_template, command_release, command_surrender.

CORRECTION, stated here rather than left to a reader who stops at this section: an earlier revision of this block claimed 18 of 18 with CHANGED: none and listed verify_request among the byte-unchanged functions. That is no longer true at this head. verify_request CHANGED: it gained the task_home shape guard (the request must carry a complete parent pair, owner_kind=secondmate, no --role secondmate, and a home_binding matching the task home). create_worker_record and command_request changed to carry task_home additively. All three changes are strengthenings, and the ordinary lane's emitted bytes are pinned unchanged by local_secondmate_lane_bytes_unchanged. The zero-diff claim is the foundation the whole review discipline rests on, so it is restated accurately rather than quietly widened.

Test evidence

All hermetic, run as python3 tests/run-one.py bash tests/<suite>.test.sh (the process reaper lives only in run-one.py).

Suite Before After
fm-worker-lifecycle.test.sh 30 units 33 units, rc=0
fm-spawn-cloud.test.sh 18 units 20 units, rc=0

New units:

  • compartment_child_task_home - real minted bindings with the meta under a --task-home secondmate home and the controller under the primary: child ADMITTED, home_binding equal to sha256 of the secondmate home and different from the document's own, account/worktree/repository bindings recomputed independently, children_total incremented exactly once. Then: a home the registry names but whose marker names a different secondmate refuses with no queue mutation and no lifetime spend; a marker naming a different secondmate refuses; a home the primary never registered refuses; a secondmate absent from the registry refuses; a missing registry refuses; a marked and registered home whose secondmate is not assigned hits the unchanged enforce_child_bounds string; --task-home with no pair, with half a pair, with --owner-kind primary and with --role secondmate all refuse with the exact string; a symlinked marker refuses. Plus a structural pin that the authorization call sits inside the controller_lock hold and before enforce_child_bounds.
  • local_secondmate_lane_bytes_unchanged - the AMENDMENT 1 lane (FM_HOME = secondmate home, no parent pair, no --task-home): the emitted queue item is compared byte for byte against an item the test builds from first principles, every digest recomputed in the test. Pinned by recomputation rather than by diffing main's binary because most CI jobs check out shallow, and a golden that needed git history would be a conditional skip dressed as coverage.
  • verify_state_home_fence_golden - AST golden pinning verify_state's exact six-field identity tuple ("schema", "home_binding", "subscription_binding", "deployment_generation", "owner", "prefix"), plus the live refusal when a document's home_binding is tampered with.
  • test_compartment_child_spawn_splits_the_task_home_from_the_money_document - end to end through the real bin/fm-spawn.sh: a compartment stood up in the primary's controller, then a child spawn with FM_HOME = primary and FM_SPAWN_TASK_HOME = secondmate home. The child's task metadata lands in the secondmate home and NOT in the primary; the primary's document carries the child with owner_kind=secondmate, the parent pair, home_binding = the secondmate home; children_total is 1; and no second controller.json is ever created under the secondmate home.
  • test_task_home_refusals_are_exact - relative path, missing directory, unmarked directory, and the state-override combination all refuse before anything is written.

Adjacent suites, all rc=0 and unchanged: fm-spawn-backlog (7), fm-spawn-batch (3), fm-spawn-dispatch-profile (20), fm-spawn-provision (63), fm-secondmate-cloud-monitor (37), fm-secondmate-liveness (16), fm-secondmate-harness (25), fm-account-directory (35), fm-bootstrap (27), fm-auto-reap (20).

bin/fm-lint.sh rc=0 over all 246 scripts; bin/fm-lint-node.sh rc=0.

Mutation proofs

Each mutation applied to the call site, suite run, red recorded, mutation reverted, tree confirmed clean.

Mutation Result
drop the marker check in authorize_task_home RED: AssertionError: queued child-2b generation gen-c2b for one isolated author worker - a home the registry names, marked for a different secondmate, is ADMITTED
drop the registry check in authorize_task_home RED: AssertionError: queued child-3 generation gen-c3 for one isolated author worker - a home carrying a planted marker is ADMITTED
accept --task-home without the parent pair RED: AssertionError: queued child-7 generation gen-c7 for one isolated author worker
run authorize_task_home outside the lock RED: AssertionError: authorize_task_home is not called inside the controller lock hold

Honest limits, and what a future wrong edit breaks

  • If --task-home is ever accepted without the complete parent pair, or the marker check is dropped, or the registry check is dropped, any directory on the box could mint requests against the primary's money document with arbitrary account and worktree bindings. That is the whole exposure of this change, it is why all three checks are separately mutation-proven, and it is why the refusal is a single exact string rather than a family of near-misses.
  • The three checks together are an authorization, not an isolation boundary: a caller that can already write the primary's data/secondmates.md can authorize any directory it likes. The registry has always been primary-owned, and this change does not raise or lower that.
  • CONFIG deliberately stays on $FM_HOME rather than moving to $TASK_HOME, per the spec's explicit list of STATE/DATA/PROJECTS. That means the child's harness/dispatch configuration is the primary's, which is what the lane does today; a future decision to move it is a separate change.
  • The monitor-side wiring (fm-secondmate-cloud-monitor.sh/.py passing FM_SPAWN_TASK_HOME) is NOT in this PR - a sibling change owns those files. Until it lands, the capability exists and is proven end to end through bin/fm-spawn.sh, but the production relay still spawns with TASK_HOME == FM_HOME and its child request is refused at verify_request for being primary-owned while carrying a parent pair.
  • docs/azure-workers.md had two stale claims about this lane (that FM_AZURE_WORKER_STATE_DIR stays pinned while FM_HOME moves, and that compartment children are blocked pending an assertable owner kind and an authorized task home). Both are corrected here rather than left contradicting the shipped code.
  • No live compute was created; every proof above is hermetic against the fixture provider.

Review round 2 (adversarial review findings 1-6, all addressed)

1 (BLOCKER, fixed) - the capability admitted a child that could never be released

Correct and worse than my original limits section said. command_authority_receipt and ordinary_authority_attempt both invoked the authority tool with --home str(env["home"]), the PRIMARY, and fm-worker-authority.py reads home/state/<task>.meta. The compartment child's meta lands ONLY under the task home, so an admitted child held a live worker slot with no ordinary exit; and because the resulting error carries the literal WORKER AUTHORITY REFUSED, an architectural home mismatch read as a genuine refusal and would have qualified every compartment child for surrender from the moment it was assigned, which is exactly what ordinary_authority_attempt's own comment says must never happen.

Fixed by carrying the PATH, not only the digest:

  • the queue item gains task_home (compartment children only), validated by verify_request and part of identity_fields;
  • create_worker_record carries it onto the worker record, additively, so an ordinary worker record keeps its exact bytes and ordinary_authority_attempt (which sees only the worker) can find it;
  • new authority_home(env, record) returns the task's own home, FENCED on the recorded home_binding, so a hand-edited path cannot redirect the authority lane;
  • both call sites now pass str(authority_home(env, worker)).

New unit compartment_child_reaches_its_ordinary_exit drives the whole exit for real against a full authority fixture (tmux shim so the endpoint proves absent, completion.md with the ordered headings in the TASK home, a clean worktree cloned from a real origin so landing proves reachability, and an account under the real account-directory helper): the child is admitted, assigned, mints all five proved receipts, and RELEASES through the unchanged release path. It then tampers task_home to a decoy home that would otherwise succeed and requires the fence to refuse.

2 (BLOCKER, fixed) - the registry parser was a reimplementation that dropped every safety check

Correct. authorize_task_home is now the STRICTEST reader of data/secondmates.md, not the most permissive: the regex is deleted and the function shells out to the canonical fm_secondmate_registry_query in bin/fm-account-routing-lib.sh, inheriting the whole-registry refusal, the absolute-home requirement, the .. refusal, the per-component lstat symlink refusal, the existing-directory check, and the duplicate-id / duplicate-home-by-inode checks. --task-home must additionally be absolute at the CLI, so a relative path can never resolve against the request process's cwd.

New unit task_home_registry_is_read_by_the_canonical_reader runs all six shapes, and for each one it FIRST proves the canonical reader itself rejects that registry (so the case cannot pass vacuously), then requires the request to refuse. It runs the relative-home shape with cwd set to the directory it would resolve against, so a cwd-anchored reader genuinely admits there rather than missing by luck. It ends by proving a valid registry still admits.

3 (MEDIUM, fixed) - the marker link was weaker than validate_secondmate_home

Correct on both proven admits. authorize_task_home now applies the home-shape rules: the task home may not be, contain, or sit inside the active firstmate home, nor be, contain, or sit inside the firstmate repository, and it must carry AGENTS.md and bin/. The marker comparison strips trailing newlines only (rstrip("\n")), exactly what the shell readers' $(cat ...) does, so a leading-whitespace marker is no longer the secondmate id. The docstring now states precisely which rules are applied rather than claiming a match it did not have.

4 (MEDIUM, fixed) - the binding mint ran before the authorization

Correct. authorize_task_home now runs BEFORE authoritative_request_bindings, so nothing reads <task_home>/state/<task>.meta, resolves caller-named worktree and account paths, or runs git -C <caller path> under a directory the primary has not authorized. The authoritative decision is still taken again inside the lock, immediately before enforce_child_bounds; the structural pin now requires both (one call inside the hold before the bounds, and a call before the mint).

5 (LOW, fixed) - dead parameter and raw marker bytes in operator output

The unused state parameter is dropped (the reviewer allowed either), and the function now cross-checks item["home_binding"] == home_binding(task_home) under the lock, which closes the gated asserted-bindings gap where --task-home was authorized and charged against children_total but then ignored for the bindings. Refusal text renders the marker through safe_marker_text: non-printable bytes and quotes become ?, and the value is truncated at 64 characters. A unit plants a marker containing \x07 and an ANSI escape and requires neither to appear in the refusal.

6 (documentation, fixed) - the third re-anchor is not a pure equivalence

Correct, and now stated plainly: bin/fm-spawn-cloud-monitor.sh deriving CONTROLLER from $FM_HOME rather than $STATE CHANGES WHAT FM_STATE_OVERRIDE MEANS for that script. FM_STATE_OVERRIDE still redirects where the task's own files are read, but it no longer redirects which controller document is consulted. It is behaviour-identical for every in-repo invocation (fm-spawn.sh pins FM_STATE_OVERRIDE to the task home's own state for cloud spawns, and every test invocation passes FM_HOME alone), and the change is what makes the money document singular across the split, but it is a semantic shift and not a pure re-anchor. bin/fm-spawn.sh also now checks the marker's CONTENT against FM_SPAWN_PARENT_TASK at startup, so a typo refuses before any lifecycle lock is taken or any convergence artifact (including the copied provider credential) is staged.

Round-2 verification

Zero-diff re-proved and widened to the release lane: ZERO-DIFF on 22 of 22 fenced functions, CHANGED: none, now also covering release_receipt, verify_release_against_worker, proof_template, command_release and command_surrender.

Suite Units Result
fm-worker-lifecycle.test.sh 30 on main, 36 here rc=0
fm-spawn-cloud.test.sh 18 on main, 20 here rc=0

Adjacent suites re-run at this head, all rc=0: fm-spawn-backlog 7, fm-spawn-batch 3, fm-spawn-dispatch-profile 20, fm-spawn-provision 63, fm-secondmate-cloud-monitor 37, fm-secondmate-liveness 16, fm-secondmate-harness 25, fm-account-directory 35, fm-bootstrap 27, fm-auto-reap 20. bin/fm-lint.sh rc=0 over 246 scripts; bin/fm-lint-node.sh rc=0.

New mutation proofs

Mutation Result
release lane passes env["home"] again (drop authority_home) RED: WORKER AUTHORITY REFUSED: ordinary task metadata authority is absent - the admitted child is stranded again, which is precisely the finding
worker record drops the durable task_home RED: KeyError: 'task_home' - the path must survive onto the record the release lane actually sees
authority_home drops the recorded-home_binding fence ADMIT-RED: a foreign task home minted receipts: authoritative endpoint/report/landing/account/worktree receipts written
registry read by the permissive regex again ADMIT-RED: shapes the canonical reader refuses were ADMITTED: ['relative home', 'path through a symlinked component', 'dot-dot component', 'one corrupt trailing line']
marker home-shape rules dropped ADMIT-RED: queued mark-1 generation gen-m1 for one isolated author worker - the PRIMARY's own home is accepted as a task home
marker comparison strips leading whitespace again ADMIT-RED: queued mark-3 generation gen-m3 for one isolated author worker
bindings minted before the task home is authorized RED: the bindings are minted from the task home before it is authorized

Honest note on the registry mutation: the permissive reader admitted four of the six shapes rather than all six. The remaining two (trailing spaces, one home under two ids) refused under the mutation because the four preceding admits had already consumed the default fan-out cap of 4, so they refused as over-cap children rather than on registry grounds. The four admits already prove the finding; at HEAD all six refuse on registry grounds, and the unit asserts that explicitly.

Remaining honest limits, restated

  • The three authorization links are an authorization, not an isolation boundary. Anything that can already write the primary's data/secondmates.md can authorize a directory. That was always true of that document; this change makes the money path read it exactly as strictly as every other consumer does.
  • The monitor-side wiring (fm-secondmate-cloud-monitor.sh/.py passing FM_SPAWN_TASK_HOME) is still NOT in this PR; a sibling change owns those files. With finding 1 fixed, a child created once that lands is releasable through the ordinary lane rather than stranded.
  • CONFIG still stays on $FM_HOME; unchanged from round 1 and still a deliberate scope boundary.
  • If --task-home is ever accepted without the complete parent pair, or the marker, home-shape or registry link is dropped, or authority_home's fence is removed, any directory on the box could mint requests against the primary's money document, or redirect the release authority at a foreign home. Every one of those is separately mutation-proven above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant