Design spec for #212: key box objects by a collision-resistant id
status.Label maps a machine id to one of 1,600 labels and both per-box objects (status/boxes, control/edges) are keyed by it, so two boxes can overwrite each other. The spec keys both objects by a 128-bit digest of the machine id, keeps the public label for people, joins readers by id with a label fallback for one release, treats duplicate identities as incomplete evidence, sweeps retired boxes' objects, and rules that the host lifecycle claim must name boxes by the id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+423
−0
docs/superpowers/specs/2026-10-06-box-identity-collisions-design.md+423 −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| new file mode 100644 |
| index 000000000..7f2ad9d1d |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-10-06-box-identity-collisions-design.md |
| @@ -0,0 +1,423 @@ |
| +# Box identity: key box objects by a collision-resistant id, keep the label for people |
| + |
| +**Date:** 2026-10-06 |
| +**Revision:** 1 |
| +**Discussion:** amadan carlos/platform #212 |
| +**Status:** draft, for Paul's review after Astra's. |
| +**Builds on:** the fleet status page (`internal/status`), the multi-region |
| +ingress edges file (2026-08-20, Part 3), 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`). |
| + |
| +## 1. The problem |
| + |
| +`status.Label` (`internal/status/snapshot.go:523`) maps a machine id to one |
| +of 40 names x 40 actions, 1,600 labels. The label is the object key for the |
| +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`. |
| + |
| +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. |
| + |
| +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. |
| + |
| +## 2. Every consumer of the label-keyed objects |
| + |
| +### 2.1 Writers |
| + |
| +| Where | What | |
| +|---|---| |
| +| `internal/status/snapshot.go:523` `Label` | the hash; `:674` CollectBox sets `Box.Label`; `:742` WriteBox keys by it | |
| +| `internal/host/agent.go:2905` `statusTick` | `:2926` fleet boxes override the label with the operator-chosen `fleet_boxes.label` | |
| +| `internal/host/edgesfile.go:273` `edgesFilePass` | writes `control/edges/<box>.json`; `:158` `Box` field; `:491` memory-only change gate; `:529` `seedEdgesFileGate` reads the key once at arm; `:637` the box name is `metricsBoxLabel` | |
| +| `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): |
| + |
| +| 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` | |
| + |
| +### 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` | |
| +| `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/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/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` | |
| +| `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) | |
| + |
| +### 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/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 |
| + |
| +`status/fleet.json` is served by the edge (`internal/edge/status.go:54`) |
| +and rendered by `internal/status/assets/index.html:173`. It carries the |
| +label and nothing that identifies a machine (`snapshot.go` package doc, |
| +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. |
| + |
| +## 3. The design |
| + |
| +### 3.1 The box id |
| + |
| +```go |
| +// BoxID is the collision-resistant key for a platform box's objects: |
| +// the first 16 bytes of sha256(machineID), lower-case hex, 32 characters. |
| +// Label is drawn from bytes 0..8 of the same digest, so a reader can |
| +// check that a label belongs to an id: LabelForID(id) == label. |
| +func BoxID(machineID string) string |
| +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`). |
| +- 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". |
| +- The label is unchanged. `Label` keeps its function and its table so the |
| + public label of every existing box is stable through the roll. |
| + |
| +### 3.2 Where the id lives and how it travels |
| + |
| +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). |
| +func boxIdentity(getenv func(string) string, machineID string) BoxIdentity |
| +``` |
| + |
| +Status snapshot. `Box` gains `BoxID string json:"box_id,omitempty"`. |
| +`WriteBox` keys by `box_id` when present, else by label. `FleetBox` does |
| +not gain the field. The fleet-box override at `agent.go:2926` sets the |
| +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. |
| +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. |
| + |
| +### 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. |
| +- **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 |
| + |
| +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. |
| + |
| +## 4. Migration with mixed versions during a roll |
| + |
| +The roll order does not matter. Each pair: |
| + |
| +| Writer | Reader | Join | |
| +|---|---|---| |
| +| 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 | |
| +| 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 | |
| + |
| +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. |
| + |
| +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. |
| + |
| +## 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`. |
| + |
| +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. |
| + |
| +## 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. |
| + |
| +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. |
| + |
| +Sweep: against a DirStore, retired after 7 days stale, superseded at once, |
| +orphan edges file reported and kept, `carlos fleet retire` removes both. |
| + |
| +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. |
| + |
| +## 8. Rollout |
| + |
| +1. Land step 1 on main; cut a release. |
| +2. 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). |
| + |
| +## 9. 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 |
| + 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? |