# 0051 — Two-key subnet: member key + admin key

Date: 2026-07-29
Status: accepted (amended 2026-07-29 — §1a, the implementable admission shape)
Amends: ADR-0005 (TOTP-seeded SPAKE2 pairing), ADR-0017 (mesh membership via seed-proof)

<!-- Ruled at the access-control grill 2026-07-28/29 (operator + doyle). CONTEXT.md
"member key / admin key" carries the canonical terms; this ADR carries the why and
the rejected shapes. Consumed by the ACCESS-CONTROL milestone. -->

## Context

Access control management (spt-bs-releases#15) needs a **subnet-scope admin
authority**: someone must be able to set a subnet's control-surface modes, and
"someone" cannot be *any member* — a shared subnet (new glossary term) contains
other operators' machines, and the adversary the access layer gates is **agents**.
The existing subnet has exactly one secret: the TOTP seed every member holds.
Possession of a member node therefore implied every power the subnet had.

Candidate authority models grilled:

- **Mother node** (creator-privileged): breaks the deliberately symmetric mesh
  (ADR-0017 — any member vouches, any member re-seeds); creator machine dying
  orphans governance; needs a succession story. Rejected.
- **Elevation on any member node**: OS elevation already separates humans from
  agents, but it makes *every* member operator a subnet admin — wrong for shared
  subnets where operators trust each other personally but not administratively.
  Rejected as the sole gate.
- **A second secret** — an admin credential distinct from membership. Accepted.

## Decisions

<!-- [doc->REQ-SUBNET-DUAL-SEED-MINT] -->
### 1. Subnet creation mints TWO TOTP seeds

The **member key** is today's subnet seed, unchanged (join ceremony, elevation-gated
`show-code`). The **admin key** is a second seed minted at the same moment.
An admin key IS a membership key (an admin code is accepted for the join ceremony);
a member key is NOT an admin credential. Admin codes gate subnet-scope access
administration — the `empower` verb (ADR-0052).

<!-- [doc->REQ-SUBNET-ADMIN-CODE-JOIN] -->
#### 1a. Amendment (2026-07-29): how "either code" is actually admitted

Decision 1 as first written — *the responder computes both codes and admits a joiner
proving either* — is **not implementable in one pass**. SPAKE2's responder step bakes
the password into `msg_b`, and `msg_b` ships a full round **before** the joiner's
confirmation tag arrives. The responder must commit to one password before it has any
evidence of which key the joiner holds. Building the wave surfaced this; ruled the same
day (doyle, on todlando's write-up of both implementable shapes).

**The shape, binding:**

- The responder runs its SPAKE2 step **twice against the same `msg_a`** — member
  password first, then admin password, independent `y` each — and sends **both**
  `(msg_b, tag)` candidates in one `Spake2` frame.
- The **member candidate leads**; the admin candidate is a *trailing, additive* field.
  That ordering is load-bearing compatibility, not taste: a joiner from before this
  wave reads the leading pair positionally and ignores the rest, so it keeps joining a
  two-key seed-holder on the member key. Admin-first would have broken every deployed
  joiner. Pinned by a unit test.
- The joiner tries the leading candidate and **falls back** to the trailing one when
  the leading tag fails. Exactly one can verify — each candidate commits to a different
  password. Both attempts run against ONE secret scalar (only one `msg_a` was ever
  sent).
- The responder **confirms the joiner's tag against either shared secret**, evaluating
  both without an early return.
- A subnet with no admin key sends exactly today's single-candidate frame.

**Rejected:** having the joiner declare which key it is typing (an unauthenticated
pre-PAKE claim, and a key-kind discriminator on the wire is precisely the oracle
REQ-SUBNET-ADMIN-CODE-JOIN forbids); and running two sequential ceremonies (doubles the
round trips, collides with the per-subnet one-at-a-time slot, and makes the first
ceremony's failure observable).

**Accepted cost — a 2x online-guess budget.** Two acceptable secrets means one admitted
ceremony yields an attacker two password guesses instead of one. The bound is *rate*,
not count: ADR-0005's per-subnet one-at-a-time admission slot plus exponential backoff
(#11) is what makes online guessing infeasible, and it is unchanged here — a wrong code
is charged a failure identically, so the ceremony rate an attacker can sustain does not
move. The doubling is accepted against that machinery, and the machinery is therefore
load-bearing for this decision: weakening the pairing rate limiter now costs twice what
it did.

**What stays indistinguishable.** The candidate *order* is fixed and public, so the
joiner learns which candidate it landed on — it typed the code, so it knew already.
What no one else learns is which candidate *matched*: a two-key seed-holder emits two
candidates on every ceremony whichever code is used, and the joiner's confirm tag is a
MAC under a key an observer cannot derive. The ceremony outcome carries **no key-kind
discriminator** — the responder does not report, log, or branch on which secret
confirmed, and an admin-code join grants nothing at join time beyond membership.

**Refusal uniformity.** A code that is neither key fails both candidates and takes the
same path today's single-key refusal takes: the same abort reason byte, the same
`Done(false)`, the same confirmation error, the same charged backoff. No new refusal
reason was minted, and no verify is skipped once another has decided the outcome.

<!-- [doc->REQ-SUBNET-ADMIN-SEED-REPLICATION] -->
### 2. Held everywhere, revealable nowhere

Every member node holds **both** seeds, replicated at join exactly like the member
seed today — required so any node can verify an `empower` code and serve an
admin-code join as SPAKE2 responder. But the admin seed has **no reveal verb**: no
`show-code` form, no QR re-provision, ever. It is displayed exactly once, at
`subnet create` (QR/`otpauth://` into the creator's authenticator, alongside the
member key's).

<!-- [doc->REQ-SUBNET-ADMIN-SEED-ROTATION] --><!-- [doc->REQ-SUBNET-REVOKE-ADMIN-GATE] --><!-- [doc->REQ-SUBNET-ADMIN-RESURFACE] -->
### 2a. Surfaced only to a proven admin — at mint and at rotation (amended 2026-07-30, fast-follow grill)

Decision 2's "displayed exactly once, at `subnet create`" could not survive
admin-seed rotation (the FF wave): every member node holds the admin seed, so
every eviction leaks it and rotation must re-mint it — but an authenticator
cannot follow a seed rotation, so a strict once-ever display bricks the
subnet's governance at its first eviction (`empower` dead, and revoke — now
admin-gated below — unrepeatable). Ruled:

- **`spt subnet revoke` requires the admin TOTP.** A member code is
  insufficient; OS elevation stays on top. An eviction is a subnet-governance
  act, and the proof doubles as the identity gate for the re-surface below.
- **Rotation re-surfaces the NEW admin key exactly once, to the human who just
  proved the OLD admin code** — same capture-proof choreography as create
  (§2b): QR/`otpauth://` shown *without* its current TOTP code, the human types
  a current code back from the authenticator, the screen is cleared. The member
  key never re-surfaces at revoke (`show-code` covers it).
- **Doctrine restated:** the admin key is surfaced only to a proven admin, at
  exactly two ceremonies — mint and rotation — and never by a reveal verb.
  Co-admins in a shared subnet receive the entry out-of-band, or by attending a
  rotation ceremony; documented, no new surface.

**Where the display actually happens — the parked pending key.** The default
rotation fires at the coalescing window's close, daemon-side, up to an hour
after the ceremony — nobody is at a screen there. The re-surface therefore runs
**inside the revoke ceremony itself**: after the old-admin proof, the pending
admin seed is minted, displayed, capture-proven, and **parked** (persisted
beside the rotation schedule, written *before* the tombstone so a schedule
without a parked seed is corruption, never a race); the window-close rotation
mints the member seed fresh and **adopts** the parked admin seed, clearing it.
A later revoke in the same window re-proves the old code and re-displays the
*same* pending key under its own capture proof; `--force-rotate-seed` is the
same choreography with an immediate close. If the close finds the parked seed
absent or corrupt it still rotates the member seed (the evicted node must not
linger in member fabric), keeps the old admin seed, and raises a loud
"admin rotation incomplete" notification naming `revoke --force-rotate-seed`
as the recovery — never a silent skip, never a bricked close.

Decision 3 stands unchanged: a lost authenticator entry cannot prove the old
code at a rotation, so recovery is still minting a new subnet.

<!-- [doc->REQ-SUBNET-DUAL-SEED-MINT] --><!-- [doc->REQ-SUBNET-CREATE-CAPTURE-PROOF] -->
### 2b. Capture proof at create (added 2026-07-30, fast-follow grill)

`subnet create` shows the admin material FIRST and **without its current TOTP
code**, requires the human to type a current admin code back — proof the
authenticator captured the seed, since the code cannot be produced from what
the screen shows — then clears the admin material, and only then displays the
member key's. Creation does not complete without the proof: decision 3's cost
is a choice a user can make, never an accident of a missed scan.

<!-- [doc->REQ-SUBNET-ADMIN-SEED-REPLICATION] -->
### 3. Irrecoverable by design

A lost authenticator entry for the admin key means admin operations are
**permanently gone** for that subnet. Documented plainly; the recovery is minting a
new subnet. This is the deliberate cost of decision 2: no node can leak what no
node can reveal, and the mesh stays symmetric — authority rides a *key*, not a
privileged node.

<!-- [doc->REQ-SUBNET-ADMIN-SEED-ROTATION] -->
### 4. Eviction rotates both seeds (amended 2026-07-30, fast-follow)

The rotation that answers a node's eviction (ADR-0005 #10) rotates **both**
seeds under **one** epoch bump. An admin key IS a membership key (the join
ceremony accepts either code), so rotating only the member seed would leave an
evicted node holding a credential that both verifies admin operations and
**rejoins the subnet** — eviction would be toothless against exactly the nodes
trusted enough to hold elevated credentials. Both seeds redistribute over the
same replication machinery the join path uses (the member-proven control
stream); the admin seed has no re-provisioning surface (decision 2), so
replication is its only answer. The one-deep prior-generation grace stays
**member-seed-only**: grading always runs on the member proof, and a benign
offliner is re-replicated the new admin seed rather than ever getting a
verification window on the old one. A record that never held an admin key
(pre-two-key join) gains none from rotation itself, but the replication leg
re-provisions it — the alternative strands such a node adminless forever.

<!-- [doc->REQ-SUBNET-ADMIN-CODE-JOIN] -->
## Consequences

- Pairing/creation surfaces change: `subnet create` provisions two authenticator
  entries; the join ceremony accepts either code (and MUST NOT reveal to the
  responder pool which kind authenticated beyond what the ceremony requires).
- The seed-rotation machinery (ADR-0005 revoke/rotate) gains a sibling secret to
  rotate. Admin-seed rotation policy rides the ACCESS-CONTROL milestone design.
- Trust posture: possession of a member node still yields the member seed (that
  baseline is unchanged from ADR-0005) — but no longer yields subnet-admin power.
