Box identity spec rev 2: answer Astra's round 1
Four P1s: steering keys Route53 SetIdentifier by label (the converger learns renames exempt from the shrink count); legacy cleanup becomes a conditional DeleteIfMatch on non-empty evidence; edgesfile.Effective picks one file per box for every reader and the rollback runbook gains carlos fleet sweep; the join is one explicit three-step algorithm with an unambiguous-only label fallback. Five P2s and the P3 accepted too: registry snapshots, config-ack and relay-ack added to the inventory, the architecture page keyed by id from HostRows, a singly-owned alerts/fleet/identity.json, step 2 gated on a privacy ruling, the claim ruling widened with a twin check, and three facts corrected. Findings committed unedited as CODEX-FINDINGS-BOX-IDENTITY-R1.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+464
−271
docs/superpowers/specs/2026-10-06-box-identity-collisions-design.md+439 −271docs/superpowers/specs/CODEX-FINDINGS-BOX-IDENTITY-R1.md+25 −0
CI passed — run details
diff --git a/docs/superpowers/specs/2026-10-06-box-identity-collisions-design.md b/docs/superpowers/specs/2026-10-06-box-identity-collisions-design.md| index 7f2ad9d1d..f478a11a1 100644 |
| --- a/docs/superpowers/specs/2026-10-06-box-identity-collisions-design.md |
| +++ b/docs/superpowers/specs/2026-10-06-box-identity-collisions-design.md |
| @@ -1,11 +1,18 @@ |
| # Box identity: key box objects by a collision-resistant id, keep the label for people |
| **Date:** 2026-10-06 |
| -**Revision:** 1 |
| +**Revision:** 2 |
| + |
| +- Rev 1 (71112c9e) went to Astra (GPT-6, via codex); the findings are in |
| + `CODEX-FINDINGS-BOX-IDENTITY-R1.md`. Four P1s, five P2s, one P3, all |
| + checked against the code and accepted. Changes are marked "(codex N)" and |
| + §11 records each disposition. |
| + |
| **Discussion:** amadan carlos/platform #212 |
| -**Status:** draft, for Paul's review after Astra's. |
| +**Status:** draft, for Paul's review after Astra's second round. |
| **Builds on:** the fleet status page (`internal/status`), the multi-region |
| -ingress edges file (2026-08-20, Part 3), nearest-region placement |
| +ingress edges file (2026-08-20, Part 3), the steering converger |
| +(`internal/steering`), nearest-region placement |
| (`2026-10-01-nearest-region-default-design.md`), the host lifecycle claim |
| (`2026-10-02-host-lifecycle-claim-design.md`, on `design-replica-ownership`). |
| @@ -18,22 +25,24 @@ two per-box objects every platform box writes: |
| - `status/boxes/<label>.json` (`snapshot.go:747`) |
| - `control/edges/<label>.json` (`internal/host/edgesfile.go:36`) |
| -Two boxes with one label overwrite each other. Birthday odds are about 0.4% |
| -for four boxes, 1% for six, 6% for twenty. Astra found a concrete pair: |
| -machine ids `00000000000000000000000000000018` and |
| -`0000000000000000000000000000003c` both give `edsger-knitting`. |
| +and the Route53 `SetIdentifier` of every steered record |
| +(`internal/steering/route53.go:601`). Two boxes with one label overwrite |
| +each other. Birthday odds are about 0.4% for four boxes, 0.9% for six, 11% |
| +for twenty (codex 10). Astra found a concrete pair: machine ids |
| +`00000000000000000000000000000018` and `0000000000000000000000000000003c` |
| +both give `edsger-knitting`. |
| When it happens: `edgesfile.EdgeIPs` loses one box's address; the fleet |
| page shows one box where there are two; nearest-region eligibility joins |
| edges files to snapshots by label and can read a keyless box as eligible; |
| -the edges writer's unchanged-file gate (`edgesfile.go:491`) can leave the |
| -wrong box's file in place indefinitely, because the gate compares against |
| -memory, not the bucket. |
| +steering keeps one of the two boxes' records; the edges writer's |
| +unchanged-file gate (`edgesfile.go:491`) can leave the wrong box's file in |
| +place indefinitely, because it compares against memory, not the bucket. |
| The label is public (fleet.json, the status page) and must stay |
| -non-reversible from the machine id. That contract is kept. The label is |
| -not changed. What changes is what the objects are keyed by, and how readers |
| -join them. |
| +non-reversible from the machine id. That contract is kept. The label is not |
| +changed. What changes is what the objects are keyed by, how readers match |
| +them, and what names a box in steering. |
| ## 2. Every consumer of the label-keyed objects |
| @@ -47,46 +56,48 @@ join them. |
| | `internal/host/metrics_loop.go:729` `metricsBoxLabel`, `:743` `BoxLabel` | the one selector: fleet label on a fleet box, else `status.Label` | |
| | `internal/host/agent.go:542` `edge.WithBoxLabel` | the edge's notion of self for the home map | |
| -The same selector names the box in other per-box keys. They are not the |
| -subject of #212 but collide the same way and are listed so the fix lands in |
| -one place (§3.5, step 2): |
| +The same selector names the box in other per-box keys. They collide the |
| +same way. They are listed so one identity fixes all of them, in two steps |
| +(§3.6). (codex 5: the first three were missing in rev 1.) |
| -| Key | Where | |
| -|---|---| |
| -| `metrics/boxes/<box>/raw/<hour>.json`, `/days/<date>.json` | `internal/metrics/chunk.go:25,31`; writer `metrics_loop.go:136` | |
| -| `alerts/boxes/<box>.json` | `internal/metrics/alert.go:120` | |
| -| `metrics/relay/<box>/<hour>.json` | `internal/relay/meter.go:26`; `relay.go:138` | |
| -| `apps/<sqid>/<app>/{logs,errors,analytics}/<box>/...` | `internal/applog/chunk.go:58`; sinks at `agent.go:244,245,477,480,569` use `status.Label` directly | |
| -| usage flush | `internal/host/usage_loop.go:72` | |
| +| Key | Where | Readers | |
| +|---|---|---| |
| +| `control/registry-snapshot/<box>.db`, `.json`; `control/registry-snapshots/<box>/` | `internal/host/snapshot_loop.go:34-37`, `:205` | the DR restore (README), `internal/dbverify/dbverify.go:28`. A collision here hands DR the wrong box's registry | |
| +| `control/config-ack/<box>/<host>.json` | `internal/appconfig/configack.go:116`; `agent.go:954` uses `BoxLabel()` | `internal/console/apirelay.go:151` | |
| +| `control/relay-ack/<box>.json` | `internal/relay/authority.go:71`; `relay.go:138` | `apirelay.go:163` | |
| +| `metrics/boxes/<box>/raw/<hour>.json`, `/days/<date>.json` | `internal/metrics/chunk.go:25,31`; `metrics_loop.go:136` | `internal/console/metricsview.go:140`, which displays the key segment (`:547`), not the body's `Box` | |
| +| `alerts/boxes/<box>.json` | `internal/metrics/alert.go:120` | `internal/console/alertwatch.go:97` | |
| +| `metrics/relay/<box>/<hour>.json` | `internal/relay/meter.go:26` | the relay status page | |
| +| `apps/<sqid>/<app>/{logs,errors,analytics}/<box>/...` | `internal/applog/chunk.go:58`; sinks at `agent.go:244,245,477,480,569` call `status.Label` directly | `carlos logs`; the cursor orders by `Box` (`internal/applog/cursor.go:24`); readable by the account's fleet credentials (`storedoor.go:403`) | |
| +| usage flush | `internal/host/usage_loop.go:72` | billing passes | |
| ### 2.2 Readers of `status/boxes/` |
| | Where | Join or key | |
| |---|---| |
| | `internal/status/fleet.go:238` `loadBoxesReport` | the ONE parser; `:150` `LoadBoxes`, `:234` `LoadBoxesComplete` | |
| -| `fleet.go:157` `BuildFleet` | public feed; `:173` copies `Label` into `FleetBox`; `:563` `PublishFleet` writes `status/fleet.json` | |
| +| `fleet.go:157` `BuildFleet` | public feed; `:173` copies `Label` into `FleetBox`; `:272` `sumInstances` counts stale boxes too; `:563` `PublishFleet` writes `status/fleet.json` | |
| | `internal/edgesfile/fold.go:46` `EdgeIPs`, `:125` `DeploymentRoutes` | join `live[b.Label]` to `f.Box` (`:64,84,137,158`) | |
| -| `internal/console/instancesplacement.go:93` `readLiveEdgesSnapshot` | `known[b.Label]`, `live[b.Label]` (`:120,122`) joined to `f.Box`; feeds automatic eligibility | |
| +| `internal/console/instancesplacement.go:93` `readLiveEdgesSnapshot` | `known[b.Label]`, `live[b.Label]` (`:120,122`) joined to `f.Box`; feeds automatic eligibility; `:138` a fresh snapshot with no file is incomplete evidence | |
| | `internal/console/view.go:854` `HostRows` | operator fleet page rows by label; `templates/fleet.html:47,109` | |
| -| `internal/console/apiinstances.go:368` `loadBoxJoin` | staged_version, upstreams, db_bytes: joined by hostname inside `Metrics`, label incidental; callers `apiinstances.go:422`, `apiroutes.go:151`, `apisidecars.go:150` | |
| +| `internal/console/apiinstances.go:368` `loadBoxJoin` | staged_version, upstreams, db_bytes: joined by hostname inside `Metrics`; callers `apiinstances.go:422`, `apiroutes.go:151`, `apisidecars.go:150` | |
| | `internal/console/apidomains.go:1144` | alias DNS and cert readings, by hostname | |
| | `internal/console/apisystemupdate.go:233,258,764` | `carlos system` rows by label; CLI `system.go:207,438` | |
| -| `internal/console/archgraph.go:561` `buildBoxCards` | keyed by label (`:574,580`), node id `"box:"+label` (`:807`), machine join by `CloudID` (`:271`); `archregion.go:59` | |
| +| `internal/console/archgraph.go:561` `buildBoxCards` | unions public `FleetBox` rows and private `HostRow`s by label (`:574,580`); node id `"box:"+label` (`:807`); machine join `claimed[CloudID]=label` (`:271`) and `targetOf` (`:993`); `archregion.go:59` | |
| | `cli.go:3389` `statusLive`, `:3418` | `carlos status -live`, label-sorted | |
| | `internal/status/dryrun.go` | the leak scan; `:182` fixture machine id `ip-10-0-0-7` | |
| -| `internal/console/fleets.go:1012,1030` | fleet heartbeats under `fleets/<name>/status/boxes/<label>.json`, joined by the operator's `fleet_boxes.label`. Out of scope (§3.6) | |
| +| `internal/console/fleets.go:1012,1030` | fleet heartbeats under `fleets/<name>/status/boxes/<label>.json`, joined by the operator's `fleet_boxes.label`. Out of scope (§3.7) | |
| ### 2.3 Readers of `control/edges/` |
| | Where | Join or key | |
| |---|---| |
| -| `internal/edgesfile/edgesfile.go:68` `File`, `:81` `Parse` | the shared reader | |
| -| `internal/edge/homemap.go:130` `edgesFileView`; `:273` `refreshHosts`; `:538` `foldHomes` | skips the file whose `Box == self` (`:547`); keys the file map by object key | |
| -| `internal/host/steering_loop.go:50` `loadEdgesFileBodies`; `internal/steering/deployment.go:56` | fails closed on a short read; folds by EIP and hosts, not by box | |
| +| `internal/edgesfile/edgesfile.go:68` `File`, `:81` `Parse` | the shared leaf package every other reader imports | |
| +| `internal/edge/homemap.go:130` `edgesFileView`; `:273` `refreshHosts`; `:538` `foldHomes` | skips the file whose `Box == self` (`:547`); on a host conflict the dial target is the first file in sorted **object key** order | |
| +| `internal/host/steering_loop.go:50` `loadEdgesFileBodies`; `internal/steering/steering.go:73` view, `:349` `desiredState`, `:310` `dedupeRecords` by `(Name, Box)`, `route53.go:601` `SetIdentifier: rec.Box` | fails closed on a short read; **keys DNS records by the label** (codex 1) | |
| | `internal/console/instancesplacement.go:38` view, `:175` `loadEdgesJoin` (`served_by.box`, `:179`), `:232` `readEdgesSnapshot`, `:320` `placements` | the create door and the instances API | |
| | `internal/host/movespass.go:335` `moveTargets` | one target per placement, tie broken by `Box` (`:346`) | |
| | `internal/console/server.go:906` `edgeFold`; `internal/host/agent.go:133` | `EdgeIPs` for the custom-domain DNS check | |
| -| `internal/edge/storedoor.go:411` | everything under `control/` is readable by fleet boxes through the store door | |
| ### 2.4 The public fleet feed and its privacy contract |
| @@ -97,11 +108,16 @@ README "Fleet status"). `BuildFleet` copies `Box` fields into `FleetBox` |
| one by one and never names `Metrics`; `dryrun.go` asserts a raw value |
| survives into the box object and never into fleet.json or index.html. |
| -The contract this spec keeps: fleet.json carries the label only. The new |
| -id never appears in fleet.json, index.html, or any account-facing page |
| -(`accountfleet.go:18` reads fleet.json only). Both label-keyed objects are |
| -bucket-private. `control/edges/` is readable by customer fleet boxes |
| -through the store door; that is already true of the label and the EIP. |
| +The contract this spec keeps: fleet.json carries the label only. The id |
| +never appears in fleet.json, index.html, any account-facing page |
| +(`accountfleet.go:18` reads fleet.json only), the instances API |
| +(`served_by.box` stays the label), or an alert mail (§3.4). `status/boxes/` |
| +and `control/edges/` are bucket-private: the store door grants fleet boxes |
| +`accounts/<sqid>/`, `apps/<sqid>/`, `fleets/<fleet>/`, `domains/` and two |
| +exact `control/system/` and `bootstrap/router/` shapes |
| +(`storedoor.go:403-425`), nothing else under `control/` (codex 10 corrects |
| +rev 1 here). The one place the id would become member-visible is the |
| +`apps/` subtree in step 2, and that is held for a ruling (§3.6, §10). |
| ## 3. The design |
| @@ -117,23 +133,35 @@ func LabelForID(id string) (label string, ok bool) |
| ``` |
| - 128 bits. A collision needs about 2^64 boxes. The label's 10.6 bits are |
| - the whole problem; 64 bits would already do, 128 costs nothing and makes |
| - the hex id visibly not a label. Fixed length, lower-case hex, so it is a |
| - valid key segment in S3, the DirStore (`store.go:269`) and the store |
| - door, and it can never be confused with a `name-action` label or a fleet |
| - label (`registry.ValidTunnelName`). |
| + the whole problem; 64 bits would already do, 128 costs nothing. Fixed |
| + length, lower-case hex: a valid key segment in S3, the DirStore |
| + (`store.go:269`) and the store door, and a valid Route53 |
| + `SetIdentifier` (limit 128). It is told from a `name-action` label by |
| + having no hyphen. It is **not** syntactically disjoint from a fleet label |
| + (`registry.ValidTunnelName`, `registry.go:337`, accepts 32 hex), so |
| + `AddFleetBox` refuses a label that parses as a box id (codex 10). |
| - Non-reversibility is the same argument the label makes: sha256 over a |
| 128-bit random `/etc/machine-id`. Exposing more digest bits does not |
| - weaken a preimage. The honest difference: for a low-entropy machine id |
| - (the dry-run fixture `ip-10-0-0-7`, a container that hands out a |
| - hostname), the id confirms a dictionary guess where the label only |
| - narrows it to 1 in 1,600. That is why the id stays bucket-private (§2.4) |
| - and the leak scan gets a needle for it (§5). |
| -- An empty machine id (`probes.go:153`, unreadable file) gives no id. The |
| - agent then behaves exactly as today: label key, no `box_id` field. Readers |
| - treat it as an old writer. This is the one case the id cannot be minted, |
| - and it is already the case `Label("")` documents as "this probe never |
| - worked". |
| + weaken a preimage. `LabelForID` reproduces the label from the id and |
| + discloses nothing about the machine id. The honest difference: for a |
| + low-entropy machine id (the dry-run fixture `ip-10-0-0-7`, a container |
| + that hands out a hostname), the id confirms a dictionary guess where the |
| + label only narrows it to 1 in 1,600. That is why the id stays |
| + bucket-private (§2.4) and the leak scan gets a needle for it (§8). |
| +- An empty machine id (`probes.go:153`) gives no id. The agent then behaves |
| + exactly as today: label key, no `box_id` field, and it refuses to arm |
| + anything that needs an identity (§5). Readers treat it as an old writer. |
| +- **Machine ids are assumed unique.** A cloned image that keeps |
| + `/etc/machine-id` produces two boxes with one id, and they overwrite one |
| + key rather than producing two objects, so §3.4's duplicate check cannot |
| + see it (codex 9). The writer checks instead: `WriteBox` already reads the |
| + current object for its etag; it now also parses it, and if that object is |
| + fresh and carries a non-empty `instance_id` different from this box's, |
| + this box has a twin. It logs, publishes the `identity` alert (§3.4) and |
| + keeps writing, so the twin is visible rather than silent. A box with no |
| + instance id (off-cloud) cannot make this check. The image build must |
| + clear `/etc/machine-id` so systemd regenerates it on first boot; the |
| + rollout checks the flagship's AMI recipe does (§9). |
| - The label is unchanged. `Label` keeps its function and its table so the |
| public label of every existing box is stable through the roll. |
| @@ -143,8 +171,9 @@ One selector replaces `metricsBoxLabel`: |
| ```go |
| type BoxIdentity struct{ ID, Label string } |
| -// On a platform box: ID = status.BoxID(machineID), Label = status.Label(machineID). |
| -// On a fleet box: both are the operator-chosen fleet_boxes.label (§3.6). |
| +// Platform box: ID = status.BoxID(machineID), Label = status.Label(machineID). |
| +// Fleet box: both are the operator-chosen fleet_boxes.label (§3.7). |
| +// ID is "" when the machine id is unreadable. |
| func boxIdentity(getenv func(string) string, machineID string) BoxIdentity |
| ``` |
| @@ -155,269 +184,408 @@ label and leaves `box_id` empty. |
| Edges file. `edgesFile` gains `BoxID string json:"box_id,omitempty"` next |
| to `Box`. `edgesFileKey` takes the id when present, else the label. `Box` |
| -keeps the label; every reader that shows a box to a person keeps reading |
| -it. The new field is part of the change gate like every field but |
| -`Generation`. |
| - |
| -Both objects carry both. A reader that has both sides new joins by id. A |
| -reader that has an old side joins by label, as today. |
| - |
| -### 3.3 Readers: one join rule, in one place |
| - |
| -`loadBoxesReport` (`fleet.go:238`) stays the one parser and gains a |
| -dedupe step before returning, reported in a new `Report` for the |
| -completeness readers: |
| - |
| -1. Group objects by `box_id` when present. Two objects (two keys) with one |
| - id is a **duplicate identity**: both are returned but flagged, `complete` |
| - is false, and the condition is reported (§3.4). |
| -2. Among objects that share a label, exactly one of which has no `box_id`, |
| - compare `Metrics.InstanceID`. Same instance id: one box, seen through an |
| - old key and a new key (a roll or a rollback in progress). Keep the |
| - fresher, mark the other **superseded**, do not count it. Different or |
| - absent instance ids: two boxes sharing a label, one old writer. Both |
| - stand; this is the collision the design exists for, and it is reported |
| - while the legacy key exists for that label. |
| +keeps the label for every surface that shows a box to a person. The new |
| +field is part of the change gate like every field but `Generation`. |
| + |
| +Steering. `steering.edgesFileView` gains `BoxID`; `desiredRecord.Box` |
| +becomes `BoxKey(file)` (the id when present, else the label), so the |
| +Route53 `SetIdentifier` and the `dedupeRecords` key become the id (codex 1). |
| +The converger's handling of the identifier change is §3.5. |
| + |
| +A `BoxKey` helper, in `internal/edgesfile` for files and `internal/status` |
| +for snapshots, returns the id if set, else the label. Both objects carry |
| +both names; which one a reader matches on is §3.3. |
| + |
| +### 3.3 Matching: one algorithm, in one place (codex 4) |
| + |
| +Three steps, each in one package, each with one owner. |
| + |
| +**Step A: snapshots** (`status.loadBoxesReport`, `fleet.go:238`). Returns |
| +the boxes plus a `Report{Duplicates, Superseded, Contested []string}`. |
| + |
| +1. Group by `box_id`. Two objects (two keys) with one id is a |
| + **duplicate**: both are returned and flagged; `complete` is false. |
| +2. For each label carried by exactly one id-less object and one or more |
| + id-bearing objects: if the id-less object and an id-bearing one carry |
| + the same non-empty `metrics.instance_id`, they are one box seen through |
| + an old key and a new key (a roll or rollback in progress). The fresher |
| + is kept; the other is **superseded** and dropped from the returned |
| + slice, so nothing downstream counts it. If instance ids differ, or |
| + either is empty, both stand and the label is **contested** (two boxes, |
| + one old writer, or an off-cloud box mid-rollback); `complete` is false |
| + for the completeness readers. Contested is not Duplicate: the public |
| + feed shows both rows, because both are boxes. |
| 3. Everything else passes through unchanged. |
| -A `BoxKey(b Box) string` helper returns `box_id` if set, else the label. |
| -The edges-file join uses it on both sides: |
| - |
| -- `edgesfile.EdgeIPs` and `DeploymentRoutes` (`fold.go:64,84,137,158`) |
| - build `live` over `BoxKey(b)` and also over `b.Label` for boxes with no |
| - id; a file joins on `box_id` when it has one, else on `box`. A file whose |
| - `box_id` is set but whose label does not equal `LabelForID(box_id)` is |
| - not a live edge: it was not written by this code. |
| -- `readLiveEdgesSnapshot` (`instancesplacement.go:120,122`): the same |
| - rule, and the dedupe report folds into `BoxesComplete`. |
| -- `homemap.foldHomes:547` skips self by id when both sides have one, else |
| - by label. Two files with one label and different ids both fold; today |
| - the second would never have existed. |
| -- `moveTargets:346` ties on `BoxKey`. |
| -- `archgraph.go:574,580,807` and `archregion.go:59` key cards and nodes by |
| - `BoxKey`; `HostRow` gains `ID`. The title stays the label; when two cards |
| - share a label the private page appends the first four hex characters of |
| - the id. fleet.json and the public page show two rows with one label, |
| - which is the true count. |
| - |
| -Because every reader lists the prefix and reads identity from the body, |
| -the object key name is not load-bearing for any reader on main today. That |
| -is what makes the migration a one-release change (§4). |
| - |
| -### 3.4 Duplicate detection is incomplete evidence |
| - |
| -Two conditions, both reported by the one parser: |
| - |
| -- **Two objects claim one id.** Impossible for honest writers; it means a |
| - copied disk image, a forged object, or a bug. Eligibility fails closed |
| - for the whole request (`BoxesComplete=false`), the same posture as a |
| - failed read. The fleet page and `carlos status -live` show both rows |
| - with a `duplicate id` chip. |
| -- **One label has two ids while a legacy key exists for that label.** The |
| - label-keyed objects are contested and nothing keyed by label can be |
| - trusted for those boxes (the step-2 keys in §2.1 included). Eligibility |
| - fails closed for the whole request. Once no legacy key remains for the |
| - label it is a display collision only and stops being reported. |
| - |
| -Alerting reuses what exists. The status lease holder evaluates these two |
| -conditions in `BuildFleet` and publishes a rule `identity` in its own |
| -`alerts/boxes/<box>.json` (`metrics.AlertState`), firing while either |
| -holds. The console's alert watcher (`alertwatch.go:97`) then mails |
| -operators as it does for disk or memory, and clears when the condition |
| -clears. One log line per condition per process, as `livenessSaid` does. |
| -Nothing reaches fleet.json. |
| +**Step B: edges files** (`edgesfile.Effective(files []Ident) (keep |
| +map[string]bool, contested []string)`, where `Ident` is `{Key, Box, BoxID, |
| +EIP, Generation}`). Every reader of `control/edges/` applies it to its |
| +listing before folding: homemap, steering's loader, the console's |
| +`readEdgesSnapshot`, `moveTargets`, `EdgeIPs`, `DeploymentRoutes`. This is |
| +what makes the object key not load-bearing; in rev 1 it was asserted, now |
| +it is a function (codex 3). |
| + |
| +1. Two files with one `box_id`: both kept, flagged duplicate. |
| +2. A file with no `box_id` and a file with one, same `box` label and the |
| + same non-empty `eip`: one box, old and new key. Two boxes never share an |
| + EIP. The newer `generation` is effective; the other is dropped. The |
| + dropped file's hosts do not fold, so the effective file alone decides |
| + what the box serves. On rollback the legacy file is rewritten by the |
| + box's first tick (§6), so it is the newer one. |
| +3. Same label, no `box_id` on one side, EIPs differ or either is empty: |
| + both kept, label contested. |
| +4. Everything else kept. |
| + |
| +**Step C: the join** (`EdgeIPs`, `DeploymentRoutes`, |
| +`readLiveEdgesSnapshot`, `foldHomes` self-skip, `moveTargets`). After A and |
| +B, with `snapshots` and `files`: |
| + |
| +1. A file with a `box_id` matches the snapshot with that `box_id`. It never |
| + falls back to the label: a box writes both objects from one process, so |
| + its snapshot carries the id within one tick of its file, and a legacy |
| + snapshot under its label during that minute is either its own |
| + not-yet-deleted old object (superseded or about to be) or a colliding |
| + sibling's, which must not vouch for it. |
| +2. A file with no `box_id` matches by label, and only when exactly one |
| + snapshot carries that label after Step A. Two or more is ambiguous: no |
| + match, label contested. |
| +3. A snapshot with no file, or a file with no snapshot, keeps today's |
| + meaning (`instancesplacement.go:120-150`: incomplete evidence for |
| + eligibility, not live for `EdgeIPs`). |
| +4. A file whose `box_id` is set and whose `box` is not `LabelForID(box_id)` |
| + was not written by this code. It is not live and is flagged. |
| +5. `foldHomes` skips self by `BoxKey` when the edge was given an id, else |
| + by label. `moveTargets` ties on `BoxKey`. |
| + |
| +Contested, duplicate and flagged all make `BoxesComplete` false for |
| +automatic eligibility, which is the posture `readLiveEdgesSnapshot` |
| +already takes for anything it cannot prove. |
| + |
| +**The architecture page** (codex 6). `buildBoxCards` stops unioning |
| +`FleetBox` rows with `HostRow`s. It builds cards from `HostRows` alone |
| +(`Box` carries every field `FleetBox` has, and `Stale` is computed the |
| +same way), keyed by `BoxKey`, node id `"box:"+BoxKey`, `claimed` and |
| +`targetOf` likewise. `HostRow` gains `ID`. When the host read failed |
| +(`ArchBoxesUnread`) the page falls back to `FleetBox` rows keyed by label, |
| +and a label shared by two rows renders one card marked ambiguous. Card |
| +titles are the label; two cards with one label append the first four hex |
| +characters of the id. |
| + |
| +### 3.4 Duplicate detection is incomplete evidence, and how it alerts |
| + |
| +Three conditions, all from §3.3: a duplicate id; a contested label; a |
| +twin (§3.1). The status lease holder evaluates them in `BuildFleet` and |
| +writes one singly-owned object, `alerts/fleet/identity.json`, in |
| +`metrics.AlertState` shape with `Box: "fleet"` and one rule per condition, |
| +**every publish tick**, firing or not (codex 7). Ownership follows the |
| +status lease: whichever box publishes writes it, so a lease transfer |
| +cannot strand a firing rule, and clearing is just the next tick's write. |
| +The alert watcher (`alertwatch.go:97`) reads this one key alongside |
| +`alerts/boxes/*.json`, and treats an object older than three snapshot |
| +periods as all-clear, the same `BoxStale` rule the boxes use. Delivery is |
| +the existing path: operators holding a keymail grant are mailed |
| +(`alertwatch.go:187`), and the webhook when `CARLOS_ALERT_WEBHOOK` is set. |
| +The existing limitation stands and is named: with no grant and no webhook |
| +the transition is logged and dropped (`alertwatch.go:194`, amadan #192). |
| +The fleet page and `carlos status -live` show the condition as a chip on |
| +the rows involved. Nothing reaches fleet.json. |
| ### 3.5 Writers |
| -Step 1 (this release): |
| - |
| -- `WriteBox` and `edgesFilePass` write the id key. |
| -- **No dual write of the legacy key.** No reader needs it (§3.3), and a |
| - second object for the same box would be counted twice by every old |
| - reader: `sumInstances` includes stale boxes, so the public instance |
| - total would double for that box, and `BuildFleet` would show two rows. |
| - Dual-writing would create the symptom we are removing. |
| -- **Own legacy key cleanup, guarded.** After its first successful id-keyed |
| - write, a box deletes `status/boxes/<label>.json` only if that object's |
| - `metrics.instance_id` equals its own, and `control/edges/<label>.json` |
| - only if that object's `eip` equals its own non-empty EIP. Otherwise the |
| - legacy object is a colliding sibling's, or unprovable, and is left for |
| - the sweep. Deleting a sibling's edges file would be the gate bug again |
| - with the roles swapped. The check runs once per process start, so a |
| - box that was rolled back and forward again cleans up after itself. |
| +- `WriteBox` and `edgesFilePass` write the id key. **No dual write of the |
| + legacy key.** No reader needs it (§3.3), and a second object for one box |
| + would be counted twice by every old reader: `sumInstances` includes stale |
| + boxes, so the public instance total would double for that box, and |
| + `BuildFleet` would show two rows. Dual-writing creates the symptom we are |
| + removing. |
| +- **Own legacy key cleanup, conditional** (codex 2). After its first |
| + successful id-keyed write, a box reads `status/boxes/<label>.json` and |
| + `control/edges/<label>.json`. It deletes the snapshot only if that |
| + object's `metrics.instance_id` equals its own and both are non-empty; |
| + the edges file only if that object's `eip` equals its own and both are |
| + non-empty. Each delete is `release.DeleteIfMatch` against the etag it |
| + just read (`internal/release/conditional_delete.go:28`), never a plain |
| + `Delete`: a sibling that overwrites the key between the read and the |
| + delete changes the etag and the delete refuses. A store that cannot |
| + enforce the condition refuses too, and the object is left for the sweep. |
| + Positive, non-empty evidence or nothing: an off-cloud box with no |
| + instance id leaves its legacy snapshot for the sweep. The check runs once |
| + per process start. |
| - **The edges gate verifies against the bucket.** Every tenth tick with |
| - no local change (about ten minutes), `edgesFilePass` reads its own key. |
| - Missing or different from `*last` (Generation aside): republish. Nothing |
| - else should write an id key, so this is defence in depth, and it is also |
| - how a file removed by the sweep or by hand comes back without waiting for |
| - a route change. |
| -- **Retired boxes.** The status lease holder runs an identity sweep on its |
| - publish tick: a snapshot stale for `retiredAfter` (7 days) is deleted |
| - together with the edges file that joins to it (by id, else by label); a |
| - superseded legacy snapshot (§3.3 rule 2) is deleted at once, with its |
| - legacy edges file when the EIP matches the fresh object's. An edges file |
| - with no snapshot at all is reported, never deleted automatically: nothing |
| - can date it. `carlos fleet retire <id|label>` deletes both objects by |
| - hand after confirming what it found. Deletes name the key only; the |
| - deployment bucket is versioned, so a wrong delete is recoverable. |
| - |
| -Step 2 (next release, its own plan): `boxIdentity().ID` replaces the label |
| -in every key of §2.1's second table, with the label carried inside the |
| -object as `box` is today (`RawChunk.Box`, `AlertState.Box`). Readers of |
| -those prefixes (`metricsview.go:140`, `alertwatch.go:131`, the logs API) |
| -take the segment as opaque and display the label from the body. This spec |
| -does not fix those collisions; it fixes the two objects that other |
| -machinery joins on, and gives step 2 its identity. |
| - |
| -### 3.6 Fleet boxes |
| + no local change, `edgesFilePass` reads its own key. Missing, or different |
| + from `*last` with Generation cleared: republish. Nothing else should |
| + write an id key, so this is defence in depth, and it is also how a file |
| + removed by the sweep or by hand comes back without waiting for a route |
| + change. It does not fix the old release's gate; nothing can. |
| +- **The steering identifier change** (codex 1). When `SetIdentifier` moves |
| + from label to id, a box's records are deleted under one identifier and |
| + upserted under another in one pass. The converger learns a **rename**: a |
| + managed-but-unwanted record whose `(Name, Value, Region)` is also being |
| + upserted under a wanted identifier is a rename, applied as upsert then |
| + delete in the same change batch and exempt from the shrink count |
| + (`route53.go:207-214`). Without this a single-armed-box deployment |
| + could never flip: all its records would count as a shrink of 100%. |
| + While old and new boxes alternate holding the housekeeper lease, an old |
| + holder renames back to labels and a new one forward to ids. Each flip is |
| + one upsert-and-delete per record with a 60-second TTL, bounded by the |
| + roll. Rollback is the same flip once, in reverse, by the old release's |
| + converger, which sees the id-identified records as managed and unwanted; |
| + for a single-box deployment the old converger's shrink guard refuses |
| + that, and the records simply stay under id identifiers, still resolving, |
| + until an operator deletes them or the box rolls forward again. |
| +- **The identity sweep** (codex 2, 3). The status lease holder runs it on |
| + its publish tick, and `carlos fleet sweep` runs the same code from an |
| + operator's machine with bucket credentials, for the case where no box is |
| + on the new release (§6). Every delete is `DeleteIfMatch` on the etag |
| + observed in that pass. Rules: a snapshot stale for `retiredAfter` (7 |
| + days) is deleted with the edges file that matches it under §3.3 (by id, |
| + else unambiguous label); a snapshot that Step A marked superseded is |
| + deleted at once, in either direction (legacy superseded by id, or id |
| + superseded by legacy after a rollback), with the edges file Step B |
| + marked non-effective for the same box; an edges file with no snapshot at |
| + all is reported, never deleted automatically, because nothing can date |
| + it. `carlos fleet retire <id|label>` deletes both objects by hand after |
| + printing what it found. The deployment bucket is versioned, which makes |
| + a wrong delete recoverable but is not a substitute for the condition. |
| + |
| +### 3.6 Step 2: the other per-box keys |
| + |
| +`boxIdentity().ID` replaces the label in every key of §2.1's second table, |
| +with the label carried inside the object as `box` is today |
| +(`RawChunk.Box`, `AlertState.Box`, `snapshotMeta.Box`, `Heartbeat.Box`, |
| +`ConfigAck.Box`). It is a separate plan because each key has readers with |
| +their own display and migration questions (codex 8): |
| + |
| +- **Registry snapshots** go first: a collision there hands the DR restore |
| + another box's registry. The restore reads the metadata's `Box` for the |
| + label and the key for the id. Legacy per-label keys are abandoned, not |
| + rewritten, the way the pre-multi-region fixed keys were |
| + (`snapshot_loop.go:31`). |
| +- `config-ack`, `relay-ack`, `metrics`, `alerts`, `relay` meter: readers |
| + take the key segment as opaque and display the label from the body. |
| + `metricsview.go:547` shows the key today and must switch to the body. |
| +- `apps/<sqid>/<app>/{logs,errors,analytics}/<box>/`: the segment is |
| + readable by the account's own fleet credentials (`storedoor.go:403`) and |
| + by `carlos logs`, so an id there is **member-visible**, and the cursor |
| + orders by it (`cursor.go:24`). Three identities need separating: the |
| + storage key, the display label, and what a member may see. Step 2 is |
| + gated on Paul's ruling (§10, question 3); until then those keys keep the |
| + label and its collision, which is inside one account's own data |
| + (`storedoor.go:482-487` records the existing limit). |
| + |
| +### 3.7 Fleet boxes |
| A fleet box's label is operator-chosen and unique within its fleet |
| (`registry/fleets.go:26` `ErrFleetBoxActive`). Its heartbeat lands under |
| `fleets/<name>/status/boxes/<label>.json` and the console joins it by |
| `fleet_boxes.label` (`fleets.go:1012`). Fleet boxes do not run the edges |
| -loop. They are not in #212 and they do not change: `boxIdentity` returns |
| -the fleet label as both ID and Label, `box_id` is omitted, and the key |
| -stays the label. |
| +loop or steering. They are not in #212 and do not change: `boxIdentity` |
| +returns the fleet label as both fields, `box_id` is omitted, the key stays |
| +the label. |
| ## 4. Migration with mixed versions during a roll |
| -The roll order does not matter. Each pair: |
| +Roll order does not matter. Each pair: |
| -| Writer | Reader | Join | |
| +| Writer | Reader | What happens | |
| |---|---|---| |
| -| old box (label key, no `box_id`) | new console or edge | `BoxKey` falls back to label. As today | |
| -| new box (id key, both fields) | old console or edge | lists the prefix, parses `label`/`box`, ignores the unknown field. As today | |
| +| old box (label key, no `box_id`) | new console, edge, housekeeper | Step C rule 2: label match, unambiguous. As today | |
| +| new box (id key, both fields) | old console, edge, housekeeper | lists the prefix, parses `label`/`box`, ignores the unknown field. Steering on an old housekeeper keys by label. As today | |
| | new box | new reader | by id | |
| -| colliding old pair | any | still collides, until both roll; reported once one has | |
| -| colliding pair, one rolled | new reader | the rolled box has its own key; the legacy key is the old sibling's and is left alone (§3.5 guard); reported as a label with two ids while the legacy key exists | |
| +| colliding pair, both old | any | still collides until one rolls | |
| +| colliding pair, one rolled | new reader | the rolled box has its own key and joins by id. The legacy objects are the sibling's: its `instance_id`/`eip` differ, so the guard leaves them and Step A marks the label contested. Eligibility is incomplete and the `identity` alert fires until the sibling rolls | |
| +| colliding pair, one rolled | old reader | the legacy objects are now the sibling's alone, so the old reader sees two boxes correctly for the first time | |
| +| one box, first minutes after roll | new reader | id snapshot and legacy snapshot share `instance_id`: superseded (A2). Legacy file and id file share `eip`: the newer is effective (B2). Then the guard deletes both legacy objects | |
| -One release of readers that accept both shapes is this release. The |
| -fallback to label stays for at least one more release after it, and is |
| -removed only when the sweep reports no legacy key in production. |
| +One release of readers accepting both shapes is this release. The label |
| +path (A2, B2, C2) stays for at least one more release and is removed only |
| +when the sweep reports no legacy key in production. |
| Nothing customer-visible changes. The public label is the same string. |
| fleet.json's shape is unchanged. The instances API's `served_by.box` keeps |
| -the label. Alert mails name the label. |
| +the label. Alert mails name the label. A DNS record's `SetIdentifier` is |
| +not visible to a resolver. |
| ## 5. The host lifecycle claim |
| `internal/hostclaim` is not on main at `04cd2d78`; it is on |
| `feat/hostclaim-library`, and the spec is on `design-replica-ownership`. |
| The claim names boxes in `holder.writer_box`, `op.exec`, |
| -`op.participants`, `touched.boxes`, `obligation.owner = "box:<b>"`, |
| -`admissions[].box`, and keys execution leases at |
| -`control/hosts/<host>/exec/<box>.json`. The spec's example is |
| -`"writer_box": "adi-knitting"`: a label. On the library branch |
| -`configAckFunc` already stamps `BoxLabel()` into config acks |
| -(`agent.go:942` there). Plan 3's box guard checks `c.Op.Exec != in.Box`. |
| +`op.participants`, `touched.boxes`, the `inventory` map's keys, `lost`, |
| +`obligations[].box` and `.owner = "box:<b>"`, `admissions[].box`, |
| +`writes_lost.box` (`claim.go:58-134` on that branch), and keys execution |
| +leases at `control/hosts/<host>/exec/<box>.json`. The spec's example is |
| +`"writer_box": "adi-knitting"`: a label. `generation.go:31` enforces exact |
| +executor equality, `c.Op.Exec != in.Box`. |
| A label collision there is worse than here: a sibling with the same label |
| passes "am I the executor", can renew or take the other's execution lease, |
| -and can complete or park the other's obligations. Ruling proposed: the |
| -claim and every later plan name a box by `boxIdentity().ID`, never by the |
| -label, before any claim-driven operation is armed. The claim carries no |
| -display name; a person reads the label through the fleet page. Step 1 of |
| -this spec lands first so the identity exists when the claim's plans build |
| -on it. No other label-keyed structure was found in the claim spec. |
| +and can complete or park the other's obligations. Ruling proposed (codex 9 |
| +widened it): |
| + |
| +- every box-naming field and key in the claim uses `boxIdentity().ID`, |
| + never the label; the claim carries no display name, a person reads the |
| + label through the fleet page; |
| +- a box whose id is empty (§3.1) refuses to arm any claim-driven work, the |
| + same way `resolveFleetIdentity` refuses an invalid fleet identity; |
| +- the claim relies on machine-id uniqueness, which §3.1 states, the image |
| + recipe enforces, and the twin check detects but cannot prevent. The |
| + claim's own fencing (execution lease, self-fence) is what bounds a twin's |
| + damage, not the id; |
| +- step 1 of this spec lands first so the identity exists when the claim's |
| + plans build on it. |
| ## 6. Rollback safety |
| A box rolled back below this release writes `status/boxes/<label>.json` |
| -and `control/edges/<label>.json` again. Its id-keyed objects remain. |
| - |
| -- A new console dedupes them by `instance_id` (§3.3 rule 2): the legacy |
| - object is fresher, the id-keyed one is superseded, counted for nothing, |
| - and deleted by the sweep on the next publish tick. |
| -- An old console shows the id-keyed snapshot as a stale second row for |
| - that label until the sweep (run by whichever box holds the status lease; |
| - if every box rolled back, 7 days, or by hand). Its instance counts are |
| - included in the public total while it is there. That is the one visible |
| - cost of rollback, bounded by the lease holder's tick when any box is on |
| - the new release. |
| -- The rolled-back box's edges file is readable by every reader: `box`, |
| - `eip`, `hosts` are unchanged. The id-keyed edges file lingers, carrying |
| - the same EIP and hosts; the home map, steering and `EdgeIPs` fold it the |
| - same way they fold the legacy one, with the same content, so nothing is |
| - wrong, only duplicated. The sweep removes it with the superseded snapshot. |
| -- The rolled-back box's own gate (`seedEdgesFileGate`) finds no legacy |
| - file if the forward run deleted it, and republishes on its first tick. |
| - |
| -Nothing on the new release writes a shape the old release cannot parse. |
| - |
| -## 7. Test plan |
| - |
| -Unit, in `internal/status`: |
| - |
| -- `BoxID` vectors: fixed digests; the known pair `...18` and `...3c` share a |
| - label and get different ids; `LabelForID(BoxID(m)) == Label(m)` for a |
| - table of machine ids; `BoxID("") == ""`. |
| -- `WriteBox` keys by id when set, by label when not; the body carries both. |
| -- `loadBoxesReport`: two keys one id (duplicate, incomplete); one label two |
| - ids with a legacy key (reported, incomplete); one label, old and new key, |
| - same instance id (superseded, fresher wins, complete); same with |
| - different instance ids (both stand); old-only and new-only fleets |
| - unchanged against the existing goldens. |
| -- `BuildFleet` with a superseded object does not double count. |
| -- `dryrun`: a needle for the id; it must appear in `status/boxes/*.json` |
| - and never in fleet.json or index.html. |
| - |
| -Unit, in `internal/host` and `internal/edgesfile`: |
| - |
| -- `edgesFilePass` writes the id key; the gate includes `box_id`; the |
| - tenth-tick bucket verify republishes a missing or foreign body. |
| -- Legacy cleanup: deletes only when instance id (status) or EIP (edges) |
| - match; leaves a sibling's object; runs once per start. |
| -- `EdgeIPs` and `DeploymentRoutes` join matrix: old/new snapshot x old/new |
| - file, plus a file whose label does not match its id (not live). |
| -- `moveTargets` with two files sharing a label and different ids. |
| - |
| -Unit, in `internal/console` and `internal/edge`: |
| - |
| -- `readLiveEdgesSnapshot` join matrix as above; the duplicate and |
| - two-ids conditions set `BoxesComplete=false` and log once. |
| -- `foldHomes` self-skip by id, and two same-label files both fold. |
| -- `buildBoxCards` gives two cards for two boxes with one label, titles |
| - disambiguated by id prefix; archregion seeds by key. |
| -- The alert watcher mails on the `identity` rule and clears it. |
| +and `control/edges/<label>.json` again. Its id-keyed objects remain, and |
| +after a while may disagree with the legacy ones (codex 3). The protocol: |
| + |
| +1. Roll the box back. Its first status tick writes the legacy snapshot |
| + (unconditional, `statusTick`). Its first edges tick finds no legacy |
| + file (the forward run deleted it) and publishes one, newer than the |
| + id-keyed file; or finds the undeleted legacy file, seeds its gate from |
| + it, and leaves it, in which case the legacy file is the older one until |
| + a route changes. |
| +2. On any box still on the new release, the next publish tick's sweep |
| + marks the id-keyed snapshot superseded (A2, same `instance_id`, legacy |
| + fresher) and the id-keyed file non-effective (B2, same `eip`) or, in |
| + the seeded-gate case above, keeps the id file effective until the |
| + legacy one is rewritten. Both id-keyed objects are then deleted |
| + conditionally. |
| +3. If every box rolled back, nothing on a box runs the sweep. The rollback |
| + runbook step is `carlos fleet sweep` from the operator's machine, with |
| + the new CLI and bucket credentials, once after the last box. Until it |
| + runs, an old console shows the id-keyed snapshot as a stale second row |
| + and `sumInstances` counts it; an old home map and steering see two |
| + files with one EIP and fold both, and on a host conflict pick by sorted |
| + key, which is wrong only if the two files disagree, which the sweep |
| + step bounds to minutes. |
| +4. An off-cloud box (no `instance_id`) cannot be deduped by A2. Its stale |
| + id-keyed snapshot stays until `retiredAfter` or `carlos fleet retire`. |
| + Its edges file is still deduped by B2 (EIP). This is the one case §6 |
| + does not make automatic, and the runbook says so. |
| + |
| +The rolled-back box's objects are readable by every reader: `label`, |
| +`box`, `eip`, `hosts` are unchanged. Nothing on the new release writes a |
| +shape the old release cannot parse. Steering on rollback is §3.5. |
| + |
| +## 7. Rollback and roll: the invariant |
| + |
| +Across every state in §4 and §6: a live box is counted for at most one |
| +row, its EIP is in the edge set, and its hosts fold from exactly one file; |
| +a retired or superseded object counts for nothing; and anything the |
| +readers cannot prove makes automatic eligibility incomplete rather than |
| +wrong. §8's simulation asserts exactly this. |
| + |
| +## 8. Test plan |
| + |
| +Unit, `internal/status`: |
| + |
| +- `BoxID` vectors; the known pair `...18` and `...3c` share a label and |
| + get different ids; `LabelForID(BoxID(m)) == Label(m)` over a table; |
| + `BoxID("") == ""`. |
| +- `WriteBox` keys by id when set, by label when not; the body carries both; |
| + the twin check fires on a fresh object with a different non-empty |
| + `instance_id` and not on an empty one. |
| +- `loadBoxesReport`: duplicate (two keys, one id); superseded (one label, |
| + old and new key, same instance id, fresher kept, dropped from the slice); |
| + contested (same, different or empty instance ids, both kept); old-only |
| + and new-only fleets byte-identical to the existing goldens. |
| +- `BuildFleet` with a superseded object does not double count; the |
| + `identity` alert object is written every tick, firing and clear. |
| +- `dryrun`: a needle for the id, present in `status/boxes/*.json`, absent |
| + from fleet.json and index.html. |
| +- `AddFleetBox` refuses a 32-hex label. |
| + |
| +Unit, `internal/edgesfile`, `internal/host`: |
| + |
| +- `Effective`: duplicate id; legacy-plus-id same EIP, newer generation |
| + wins in both directions; different EIPs both kept and contested; a |
| + mismatched `box`/`box_id` flagged. |
| +- `edgesFilePass` writes the id key; `box_id` is in the gate; the |
| + tenth-tick verify republishes a missing or foreign body. |
| +- Legacy cleanup: deletes only on non-empty matching evidence; refuses |
| + when the etag changed between read and delete (a fake |
| + `ConditionalDeleter`); refuses on a store without conditional delete; |
| + runs once per start. |
| +- `EdgeIPs`, `DeploymentRoutes`, `moveTargets`: the §4 matrix, plus the |
| + ambiguous-label case (two snapshots, one legacy file: no match). |
| +- Steering: a rename is one upsert-and-delete batch, exempt from the |
| + shrink count; a single-box flip converges; `dedupeRecords` keys by id. |
| + |
| +Unit, `internal/console`, `internal/edge`: |
| + |
| +- `readLiveEdgesSnapshot`: the §4 matrix; duplicate, contested and |
| + flagged each set `BoxesComplete=false` and log once. |
| +- `foldHomes`: self-skip by id; two same-label files with different ids |
| + both fold; a non-effective file does not. |
| +- `buildBoxCards`: two boxes with one label give two cards from |
| + `HostRows`; the `FleetBox` fallback gives one ambiguous card; `targetOf` |
| + and `claimed` resolve to `"box:"+id`. |
| +- The alert watcher reads `alerts/fleet/identity.json`, mails on a |
| + transition, clears on an all-clear or a stale object. |
| Mixed-version goldens: a committed legacy-shape object for each prefix, |
| -parsed by every new reader; and a committed new-shape object asserted to |
| -keep `label`, `box`, `v`, `eip`, `hosts`, `generation` at the same JSON |
| -field names, which is what an old reader needs. |
| +parsed by every new reader; a committed new-shape object asserted to keep |
| +`label`, `box`, `v`, `eip`, `hosts`, `generation` under the same JSON |
| +names, which is all an old reader needs. |
| -Sweep: against a DirStore, retired after 7 days stale, superseded at once, |
| -orphan edges file reported and kept, `carlos fleet retire` removes both. |
| +Sweep: against a DirStore with a fake conditional delete: retired after |
| +7 days, superseded at once in both directions, orphan edges file reported |
| +and kept, `carlos fleet sweep` and `retire` paths. |
| -The simulation habit from the moves work applies to §3.3's dedupe: a |
| -table-driven run over random fleets with injected collisions, rolls and |
| -rollbacks, asserting that a live box is never counted for nothing and a |
| -retired box never counts for eligibility. |
| +Simulation (the moves work's habit): random fleets with injected |
| +collisions, rolls, rollbacks, off-cloud boxes and sweep timing, over |
| +thousands of seeds, asserting §7's invariant at every step. |
| -## 8. Rollout |
| +## 9. Rollout |
| -1. Land step 1 on main; cut a release. |
| -2. Roll the boxes in any order (the platform roll recipe). After each box, |
| +1. Confirm the flagship and Tito image recipes clear `/etc/machine-id` |
| + (§3.1). If not, fix that first; it is a prerequisite, not a step. |
| +2. Land step 1 on main; cut a release. |
| +3. Roll the boxes in any order (the platform roll recipe). After each box, |
| `carlos status -live` shows its id and the fleet page shows one row per |
| - box with no `duplicate` chip. |
| -3. Watch the lease holder's log for the identity sweep's first pass: the |
| - legacy keys of rolled boxes disappear, nothing else does. |
| -4. Roll the console. Nearest-region eligibility keeps reading, now by id. |
| -5. After every box is on the release and the sweep reports no legacy key, |
| - file the step-2 plan (metrics, alerts, relay, logs, usage) and the claim |
| - ruling in §5 on the claim branch. |
| -6. One release later, remove the label fallback from the join (§4). |
| + box, no `duplicate` or `contested` chip once its sibling, if any, has |
| + rolled. |
| +4. Watch the lease holder's log for the sweep's first pass: the rolled |
| + boxes' legacy keys disappear, nothing else does. Watch the steering log |
| + for renames, not shrink refusals. |
| +5. Roll the console. Nearest-region eligibility keeps reading, now by id. |
| +6. After every box is on the release and the sweep reports no legacy key, |
| + file the step-2 plan (registry snapshots first) and record the §5 |
| + ruling on the claim branch. |
| +7. One release later, remove the label path from §3.3. |
| -## 9. Open questions for Paul |
| +Rollback is §6; its runbook step is `carlos fleet sweep`. |
| + |
| +## 10. Open questions for Paul |
| 1. Should fleet.json gain a short, non-reversible disambiguator (the first |
| four hex characters of the id) so two public rows with one label can be |
| told apart, or is two identical rows the honest public answer? This |
| spec keeps fleet.json label-only. |
| -2. `retiredAfter` is 7 days. Shorter makes a box that is down for a long |
| +2. `retiredAfter` is 7 days. Shorter makes a box that is down over a long |
| weekend lose its edges file and its steered records; longer leaves |
| stale rows. Is 7 right for the flagship? |
| -3. Step 2 changes the segment under `apps/<sqid>/<app>/logs/<box>/` from a |
| - label to an id. `carlos logs` shows it today. Is that acceptable for |
| - members, or should the logs API map it back to the label? |
| -4. The claim ruling in §5 is proposed here and belongs on the claim branch. |
| - Do you want it recorded there now, or when step 1 has landed? |
| +3. Step 2's `apps/` keys: is a box id in a member-visible key acceptable |
| + (bucket-private to the account, 128 bits of a digest of the platform's |
| + own machine id), or should those keys use a per-account pseudonym? Step |
| + 2 is gated on this. |
| +4. The §5 ruling belongs on the claim branch. Record it there now, or when |
| + step 1 has landed? |
| + |
| +## 11. Review dispositions |
| + |
| +| Round, finding | Severity | Disposition | |
| +|---|---|---| |
| +| R1-1 steering keys DNS by label | P1 | accepted. `SetIdentifier` becomes `BoxKey`; the converger learns renames exempt from the shrink count; flip-flop under mixed housekeepers bounded and described (§3.2, §3.5, §4) | |
| +| R1-2 cleanup can delete a live sibling's object | P1 | accepted. `DeleteIfMatch` on the observed etag, non-empty evidence only, never a plain delete, for the guard and the sweep (§3.5) | |
| +| R1-3 rollback duplicates not equivalent; key order load-bearing; no sweeper if all roll back | P1 | accepted. `edgesfile.Effective` selects one file per box for every reader; supersession in both directions; `carlos fleet sweep` as the runbook step; off-cloud caveat stated (§3.3 B, §3.5, §6) | |
| +| R1-4 mixed-shape join does not fall back as promised | P1 | accepted. One explicit three-step algorithm; label fallback only when unambiguous; id-bearing files never fall back (§3.3) | |
| +| R1-5 registry snapshots, config-ack, relay-ack missing | P2 | accepted. Added to §2.1 with readers; registry snapshots lead step 2 (§3.6) | |
| +| R1-6 architecture page join not implementable | P2 | accepted. Cards from `HostRows` keyed by id; `FleetBox` fallback marked ambiguous; `claimed`/`targetOf` by id (§3.3) | |
| +| R1-7 identity alert competes with the metrics loop | P2 | accepted. Separate `alerts/fleet/identity.json`, written every tick by the lease holder, stale means clear; delivery limits named (§3.4) | |
| +| R1-8 step 2 contradicts bucket-private | P2 | accepted. Store-door read scope corrected; step 2 split and gated on a ruling; `metricsview` and the cursor named (§2.4, §3.6, §10) | |
| +| R1-9 claim ruling incomplete; clones overwrite | P2 | accepted. Every box-naming field listed; empty id refuses to arm; uniqueness assumed, enforced by the image recipe, detected by the twin check (§3.1, §5, §9) | |
| +| R1-10 store-door readability, `ValidTunnelName`, 11% | P3 | accepted. All three corrected (§1, §2.4, §3.1) | |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-BOX-IDENTITY-R1.md b/docs/superpowers/specs/CODEX-FINDINGS-BOX-IDENTITY-R1.md| new file mode 100644 |
| index 000000000..3d97b6cc5 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-BOX-IDENTITY-R1.md |
| @@ -0,0 +1,25 @@ |
| +Reviewed `71112c9e` against code at `origin/main` (`04cd2d78`), plus both claim branches via `git show`. No files changed. |
| + |
| +1. **P1 — Steering still collapses colliding boxes.** §2.3 says steering folds by EIP and hosts, “not by box.” Actually, `desiredState` copies `e.Box`, `dedupeRecords` drops duplicate `(Name, Box)` pairs, and Route53 uses `Box` as `SetIdentifier` ([steering.go:371](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/steering/steering.go:371), [steering.go:318](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/steering/steering.go:318), [route53.go:601](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/steering/route53.go:601)). Two new files retaining one label still lose one edge. **Fix:** migrate steering identity too, explicitly handling existing DNS identifiers and old housekeepers during rollout and rollback. |
| + |
| +2. **P1 — Cleanup can delete a live sibling’s object.** §3.5 checks ownership, then deletes by key. Between those operations, an old sibling can overwrite that key. The status guard also permits empty instance IDs unless explicitly prohibited; empty is a supported off-cloud state ([snapshot.go:229](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/status/snapshot.go:229)). Losing the sibling’s edges file can persist indefinitely because its old writer checks only memory ([edgesfile.go:491](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/edgesfile.go:491)). **Fix:** require positive, non-empty ownership evidence and `release.DeleteIfMatch` against the observed revision, already available at [conditional_delete.go:28](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/release/conditional_delete.go:28). Apply this to cleanup and sweeping; bucket versioning does not prevent the outage. |
| + |
| +3. **P1 — Rollback duplicates are not necessarily equivalent or bounded.** §6 assumes lingering edges files retain identical EIP/hosts. After rollback, a route, replica flag, capability, or address can change only in the legacy file. `foldHomes` retains old-only hosts and chooses conflicting targets by sorted **object keys** ([homemap.go:538](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/edge/homemap.go:538)); steering retains whichever duplicate sorts first. Thus the claim that key names are never load-bearing is too strong. If every box rolls back, no new sweeper exists: stale snapshots remain counted indefinitely ([fleet.go:272](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/status/fleet.go:272)), not seven days. **Fix:** specify an executable rollback cleanup protocol and effective-file selection for all edges consumers. Correct §3.5’s legacy-only supersession cleanup to cover superseded ID objects too. |
| + |
| +4. **P1 — The stated mixed-shape join does not implement the promised fallback.** §3.2 promises label fallback when either side is old; §3.3 indexes an ID-bearing snapshot only by ID and looks up an ID-bearing edge only by ID. Consequently, neither old-status/new-edge nor new-status/old-edge joins. These states occur during independent publication and rollback. Missing joins remove addresses/routes ([fold.go:84](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/edgesfile/fold.go:84)) and make eligibility incomplete ([instancesplacement.go:138](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/instancesplacement.go:138)). **Fix:** define one explicit matching and supersession algorithm, permitting fallback only with unambiguous evidence. Preserve superseded identity aliases while reconciling the edges pair. Include missing-instance-ID cases: §3.3 deliberately cannot dedupe them, so §6’s unconditional rollback promise fails. |
| + |
| +5. **P2 — The consumer inventory misses three key families.** Registry backups use `metricsBoxLabel` and overwrite `control/registry-snapshot/<box>.{db,json}` plus generation objects ([snapshot_loop.go:34](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/snapshot_loop.go:34), [snapshot_loop.go:205](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/snapshot_loop.go:205)). Config acknowledgements already use `BoxLabel` on main and key `control/config-ack/<box>/<host>.json` ([agent.go:954](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/agent.go:954), [configack.go:116](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/appconfig/configack.go:116)). Relay heartbeats key `control/relay-ack/<box>.json` ([authority.go:71](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/relay/authority.go:71)). **Fix:** add their writers, readers, restore procedures, and explicit migration scope. Registry backup collisions are particularly consequential. |
| + |
| +6. **P2 — The architecture-page change lacks an implementable join.** §3.3 says to key cards by `BoxKey`, but half the inputs are `FleetBox`, intentionally lacking IDs. Current code unions public fleet rows and private host rows by label ([archgraph.go:561](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/archgraph.go:561)). Merely changing private keys creates unmatched or duplicate cards. Machine-to-node links also retain labels at [archgraph.go:271](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/archgraph.go:271) and [archgraph.go:993](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/archgraph.go:993). **Fix:** build identified cards from private snapshots and define an explicitly ambiguous public-only fallback; migrate every node reference. |
| + |
| +7. **P2 — Identity alerts introduce competing writers and unreliable clearing.** §3.4 writes into the lease holder’s existing alert object. The metrics loop holds separate local state and replaces that entire object on transitions ([metrics_loop.go:496](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/metrics_loop.go:496)), erasing independently added rules. Lease transfer can strand a firing rule on the previous holder. The watcher accepts unknown rule names, but mail requires configured delivery and operator grants; transitions without recipients are dropped ([alertwatch.go:103](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/alertwatch.go:103), [alertwatch.go:187](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/alertwatch.go:187)). **Fix:** give fleet identity alerts a stable, singly owned object with takeover/clear semantics, and specify delivery through the existing operator sinks. |
| + |
| +8. **P2 — Step 2 contradicts “the ID stays bucket-private.”** Customer fleet credentials can read their account’s entire `apps/` subtree ([storedoor.go:403](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/edge/storedoor.go:403)), exposing IDs placed in log/error/analytics keys even if bodies retain labels. Also, metrics views display the key-derived box value, not `RawChunk.Box` ([metricsview.go:547](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/metricsview.go:547)). Logs use `Box` for cursor ordering, so preserving a colliding display label is not sufficient identity separation ([cursor.go:24](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/applog/cursor.go:24)). **Fix:** separate storage identity, display label, and member-visible identity; settle the privacy boundary before specifying step 2. |
| + |
| +9. **P2 — The hostclaim ruling is necessary but incomplete.** Exact executor equality is already enforced in `feat/hostclaim-library:internal/hostclaim/generation.go:31`, so replacing labels is correct. However, the inventory also includes `inventory` keys, `lost`, `Obligation.Box`, and `WritesLost.Box` (`claim.go:132`, `134`, `114`, `76` on that branch). More fundamentally, cloned machine IDs produce the **same object key**: they overwrite rather than produce the two objects §3.4 expects to detect. **Fix:** extend the ruling to every authority-bearing box reference, require non-empty identity, and state/enforce the machine-ID uniqueness assumption before claiming clone detection or safe claim admission. |
| + |
| +10. **P3 — Correct two factual details.** `control/edges/` is **not** readable through the store door: only specific system-update shapes are allowed under `control/` ([storedoor.go:408](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/edge/storedoor.go:408)). Also, a 32-character lowercase hex string satisfies `ValidTunnelName` ([registry.go:337](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/registry/registry.go:337)); it is not syntactically disjoint from fleet labels. The twenty-box collision probability is approximately **11.24%**, not 6%. |
| + |
| +The core choices remain sound: avoid legacy dual-writes; retain labels in public projections; use a 128-bit digest for collision resistance. `LabelForID` reproduces the existing digest-derived label and adds no machine-ID disclosure by itself ([snapshot.go:523](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/status/snapshot.go:523)). Step 1’s explicit `FleetBox` projection, label-only instances response, and alert display fields can preserve privacy; key compatibility alone does not establish migration safety. |
| + |
| +**Verdict: not ready. P1s: steering identity, unsafe deletion, rollback reconciliation, and mixed-shape joins.** |
| \ No newline at end of file |