carlos / platform Public

Box identity Option B rev 2: answer Astra's round 1 on detect-and-refuse

Seven P1s: a label is never reassigned automatically and the claim
references box_id rather than the label; equal box_ids with differing
instance ids are a twin and an unreadable machine id publishes nothing;
unproven never promotes itself and a stale unproven object is taken only
on EIP evidence; the decision is made at arm before any publisher and
expires after three periods; a local identity file gates relay serve and
adopt, with the twelve entry points inventoried; transitions to owned
reset the edges gate and discard a foreign seed; refusal's limit on
routing is stated rather than claimed. Five P2s: every decision branch
and the CAS backend scope, directory and split-store deployments, a
durable fleet observation file under the lease, privacy fixtures at the
three projections, and an honest comparison. Findings committed unedited
as CODEX-FINDINGS-BOX-IDENTITY-B-R1.md. Rev 2 is not yet reviewed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 8c362f122bdc5a966e77678eb95164f5da35354c parent 7ff690c
2 files changed, +412 −272
  • docs/superpowers/specs/2026-10-06-box-identity-detect-design.md +349 −272
  • docs/superpowers/specs/CODEX-FINDINGS-BOX-IDENTITY-B-R1.md +63 −0

CI passed — run details

diff --git a/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md b/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md
index e78067685..f9bceb720 100644
--- a/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md
+++ b/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md
@@ -1,109 +1,141 @@
# Box identity, Option B: detect and refuse, no key migration
**Date:** 2026-10-06
-**Revision:** 1
+**Revision:** 2
+
+- Rev 1 (7ff690c5) went to Astra (GPT-6, via codex); the findings are in
+ `CODEX-FINDINGS-BOX-IDENTITY-B-R1.md`: seven P1s, five P2s, all checked
+ against the code and accepted. Changes are marked "(codex B-N)" and §10
+ records each disposition. Rev 2 has not been reviewed.
+
**Discussion:** amadan carlos/platform #212
**Sibling of:** `2026-10-06-box-identity-collisions-design.md` (Option A,
migrate the keys), rev 3, whose third Astra round
(`CODEX-FINDINGS-BOX-IDENTITY-R3.md`) still found four P1s in the
-key-migration protocol. This spec is the alternative, and §6 compares the
-two and recommends one.
-**Status:** draft, for Astra's review, then Paul's.
+key-migration protocol. §6 compares the two and recommends one.
+**Status:** draft, for Astra's second round, then Paul's.
## 1. What Option B is
Nothing moves. `status/boxes/<label>.json` and `control/edges/<label>.json`
keep their keys; Route53 `SetIdentifier` stays the label; every other
-per-box key stays as it is. Two things are added:
+per-box key stays as it is. Three things are added:
1. Both objects carry `box_id = hex(sha256(machineID)[0:16])`, 32 hex,
bucket-private, never in fleet.json or any public or member-facing
surface. It is the proof of which machine wrote the object.
-2. A writer that finds its label's object **fresh and written by another
- machine** does not overwrite it. It refuses to publish, latches, alerts
- with instructions, and takes no claim work. Readers treat a label whose
- writer changed, or whose two objects disagree about the writer, as
- incomplete evidence.
+2. A writer that finds its label's object **written by another machine**
+ does not overwrite it, fresh or stale. It refuses to publish, latches,
+ alerts with instructions, and takes no claim work. A label is never
+ reassigned automatically; an operator frees it (codex B-1).
+3. The host lifecycle claim names a box by `box_id`, not by the label
+ (§4). The reference is bound to one machine by construction, which is
+ what claim rev 6.16 asks for, and it does not depend on who holds a
+ label.
A collision therefore costs the colliding box, not the fleet: the first
writer keeps the label and keeps working, the second is loud and
-withdrawn until an operator regenerates its machine id. That is the trade
-against Option A, where both boxes keep working and the migration carries
-the risk.
+withdrawn until an operator acts. That is the trade against Option A,
+where both boxes keep working and the migration carries the risk.
## 2. The writer rule
-The arbiter is the status snapshot, because `statusTick` writes it
-unconditionally every tick (`internal/host/agent.go:2905`, five minutes
-plus jitter) and `status.BoxStale` already defines its freshness. The
-edges file follows the snapshot's verdict; it does not arbitrate.
-
### 2.1 Ownership of a label
-`status.WriteBox` (`snapshot.go:742`) replaces its read-etag-then-Put with
-a decide loop, three attempts:
-
-1. `Get` the label's object. Not found: `Put` with `ifMatch ""`,
- create-only. Success: **owned**. `ErrCASConflict`: another writer got
- there first; go to 1 (this is the simultaneous-start race, §2.4).
-2. Found: parse it. Then:
- - its `box_id` equals ours: **owned**, `Put` with the etag.
- - its `box_id` is non-empty and differs, and the object is fresh
- (`BoxStale` false): **foreign**. Do not write. Refuse (§2.2).
- - its `box_id` is non-empty and differs, and the object is stale:
- **takeover**. `Put` with the etag; log once that the label was taken
- from a dark writer. Its `box_id` is recorded in the evidence (§2.3)
- so readers can see the change.
- - its `box_id` is absent (an old-release writer, or this box before it
- rolled): compare `metrics.instance_id`. Equal and non-empty: owned.
- Both non-empty and different, object fresh: foreign; refuse. Either
- empty: **unproven**. Write, as today, and log once. An unproven
- label is not a proof of uniqueness (§4).
-3. A `Put` that loses the CAS race goes back to 1 and re-decides. It
- never re-Puts with a fresh etag without re-reading the body; that
- re-Put is the overwrite this spec removes.
-
-`CollectBox` sets `Box.BoxID` from `Probes.MachineID`. Fleet boxes
-(`agent.go:2926`, operator-chosen label, unique per fleet by
-`registry.ErrFleetBoxActive`) publish no `box_id` and skip the check;
-nothing changes for them.
-
-The verdict is held in a process-wide `labelOwnership` value: owned,
-foreign, unproven, or unknown (no successful read yet), with the time of
-the last successful decision.
-
-### 2.2 Refusal
-
-On foreign:
-
-- `WriteBox` returns `ErrLabelForeign`, carrying the foreign `box_id` and
- `instance_id`. `statusTick` does not publish; the lease election is not
- entered (a refusing box must not become the fleet's publisher).
-- The **twin latch** is set. While set: `edgesFilePass` publishes nothing
- and deletes nothing; the metrics, alerts, registry-snapshot, config-ack,
- relay-ack and usage loops skip their writes (they all key by the same
- label, `metricsBoxLabel`, `metrics_loop.go:729`, and would overwrite the
- owner's objects); the log, error and analytics sinks keep buffering to
- their caps and ship nothing (the chunk prefix is the label segment and
- the hourly prune would delete the owner's chunks, `storedoor.go:482`);
- the claim's arm and every claim step refuse (§4). Serving traffic is
- untouched: the edge, the activator and the reconcile pass do not depend
- on these publishers.
-- The box logs, once per transition, with the instructions:
- `box <label> is already held by another machine (instance <id>); this
- box (instance <id>) will not publish. Regenerate this box's machine id:
- rm /etc/machine-id && systemd-machine-id-setup && reboot. Its label
- will change.`
-- The box writes the evidence object (§2.3) every tick while latched, and
+The arbiter is the status snapshot: `statusTick` writes it unconditionally
+every tick (`internal/host/agent.go:2905`) and `status.BoxStale` defines
+its freshness. The decision is made by `status.DecideLabel`, called by the
+agent at arm **before any per-box publisher starts** (codex B-4) and again
+on every status tick. It reads the label's object and returns one verdict:
+
+| Object under our label | Verdict |
+|---|---|
+| not found | **owned**, after a create-only `Put` (`ifMatch ""`) succeeds. A CAS conflict re-reads and re-decides; it never re-Puts with a fresh etag without re-reading the body (that re-Put, `snapshot.go:749-760`, is the overwrite this spec removes) |
+| `box_id` equals ours, and `metrics.instance_id` equals ours or either is empty | **owned**, replace with the etag |
+| `box_id` equals ours, both `instance_id`s non-empty and different | **twin**: two machines with one machine id (codex B-2). Refuse |
+| `box_id` non-empty and different, fresh or stale | **foreign**. Refuse. Staleness does not free a label (codex B-1) |
+| `box_id` absent (an old-release writer, or this box before it rolled), `instance_id` equal and non-empty | **owned** |
+| `box_id` absent, both `instance_id`s non-empty and different | **foreign**. Refuse |
+| `box_id` absent, either `instance_id` empty, object fresh | **unproven**. Refuse and wait: the writer may be an old-release sibling that cannot refuse, or this box before it rolled |
+| `box_id` absent, either `instance_id` empty, object stale | **unproven**, unless the label's edges file carries our non-empty EIP, in which case the stale object is this box's own pre-roll snapshot and the verdict is **owned**. Two machines never share an EIP. No EIP evidence: stay unproven and say so; an operator frees the label (codex B-3) |
+| body does not parse, read fails, CAS exhausted (three attempts) | **unknown**. No write; retry next tick |
+
+Nothing is ever promoted to owned by reading back its own bytes: the
+only writes are the create-only `Put` and a replace of an object already
+proved ours (codex B-3). `owned` is recorded only after that write
+succeeds (codex B-8).
+
+Fleet boxes (`agent.go:2926`, operator-chosen label, unique per fleet by
+`registry.ErrFleetBoxActive`) publish no `box_id` and skip the decision.
+A box whose machine id is unreadable (`probes.go:153`) has no `box_id` and
+no identity: it is **unidentified**, publishes nothing keyed by its label,
+takes no claim work, and logs once an hour (codex B-2). `Label("")` is no
+longer a thing any box publishes under.
+
+### 2.2 The gate
+
+The verdict, its time, the label and the `box_id` are held in memory and
+written to `<dataDir>/identity.json` (codex B-5), because not every
+publisher lives in the agent process. `host.Ownership()` reads it and
+answers **publishable** only when the verdict is owned and decided within
+three snapshot periods (`status.SnapshotPeriod`, the `BoxStale` rule). A
+missing or stale file is not publishable: a box that cannot re-prove its
+label for fifteen minutes stops writing under it, so an outage on one box
+cannot leave two machines writing one label.
+
+Every write or prune under a label-keyed key goes through the gate, from
+startup (codex B-4), in every process (codex B-5). The entry points, each
+of which today derives the label from `metricsBoxLabel`, `BoxLabel` or
+`status.Label(readMachineID())`:
+
+| Process | Entry point | Key |
+|---|---|---|
+| agent | `statusTick` (`agent.go:2905`); the lease election at `agent.go:2865` moves after the decision, so a non-owner never becomes the fleet's publisher | `status/boxes/<label>.json` |
+| agent | `edgesFilePass` (`edgesfile.go:273`) | `control/edges/<label>.json` |
+| agent | `metricsFlush`, `alertTick` (`metrics_loop.go:162,444`) | `metrics/boxes/<label>/`, `alerts/boxes/<label>.json` |
+| agent | `registrySnapshotLoop` (`snapshot_loop.go:205`) | `control/registry-snapshot/<label>.*`, `control/registry-snapshots/<label>/` |
+| agent | `usageFlush` (`usage_loop.go:72`) | usage objects stamped with the box |
+| agent | `logshipLoop` and `chunkPrune`, `errshipLoop`, the analytics meter's flush (`agent.go:244,245,477,480,569`; `logship.go:71,161`) | `apps/<sqid>/<app>/{logs,errors,analytics}/<label>/` |
+| agent, and root-side `adopt` | `configAckFunc` (`agent.go:954`, `agent.go:3486`) | `control/config-ack/<label>/<host>.json` |
+| `relay serve` | heartbeat and meter flush (`relay.go:138`, `internal/relay/serve.go:343,354`) | `control/relay-ack/<label>.json`, `metrics/relay/<label>/` |
+
+A publisher that is not publishable skips its write and keeps its state:
+the sinks buffer to their caps, the sampler keeps its ring, the edges
+loop keeps its fingerprint. Serving traffic is untouched: the edge, the
+activator and the reconcile pass do not depend on these publishers.
+
+Transitions **to** owned reset the edges gate: `*last` is cleared so the
+next pass republishes, and `seedEdgesFileGate` (`edgesfile.go:529`)
+discards a seeded file whose `box_id` is not ours and never inherits its
+EIP for the blank-probe fallback (`edgesfile.go:478`) (codex B-6).
+
+### 2.3 Refusal
+
+On foreign, twin or unproven:
+
+- the box logs, once per transition, with the instructions. Foreign:
+ `label <label> is held by another machine (instance <id>); this box
+ (instance <id>) is not publishing. If this box is the new one:
+ rm /etc/machine-id && systemd-machine-id-setup && reboot; its label will
+ change. If the holder is retired: carlos fleet retire <label>.` Twin:
+ `two machines share this machine id; regenerate it on the one that is
+ new.` Unproven: `cannot prove this label is ours (no instance id to
+ compare); if it is, carlos fleet retire <label> frees it.` The holder is
+ never told to regenerate: its label is referenced by its own objects and
+ history, and the refuser has none;
+- the box writes the evidence object (§2.4) every tick while refusing, and
delivers one `metrics.Event{Rule: "identity"}` through the metrics
loop's sinks (`metrics_loop.go:510`) on the transition, so
- `CARLOS_ALERT_WEBHOOK` fires from the box that knows.
-- Every tick it re-runs §2.1. The latch clears when the verdict is owned
- (the holder went dark and this box took over) or when this box's own
- label changes (machine id regenerated, process restarted). Clearing is
- a transition and is logged and delivered the same way.
+ `CARLOS_ALERT_WEBHOOK` fires from the box that knows;
+- the claim's arm and every claim step refuse (§4);
+- every tick re-runs §2.1. The verdict changes only when the object
+ changes: an operator's `carlos fleet retire <label>` deletes both
+ objects (after printing what it found and refusing while the claim
+ references the holder's `box_id`), or the box's own label changes
+ (machine id regenerated, restart). Clearing is a transition, logged and
+ delivered the same way.
-### 2.3 Evidence
+### 2.4 Evidence
`status/identity/<own box_id>.json`:
@@ -114,104 +146,112 @@ On foreign:
```
Keyed by the writer's own id, so two colliding boxes never share an
-evidence key. Written every tick while the verdict is foreign or a
-takeover happened within the last three periods; an entry older than
-three snapshot periods is expired and ignored. The status lease holder
-lists the prefix on its publish tick. The prefix is under `status/` but
-is not one of the three keys the edge serves (`internal/edge/status.go:54`),
-and the leak scan (`dryrun.go`) scopes it like `status/boxes/`: private
-values allowed there, forbidden in fleet.json and index.html.
-
-### 2.4 Two new boxes start at once with an empty key
-
-Both read not-found and both `Put` with `ifMatch ""`. `release.Store`'s
-Put with an empty etag is create-only (the contract `WriteBox`'s own doc
-describes; `s3store` uses `If-None-Match`, `dirstore` the same rule), so
-exactly one succeeds. The other gets `ErrCASConflict`, re-reads, finds a
-fresh object with a foreign `box_id`, and refuses. The edges loop on both
-boxes waits for an owned verdict before its first publish (it starts a
-minute after arm, `edgesfile.go:749`, and the status loop's first tick is
-also at a minute, `agent.go:2769`; the wait makes the order irrelevant),
-so the loser never writes an edges file. If the label's edges file is a
-retired box's leftover with another `box_id`, the owner overwrites it:
-the snapshot settled the label.
-
-The one store whose create-only semantics are not the backend's own is
-the fleet store door; fleet boxes skip the check, so it does not matter.
-
-### 2.5 Takeover and flapping
-
-A holder whose snapshot goes stale (three missed periods, fifteen
-minutes) can be taken over by a latched sibling. When the holder's next
-write succeeds it finds a fresh foreign object and latches itself. One
-swap per staleness window at most, each logged, delivered and recorded in
-evidence; readers see the `box_id` change (§3). A transient bucket outage
-on one box can cause one swap; the alert then names both machines and
-the operator fixes the one that is new.
+evidence key, and written in the **status store** beside the snapshots,
+so whichever store a reader loads snapshots from also holds the evidence
+(codex B-9). Written every tick while the verdict is not owned; an entry
+older than three snapshot periods is expired and ignored. The prefix is
+under `status/` but is not one of the three keys the edge serves
+(`internal/edge/status.go:54`).
+
+### 2.5 Two new boxes start at once with an empty key
+
+Both read not-found and both `Put` with `ifMatch ""`. On S3 that is
+`If-None-Match: *` (`internal/release/store.go:620`), so exactly one
+succeeds; the other gets `ErrCASConflict`, re-reads, finds a fresh object
+with a foreign `box_id`, and refuses. The decision happens at arm, before
+any other publisher runs, so the loser writes nothing at all. S3 is the
+arbitrating backend. `DirStore` enforces CAS under one process-wide mutex
+(`store.go:255-262`) and is a one-box, one-process shape; it is not
+claimed to arbitrate two processes (codex B-8). A deployment with no
+status store (`CARLOS_DEPLOYMENT_DIR`, `agent.go:2717`) is one box by
+construction: its verdict is owned by configuration, logged once, and the
+gate is open (codex B-9).
+
+### 2.6 What refusal does not do
+
+It does not withdraw routing (codex B-7). Steering (`steering.go:119`)
+and the home map (`homemap.go:273`) read edges files as they do today and
+do not consume §3's verdicts; Option B changes nothing there. What holds
+instead: a refuser has written nothing under the label since it refused,
+and before that only as owner, so the edges file in the bucket is the
+holder's. The one exception is the roll: an old-release sibling cannot
+refuse and overwrites the holder's objects with its own; routing then
+follows the old box, which is a real edge, until it rolls (§5). When a
+label's edges file flips between two machines' host sets, the steering
+converger sees one box's records replaced by another's and its shrink
+guard (`route53.go:209`) may refuse a pass; that is today's collision
+behaviour and this spec does not make it worse, only shorter.
## 3. Readers
-Readers add two stateless rules and one stateful one. Nothing about keys
-or joins changes.
+Readers add two stateless rules and one durable one. Keys and joins do
+not change.
1. **Disagreeing objects.** Where a snapshot and an edges file share a
- label and both carry `box_id`, they must be equal. A mismatch is a
- sibling's leftover or a race, and the pair is **contested**: not live
- for `edgesfile.EdgeIPs` and `DeploymentRoutes` (`fold.go:84,158`),
- `BoxesComplete=false` for `readLiveEdgesSnapshot`
- (`instancesplacement.go:93`). Objects without `box_id` join by label
- as today.
-2. **Evidence.** An unexpired `status/identity/*` entry naming a label
- makes that label contested for `readLiveEdgesSnapshot` and
- `BoxesComplete=false`. This is how the console, which has no memory
- between requests, learns of a collision the writers saw.
-3. **Writer change.** The status lease holder keeps, in process memory,
- the last `box_id` and `generated_at` it saw per label. A `box_id` that
- changes, or appears or disappears, while the previous object was still
- fresh at the new object's `generated_at`, is a writer change: two
- machines are writing one label (an old-release sibling, which cannot
- refuse, or a takeover). It is reported as contested for three periods.
- A console restart forgets this; rules 1 and 2 do not.
+ label and both carry `box_id`, they must be equal. A mismatch makes
+ the pair **contested**: not live for `edgesfile.EdgeIPs` and
+ `DeploymentRoutes` (`fold.go:84,158`), `BoxesComplete=false` for
+ `readLiveEdgesSnapshot` (`instancesplacement.go:93`). Objects without
+ `box_id` join by label as today.
+2. **Evidence.** `readLiveEdgesSnapshot` reads `status/identity/` from
+ the store it reads snapshots from, **before** its empty-files early
+ return (`instancesplacement.go:98`); a failed read is incomplete
+ evidence (codex B-10). An unexpired entry naming a label makes that
+ label contested.
+3. **Writer change.** The status lease holder keeps
+ `status/identity/fleet.json`: per label, the last `box_id` and
+ `generated_at` it saw and a `contested_until`. It reads the file at
+ the start of each publish and writes it at the end, under the status
+ lease, so the record survives the lease moving between boxes
+ (codex B-10; the lease is released after every publish,
+ `agent.go:2879`). A `box_id` that changes, appears or disappears while
+ the previous object was still fresh at the new object's
+ `generated_at` sets `contested_until` three periods ahead. The console
+ reads the same file under rule 2.
The lease holder folds all three into one singly-owned
`alerts/fleet/identity.json` (`metrics.AlertState`, `Box: "fleet"`, rules
-`foreign`, `contested`, `changed`), written every publish tick, firing or
-not, so a lease transfer cannot strand a firing rule. The console's alert
-watcher (`alertwatch.go:97`) reads it alongside `alerts/boxes/*.json`,
-treats an object older than three periods as clear, and mails operators
-holding a grant; with none the transition is logged and dropped
-(`alertwatch.go:194`, amadan #192). The fleet page and
-`carlos status -live` show a `contested` chip and the holder's and the
-refuser's instance ids, so the operator can tell which box is new.
+`foreign`, `twin`, `unproven`, `contested`), written every publish tick,
+firing or not. The console's alert watcher (`alertwatch.go:97`) reads it
+alongside `alerts/boxes/*.json`, treats an object older than three
+periods as clear, and mails operators holding a grant; with none the
+transition is logged and dropped (`alertwatch.go:194`, amadan #192). The
+mail body (`alertMessage`, `alertwatch.go:285`) names the label and the
+rule, never a `box_id` or `instance_id`; those are on the operator fleet
+page and in `carlos status -live`, which show a `contested` chip and
+both machines' instance ids.
## 4. The host lifecycle claim
Claim rev 6.16 (`design-replica-ownership`,
`2026-10-02-host-lifecycle-claim-design.md:238-246`) says box references
are opaque identifiers compared by exact equality, each naming exactly one
-machine, and that how a box obtains a unique reference and refuses claim
-work when it cannot prove uniqueness is this design's job. Under Option B
-the reference stays the label, and the proof is label ownership:
-
-- a box takes or continues claim work only while `labelOwnership` is
- **owned** and its last successful decision is within three snapshot
- periods. Foreign, unproven, unknown, or a stale decision all refuse,
- the same way `resolveFleetIdentity` refuses an invalid fleet identity;
-- a box whose verdict turns foreign mid-operation drops its execution
- lease and lets the claim's own recovery owner take over, as it would
- for any lost lease;
-- an **unproven** label (an old-release or off-cloud object under our
- label with no instance id to compare) is exactly the case rev 6.16
- names: the box cannot prove uniqueness and does not take claim work.
- On the flagship every box has an instance id, so this bites only during
- the roll (an old-release sibling's object) and on hardware no cloud
- owns.
-
-No claim field changes. Twins (one machine id on two machines) overwrite
-one key and cannot be told apart by `box_id`; `instance_id` tells them
-apart, and the rule in §2.1 refuses the second. Machine-id uniqueness
-stays a prerequisite the image recipe enforces, and this spec makes no
-clone-safety promise beyond that detection.
+machine, and that how a box obtains one, and refuses claim work when it
+cannot prove uniqueness, is this design's job. The answer (codex B-1):
+
+- the reference is `box_id`. It is a function of the machine id alone, so
+ it names exactly one machine without any arbitration, survives a label
+ being freed and reassigned, and is what `generation.go:31`'s
+ `op.exec == box` compares, what `claim.go:132`'s inventory is keyed by,
+ and what `delete.go:73`'s `box:<b>` obligations name. A label is a
+ display name and is never a claim reference;
+- a box takes or continues claim work only when it is identified
+ (non-empty `box_id`), not a twin, and **publishable** (§2.2). Foreign
+ and unproven boxes refuse because they are withdrawn from the fleet's
+ view, not because their reference is in doubt: nothing can reach them
+ through the home map, and nobody can see them on the fleet page;
+- a box whose verdict leaves owned mid-operation drops its execution
+ lease and lets the claim's recovery owner take over, as for any lost
+ lease;
+- twins (one machine id on two machines) share a `box_id` and would be one
+ box to the claim. §2.1 detects them by `instance_id` and refuses the
+ second; a pair with no instance ids cannot be told apart. Machine-id
+ uniqueness is a prerequisite the image recipe enforces, and this spec
+ makes no clone-safety promise beyond that detection.
+
+`carlos fleet retire <label>` refuses while any claim references the
+holder's `box_id`; retiring a box the claim still names is the claim's
+own retirement procedure, not a label operation.
## 5. Mixed versions and rollback
@@ -221,18 +261,22 @@ clone-safety promise beyond that detection.
- **New writer, old reader.** The field is additive and ignored.
- **Old sibling colliding with a new box.** The old box cannot refuse and
overwrites every tick; the new box sees a fresh object with no `box_id`
- and a different `instance_id`, refuses, latches, alerts. The old box
- holds the label until the roll reaches it; then both run §2.1, and
- whichever object is fresh at the other's next tick holds. Today both
- overwrite silently; this is strictly better from the first rolled box.
+ and a different `instance_id`, refuses, latches, alerts. Detection is
+ on the new side only; refusal waits for the old box to roll, when both
+ run §2.1 and whichever object is in the bucket decides (codex B-12).
+ Today both overwrite silently.
+- **An established holder during the roll.** The one way a production
+ holder loses its label is an old-release sibling overwriting it, as
+ above. It then refuses, withdraws its publishers, and keeps serving. Its
+ claim references are unaffected (they are `box_id`), its edges file is
+ replaced by the sibling's, and its routing follows the sibling until the
+ operator fixes the new box (codex B-12).
- **Rollback.** An old-release box has no check and overwrites blindly, as
- today. No object moved, so there is nothing to sweep, no key shape to
- reconcile, no DNS identifier to rename. The evidence prefix and the
- fleet alert object are additive; an old reader never lists them. The
- cost of rolling back is losing the detection.
-- **Steering** is untouched: `SetIdentifier` stays the label, and a
- refusing box publishes no edges file, so there is nothing to dedupe.
- Its records are simply absent, which is §1's cost.
+ today. No object moved, so there is nothing to sweep and no key shape
+ to reconcile. `identity.json` in `dataDir` and the evidence prefix are
+ additive; an old reader never consults them. The cost of rolling back is
+ losing the detection.
+- **Steering** is untouched (§2.6).
## 6. Findings, costs and the comparison
@@ -240,120 +284,153 @@ clone-safety promise beyond that detection.
| Finding | Under Option B |
|---|---|
-| R1-1, R2-3, R3-4: steering `SetIdentifier` is the label; renames; shrink guard | **gone.** No identifier changes. The refusing box has no records, which is a cost, not a hole |
-| R1-2, R2-1, R3-1: cleanup or sweep deletes a live writer's object | **gone.** Nothing is deleted |
-| R1-3, R2-2, R3-2, R3-3, R3-5: rollback duplicates, aliases, effective-file choice, orphan edges files | **gone.** One key shape, one object per label, no supersession |
+| R1-1, R2-3, R3-4: steering `SetIdentifier`, renames, shrink guard | **gone.** No identifier changes |
+| R1-2, R2-1, R3-1: cleanup or sweep deletes a live writer's object | **gone.** Nothing is deleted except by an operator |
+| R1-3, R2-2, R3-2, R3-3, R3-5: rollback duplicates, aliases, effective-file choice, orphans | **gone.** One key shape, one object per label |
| R1-4: the mixed-shape join | **gone** |
-| R1-5, R3-6: the other label-keyed families (registry snapshots, acks, metrics, alerts, logs, `alert_seen`) | **covered by refusal**, not fixed: the latched box suspends those publishers, so the owner's objects are not overwritten. Their collisions are not removed, they are prevented from happening twice |
-| R1-6, R2-7: architecture page | **gone.** One box per label publishes. The refuser's machine appears as an unclaimed cloud instance, which the page already renders |
-| R1-7, R2-6: alert object ownership and webhook delivery | **remain**, same design: singly-owned fleet object, box-side webhook (§2.2, §3) |
-| R1-8: step 2 privacy in `apps/` keys | **gone.** No key changes; `box_id` is in bodies only |
-| R1-9, R2-4: claim references and twins | **remain**, simpler: the label stays the reference, ownership is the proof, unproven refuses (§4) |
-| R2-5: twin evidence reaching the publisher | **remains**, same mechanism (§2.3) |
-| R1-10: three facts | corrected in Option A rev 2; nothing to carry |
+| R1-5, R3-6: the other label-keyed families | **covered by the gate**, not fixed: a non-owner writes none of them, so the owner's are not overwritten |
+| R1-6, R2-7: architecture page | **gone.** One box per label publishes; the refuser's machine shows as an unclaimed instance |
+| R1-7, R2-6: alert object ownership and webhook delivery | **remain**, same design (§2.3, §3) |
+| R1-8: step 2 privacy in `apps/` keys | **gone.** No key changes |
+| R1-9, R2-4: claim references and twins | **remain**, answered: `box_id` is the reference (§4) |
+| R2-5: twin evidence reaching the publisher | **remains**, same mechanism (§2.4) |
+| B-1 to B-12 | answered in this revision (§10); not yet re-reviewed |
-### 6.2 Residual risk under Option B
+### 6.2 Residual risk and cost under Option B
- A colliding box is out of multi-region routing, fleet status, metrics,
- logs and claim work until an operator regenerates its machine id. Its
- own edge serves its hosts locally. On the flagship a new box collides
- with one of four existing labels with probability 4 in 1,600, 0.25%,
- per box added; Tito 6 in 1,600. The first start of a new box is where
- it fires, so the cost lands in provisioning, not in production.
-- An old-release sibling cannot refuse. During the roll a collision
- between an old and a new box is detected and alerted but the old box
- keeps overwriting until it rolls.
-- An off-cloud box (no `instance_id`) under an old-release object is
- unproven: it publishes as today and takes no claim work.
-- Retired boxes' objects still linger (nothing deletes them today either);
- `EdgeIPs` and eligibility already handle that through liveness.
-- The detection trusts the status store: a deployment whose status bucket
- is split from its deployment bucket (`CARLOS_STATUS_BUCKET`) still
- arbitrates correctly, because both colliding boxes use the same
- configuration; the console's rule 2 then reads evidence from the status
- store it already reads snapshots from.
-
-### 6.3 Operator cost
-
-One alert, one `rm /etc/machine-id && systemd-machine-id-setup && reboot`,
-about fifteen minutes, on a box that has done no claim work and published
-nothing under the label. Its label changes; since it never held the label
-it has no history under it to lose. No runbook step on roll, none on
-rollback.
-
-### 6.4 Comparison
+ logs and claim work until an operator acts. Its own edge serves its
+ hosts locally. On the flagship a new box collides with one of four
+ labels with probability 4 in 1,600 per box added; Tito 6 in 1,600.
+ Because the decision is made at arm, a new box fails at its first
+ start, before it has served anything.
+- During the roll, an old-release sibling can displace an established
+ holder (§5). The holder keeps serving and keeps its claim references;
+ its routing follows the sibling until the new box is fixed. The repair
+ is the same as for a new box: regenerate the new box's machine id.
+- An unproven label (no instance ids to compare, and no EIP evidence)
+ needs an operator to free it. On the flagship and Tito every box has an
+ instance id, so this is a roll-time wait of one staleness window at
+ most for a cloud box, and an operator step only for hardware no cloud
+ owns.
+- Retired boxes' objects still linger, as today; a new box that collides
+ with a retired label is refused until `carlos fleet retire`.
+- A box that cannot read its status store for fifteen minutes stops
+ publishing under its label (§2.2). Today it would keep trying; the
+ difference is only that it also stops its other label-keyed publishers.
+- Steering and the home map do not consume the verdicts (§2.6).
+
+Operator cost: one alert; for a new box, `rm /etc/machine-id &&
+systemd-machine-id-setup && reboot`; for a retired holder, `carlos fleet
+retire <label>`. No runbook step on roll, none on rollback. How long a
+repair takes depends on who is on call; the spec does not estimate it.
+
+### 6.3 Comparison
| | Option A: migrate the keys | Option B: detect and refuse |
|---|---|---|
-| Residual risk | Three review rounds in, four open P1s in the migration protocol (writer retirement, cross-object cleanup, effective-file choice for steering, DNS rename in N). Deletes production objects. Rollback has a runbook and known costs | A colliding box is withdrawn until fixed by hand (§6.2). No deletes, no key moves, no DNS changes. Old-release siblings undetected until they roll |
-| What it fixes | Both colliding boxes keep working, now and forever; the label is purely cosmetic | The collision is loud and deterministic; one box works, the other is fixed in minutes |
-| Implementation size | `status`, `edgesfile` (`Effective`), `host` (writer, guard, sweep, gate verify), `console` (join, arch page, alerts, CLI `fleet sweep`/`retire`), `edge` (homemap), `steering` (two releases), plus step 2 for six more key families. Two releases minimum, three with step 2 | `status.WriteBox` decide loop and `box_id`; a latch in `host` consulted by eight loops; the evidence object; one fleet alert object written by the lease holder; two console rules; the claim arm gate. One release |
-| Review rounds still likely | Two to three: the migration state machine and the DNS boundary are protocol decisions Astra has asked for twice | One to two: the writer rule and the race are small enough to simulate |
-| Flagship (4 boxes, no collision today) | A roll with a sweep, an N+1 DNS flip, a rollback runbook with a named steering cost; nothing visible if it goes well | A roll; nothing moves; a chip and an alert if a future box collides |
-| Tito (6 boxes) | the same, on a customer's fleet, with `alert_seen` migration for step 2 | the same as the flagship |
-| dev (1 box) | the key flip and sweep run for nothing | nothing happens |
-
-### 6.5 Recommendation
-
-**Option B now.** It removes the silent failure #212 describes, costs one
-release and no object moves, and its one residual cost is a quarter of a
-percent per box added, paid at provisioning in fifteen minutes. Option A
-buys two colliding boxes both working, which at this fleet size is not
-worth a deletion protocol that has needed three rounds and is not yet
-closed. Option A's spec stays on the branch as the record of why, and as
-the design to return to if a fleet ever grows to where regenerating a
-machine id is not an acceptable answer.
+| What it fixes | Both colliding boxes keep working; the label becomes cosmetic | The collision is loud and deterministic; one box works, the other is withdrawn until fixed |
+| Residual risk | Four open P1s after three rounds, all protocol: writer retirement, cross-object cleanup, effective-file choice for steering, the rename exemption in N. Deletes production objects. Rollback needs a runbook | A withdrawn box; a holder displaced by an old sibling during the roll; unproven labels on off-cloud hardware; routing not withdrawn. Rev 2 answers seven P1s and is unreviewed |
+| Changes to objects, keys, DNS | Two key shapes for two releases, a sweep, a DNS identifier flip, step 2 for six more key families | Two additive fields, one new private prefix, one local file |
+| Code touched | `status`, `edgesfile`, `host`, `console`, `edge`, `steering`, CLI; two releases minimum | `status` (decide), `host` (gate at twelve entry points, identity file), `relay` and `adopt` (read the file), `console` (two reader rules, evidence file, alert), CLI `fleet retire`; one release |
+| Review | Astra has twice asked for an explicit migration state machine and the DNS boundary; both are open | One round done; rev 2 is a judgment that the remaining risk is operational, which the next round tests |
+| Flagship (4 boxes, no collision today) | A roll with a sweep, an N+1 DNS flip, a rollback runbook | A roll; nothing moves; an alert and a withdrawn box if a future box collides |
+| Tito (6 boxes) | the same, on a customer's fleet, plus `alert_seen` migration in step 2 | the same as the flagship |
+| dev (1 box, directory store possible) | the flip and sweep run for nothing | owned by configuration; nothing happens |
+
+### 6.4 Recommendation
+
+**Option B.** It removes the silent failure #212 describes with two
+additive fields and a gate, moves nothing, and its residual costs are
+operational and visible: a new box that fails loudly at first start, and
+a displaced holder only while a roll is in progress. Option A buys two
+colliding boxes both working, at the price of a deletion-and-rename
+protocol that three rounds have not closed. Option A's spec stays on the
+branch as the record and as the design to return to if the fleet ever
+grows to where a withdrawn box is not an acceptable answer.
## 7. Test plan
-- `WriteBox` decide loop: not-found creates; owned replaces; foreign
- fresh refuses with the foreign ids in the error; foreign stale takes
- over and logs; absent `box_id` with equal, differing and empty
- `instance_id`; CAS conflict re-reads and re-decides, never re-Puts
- blind. Two writers against one DirStore from one start: exactly one
- owns, the other refuses, in both orders.
-- The latch: every listed publisher skips while set; the edges loop
- waits for an owned verdict before its first publish; the sinks buffer
- to cap; the claim gate refuses on foreign, unproven, unknown and stale
- decisions; clear on takeover and on label change.
-- Evidence: written each tick while latched, expires after three periods,
- keyed by the writer's id; the dry run allows private values there and
- forbids them in fleet.json and index.html.
-- Readers: rule 1 (disagreeing `box_id`) makes a pair not live and
- incomplete; rule 2 (evidence) makes a label contested for the console;
- rule 3 (writer change) fires on a change within freshness and not after
- staleness; the fleet alert object is written firing and clear; the
- watcher mails and clears.
-- Mixed versions: an old-shape object (no `box_id`) under each prefix
- joins as today; a new-shape object asserted byte-compatible for the
- fields an old reader uses.
-- Simulation: random fleets with injected collisions, simultaneous
- starts, rolls, rollbacks and one-box bucket outages, asserting that at
- most one machine writes a label while its object is fresh, that a
- latched box takes no claim work, and that every collision is in the
+- `DecideLabel`: every row of §2.1's table, including stale foreign
+ (still foreign), stale unproven with and without EIP evidence, twin by
+ instance id, malformed body and CAS exhaustion (unknown, no write);
+ a CAS conflict re-reads and re-decides; owned is recorded only after a
+ successful conditional write. Two writers against one `DirStore` handle
+ from one start: exactly one owns, in both orders. Against S3 in the
+ integration suite: two processes.
+- The gate: every entry point in §2.2's table skips when not publishable,
+ from startup, in the agent, `adopt` and `relay serve`; publishable
+ expires after three periods without a decision; a one-box directory
+ deployment is open; transitions to owned reset the edges gate and
+ `seedEdgesFileGate` discards a foreign seed and its EIP.
+- An unidentified box (empty machine id) publishes nothing and refuses
+ claim work.
+- Evidence: written each tick while not owned, keyed by the writer's id,
+ in the status store, expiring after three periods; the fleet
+ observation file survives a lease move.
+- Readers: rule 1 makes a disagreeing pair not live and incomplete; rule
+ 2 runs before the empty-files return and a failed read is incomplete;
+ rule 3 fires on a change within freshness, not after staleness; the
+ fleet alert object is written firing and clear; the watcher mails and
+ clears; the mail body carries no `box_id` or `instance_id`.
+- Privacy fixtures (codex B-11): distinct sentinels for `box_id` and
+ `instance_id` in the dry run (`dryrun.go:62`), a collision evidence
+ object created in the run, positive checks in `status/boxes/` and
+ `status/identity/`, negative in fleet.json and index.html; unit tests
+ at `fleet.go:172` (`FleetBox`), `apiinstances.go:327` (the instances
+ response) and `alertwatch.go:285` (the mail) asserting neither field
+ appears.
+- Claim: the arm gate refuses unidentified, twin and non-publishable
+ boxes; `fleet retire` refuses while the claim names the holder.
+- Mixed versions: old-shape objects join as today; new-shape objects are
+ byte-compatible for the fields an old reader uses.
+- Simulation: random fleets with injected collisions, simultaneous starts,
+ clones, rolls, rollbacks and one-box store outages, asserting that at
+ most one machine writes a label while its object is fresh, that no
+ label is ever reassigned without an operator, that a non-owner writes
+ nothing under any label-keyed key, and that every collision is in the
evidence within one period.
## 8. Rollout
1. Confirm the image recipes clear `/etc/machine-id`.
2. Land on main; cut a release; roll boxes in any order; roll the
- console. Nothing moves, so there is no order and no sweep.
-3. Add the alert's instruction text to the operator runbook, and
- `rm /etc/machine-id && systemd-machine-id-setup && reboot` to the
- provisioning recipe's "if the box reports a foreign label" note.
-4. Record the §4 gate on the claim branch as the answer to rev 6.16's
- open reference.
+ console. Nothing moves, so there is no order and no sweep. Expect one
+ staleness window of silence from any box whose own pre-roll snapshot
+ has no instance id (none on the flagship or Tito).
+3. Add the alert texts and the two repairs to the operator runbook and
+ the provisioning recipe.
+4. Record §4 on the claim branch as the answer to rev 6.16's open
+ reference: `box_id`.
## 9. Open questions for Paul
-1. Option B or Option A (§6.5)?
-2. Under B, should the first start of a new box check its label against
- the fleet before joining (a `carlos fleet check-label` in the
- provisioning recipe), so the collision is found before the box is
- built rather than at its first tick?
-3. A latched box suspends log shipping and buffers to cap. Is losing a
- colliding box's logs for the minutes until it is fixed acceptable, or
- should it ship under a temporary `<label>-<4 hex>` segment?
-4. Should the public fleet.json say anything when a label is contested
- (a `contested: true` on the row), or stay silent as it does for every
- other operator-only condition? This spec keeps it silent.
+1. Option B or Option A (§6.4)?
+2. Should a new box's provisioning check its label against the fleet
+ before it joins (`carlos fleet check-label <machine-id>`), so the
+ collision is found before the box is built?
+3. A withdrawn box buffers logs to cap and ships nothing. Acceptable for
+ the time until it is fixed, or should it ship under a temporary
+ `<label>-<4 hex>` segment?
+4. A box that cannot read its status store for fifteen minutes now stops
+ every label-keyed publisher, not only status. Is that the right
+ posture, or should metrics and logs keep flowing on the last verdict?
+5. Should fleet.json say anything when a label is contested? This spec
+ keeps it silent, as for every other operator-only condition.
+
+## 10. Review dispositions
+
+| Finding | Severity | Disposition |
+|---|---|---|
+| B-1 takeover violates the claim's one-machine contract | P1 | accepted. No automatic reassignment, fresh or stale; the claim references `box_id`, not the label; `fleet retire` refuses while the claim names the holder (§1, §2.1, §4) |
+| B-2 twin detection bypassed; empty machine id manufactures an identity | P1 | accepted. Equal `box_id` with differing instance ids is a twin and refuses; an unreadable machine id is unidentified and publishes nothing (§2.1) |
+| B-3 unproven becomes owned by reading its own write | P1 | accepted. Unproven never writes; a stale unproven object is taken only on EIP evidence, else an operator frees it (§2.1) |
+| B-4 startup and expiry gating | P1 | accepted. Decision at arm before any publisher; publishable expires after three periods; the election follows the decision (§2.1, §2.2) |
+| B-5 cross-process publishers | P1 | accepted. `<dataDir>/identity.json` read by `relay serve` and `adopt`; the entry-point inventory replaces "eight loops" (§2.2) |
+| B-6 stale edges after regaining ownership | P1 | accepted. Transitions to owned reset the gate; a foreign seed and its EIP are discarded (§2.2) |
+| B-7 refusal does not withdraw routing | P1 | accepted as a stated limit, not a protocol: steering and the home map are unchanged; the invariant and its one exception (an old sibling during the roll) are written down with the shrink-guard note (§2.6) |
+| B-8 remaining branches and the CAS guarantee | P2 | accepted. The table has every branch; S3 arbitrates, `DirStore` is one process; owned only after a successful write (§2.1, §2.5) |
+| B-9 directory deployments and split status stores | P2 | accepted. Owned by configuration with no status store; evidence lives in the status store beside the snapshots (§2.4, §2.5) |
+| B-10 writer-change evidence does not survive a publisher change | P2 | accepted. Durable `status/identity/fleet.json` under the lease; the console reads evidence before the early return and fails closed on a read error (§3) |
+| B-11 privacy fixtures | P2 | accepted. Sentinels, a collision object in the dry run, and boundary tests at the three projections (§7) |
+| B-12 the comparison understates B's costs | P2 | accepted. Timing and round estimates removed; displacement during the roll, unproven labels and routing stated as costs; "undetected" corrected to "detected, not refused" (§5, §6.2, §6.3) |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-BOX-IDENTITY-B-R1.md b/docs/superpowers/specs/CODEX-FINDINGS-BOX-IDENTITY-B-R1.md
new file mode 100644
index 000000000..6bc7128b5
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-BOX-IDENTITY-B-R1.md
@@ -0,0 +1,63 @@
+Reviewed Option B rev 1 at `7ff690c5` against `origin/main` (`04cd2d78`) and the two requested claim branches. No files changed or tests run. Option A was context only.
+
+1. **P1 — Label takeover violates the claim’s machine-identity contract.**
+ **Claim:** §§2.5/4 allow another machine to acquire the same claim reference after fifteen minutes.
+ **Evidence:** Claim rev 6.16:238–246 requires each reference to name exactly one machine. On `origin/feat/hostclaim-library`, `internal/hostclaim/generation.go:31` authorizes admission through `op.exec == box`; `claim.go:132` retains machine-local inventory under that reference, and `delete.go:73` assigns `box:<label>` cleanup obligations. Taking over a snapshot does not transfer the old machine’s disks, inventory or obligations. Even perfect exclusion of simultaneous writers cannot fix this.
+ **Fix:** Use immutable machine references for claims while retaining label-keyed publication, or prohibit label reassignment until all durable references are safely retired. Regenerating an established machine’s ID also needs this treatment.
+
+2. **P1 — The promised twin detection is bypassed.**
+ **Claim:** §4 says differing instance IDs distinguish cloned machine IDs.
+ **Evidence:** §2.1:50 immediately accepts equal `box_id`; the instance comparison runs only when `box_id` is absent. Clones therefore both obtain `owned`. Moreover, [probes.go:153](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/probes.go:153) returns an empty machine ID on read failure; hashing that value manufactures the same apparently valid identity everywhere.
+ **Fix:** Reject missing machine identity, and compare non-empty instance IDs before accepting equal `box_id`. Explicitly define refusal when clone identity cannot be resolved.
+
+3. **P1 — “Unproven” becomes “owned” merely by reading back its own write.**
+ **Claim:** §§2.1/4 promise that an off-cloud legacy collision cannot authorize claim work.
+ **Evidence:** §2.1:60 permits an unproven writer to overwrite the legacy object with its own `box_id`. At its next tick, line 50 accepts that ID as owned—even though nothing established that the legacy sibling stopped. Existing [WriteBox:749](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/status/snapshot.go:749) lets that sibling keep overwriting through ETag replacement.
+ **Fix:** Preserve unresolved ownership across writes and restarts; do not promote it based on self-authored bytes. Require an explicit resolution or a protocol that excludes the legacy writer.
+
+4. **P1 — A foreign-only latch leaves startup and outage writes unprotected.**
+ **Claim:** §§2.2/2.4 say the losing box never damages the owner’s objects.
+ **Evidence:** Status first runs after one minute ([agent.go:2769](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/agent.go:2769)); log shipping and pruning run after ten seconds or an event ([logship.go:71](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/logship.go:71)). Registry snapshots and alerts also race that first decision. Only edges explicitly wait. Later, failed status reads never produce `foreign`, so an expired owner can keep publishing while its sibling takes over.
+ **Fix:** Gate every affected write and prune on valid ownership from startup, including expiry and in-flight operations. Define the intended legacy/unproven exception separately. Move arbitration before election: today [agent.go:2865](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/agent.go:2865) acquires the lease before `statusTick`.
+
+5. **P1 — The process-wide latch cannot cover all listed publishers.**
+ **Claim:** §2.2 covers config acknowledgements, relay acknowledgements and usage through one process-local value.
+ **Evidence:** Standalone `relay serve` obtains the label at [relay.go:138](/home/paulca/amadan.net/carlos/platform-design-box-identity/relay.go:138), writes heartbeats at [serve.go:343](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/relay/serve.go:343), and flushes separate relay metering at line 354. Root-side `adopt` also invokes the config-ack callback ([agent.go:3486](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/agent.go:3486)). Neither shares the agent’s memory. Relay metering is missing from the stated coverage.
+ **Fix:** Specify a shared, expiring ownership mechanism consumed by every process, and inventory write/prune entry points rather than “eight loops.”
+
+6. **P1 — Regaining ownership can leave the foreign edges file indefinitely.**
+ **Claim:** §§2.4/2.5 assume the snapshot winner overwrites the previous holder’s edges file.
+ **Evidence:** [edgesfile.go:491](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/edgesfile.go:491) skips publication when local content equals the cached `last` value. A publishes, B takes over, A latches, then A later takes over again: A’s unchanged cached body suppresses the write even though the bucket contains B’s file. The new snapshot/edges mismatch persists. Startup seeding also retains the previous writer’s EIP for blank-probe fallback ([edgesfile.go:478](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/edgesfile.go:478)).
+ **Fix:** Invalidate the publication gate on ownership transitions, force successful republication before resuming dependent work, and never inherit another machine’s address.
+
+7. **P1 — Refusal does not withdraw existing routing records.**
+ **Claim:** §5:233–235 says steering is untouched and the refuser’s records are simply absent.
+ **Evidence:** Refusal stops writes but preserves existing edges objects. Steering accepts files by EIP and region, without snapshot ownership ([steering.go:119](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/steering/steering.go:119)); the home map likewise reads edges directly ([homemap.go:273](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/edge/homemap.go:273)). Neither consumes §3’s contested verdict. Mixed-version overwrite and rollback can therefore leave a refuser advertised. Replacing its host set can also trigger the existing [DNS shrink guard:209](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/steering/route53.go:209), despite unchanged identifiers.
+ **Fix:** Define authoritative routing ownership/withdrawal and compatibility with old readers. Test pre-existing edges, takeover, rollback and host-set shrink; unchanged key names alone do not establish safety.
+
+8. **P2 — Specify the remaining decision branches and narrow the CAS guarantee.**
+ **Claim:** §§2.1/2.4 describe a complete decision loop and backend-independent simultaneous-start result.
+ **Evidence:** Empty-ETag creation **is correct on S3** ([store.go:620](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/release/store.go:620)). DirStore enforces it under a mutex belonging to one store instance ([store.go:258](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/release/store.go:258)); separate handles/processes do not share that lock. The decision table also omits stale legacy objects with differing instance IDs, malformed bodies and final CAS exhaustion.
+ **Fix:** State supported concurrency explicitly and test S3 plus independent DirStore handles if those are supported. Define every error/staleness branch, and publish `owned` only after a successful conditional write.
+
+9. **P2 — The ownership dependency breaks existing deployment shapes.**
+ **Claim:** Edges wait for `owned`, while §6.2 says split status storage already reaches the console.
+ **Evidence:** Directory deployments can publish edges but never arm status ([agent.go:2717](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/agent.go:2717)), leaving the new wait permanent. The console explicitly does **not** read `CARLOS_STATUS_BUCKET` ([instancesplacement.go:85](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/instancesplacement.go:85)); both joins currently receive one store.
+ **Fix:** Define arbitration for directory deployments and explicitly wire status-store/prefix access into readers. Validate that all writers sharing deployment keys also share the arbitration namespace.
+
+10. **P2 — Writer-change evidence does not survive publisher changes.**
+ **Claim:** §3 promises three periods of contested state using publisher-local memory and a shared alert object.
+ **Evidence:** The status lease is released after each publication ([agent.go:2879](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/agent.go:2879)). A different publisher with no observation history can immediately write `changed=false`, clearing the previous publisher’s alert. Writer-change-only observations also never enter §2.3’s evidence prefix, so the stateless console cannot use them.
+ **Fix:** Persist observations and contested-until times. Define incomplete evidence on failed evidence reads, and evaluate it before [instancesplacement.go:98](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/instancesplacement.go:98)’s empty-files early return.
+
+11. **P2 — Privacy needs explicit fixtures across every forbidden surface.**
+ **Claim:** §§2.3/7 rely on the existing dry-run scoping.
+ **Evidence:** [dryrun.go:62](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/status/dryrun.go:62) contains neither identity sentinel; its scan lists only `status/` at line 336 and privileges only `status/boxes/` at line 380. It neither creates collision evidence nor exercises account pages, instances responses or mail. Existing explicit projections—[fleet.go:172](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/status/fleet.go:172), [apiinstances.go:327](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/apiinstances.go:327), and [alertwatch.go:285](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/alertwatch.go:285)—provide suitable boundaries.
+ **Fix:** Add distinct `box_id`/`instance_id` fixtures, positive private-object checks and negative checks for all named public/member/mail surfaces. Keep detailed remediation IDs confined to operator views.
+
+12. **P2 — §§6.4/6.5 understate Option B’s costs.**
+ **Claim:** The residual cost occurs only during provisioning, involves a box with no history, and requires fifteen minutes.
+ **Evidence:** §2.5 itself permits an established production holder to lose ownership after a status outage. Its claim history, cached publications and routing records remain, as above. “Old-release siblings undetected until they roll” also contradicts §5’s instance-ID detection. The probability calculation does not establish repair duration or blast radius.
+ **Fix:** Retain the valid advantage—no key migration—but compare the completed ownership, routing and cross-process protocols. Treat production takeover, mixed versions and rollback as operational costs; remove unsupported timing and review-round estimates.
+
+**Verdict: not ready. P1s: claim-reference reuse; bypassed twin detection; unproven ownership becoming owned; incomplete startup/expiry gating; cross-process publisher gaps; stale edges after takeover; and routing that survives refusal.**
\ No newline at end of file