# FIX-204 — engine room's perch never registers (PORTER #205 wave 1)

Lane: `fix/204-er-perch-registration`, worktree `.worktrees/fix-204`, base `b88fab2a` (= `origin/main`
= `v0.58.0` — the field-measured cut exactly; verified empty diff, so no read-at-the-wrong-sha hazard).

Status: **GO received, BUILDING.** doyle ruled both halves (see "doyle's rulings" below).

## doyle's rulings (binding, GO message 2026-08-21)

- **RULING 1 — anchor at BRING-UP.** Bring-up creates and anchors the skeleton so the hook bind finds
  `Some(prior)`. Do NOT teach bind to inherit the broker parent's anchor — the creation refusal on a
  multi-homed node is a deliberate explicit-choice seam (anchor subnet = default-scope subnet, so
  implicit inheritance is a silent scope decision). **The general fresh-bind-no-subnet case KEEPS
  refusing**; its defect was visibility, and visibility is the adapter half. A test must pin that the
  creation arm still refuses without an explicit or bring-up-supplied anchor — the seam must survive.
- **Riders:** D3 alarm split (absent must not promise; fail-open predicate, no false-fire on
  re-attach). E2E replacement approved as framed; falsify against `b88fab2a` first and cite the red.
  Public-contract doc amendment rides THIS lane (doc-stage tag).
- **RULING 2 — doyle carries the adapter half to perri himself.** I stay out of spt-claude-code.
- **WAVE 2 queued:** ER cluster #199/#187/#201, measure-first; the tolerant-arm asymmetry and this
  refusal chain are candidate mechanisms. Pin shared-vs-separate mechanism before building.

## Build state (this lane)

Pool claimed (`fix-204`). Full workspace build green, 3m26s.

- `crates/spt-store/src/info.rs` — NEW `create_anchored_skeleton(perch, id, adapter, home, cwd, vis)`.
  Idempotent BY REFUSAL (existing record untouched, `Ok(false)`). Takes the caller's
  `VisibilityStore` exactly as `stamp_creation_fields` does — an earlier draft loaded/saved the real
  store internally, which would have been a hidden global write and untestable; corrected.
- `crates/spt-daemon/src/broker.rs` — call site in `dispatch_bring_up_within` beside
  `note_bringup_in_flight` (pre-launch), gated on a non-empty `room.home_subnet`, LOUD-never-refusal
  (`ENGINE_ROOM_PERCH_ANCHORED` / `..._ANCHOR_FAILED`), following `empower_home_for_bringup`'s rule.
  Plus `perch_record_absent()` + the two-arm alarm split in `present_engine_room_briefing`.
- `traceable-reqs.toml` — `REQ-ER-BRINGUP-ANCHORS-PERCH` minted ACTIVE, `["doc","impl","unit","int"]`.
- Unit: 2 cells in info.rs (anchor-on-fresh incl. scope seeding; no-overwrite-on-existing) — **2/2
  green**. 1 cell in broker.rs (`the_absent_arm_fires_only_on_a_measured_absence`, incl. the
  fail-open unparseable case).
- Int: `a_bringup_on_a_multi_subnet_node_leaves_the_room_a_perch` appended to
  `crates/spt/tests/engine_room_bringup_e2e.rs` — compiles. That binary is ALREADY in both HEAVY
  filter copies by binary name, so no filter edit and no byte-compare chore this time.
- Doc: `docs-site/src/harness-contract/api.md`, `api bind` section — the registration-before-claim
  sentence + no-perch-on-refusal shape, `[doc->REQ-ER-BRINGUP-ANCHORS-PERCH]`.

## BUILT — committed `f1bd26796648a8c0224483d38197a33f7268ff71`, unpushed

Reported to doyle (SENT) and to the issue (issuecomment-5367569018). Awaiting his gate.

### Falsification (doyle required the red be witnessed)

Pre-fix @ `b88fab2a`: FAILED 48.42s on the record assertion. **That first red had an EMPTY panel** —
I read the raw inherited `brain_log`, which `daemon run` stops writing to a few statements in; the
e2e file's own header documents that trap ("four bring-up reds, all four panels blank"), so my
`ANCHOR_REFUSED` assertion was VACUOUS. Repointed at `daemon_diagnostics` (sink + breadcrumb),
re-ran: FAILED 49.15s carrying the field's exact mechanism from the rig's own breadcrumb —
`bind-done status=ExitStatus(1) stderr="ANCHOR_REFUSED: this node holds 3 subnets (erhome, ernet-b,
ernet-c) — pass --subnet <name> ..."`. Post-fix 1 passed, 3.34s (the 48s→3s drop is the signal: the
red burned the whole 45s `wait_for_session` bound).

### Legs

spt-store lib 491/491 · spt-daemon lib 860/860 (two runs) · int 1/1 · clippy 0 warnings ·
`traceable-reqs check` exit 0 with `+doc +impl +unit +int`.

### Ruling-1 rider resolved as a KEEP, not an add

`home.rs::stamp_creation_fields_news_assign_and_revives_carry_forward` ALREADY pins "NEW on a
multi-subnet node refuses without an explicit pick". Retagged it `[unit->REQ-ER-BRINGUP-ANCHORS-PERCH]`
with a paragraph on why it is load-bearing here, rather than writing a duplicate. Told doyle; offered
to add a separate cell if he wants one.

## NOT MINE, but recorded — a latent CI hazard

`failedaddr::tests::an_expired_record_stops_matching_and_is_swept` reds on any box with **under ten
minutes of uptime**: it does `Instant::now().checked_sub(FAILED_ADDR_TTL + 1s).expect(...)`,
`FAILED_ADDR_TTL` is 600s, and Windows `Instant` counts from BOOT. Measured, not assumed — my diff
touches that file in 0 files; predicted the identical code would pass once uptime crossed 10m01s,
changed nothing, re-ran, ok. **A freshly booted CI runner is exactly that box**, so this is a
plausible source of an unexplained golden red. Rig/test work is hertz's; flagged to doyle, not filed.

## Next

1. Wait on doyle's gate verdict for `f1bd2679`. A finding → fix as a NEW commit on this branch,
   report the fixup sha.
2. Then WAVE 2: ER cluster #199 / #187 / #201, measure-first. doyle's steer: the tolerant-arm
   asymmetry and this refusal chain are candidate mechanisms for #199's 1/20 erhost timeout and
   #187's stale projection — **pin shared-vs-separate mechanism before building**. Read their bodies
   but commit nothing until the mechanism is pinned.
3. Lane disk: `.worktrees/fix-204/target` is a fresh cold build; reap the SUBTREE only when doyle
   asks (classify outbound, sweep inbound, numbers both sides, worktree+branch intact).

## The chain, every link measured on the field run itself

The v0.58.0 acceptance ran on THIS box (HFENDULEAM), so the run is still on disk. Nothing below is
inferred from the census; each link was re-measured locally.

1. **ER bringup spawns the CC session but never creates the endpoint record.**
   `owlery/engine-room/` holds `spool.db` (36864 b) and `.has-messages` — but **no `info.json`**.
   The skeleton `multi_subnet_bringup_e2e` assumes was never created.

2. **The hook binds with no subnet.** `verb=bind` → `spt api bind engine-room --set-session-id <sid>`
   (`hook.rs:1724`). No `--subnet`.

3. **Core refuses, correctly, at the fresh-creation branch.**
   `startup.rs:677` → `home.rs:stamp_creation_fields`. `prior` is `None` (link 1), so the `None` arm
   runs `assign_home(store, explicit_subnet)?` → 3 subnets, no explicit → `Ambiguous` →
   `BindError::Home` → `ANCHOR_REFUSED` (`startup.rs:1213`).
   Note the asymmetry: the `Some(prior)` arm deliberately tolerates the ambiguous case
   ("a revive must not start refusing", `home.rs:205`) and returns `Ok(false)`. Only *creation* refuses.

4. **The refusal is loud — into a file nobody reads.** `hook-trace.log:1962`, pid 3620,
   wall `1787297916706`:
   `claude-spt hook: bind(startup) re-pin FAILED for engine-room: ANCHOR_REFUSED: this node holds 3
   subnets (BIGNET, SPT_MANTLE, SPT_DEV) — pass --subnet <name> (home is assigned at creation, no
   guessing)`
   `spt_strict` DID await it. `registered` stays `None`.

5. **647 ms later the same hook tells the session it owns a perch.** The brief is gated on
   `verb` (`hook.rs:1897`), never on `registered` — which gates only the sid-state persist
   (`hook.rs:1776`). Recovered the ER's own spill: `sptc-drain-35a31be3-…-1787297917353-3620.txt`,
   **67 lines / 6782 bytes** (census figures reproduced exactly), line 2 =
   "This Claude Code session already owns a live perch", and `grep -ci` for
   access-grid/posture/governance = **0**.

6. **No record ⇒ no roster row ⇒ `NO_PERCH` forever**, and the retained briefing row is
   structurally undeliverable. Alarm fired at `1787297921947`, ~10 s after `ENGINE_ROOM_BROUGHT_UP`.

7. **Cascade.** Because step 4 never persisted sid state, the later re-attempts were refused as
   `ambient $SPT_ENDPOINT_ID (engine-room) refused … no custody proof` (trace 1966, 1974).

## The closer

The sink prints `ENGINE_ROOM_ADMIN_BRINGUP_EMPOWERED:SPT_MANTLE` in the same window. **The bring-up
path already knows the anchor and does not pass it to the creation that needs it.** Memory confirms
SPT_MANTLE is the operator-ruled engine-room anchor (2026-08-05). This is not a guess core has to
make — it is a value core has in hand and drops.

## Answers to doyle's three discriminators

- **D1 — authoring site / what it checks.** `spt-claude-code/adapter/strings/briefs/identity.md:1`,
  a static template. It checks NOTHING. Its gate is `verb ∈ {bind, boundary}` + a resolvable id.
  Intent, not outcome. (The adapter *does* branch perch/no-perch for the messaging block —
  `messaging-perch.md` vs `messaging-no-perch.md` — so the identity block is the odd one out.)
- **D2 — fire-and-forget or awaited?** **Awaited and strict**, and it refused loudly. All three
  census hypotheses (a/b/c) are refuted: registration was performed, was awaited, did not die
  silently. The real shape is (d): a loud refusal routed only to `hook-trace.log`, plus a root cause
  nobody listed — creation-time subnet ambiguity on a multi-homed node.
- **D3 — alarm text.** Yes, split it. Perch-ABSENT (no record exists — unfulfillable) is a different
  event from perch-SLOW (record exists, not yet listening — the retained row does deliver). Today one
  text promises a delivery only the second can honor.

## Blast radius

Not ER-specific in mechanism, though ER is the only current victim: exactly **1** `ANCHOR_REFUSED` in
the whole trace. Every other endpoint pre-dates the node's third subnet or was created via CLI with an
explicit subnet — they hold a `prior`, so they take the tolerant arm. **Any fresh endpoint minted
through the hook bind path on a multi-homed node fails identically.**

## Proposed split (needs doyle's ruling — one seam is not mine)

- **CORE (mine):** ER bringup creates/anchors the skeleton to the subnet it already logs, so the
  hook's bind finds `prior` and inherits — restoring the premise `multi_subnet_bringup_e2e` already
  pins. Plus D3's alarm split.
- **ADAPTER (perri's repo):** gate the identity brief's perch claim on `registered`, not on `verb`;
  on a failed bind emit the no-perch shape and say the bind was refused. I have NOT touched
  spt-claude-code.

## E2E vacuity to close

`multi_subnet_bringup_e2e` passes because the rig pre-creates an anchored skeleton — the exact
condition the real ER path fails to establish. The new witness must assert the record EXISTS and is
rostered after a real bringup on a multi-subnet node, not that a pre-anchored skeleton binds.
