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>
2 files changed,
+412
−272
docs/superpowers/specs/2026-10-06-box-identity-detect-design.md+349 −272docs/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 |