# 0052 — Engine-room endpoint and the `empower` verb

Date: 2026-07-29
Status: accepted
Relates: ADR-0051 (two-key subnet), ADR-0009 (endpoint access control model)

<!-- Ruled at the access-control grill 2026-07-28/29 (operator + doyle). CONTEXT.md
"engine-room endpoint" / "empower" / "control-surface modes" carry the canonical
terms; this ADR carries the why and the rejected shapes. -->

## Context

Control-surface modes (node-level and subnet-level `open`/`closed` defaults) need a
designated mutation surface. Requirements that killed the obvious candidates:

- **Agents must not reach it** — modes are exactly the thing a confused or
  adversarial agent would loosen. A plain CLI verb is agent-invocable.
- **OS elevation alone is the wrong gate for subnet scope** — it proves "human at
  THIS machine," not "holder of THIS subnet's admin authority" (ADR-0051 context).
- **No privileged node** — the mesh stays symmetric.

## Decisions

### 1. One engine-room endpoint per node

<!-- [doc->REQ-ER-RESERVED-ENDPOINT] -->
<!-- [doc->REQ-ACL-NODE-MODE-SET] -->
A locked-down **agent endpoint** (harness-adapter-backed, spt-hosted, has a mind —
it can be briefed on and reason about the node's access posture). It is the
designated way to set the **node's** control-surface modes.

"One per node" is **structural, not policed** (W3): the engine room lives at a
reserved endpoint id, so a second one is unrepresentable rather than refused. Its
home subnet and its bound harness adapter are written by the create/reset ceremony
alone — and **creation IS that ceremony run against an empty record**, one code
path, so a creation cannot skip a lock a reset applies.

### 2. Bring-up gate is TOTP possession, not elevation

<!-- [doc->REQ-ER-BRINGUP-TOTP-GATE] -->
Bring-online + controller-attach requires a **same-node CLI call plus a
member-or-admin TOTP** for the engine-room's home subnet. The gate proves a human
holding subnet material is at the controls; agents cannot pass it.

Bring-up **fails closed when the bound harness adapter is missing** — an engine
room that cannot host its own mind must not come online half-formed (ADR-0053
spirit). Note the deliberate split of gates: bring-up is TOTP and never
elevation, while the create/reset ceremony (decision 4) is elevation and never
TOTP. They answer different questions — "a human holding THIS subnet's material
is at the controls" versus "a human at THIS machine is setting node-scope facts"
— and elevation additionally has to work at bootstrap, when the node holds no
subnet material to prove a TOTP against.

<!-- [doc->REQ-ER-BRINGUP-PROMPT] -->
**Entry UX (ruled 2026-07-30, fast-follow grill):** bare `spt rc engine-room`
opens an interactive TOTP prompt (Esc-cancel overlay, the releases#19 pattern);
`--code <totp>` stays for direct passing, with the documented caveat that argv
is readable by other processes on the box while the code is still valid.

### 2a. The bring-up gate's local attempt bound (added 2026-07-29)

<!-- [doc->REQ-ER-BRINGUP-ATTEMPT-BOUND] -->
Accepting either seed hands an attacker two acceptable secrets, the ADR-0051 §1a
shape. §1a answered it with a **network** rate limit; that argument **does not
carry here** — this path is a local CLI call with no round trip, and the adversary
is an agent on the same box. W3's bound is instead **one shared failure ledger plus
exponential backoff**:

- **One persisted counter covering BOTH secrets.** Any failed attempt bumps the
  same ledger, so the budget is the ledger's, not the keyring's, and a second
  acceptable secret buys nothing.
- **Backoff 1s doubling to a 1h cap.** Attempts arriving *during* a backoff are
  **refused without counting** — an agent must not be able to ratchet the human
  into permanent lockout, because denial of governance is itself the attack. The
  throttle is evaluated *before* the code is verified, so a shut gate is not an
  oracle either.
<!-- [doc->REQ-ER-BRINGUP-ABSENT-CODE-UNCOUNTED] -->
- **An ABSENT code is refused without counting, the same way** (added 2026-08-19,
  releases#197). The ledger bounds *guesses*, and no-code-at-all is not a guess:
  it carries no candidate, can never admit, and is the truthful answer from every
  attach path on the box with no human at a keyboard to read a code off. Counting
  it handed any local process the exact ratchet the bullet above forbids — N
  codeless attaches shut the gate a human needs to take the controls back. This
  is the same *fact about this node's own configuration* shape that already makes
  a missing bound adapter uncounted (§2), applied one input later. Two properties
  bound it, and each is a test: the distinction is **presented-versus-absent and
  never which-key-matched**, so ADR-0051's indistinguishability is untouched (an
  absent code carries zero member-versus-admin bits, and refusal uniformity binds
  codes that are *neither* key); and the **throttle still runs first**, so a
  codeless attempt at a shut gate is answered `Throttled` exactly as a wrong code
  is, and gate state stays unreadable. The ledger is not written at all for this
  outcome — extending the missing-adapter write-skip rather than writing it back
  unchanged, because a write that *failed* would turn a benign codeless attach
  into a ledger-unwritable refusal, a worse lie than the one being fixed.
- **Reset on success; persisted across daemon restarts.** A bounce that cleared
  the ledger would restore the full budget on demand (the per-process-mint defect
  class the access-model spec found).
- **Loud past the third failure** — a notification, so brute force is something
  the human hears and silence buys the attacker nothing.

### 2b. `empower` gets its own ledger, not bring-up's (added 2026-07-29)

<!-- [doc->REQ-SUBNET-EMPOWER-VERB] -->
`empower` verifies a six-digit admin code from the same local surface, so it is
bounded the same way — §2a's classifier and backoff curve exactly, **one
implementation, two instances**. What it does **not** share is the counter.

§2a's single ledger answers *two seeds on one surface*: one principal class
attempting bring-up, where alternating seeds must not double the budget.
`empower` is a **different principal class** — an agent-side `spt api` verb.
Coupling an agent-reachable surface to the human-only bring-up ledger would let
an agent's failed attempts back off the gate a human needs to take the controls
back, which is precisely the denial-of-governance harm §2a exists to prevent.

Separation is therefore deliberate, and so is its cost: **the admin seed faces a
2× guess surface** across the two ledgers. Three properties bound it, and each is
a test:

- **The attempt surface is engine-room-only.** The identity check runs **before**
  any proof verification, and a caller that is not the engine room is refused
  **without the empower ledger being read or written** — otherwise any agent
  could spend the engine room's budget with deliberate failures and lock it out
  of its own verb, the same harm one level down.
- **The same backoff curve** applies: 1s doubling to a 1h cap, attempts during a
  backoff refused without counting, reset on success, persisted across restarts.
- **Loud past the third failure**, plus a **coalesced probe alert** on any
  non-engine-room caller reaching for the verb at all. The reserved id is
  published and the verb has exactly one legitimate invoker, so a refusal there
  is a probe, not a fumble. The alert coalesces under one fixed key because the
  path is reachable by any local agent and an uncoalesced alert would hand it the
  human's notification surface as a flood channel.

### 3. Structural locks

<!-- [doc->REQ-ER-INBOUND-LOCK] -->
<!-- [doc->REQ-ER-NOT-ADVERTISED] -->
<!-- [doc->REQ-ER-SESSION-BRIEFING] -->
<!-- [doc->REQ-ER-RULESET-TABLE] -->
<!-- [doc->REQ-ER-RULESET-NODE-NAMES] -->
Refuses all inbound access traffic except replies to its own outbound. Knocks and
knock-codes ride the daemon-owned knock family, whose rate limit, TTL, and
single-pending controls apply independently; the inbound access lock deliberately
does not mediate them. The engine room is online **only while a controller is
attached** — detach drops it offline and every empowerment dies with it;
`rc --view` denied even locally; remote attach denied; **local `rc --take`
allowed** precisely because it forces a harness restart and revokes all
empowerments; not registry-advertised by default (only to endpoints it has
whitelisted); every session start delivers a briefing message stating its
capabilities and responsibilities; it presents access rulesets as **markdown
tables whose node subjects read as node names** (amended by replacement
2026-08-21, releases#210: the decision was a table, and a space-aligned grid
reflowed by the chat surface a briefing is read on is not one; an unnameable
node keeps its full pubkey hex rather than a truncation).

The briefing is a **system-authored message spooled at bring-up**, not
role text: role is durable identity, posture is per-session state, and mutating
role per session would race the role editor and pollute the mind's substrate.

### 3a. What "offline on detach" and "take revokes" mean against the code (amended 2026-07-29)

<!-- [doc->REQ-ER-CONTROLLER-BOUND-POSTURE] -->
<!-- [doc->REQ-ER-RC-INTENT-LOCKS] -->
Decision 3 was written in the language of the ceremony, and two of its clauses do
not survive contact with the daemon's lifecycle invariants. Both are amended here
rather than built literally.

**Detach drops it offline = ACCESS-POSTURE offline, never process teardown.** A
detach that killed the harness would re-break the attach-lifecycle invariant
(`docs/KNOWN-HAZARDS.md`: an `rc` pump detaching ends ONLY the viewport; the
harness must keep running and stay re-attachable) — an invariant `harnesshost`
satisfies by construction, since the broker owns the PTY precisely so a detach
cannot terminate the session. The engine room's offline is therefore a **posture**:
on detach it refuses all inbound (the reply exemption included), drops every
empowerment, and stops being advertised. The process lives; the authority does not.

**`rc --take` revokes explicitly — it does not restart anything.** The original
rationale ("local take is allowed precisely because it forces a harness restart and
revokes all empowerments") does not hold: `Take` is a broker lease displacement
(`resolve_subscribe` → `TookControl`, loud `displaced_envelope`) and restarts no
harness on any path. Local take stays allowed on two honest grounds instead: the
**displacing controller must pass the same same-node bring-up gate** the incumbent
did (§2), so a take is a gate attempt rather than a bypass of one; **and the
displacement is loud** (`displaced_envelope`), so an incumbent human cannot be
silently unseated. Both grounds carry weight in a threat model whose adversary is
an agent. Revocation of empowerments on take is an **explicit** step in the take
path, and is likewise explicit on detach.

**Consequence.** Empowerment is session state with two named drop edges (detach,
take), each of which needs its own test; "revoked by restart" is not an available
argument. A THIRD named edge exists and is not a drop: the session boundary, whose
polarity is **carry** (§3c). The bring-up gate's local attempt bound (§2a) applies identically to a
take's gate pass — **a take attempt is a gate attempt**.

**What the drop edge keys on.** Both named edges are transitions of the broker's
controller seat, so the revocation lives at the seat resolver and is keyed on the
**attach generation**, not on node identity: two viewports on one machine are the
same `driven_by` and distinct controllers (the ADR-0044 lease ladder). An equal
generation is the *same* lease being re-served — the successor re-serve after a
dispatcher restart, and the controller gap-resume re-fetch — and must keep its
authority, or a controller would lose its keys for falling behind its own output.
Every other transition (seated, displaced, released) drops. A controller that
dies with its connection is released by the off-lock stamp convergence rather
than by a detach, so that path drops too — three call sites, one rule.

**Posture is derived, not stored.** "Online" is `provisioned ∧ a controller is
attached`, read from the record and the perch stamp the broker is the single
writer of. A persisted `posture` field would be a third fact free to outlive the
two it summarizes — the latched-`driven_by` defect class this codebase has
already paid for twice.

### 3b. What the advertisement whitelist can be keyed on (amended 2026-07-29)

<!-- [doc->REQ-ER-NOT-ADVERTISED] -->
Decision 3 says the engine room is advertised "only to endpoints it has
whitelisted". The whitelist is built **node-keyed**, not endpoint-keyed, because
endpoint granularity is not enforceable at the only seam advertisement has:
a registry row is pushed to a peer **node**, and once it lands there every
endpoint on that node reads it out of the same registry. An endpoint-keyed
whitelist would be a promise the wire cannot keep — and no endpoint identity
travels with a feed push to check it against (`sender_endpoint` is inert on the
whole access surface). The whitelist therefore names viewer nodes, which is the
same granularity every other access rule already uses.

Two consequences the build carries:

- **The filter precedes the same-node allow.** The `DISCOVER` gate short-circuits
  `true` for a loopback viewer, and this node is exactly where the adversary
  stands, so the engine room's rule is asked first — a node is not implicitly
  whitelisted to itself. It can be whitelisted explicitly, like any other viewer.
- **De-advertisement rides the posture drop, and withholds rather than retracts
  off-node.** A withheld engine room contributes no row to the scan at all —
  peers and this node's own registry alike — and a row it authored while listed
  is taken down locally on the drop (own rows never decay by themselves). A copy
  a peer already holds is not retracted: retracting it would mean disclosing to
  the very viewers the filter is refusing. That is the same convergence gap
  per-subnet visibility hiding already has, and it should close for both at once
  rather than grow a second answer here.

### 3c. The session boundary is a third named edge, and its polarity is CARRY (added 2026-08-03)

<!-- [doc->REQ-HAZARD-EMPOWER-SESSION-RESURRECT] -->
§3a names the two DROP edges (detach, take) and both remain drops. A third edge
exists that neither of them covers and that is **not a drop**: `spt api boundary`
rotates the session id the engine room's perch is bound to, at a harness `/clear`
or `/compact`. An empowerment is filed under that session id, so the rotation
moves the record's key out from under the authority while the seat, the human and
the controller are all unchanged.

**It is not a seat transition.** §3a keys the drop on the broker's controller seat
and its attach GENERATION, because that is what distinguishes one controller from
another. A boundary touches none of it: the same viewport is still attached, no
lease is displaced, no gate is re-run. Treating it as a drop would revoke a
human's authority because their agent compacted its context — the same failure
shape §3a already refuses when it keeps authority across an equal generation, so
that a controller does not lose its keys for falling behind its own output.

**So the boundary CARRIES, and the carry is a MOVE.** The empowerment follows the
rotation to the new session id, and the old session's record is GONE when the
boundary returns. Two records for one grant is the same leak wearing a different
hat: `--to-session-id` is arbitrary past a guard that refuses only psyche-custody
squats, so a session id can be returned to, and a record left at the old one makes
a DROPPED empowerment re-acquirable with no admin code proved — authority
surviving at rest, which the Consequences forbid in as many words.

**It fails closed, and it never fails the boundary.** If the move cannot complete,
the empowerment is DROPPED with a loud diagnostic rather than left standing at the
old session id: losing it costs a human one re-proved admin code, keeping a stale
one costs the property this decision rests on. The boundary itself still succeeds
— the harness has already rotated, and turning an authority-hygiene error into a
refusal would desync the record from the reality it exists to describe.

**The hygiene sweep is not this mechanism and must not be promoted into one.** It
runs at mint time, so a re-acquire happening later is past it either way. The
boundary's own move is what closes this.

**Scope.** This edge is the boundary. Two other mechanisms rotate a bound session
id — the dead-owner re-pin and an `establish_perch` re-bind — and they leave the
prior session's record standing too. Their polarity is the OPPOSITE: a boundary is
one session lineage continuing, while those are a new session taking over a perch
whose prior owner is gone, so the authority does not follow, it drops. They are
filed as releases#138 and are not closed here.

### 4. Purge resets, never deletes

<!-- [doc->REQ-ER-PURGE-RESETS] -->
`endpoint purge` against an engine-room **requires OS elevation** and resets the
endpoint instead of deleting it. The reset is the only way to change its home
subnet or its harness adapter.

Reset-not-delete is what keeps "one per node" structural: a node whose engine room
can be deleted is a node that can be left with no governance surface, and the
recovery path would then have to be a create verb — the very thing decision 1
removes.

### 4a. The ceremony's own gates: create unelevated, reset elevated, agents denied on both (amended 2026-07-30)

<!-- [doc->REQ-ER-CEREMONY-VERB] -->
Ruled at the fast-follow grill 2026-07-30 (operator + doyle). The ceremony ships
as **`spt endpoint engine-room <subnet> --adapter <id>`** — one verb, one code
path (`provision_at`), reporting `Created` against an absent record and `Reset`
against a present one.

- **Creation requires no OS elevation.** Bootstrap friction was ruled to
  outweigh the pre-emption risk: the unelevated window exists only while the
  record is absent and closes permanently at the first ceremony run. The
  documented mitigation is to run the ceremony early in a node's life.
- **Reset keeps the elevation gate** (decision 4): it rebinds facts an incumbent
  governance surface already depends on, and an agent must never be able to
  re-home a live engine room.
- **Both arms refuse invocation by an SPT agent.** The deny hinges on
  environment markers **and process identity** — the caller's process ancestry
  checked against the sessions the broker itself hosts — not env alone, which an
  agent controls. This check is defense-in-depth in front of both arms, not a
  substitute for the reset elevation gate.

Consequence: decision 2's "the create/reset ceremony is elevation and never
TOTP" now reads **reset-only** on the elevation half. A fresh node's create is
guarded by the agent-deny alone — accepted deliberately, with the early-ceremony
mitigation documented.

### 5. `empower <subnet-id> --admin-code <admin-totp>`

<!-- [doc->REQ-SUBNET-EMPOWER-VERB] -->
The verb an engine-room invokes to gain authority over the named subnet's
control-surface modes. Verified locally (every member holds the admin seed,
ADR-0051). The grant lasts until session end or controller detach.

It ships as an **`spt api` verb** gated by the perch capability proof plus an
engine-room identity check, not as a plain CLI verb (W3). The caller *is* an agent
— the engine room has a mind and is the specified invoker — so the gate cannot be
"no agent" and must be "no agent **except** the reserved one". The admin TOTP stays
the authority proof; the engine-room binding is what gives the grant a controller
lifecycle to die with, which a grant to an arbitrary CLI session would not have.
One shared authentication function serves this verb and the capture-refresh of
decision 6 — two call sites, one gate.

### 5a. An ADMIN-code bring-up empowers the home subnet (added 2026-08-01)

<!-- [doc->REQ-ENGINEROOM-ADMIN-BRINGUP-EMPOWERS] -->
Decision 2's gate accepts a **member or admin** TOTP and decision 5's verb accepts
an **admin** TOTP. An operator who brought the room up with the admin code was
therefore made to prove that same key a second time before touching a subnet mode
— a double entry of one credential that teaches nothing and protects nothing.
Ruled at the 2026-08-01 bag grill (releases#102): passing the bring-up gate with
the **admin** TOTP empowers the seat for the engine room's **home subnet** at
attach.

**No new capability.** The authority granted is exactly what `empower` would have
granted for that one subnet, to the same seat, for the same lifetime — until
session end or controller detach, dropped on detach, re-proved on re-attach. It is
wired through the **same writer** an explicit `empower` uses
(`spt_store::empower::grant_session`), so the lifetime is identical by
construction rather than by a parallel implementation that could drift from it.

**Gate parity.** Attach and take are the same gate (§3a), so they yield the same
grant. A take already re-proves the gate — that is why §3a allows it at all — so
there is no seat that holds these controls without having paid for them.

**The discriminant is WHICH SEED verified, never that the gate passed.** A grant
keyed on the admit would empower every member bring-up, which is precisely what
this decision does not do. The gate's verifier accordingly returns the credential
kind rather than a bool. Both seeds are still tried unconditionally and combined
bitwise, so the gate's TIMING still reveals nothing; what changed is the answer,
deliberately. **This does not touch ADR-0051's indistinguishability requirement,**
which is about the SPAKE2 join ceremony — the responder does not report, log or
branch on which secret confirmed, and an admin-code join still grants nothing at
join time beyond membership. The bring-up gate is a local TOTP compare and reaches
no joiner-facing wire.

**A tie resolves to "member".** Two distinct seeds can emit the same six digits on
the same step, and digits that verify against both prove possession of *a* seed,
not of *the* admin seed. The ambiguous case therefore grants nothing: an unearned
governance grant is unrecoverable, and a missing one costs the operator one
`empower` command.

**Silence is the refused shape.** The grant is stated on both surfaces a reader
actually reads — the attach output and the session-start briefing — from one
composed sentence, not two spellings. An empowered seat that never says so is
indistinguishable from an unempowered one until a mode change succeeds
unexpectedly.

### 6. Subnet modes spread by advisory gossip + local refresh only

<!-- [doc->REQ-ACL-ACCESS-REFRESH-ER-ONLY] -->
A node's **effective** subnet modes are captured at its join and are immutable from
outside. Mode changes replicate as advisory metadata: they surface as a notif, and
the engine-room is briefed at session start with the exact new posture, encouraged
to offer the user a sync. The capture-refresh is an `spt api` verb **only the
engine-room can invoke**, and it updates only the captured subnet-level fallbacks —
never the node's own rules. No remote actor can change a node's effective posture.

### 6a. The advisory leaves a note: `declared_seen` (added 2026-07-29)

<!-- [doc->REQ-ACL-MODE-ADVISORY-GOSSIP] -->
As W2 built it, the advisory was **a doorbell with nothing written down**: gossip
raised the notification and persisted nothing. The only local source of "what the
subnet declares" was `SubnetRecord.mode`, written at join and never again — so a
re-postured subnet left its members with no record that a new declaration existed,
the refresh could only ever answer *already current*, and **only the subnet's
creator could adopt anything**. The verb was perfectly gated and unreachable.

The fix is one shadow field beside the fact it shadows: `SubnetRecord.declared_seen`
(the seen declaration + when it was seen), **not** a pending-deltas store. Mode
deltas are latest-wins — an intermediate declaration is dead the moment a newer one
lands and must never be adoptable — so a queue would be speculative generality for
a second delta kind that does not exist yet (W4 may mint one; it can rule then).

- **Every ceremony that declares a mode records the sighting**, not only a
  diverging one. A subnet that re-postures and then changes its mind back retracts
  via a ceremony that declares what the member already enforces, and that
  retraction must **erase** the pending delta rather than leave it parked where a
  later refresh would adopt something the subnet has since withdrawn.
- **The sighting is present only while it differs from the held declaration**, so
  "a delta is pending" and "the field is set" are one question rather than two that
  can disagree. The briefing (§3, T8) reads it as the pending-delta line.
- **Adopting** moves it into `mode` and clears it. The enforced fallback is written
  **first**, so a half-completed refresh leaves the posture the human just chose in
  force with the sighting still pending — never a cleared delta over an unchanged
  posture, which would read as done and not be.
- **Seeing is still not adopting.** The access store — what the node enforces — is
  untouched by gossip on every path. The creator's path is unchanged: it declares,
  nothing shadows it, and its refresh reports already-current.

## Rejected shapes

- **Mother node** — asymmetric mesh; orphaned governance (see ADR-0051).
- **Elevation-gated mode verbs on any CLI** — agent-unreachable, but grants every
  member operator subnet-admin power and offers no reasoning surface.
- **Auto-applying replicated modes** — an empowered engine-room anywhere could
  silently flip every node's posture; violates local consent.
- **Leave/re-join as the mode-update path** (original sketch) — costs a roster
  tombstone + deliberate re-admit + re-pairing for what a consented refresh does.

## Consequences

- A new reserved endpoint per node with special lifecycle rules threaded through
  bring-up, rc intent gating, purge, and registry advertisement.
- Empowerment is session-state, not stored grants — nothing to revoke at rest.
- The TOTP-ceremony overlay pattern minted here is shared with wax-seal minting.
