carlos / platform Public

Box identity Option B rev 3, chosen by Paul, with the implementation plan

Astra's round 2 on rev 2 (CODEX-FINDINGS-BOX-IDENTITY-B-R2.md, committed
unedited) found four P1s and six P2s, all accepted: the EIP branch that
promoted a stale unproven label is removed; Publishable binds the
calling machine's box_id, the exact label and the store, so a mismatched
CARLOS_RELAY_BOX publishes nothing; the box_id disagreement rule applies
to eligibility only and EdgeIPs and DeploymentRoutes are unchanged, so
hostname occupancy is never weakened; freeing a label is a four-step
operator procedure (stop, regenerate, retire after staleness, reclaim);
the gate is enforced at every Put and Delete through a gatedStore; one
arbitration namespace per publication namespace; the fleet observation
file is written conditionally after held.Err(); per-label alert rules;
fleet boxes get a fleet verdict; guarantees stated per state.

Rev 3 records Paul's choice of Option B and four default rulings
(check-label added, a withdrawn box buffers to cap, an unreadable status
store stops every label-keyed publisher, fleet.json stays silent), and
the verified prerequisite: flagship, Tito and dev all launch Debian's
stock debian-13-arm64 AMI and never touch /etc/machine-id.

The plan has ten TDD tasks with exact files and tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 2b7641ecd613635504427d52411c7d1a796a8005 parent 8c362f1
3 files changed, +2360 −271
  • docs/superpowers/plans/2026-10-06-box-identity-detect.md +1888 −0
  • docs/superpowers/specs/2026-10-06-box-identity-detect-design.md +398 −271
  • docs/superpowers/specs/CODEX-FINDINGS-BOX-IDENTITY-B-R2.md +74 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-06-box-identity-detect.md b/docs/superpowers/plans/2026-10-06-box-identity-detect.md
new file mode 100644
index 000000000..68d496de0
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-06-box-identity-detect.md
@@ -0,0 +1,1888 @@
+# Box identity, Option B (detect and refuse) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Two boxes whose machine ids hash to the same label stop silently overwriting each other's `status/boxes/<label>.json` and `control/edges/<label>.json`: the second writer refuses, alerts, and takes no claim work, and nothing moves.
+
+**Architecture:** `status.DecideLabel` reads the label's snapshot and returns a verdict (owned, foreign, twin, unproven, unknown) from a private `box_id` and the cloud instance id; the agent decides at arm and every status tick, writes the verdict to `<dataDir>/identity.json`, and every label-keyed publisher in every process writes through a `gatedStore` that refuses unless `Publishable(label, target)`. The console learns of collisions from a `box_id` mismatch between the two objects, from evidence objects the refusing box writes, and from a fleet observation file the status lease holder keeps; all three fail automatic region eligibility closed and feed one per-label alert object. Two CLI verbs let an operator check a label before a box joins and free a label after a collision.
+
+**Tech Stack:** Go 1.25 (`go.mod`), `release.Store` (S3 and DirStore), `internal/status`, `internal/host`, `internal/edgesfile`, `internal/console`, `internal/relay`, `internal/metrics`.
+
+**Spec:** `docs/superpowers/specs/2026-10-06-box-identity-detect-design.md` (rev 3). Its §2.1 table, §2.2 entry-point table and §3 reader rules are normative; this plan cites them by section.
+
+## Global Constraints
+
+- No object key, prefix or Route53 `SetIdentifier` changes (spec §1).
+- `box_id` and `instance_id` never appear in `status/fleet.json`, `status/index.html`, any account page, the instances API response, or an alert mail body (spec §1, §3, §7).
+- A label is never reassigned automatically; only `carlos fleet retire` deletes a label's objects, and only when both are stale (spec §2.1, §2.3).
+- `owned` is recorded only after a successful conditional write; no verdict is promoted by reading back the box's own bytes (spec §2.1).
+- Every `Put` and `Delete` under a label-keyed key goes through `gatedStore`; reads and lists are not gated (spec §2.2).
+- Copy voice: plain sentences, no em dashes, no new user-facing strings beyond the log lines and the two CLI verbs' output in this plan (house rule).
+- Cross-compile gate: CI builds darwin and windows; anything Linux-only in `internal/host` needs `//go:build linux` and a `!linux` stub.
+- Tests use `release.NewDirStore(t.TempDir())`; the DirStore arbitrates only writers sharing one handle (spec §2.5).
+- Run the package tests with `GOFLAGS=-buildvcs=false go test ./internal/<pkg>/...`; the full gate needs the `-exec` wrapper from the repo notes.
+
+## Review Focus
+
+Inputs the spec implies but no task's tests exercised at first draft; each line's test is added to the owning task below.
+
+1. A snapshot body that parses but has `generated_at` zero (an old or hand-edited object): `BoxStale` says stale; the verdict must still be foreign or unproven, never owned. Task 2.
+2. A `box_id` in the object that is not 32 lower-case hex (a hand edit): treated as present and different, so foreign, not as absent. Task 2.
+3. `identity.json` written by a previous release of the agent with a different `v` or no `store`: `Publishable` must answer false, not panic or default to open. Task 3.
+4. The lease holder reads a `status/identity/fleet.json` whose `contested_until` is in the far future (clock skew on a previous publisher): it must never shorten it, and must still clear it when the time passes. Task 7.
+5. `carlos fleet retire` on a label whose edges file is absent but snapshot stale: deletes the snapshot only, exits 0, prints that the edges file was already gone. Task 8.
+
+---
+
+### Task 1: `box_id` on the wire
+
+**Files:**
+- Modify: `internal/status/snapshot.go:387-445` (Box), `:523-531` (Label), `:535` (CollectBox), `:674`
+- Modify: `internal/edgesfile/edgesfile.go:68-79` (File)
+- Modify: `internal/host/edgesfile.go:157-160` (edgesFile)
+- Test: `internal/status/status_test.go`, `internal/edgesfile/edgesfile_test.go`, `internal/host/edgesfile_test.go`
+
+**Interfaces:**
+- Produces: `status.BoxID(machineID string) string` (32 lower-case hex, `""` for `""`); `status.Box.BoxID string json:"box_id,omitempty"`; `status.Probes` unchanged; `edgesfile.File.BoxID string json:"box_id,omitempty"`; `host.edgesFile.BoxID string json:"box_id,omitempty"`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/status/status_test.go
+func TestBoxIDIsThirtyTwoHexAndCollisionFree(t *testing.T) {
+ a, b := status.BoxID("00000000000000000000000000000018"), status.BoxID("0000000000000000000000000000003c")
+ if status.Label("00000000000000000000000000000018") != status.Label("0000000000000000000000000000003c") {
+ t.Fatal("fixture: these two machine ids must share a label (edsger-knitting)")
+ }
+ if a == b {
+ t.Fatal("BoxID must differ where Label collides")
+ }
+ for _, id := range []string{a, b} {
+ if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(id) {
+ t.Fatalf("BoxID %q is not 32 lower-case hex", id)
+ }
+ }
+ if status.BoxID("") != "" {
+ t.Fatal("an empty machine id has no BoxID")
+ }
+ sum := sha256.Sum256([]byte("m"))
+ if want := hex.EncodeToString(sum[:16]); status.BoxID("m") != want {
+ t.Fatalf("BoxID = %q, want first 16 bytes of sha256: %q", status.BoxID("m"), want)
+ }
+}
+
+func TestCollectBoxCarriesBoxIDPrivately(t *testing.T) {
+ b := status.CollectBox(nil, status.Probes{MachineID: "m"}, time.Now())
+ if b.BoxID != status.BoxID("m") {
+ t.Fatalf("Box.BoxID = %q, want %q", b.BoxID, status.BoxID("m"))
+ }
+ body, _ := json.Marshal(status.CollectBox(nil, status.Probes{MachineID: ""}, time.Now()))
+ if strings.Contains(string(body), "box_id") {
+ t.Fatal("an empty machine id must omit box_id, not publish an empty one")
+ }
+}
+```
+
+```go
+// internal/edgesfile/edgesfile_test.go
+func TestParseCarriesBoxID(t *testing.T) {
+ f, err := edgesfile.Parse([]byte(`{"v":1,"box":"annie-welding","box_id":"0123456789abcdef0123456789abcdef","eip":"1.2.3.4","hosts":{}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f.BoxID != "0123456789abcdef0123456789abcdef" {
+ t.Fatalf("BoxID = %q", f.BoxID)
+ }
+ old, err := edgesfile.Parse([]byte(`{"v":1,"box":"annie-welding","eip":"1.2.3.4","hosts":{}}`))
+ if err != nil || old.BoxID != "" {
+ t.Fatalf("an old file parses with no BoxID: %v %q", err, old.BoxID)
+ }
+}
+```
+
+```go
+// internal/host/edgesfile_test.go
+func TestEdgesFilePassPublishesBoxID(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ reg := newTestRegistry(t) // the helper the existing edgesFilePass tests use
+ var last []byte
+ ident := edgesFile{Box: "annie", BoxID: "0123456789abcdef0123456789abcdef"}
+ if err := edgesFilePass(context.Background(), s, reg, ident, "1.2.3.4", &last); err != nil {
+ t.Fatal(err)
+ }
+ body, _, err := s.Get(context.Background(), edgesFileKey("annie"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got struct {
+ BoxID string `json:"box_id"`
+ }
+ _ = json.Unmarshal(body, &got)
+ if got.BoxID != ident.BoxID {
+ t.Fatalf("published box_id = %q", got.BoxID)
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `GOFLAGS=-buildvcs=false go test ./internal/status/ ./internal/edgesfile/ ./internal/host/ -run 'BoxID|CarriesBoxID|PublishesBoxID' -v`
+Expected: FAIL, `undefined: status.BoxID`, `f.BoxID undefined`, `unknown field BoxID`.
+
+- [ ] **Step 3: Implement**
+
+```go
+// internal/status/snapshot.go, after Label:
+
+// BoxID is the private, collision-resistant identity of a platform box:
+// the first 16 bytes of sha256(machineID), lower-case hex, 32 characters.
+// Label is drawn from the first 8 bytes of the same digest, so a label
+// collision (1 in 1,600) is never a BoxID collision (1 in 2^128). It is
+// bucket-private: BuildFleet never copies it, and the dry run's needles
+// prove it (dryrun.go). "" for an empty machine id: a box that cannot read
+// /etc/machine-id has no identity and publishes nothing keyed by it.
+func BoxID(machineID string) string {
+ if machineID == "" {
+ return ""
+ }
+ sum := sha256.Sum256([]byte(machineID))
+ return hex.EncodeToString(sum[:16])
+}
+```
+
+Add to `Box` after `Label`:
+
+```go
+ // BoxID is the machine's private identity (BoxID). Omitted for a box
+ // with no machine id and for fleet boxes. Never copied into FleetBox.
+ BoxID string `json:"box_id,omitempty"`
+```
+
+In `CollectBox` set `BoxID: BoxID(p.MachineID),` beside `Label:`. Add `"encoding/hex"` to the imports.
+
+`internal/edgesfile/edgesfile.go`, in `File` after `Box`:
+
+```go
+ BoxID string `json:"box_id,omitempty"`
+```
+
+`internal/host/edgesfile.go`, in `edgesFile` after `Box`:
+
+```go
+ // BoxID is status.BoxID of this machine, so a reader can tell whether
+ // this file and the label's snapshot were written by one machine. Part
+ // of the change gate like every field but Generation.
+ BoxID string `json:"box_id,omitempty"`
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: the same command. Expected: PASS. Also run `go test ./internal/status/` whole: the existing `TestWriteBoxPutsUnderLabeledKey` still passes (the key is still the label).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/status/snapshot.go internal/status/status_test.go internal/edgesfile/edgesfile.go internal/edgesfile/edgesfile_test.go internal/host/edgesfile.go internal/host/edgesfile_test.go
+git commit -m "status, edgesfile: carry a private box_id on both per-box objects (#212)"
+```
+
+---
+
+### Task 2: `status.DecideLabel` and a `WriteBox` that never overwrites another machine
+
+**Files:**
+- Create: `internal/status/decide.go`
+- Modify: `internal/status/snapshot.go:742-765` (WriteBox)
+- Test: `internal/status/decide_test.go`, `internal/status/status_test.go:225` (`TestWriteBoxOverwritesOnSecondTick` stays green)
+
+**Interfaces:**
+- Produces:
+
+```go
+type Verdict string
+const (
+ VerdictOwned Verdict = "owned"
+ VerdictForeign Verdict = "foreign"
+ VerdictTwin Verdict = "twin"
+ VerdictUnproven Verdict = "unproven"
+ VerdictUnknown Verdict = "unknown"
+)
+// Decision is one reading of the label's object.
+type Decision struct {
+ Verdict Verdict
+ Label string
+ BoxID string // ours
+ HolderBoxID string // the object's, "" if absent
+ HolderInstanceID string // the object's metrics.instance_id, "" if absent
+ HolderFresh bool
+ DecidedAt time.Time
+}
+// DecideLabel reads status/boxes/<b.Label>.json and, when the verdict is
+// owned, writes b there. Spec 2.1 table. It never writes otherwise.
+func DecideLabel(ctx context.Context, s release.Store, b Box, now time.Time) (Decision, error)
+var ErrNotOwned = errors.New("status: label is not this machine's")
+```
+
+- `WriteBox(ctx, s, b)` becomes `_, err := DecideLabel(...)`, returning `ErrNotOwned` (wrapped with the decision) when not owned. Callers that only need the old behaviour keep calling `WriteBox`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/status/decide_test.go
+package status_test
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+func boxFor(machine, instance string, at time.Time) status.Box {
+ return status.CollectBox(nil, status.Probes{MachineID: machine, InstanceID: instance, Region: "x"}, at)
+}
+
+// put writes a snapshot body under the label without any decision, standing
+// in for an old-release writer or a hand-placed object.
+func put(t *testing.T, s release.Store, label string, body any) {
+ t.Helper()
+ raw, _ := json.Marshal(body)
+ key := "status/boxes/" + label + ".json"
+ _, etag, _ := s.Get(context.Background(), key)
+ if _, err := s.Put(context.Background(), key, raw, etag); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestDecideLabelTable(t *testing.T) {
+ now := time.Unix(1_700_000_000, 0)
+ fresh, stale := now.Add(-time.Minute), now.Add(-time.Hour)
+ me := boxFor("mine", "i-me", now)
+ label := me.Label
+ type object struct {
+ boxID, instance string
+ at time.Time
+ }
+ cases := []struct {
+ name string
+ obj *object // nil = not found
+ want status.Verdict
+ written bool
+ }{
+ {"not found creates", nil, status.VerdictOwned, true},
+ {"own id replaces", &object{me.BoxID, "i-me", fresh}, status.VerdictOwned, true},
+ {"own id, empty instance, replaces", &object{me.BoxID, "", fresh}, status.VerdictOwned, true},
+ {"own id, other instance is a twin", &object{me.BoxID, "i-other", fresh}, status.VerdictTwin, false},
+ {"foreign fresh", &object{"ffffffffffffffffffffffffffffffff", "i-other", fresh}, status.VerdictForeign, false},
+ {"foreign stale stays foreign", &object{"ffffffffffffffffffffffffffffffff", "i-other", stale}, status.VerdictForeign, false},
+ {"foreign zero generated_at", &object{"ffffffffffffffffffffffffffffffff", "i-other", time.Time{}}, status.VerdictForeign, false},
+ {"malformed box_id is foreign", &object{"not-hex", "i-me", fresh}, status.VerdictForeign, false},
+ {"old writer, same instance, owned", &object{"", "i-me", fresh}, status.VerdictOwned, true},
+ {"old writer, other instance, foreign", &object{"", "i-other", fresh}, status.VerdictForeign, false},
+ {"old writer, no instance, fresh, unproven", &object{"", "", fresh}, status.VerdictUnproven, false},
+ {"old writer, no instance, stale, still unproven", &object{"", "", stale}, status.VerdictUnproven, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ if tc.obj != nil {
+ prior := status.Box{GeneratedAt: tc.obj.at, Label: label, BoxID: tc.obj.boxID}
+ if tc.obj.instance != "" {
+ prior.Metrics = &status.BoxMetrics{InstanceID: tc.obj.instance}
+ }
+ put(t, s, label, prior)
+ }
+ d, err := status.DecideLabel(context.Background(), s, me, now)
+ if err != nil {
+ t.Fatalf("DecideLabel: %v", err)
+ }
+ if d.Verdict != tc.want {
+ t.Fatalf("verdict = %s, want %s", d.Verdict, tc.want)
+ }
+ body, _, gerr := s.Get(context.Background(), "status/boxes/"+label+".json")
+ var got status.Box
+ if gerr == nil {
+ _ = json.Unmarshal(body, &got)
+ }
+ if tc.written && !got.GeneratedAt.Equal(now) {
+ t.Fatalf("owned must have written our snapshot; object generated_at = %v", got.GeneratedAt)
+ }
+ if !tc.written && gerr == nil && got.GeneratedAt.Equal(now) {
+ t.Fatal("a non-owned verdict must not write")
+ }
+ })
+ }
+}
+
+func TestDecideLabelMalformedBodyIsUnknownAndWritesNothing(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ me := boxFor("mine", "i-me", time.Now())
+ key := "status/boxes/" + me.Label + ".json"
+ if _, err := s.Put(context.Background(), key, []byte("{not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ d, err := status.DecideLabel(context.Background(), s, me, time.Now())
+ if err == nil || d.Verdict != status.VerdictUnknown {
+ t.Fatalf("want unknown with an error, got %s %v", d.Verdict, err)
+ }
+ body, _, _ := s.Get(context.Background(), key)
+ if string(body) != "{not json" {
+ t.Fatal("unknown must leave the object alone")
+ }
+}
+
+func TestDecideLabelSimultaneousStartOneWins(t *testing.T) {
+ now := time.Now()
+ a := boxFor("00000000000000000000000000000018", "i-a", now)
+ b := boxFor("0000000000000000000000000000003c", "i-b", now)
+ if a.Label != b.Label {
+ t.Fatal("fixture: the pair must share a label")
+ }
+ for _, order := range [][2]status.Box{{a, b}, {b, a}} {
+ s := release.NewDirStore(t.TempDir())
+ first, err := status.DecideLabel(context.Background(), s, order[0], now)
+ if err != nil || first.Verdict != status.VerdictOwned {
+ t.Fatalf("first: %s %v", first.Verdict, err)
+ }
+ second, err := status.DecideLabel(context.Background(), s, order[1], now)
+ if err != nil || second.Verdict != status.VerdictForeign {
+ t.Fatalf("second: %s %v, want foreign", second.Verdict, err)
+ }
+ if second.HolderBoxID != order[0].BoxID || second.HolderInstanceID == "" {
+ t.Fatalf("the decision must name the holder: %+v", second)
+ }
+ }
+}
+
+func TestWriteBoxRefusesAForeignLabel(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ now := time.Now()
+ holder := boxFor("00000000000000000000000000000018", "i-a", now)
+ if err := status.WriteBox(context.Background(), s, holder); err != nil {
+ t.Fatal(err)
+ }
+ err := status.WriteBox(context.Background(), s, boxFor("0000000000000000000000000000003c", "i-b", now))
+ if !errors.Is(err, status.ErrNotOwned) {
+ t.Fatalf("WriteBox for the colliding box = %v, want ErrNotOwned", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `GOFLAGS=-buildvcs=false go test ./internal/status/ -run 'DecideLabel|RefusesAForeign' -v`
+Expected: FAIL, `undefined: status.DecideLabel`.
+
+- [ ] **Step 3: Implement `decide.go`**
+
+```go
+package status
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "regexp"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type Verdict string
+
+const (
+ VerdictOwned Verdict = "owned"
+ VerdictForeign Verdict = "foreign"
+ VerdictTwin Verdict = "twin"
+ VerdictUnproven Verdict = "unproven"
+ VerdictUnknown Verdict = "unknown"
+)
+
+// ErrNotOwned is WriteBox's refusal: the label's object was written by
+// another machine (or cannot be proved ours), and this box must not
+// overwrite it. Spec 2026-10-06-box-identity-detect-design.md 2.1.
+var ErrNotOwned = errors.New("status: label is not this machine's")
+
+// Decision is one reading of status/boxes/<label>.json against this box.
+type Decision struct {
+ Verdict Verdict
+ Label string
+ BoxID string
+ HolderBoxID string
+ HolderInstanceID string
+ HolderFresh bool
+ DecidedAt time.Time
+}
+
+var boxIDRe = regexp.MustCompile(`^[0-9a-f]{32}$`)
+
+const decideAttempts = 3
+
+// DecideLabel is the spec's 2.1 table. It reads the label's object, decides,
+// and writes b only when the verdict is owned: a create-only Put when the
+// key is absent, a replace on the etag read when the object is proved
+// ours. A CAS conflict re-reads and re-decides; it never re-Puts on a
+// fresh etag without re-reading the body. Every other verdict writes
+// nothing. Unknown (unreadable, unparsable, CAS exhausted) returns an
+// error alongside the decision.
+func DecideLabel(ctx context.Context, s release.Store, b Box, now time.Time) (Decision, error) {
+ d := Decision{Verdict: VerdictUnknown, Label: b.Label, BoxID: b.BoxID, DecidedAt: now}
+ if b.BoxID == "" {
+ return d, fmt.Errorf("%w: this box has no machine id", ErrNotOwned)
+ }
+ key := "status/boxes/" + b.Label + ".json"
+ body, err := json.MarshalIndent(b, "", " ")
+ if err != nil {
+ return d, err
+ }
+ var lastErr error
+ for attempt := 0; attempt < decideAttempts; attempt++ {
+ raw, etag, err := s.Get(ctx, key)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ if _, err := s.Put(ctx, key, body, ""); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ lastErr = err
+ continue
+ }
+ return d, err
+ }
+ d.Verdict = VerdictOwned
+ return d, nil
+ case err != nil:
+ return d, err
+ }
+ var prior Box
+ if err := json.Unmarshal(raw, &prior); err != nil {
+ return d, fmt.Errorf("status: %s: unparsable: %w", key, err)
+ }
+ d.HolderBoxID = prior.BoxID
+ if prior.Metrics != nil {
+ d.HolderInstanceID = prior.Metrics.InstanceID
+ }
+ d.HolderFresh = !BoxStale(prior, now, SnapshotPeriod)
+ myInstance := ""
+ if b.Metrics != nil {
+ myInstance = b.Metrics.InstanceID
+ }
+ d.Verdict = classify(b.BoxID, myInstance, prior.BoxID, d.HolderInstanceID)
+ if d.Verdict != VerdictOwned {
+ return d, nil
+ }
+ if _, err := s.Put(ctx, key, body, etag); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ lastErr = err
+ d.Verdict = VerdictUnknown
+ continue
+ }
+ return d, err
+ }
+ return d, nil
+ }
+ return d, fmt.Errorf("status: %s: lost the CAS race %d times: %w", key, decideAttempts, lastErr)
+}
+
+// classify is the 2.1 table without I/O, so every row is a unit test.
+func classify(myID, myInstance, theirID, theirInstance string) Verdict {
+ bothInstances := myInstance != "" && theirInstance != ""
+ switch {
+ case theirID == "":
+ switch {
+ case bothInstances && myInstance == theirInstance:
+ return VerdictOwned
+ case bothInstances:
+ return VerdictForeign
+ default:
+ return VerdictUnproven
+ }
+ case !boxIDRe.MatchString(theirID) || theirID != myID:
+ return VerdictForeign
+ case bothInstances && myInstance != theirInstance:
+ return VerdictTwin
+ default:
+ return VerdictOwned
+ }
+}
+```
+
+Replace `WriteBox`'s body:
+
+```go
+func WriteBox(ctx context.Context, s release.Store, b Box) error {
+ d, err := DecideLabel(ctx, s, b, b.GeneratedAt)
+ if err != nil {
+ return err
+ }
+ if d.Verdict != VerdictOwned {
+ return fmt.Errorf("%w: %s (holder box_id %q, instance %q, fresh %v)", ErrNotOwned, d.Verdict, d.HolderBoxID, d.HolderInstanceID, d.HolderFresh)
+ }
+ return nil
+}
+```
+
+Keep `currentEtag` only if `putOverwrite` still uses it (it does, for `PublishFleet`); otherwise delete it. Fleet boxes: `statusTick` sets `box.Label = labelOverride` and must also clear `box.BoxID = ""`; with an empty `BoxID`, `WriteBox` must still write for them. Add this branch at the top of `WriteBox`, before `DecideLabel`:
+
+```go
+ if b.BoxID == "" && b.Label != "" && isFleetLabel(b) {
+ return putOverwrite(ctx, s, "status/boxes/"+b.Label+".json", mustJSON(b))
+ }
+```
+
+where `isFleetLabel` is `b.Fleet` ... there is no such field; instead give `WriteBox` the explicit seam: add `func WriteFleetBox(ctx, s, b) error { return putOverwrite(...) }` and have `statusTick` call it when `labelOverride != ""`. Do not let `WriteBox` guess.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `GOFLAGS=-buildvcs=false go test ./internal/status/ -v`
+Expected: PASS, including `TestWriteBoxOverwritesOnSecondTick` (same machine, same `BoxID`, so owned) and the dry run test (one box, not found then owned).
+
+- [ ] **Step 5: Add the zero-`generated_at` and malformed-`box_id` rows (Review Focus 1, 2)**
+
+They are already rows in `TestDecideLabelTable` ("foreign zero generated_at", "malformed box_id is foreign"). Confirm both pass; if `classify` let `not-hex` through as owned, the regexp check is wrong.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/status/decide.go internal/status/decide_test.go internal/status/snapshot.go internal/status/status_test.go
+git commit -m "status: DecideLabel refuses to overwrite another machine's snapshot (#212)"
+```
+
+---
+
+### Task 3: the identity file, `Publishable`, `gatedStore`, `ClaimEligible`
+
+**Files:**
+- Create: `internal/host/identity.go`, `internal/host/identity_test.go`
+- Modify: `internal/host/metrics_loop.go:729-743` (`metricsBoxLabel`, `BoxLabel` keep working; add `boxIdentity`)
+
+**Interfaces:**
+- Produces:
+
+```go
+// StoreTarget names the arbitration namespace: the status store a verdict
+// was decided against. Bucket is "" and Dir set for a directory deployment.
+type StoreTarget struct {
+ Bucket string `json:"bucket"`
+ Prefix string `json:"prefix"`
+ Dir string `json:"dir,omitempty"`
+}
+type BoxIdentity struct {
+ ID string // status.BoxID(machineID); "" when unidentified; the fleet label on a fleet box
+ Label string
+ Fleet bool
+}
+func boxIdentity(getenv func(string) string, machineID string) BoxIdentity
+type identityFile struct {
+ V int `json:"v"`
+ BoxID string `json:"box_id"`
+ Label string `json:"label"`
+ Verdict status.Verdict `json:"verdict"` // plus "fleet"
+ DecidedAt time.Time `json:"decided_at"`
+ Store StoreTarget `json:"store"`
+}
+const VerdictFleet status.Verdict = "fleet"
+func writeIdentity(dir string, f identityFile) error
+func readIdentity(dir string) (identityFile, error)
+// Publishable: spec 2.2. myID is status.BoxID(readMachineID()) of the CALLER.
+func publishable(dir string, myID, label string, target StoreTarget, now time.Time) bool
+func Publishable(label string, target StoreTarget) bool // dataDir(), readMachineID(), time.Now()
+var ErrNotPublishable = errors.New("host: not publishable under this label")
+// gatedStore refuses Put and Delete unless publishable; Get/List pass.
+func gated(s release.Store, label string, target StoreTarget) release.Store
+func ClaimEligible() bool // identified && verdict != twin && Publishable(label, target)
+```
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/host/identity_test.go
+package host
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+func TestBoxIdentityPlatformAndFleet(t *testing.T) {
+ p := boxIdentity(envFunc(map[string]string{}), "m")
+ if p.ID != status.BoxID("m") || p.Label != status.Label("m") || p.Fleet {
+ t.Fatalf("platform identity = %+v", p)
+ }
+ f := boxIdentity(envFunc(map[string]string{"CARLOS_FLEET": "pi", "CARLOS_FLEET_BOX": "desk"}), "m")
+ if f.ID != "desk" || f.Label != "desk" || !f.Fleet {
+ t.Fatalf("fleet identity = %+v", f)
+ }
+ if u := boxIdentity(envFunc(map[string]string{}), ""); u.ID != "" || u.Label != "" {
+ t.Fatalf("unidentified box must have no id and no label: %+v", u)
+ }
+}
+
+func TestPublishableBindsMachineLabelStoreAndAge(t *testing.T) {
+ dir := t.TempDir()
+ now := time.Now()
+ target := StoreTarget{Bucket: "b", Prefix: ""}
+ me := status.BoxID("m")
+ write := func(f identityFile) {
+ t.Helper()
+ if err := writeIdentity(dir, f); err != nil {
+ t.Fatal(err)
+ }
+ }
+ base := identityFile{V: 1, BoxID: me, Label: "annie-welding", Verdict: status.VerdictOwned, DecidedAt: now, Store: target}
+ write(base)
+ if !publishable(dir, me, "annie-welding", target, now) {
+ t.Fatal("owned, fresh, same machine, label and store must be publishable")
+ }
+ if publishable(dir, status.BoxID("other"), "annie-welding", target, now) {
+ t.Fatal("another machine's file must not authorise this one")
+ }
+ if publishable(dir, me, "bea-sailing", target, now) {
+ t.Fatal("another label must not be authorised")
+ }
+ if publishable(dir, me, "annie-welding", StoreTarget{Bucket: "c"}, now) {
+ t.Fatal("another store must not be authorised")
+ }
+ if publishable(dir, me, "annie-welding", target, now.Add(3*status.SnapshotPeriod+time.Second)) {
+ t.Fatal("a decision older than three periods must not be publishable")
+ }
+ for _, v := range []status.Verdict{status.VerdictForeign, status.VerdictTwin, status.VerdictUnproven, status.VerdictUnknown} {
+ f := base
+ f.Verdict = v
+ write(f)
+ if publishable(dir, me, "annie-welding", target, now) {
+ t.Fatalf("%s must not be publishable", v)
+ }
+ }
+ fleet := base
+ fleet.Verdict, fleet.BoxID, fleet.Label = VerdictFleet, "desk", "desk"
+ write(fleet)
+ if !publishable(dir, "desk", "desk", target, now) {
+ t.Fatal("a fleet box is publishable for its fleet label")
+ }
+}
+
+func TestPublishableRefusesMissingOrForeignShapedFile(t *testing.T) {
+ dir := t.TempDir()
+ if publishable(dir, status.BoxID("m"), "annie-welding", StoreTarget{Bucket: "b"}, time.Now()) {
+ t.Fatal("no file must mean not publishable")
+ }
+ if err := os.WriteFile(filepath.Join(dir, "identity.json"), []byte(`{"v":0,"label":"annie-welding","verdict":"owned"}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if publishable(dir, status.BoxID("m"), "annie-welding", StoreTarget{Bucket: "b"}, time.Now()) {
+ t.Fatal("an older-shaped file (v 0, no store, no box_id) must not be publishable")
+ }
+}
+
+func TestGatedStoreRefusesWritesAndPassesReads(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("CARLOS_DATA_DIR", dir)
+ inner := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if _, err := inner.Put(ctx, "k", []byte("x"), ""); err != nil {
+ t.Fatal(err)
+ }
+ g := gated(inner, "annie-welding", StoreTarget{Bucket: "b"})
+ if _, _, err := g.Get(ctx, "k"); err != nil {
+ t.Fatalf("Get must pass: %v", err)
+ }
+ if _, err := g.Put(ctx, "k2", []byte("y"), ""); !errors.Is(err, ErrNotPublishable) {
+ t.Fatalf("Put without a verdict = %v, want ErrNotPublishable", err)
+ }
+ if err := g.Delete(ctx, "k"); !errors.Is(err, ErrNotPublishable) {
+ t.Fatalf("Delete without a verdict = %v", err)
+ }
+ if _, _, err := inner.Get(ctx, "k"); err != nil {
+ t.Fatal("the refused Delete must not have deleted")
+ }
+}
+```
+
+`gated` needs the caller's machine id; in tests `readMachineID()` reads `/etc/machine-id`, so give `gatedStore` an injectable `myID func() string` and have `gated` pass `func() string { return status.BoxID(readMachineID()) }`. In this test, write an identity file for `status.BoxID(readMachineID())` to prove the positive path too:
+
+```go
+ me := status.BoxID(readMachineID())
+ if me != "" {
+ if err := writeIdentity(dir, identityFile{V: 1, BoxID: me, Label: "annie-welding", Verdict: status.VerdictOwned, DecidedAt: time.Now(), Store: StoreTarget{Bucket: "b"}}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := g.Put(ctx, "k2", []byte("y"), ""); err != nil {
+ t.Fatalf("Put with an owned verdict = %v", err)
+ }
+ }
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `GOFLAGS=-buildvcs=false go test ./internal/host/ -run 'BoxIdentity|Publishable|GatedStore' -v`
+Expected: FAIL, undefined symbols.
+
+- [ ] **Step 3: Implement `identity.go`**
+
+```go
+package host
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// Spec docs/superpowers/specs/2026-10-06-box-identity-detect-design.md 2.2.
+
+type StoreTarget struct {
+ Bucket string `json:"bucket"`
+ Prefix string `json:"prefix"`
+ Dir string `json:"dir,omitempty"`
+}
+
+type BoxIdentity struct {
+ ID string
+ Label string
+ Fleet bool
+}
+
+// boxIdentity replaces metricsBoxLabel as the one selector. On a fleet box
+// both fields are the operator's fleet_boxes.label (fleetidentity.go); on
+// a platform box ID is status.BoxID and Label status.Label of the machine
+// id; an unreadable machine id gives neither.
+func boxIdentity(getenv func(string) string, machineID string) BoxIdentity {
+ if identity, err := resolveFleetIdentity(getenv); err == nil {
+ return BoxIdentity{ID: identity.Label, Label: identity.Label, Fleet: true}
+ }
+ if machineID == "" {
+ return BoxIdentity{}
+ }
+ return BoxIdentity{ID: status.BoxID(machineID), Label: status.Label(machineID)}
+}
+
+const VerdictFleet status.Verdict = "fleet"
+
+type identityFile struct {
+ V int `json:"v"`
+ BoxID string `json:"box_id"`
+ Label string `json:"label"`
+ Verdict status.Verdict `json:"verdict"`
+ DecidedAt time.Time `json:"decided_at"`
+ Store StoreTarget `json:"store"`
+}
+
+const identityFileV = 1
+
+func identityPath(dir string) string { return filepath.Join(dir, "identity.json") }
+
+func writeIdentity(dir string, f identityFile) error {
+ f.V = identityFileV
+ body, err := json.MarshalIndent(f, "", " ")
+ if err != nil {
+ return err
+ }
+ tmp := identityPath(dir) + ".tmp"
+ if err := os.WriteFile(tmp, body, 0o644); err != nil {
+ return err
+ }
+ return os.Rename(tmp, identityPath(dir))
+}
+
+func readIdentity(dir string) (identityFile, error) {
+ var f identityFile
+ body, err := os.ReadFile(identityPath(dir))
+ if err != nil {
+ return f, err
+ }
+ if err := json.Unmarshal(body, &f); err != nil {
+ return f, err
+ }
+ return f, nil
+}
+
+// publishable is the gate: this machine's file, this exact label, this
+// store, owned (or a fleet box's own label), decided within three periods.
+func publishable(dir, myID, label string, target StoreTarget, now time.Time) bool {
+ f, err := readIdentity(dir)
+ if err != nil || f.V != identityFileV {
+ return false
+ }
+ if myID == "" || f.BoxID != myID || f.Label == "" || f.Label != label || f.Store != target {
+ return false
+ }
+ if f.Verdict != status.VerdictOwned && f.Verdict != VerdictFleet {
+ return false
+ }
+ return now.Sub(f.DecidedAt) <= 3*status.SnapshotPeriod
+}
+
+func Publishable(label string, target StoreTarget) bool {
+ return publishable(dataDir(), status.BoxID(readMachineID()), label, target, time.Now())
+}
+
+var ErrNotPublishable = errors.New("host: not publishable under this label (identity.json says this machine does not hold it)")
+
+type gatedStore struct {
+ release.Store
+ label string
+ target StoreTarget
+ myID func() string
+ dir func() string
+ now func() time.Time
+}
+
+func gated(s release.Store, label string, target StoreTarget) release.Store {
+ return &gatedStore{Store: s, label: label, target: target,
+ myID: func() string { return status.BoxID(readMachineID()) }, dir: dataDir, now: time.Now}
+}
+
+func (g *gatedStore) ok() bool { return publishable(g.dir(), g.myID(), g.label, g.target, g.now()) }
+
+func (g *gatedStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ if !g.ok() {
+ return "", ErrNotPublishable
+ }
+ return g.Store.Put(ctx, key, body, ifMatch)
+}
+
+func (g *gatedStore) Delete(ctx context.Context, key string) error {
+ if !g.ok() {
+ return ErrNotPublishable
+ }
+ return g.Store.Delete(ctx, key)
+}
+
+// ClaimEligible is the host lifecycle claim's gate (spec 4): identified,
+// not a twin, and publishable for this machine's label in the store the
+// agent decided against.
+func ClaimEligible() bool {
+ f, err := readIdentity(dataDir())
+ if err != nil || f.Verdict == status.VerdictTwin {
+ return false
+ }
+ return Publishable(f.Label, f.Store)
+}
+```
+
+The fleet-box case in the test writes `BoxID: "desk"`; `publishable` compares `f.BoxID != myID` where the caller passes `"desk"` as `myID` for a fleet box, so `gated` on a fleet box must pass the fleet label as `myID`. Make `gated` take the identity: `gated(s, id BoxIdentity, target)` and use `id.ID` as `myID` and `id.Label` as the label. Update the test accordingly (`gated(inner, BoxIdentity{ID: me, Label: "annie-welding"}, ...)`).
+
+`release.Store` also has `ListDirs`; the embedded `Store` supplies it and `List` and `Get`. If `release.ConditionalDeleter` is checked by type assertion anywhere a gated store is passed, add a `DeleteIfMatch` passthrough with the same gate.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: the same command. Expected: PASS. Then `go vet ./internal/host/` and `GOOS=darwin go build ./...` (nothing Linux-only was added).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/host/identity.go internal/host/identity_test.go
+git commit -m "host: identity.json, Publishable and a gated store for label-keyed writes (#212)"
+```
+
+---
+
+### Task 4: decide at arm, gate every publisher, in every process
+
+**Files:**
+- Modify: `internal/host/agent.go` (RunAgent arm order near `:482-500`; `statusLoop` `:2681-2882`; `statusTick` `:2905-2935`; `configAckFunc` `:948-960`; `adopt` `:3486`; sinks at `:244,245,477,480,569`; `edge.WithBoxLabel` `:542`)
+- Modify: `internal/host/edgesfile.go:529-545` (seed), `:626-700` (loop)
+- Modify: `internal/host/metrics_loop.go:799` and `:729-743`, `internal/host/snapshot_loop.go:205`, `internal/host/usage_loop.go:72`, `internal/host/logship.go:39-95`
+- Modify: `relay.go:125-145`, `internal/relay/serve.go:144-215`
+- Test: `internal/host/agent_test.go`, `internal/host/edgesfile_test.go`, `internal/host/snapshot_loop_test.go`, `internal/relay/serve_test.go`
+
+**Interfaces:**
+- Consumes: Task 2 `status.DecideLabel`, `status.WriteFleetBox`; Task 3 `boxIdentity`, `writeIdentity`, `gated`, `StoreTarget`, `Publishable`.
+- Produces: `func decideIdentity(ctx, store release.Store, target StoreTarget, id BoxIdentity, probes status.Probes, now time.Time) (status.Decision, error)` which calls `DecideLabel` and `writeIdentity`; `func statusStoreTarget(getenv func(string) string) StoreTarget`; `relay.Config.Publishable func(label string) bool` (nil means always, for tests).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/host/agent_test.go
+func TestDecideIdentityWritesTheFileAndRefusesAForeignLabel(t *testing.T) {
+ dir := t.TempDir()
+ s := release.NewDirStore(t.TempDir())
+ target := StoreTarget{Bucket: "b"}
+ now := time.Now()
+ a := BoxIdentity{ID: status.BoxID("00000000000000000000000000000018"), Label: status.Label("00000000000000000000000000000018")}
+ b := BoxIdentity{ID: status.BoxID("0000000000000000000000000000003c"), Label: a.Label}
+ da, err := decideIdentityIn(context.Background(), dir, s, target, a, status.Probes{MachineID: "00000000000000000000000000000018", InstanceID: "i-a"}, now)
+ if err != nil || da.Verdict != status.VerdictOwned {
+ t.Fatalf("a: %s %v", da.Verdict, err)
+ }
+ if !publishable(dir, a.ID, a.Label, target, now) {
+ t.Fatal("a must be publishable after an owned decision")
+ }
+ dirB := t.TempDir()
+ db, err := decideIdentityIn(context.Background(), dirB, s, target, b, status.Probes{MachineID: "0000000000000000000000000000003c", InstanceID: "i-b"}, now)
+ if err != nil || db.Verdict != status.VerdictForeign {
+ t.Fatalf("b: %s %v", db.Verdict, err)
+ }
+ if publishable(dirB, b.ID, b.Label, target, now) {
+ t.Fatal("b must not be publishable")
+ }
+ f, _ := readIdentity(dirB)
+ if f.Verdict != status.VerdictForeign || f.Store != target {
+ t.Fatalf("b's identity file = %+v", f)
+ }
+}
+
+func TestStatusStoreTargetNamesBucketPrefixOrDir(t *testing.T) {
+ got := statusStoreTarget(envFunc(map[string]string{"CARLOS_DEPLOYMENT_BUCKET": "d", "CARLOS_STATUS_PREFIX": "fleets/pi/"}))
+ if got != (StoreTarget{Bucket: "d", Prefix: "fleets/pi/"}) {
+ t.Fatalf("target = %+v", got)
+ }
+ got = statusStoreTarget(envFunc(map[string]string{"CARLOS_STATUS_BUCKET": "s", "CARLOS_DEPLOYMENT_BUCKET": "d"}))
+ if got.Bucket != "s" {
+ t.Fatalf("CARLOS_STATUS_BUCKET must win: %+v", got)
+ }
+}
+```
+
+```go
+// internal/host/edgesfile_test.go
+func TestSeedEdgesFileGateDiscardsAForeignSeedAndItsEIP(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ foreign, _ := json.Marshal(edgesFile{V: 1, Box: "annie", BoxID: "ffffffffffffffffffffffffffffffff", EIP: "9.9.9.9", Hosts: map[string]edgeHost{}})
+ if _, err := s.Put(ctx, edgesFileKey("annie"), foreign, ""); err != nil {
+ t.Fatal(err)
+ }
+ if got := seedEdgesFileGate(ctx, s, "annie", "0123456789abcdef0123456789abcdef"); got != nil {
+ t.Fatalf("a seed written by another machine must be discarded, got %s", got)
+ }
+ own, _ := json.Marshal(edgesFile{V: 1, Box: "annie", BoxID: "0123456789abcdef0123456789abcdef", EIP: "1.2.3.4", Hosts: map[string]edgeHost{}})
+ _, etag, _ := s.Get(ctx, edgesFileKey("annie"))
+ if _, err := s.Put(ctx, edgesFileKey("annie"), own, etag); err != nil {
+ t.Fatal(err)
+ }
+ if got := seedEdgesFileGate(ctx, s, "annie", "0123456789abcdef0123456789abcdef"); got == nil {
+ t.Fatal("our own seed must be kept")
+ }
+ legacy, _ := json.Marshal(edgesFile{V: 1, Box: "annie", EIP: "1.2.3.4", Hosts: map[string]edgeHost{}})
+ _, etag, _ = s.Get(ctx, edgesFileKey("annie"))
+ _, _ = s.Put(ctx, edgesFileKey("annie"), legacy, etag)
+ if got := seedEdgesFileGate(ctx, s, "annie", "0123456789abcdef0123456789abcdef"); got == nil {
+ t.Fatal("a pre-box_id seed (our own old file) is kept; the status decision already proved the label")
+ }
+}
+```
+
+```go
+// internal/host/snapshot_loop_test.go
+func TestRegistrySnapshotAbortsMidSequenceWhenTheGateCloses(t *testing.T) {
+ // A fake store that flips a shared *bool after the first Put; the
+ // gatedStore's ok() reads the same bool through an injected dir whose
+ // identity.json the test rewrites to foreign on that flip.
+ dir := t.TempDir()
+ me := status.BoxID("m")
+ target := StoreTarget{Bucket: "b"}
+ _ = writeIdentity(dir, identityFile{V: 1, BoxID: me, Label: "annie", Verdict: status.VerdictOwned, DecidedAt: time.Now(), Store: target})
+ inner := &flipStore{Store: release.NewDirStore(t.TempDir()), onFirstPut: func() {
+ _ = writeIdentity(dir, identityFile{V: 1, BoxID: me, Label: "annie", Verdict: status.VerdictForeign, DecidedAt: time.Now(), Store: target})
+ }}
+ g := &gatedStore{Store: inner, label: "annie", target: target, myID: func() string { return me }, dir: func() string { return dir }, now: time.Now}
+ err := snapshotPass(context.Background(), g, newTestRegistry(t), "annie", time.Now()) // the existing per-tick function
+ if !errors.Is(err, ErrNotPublishable) {
+ t.Fatalf("the second write must be refused: %v", err)
+ }
+ if inner.puts != 1 {
+ t.Fatalf("exactly one write before the gate closed, got %d", inner.puts)
+ }
+}
+```
+
+(`flipStore` is a ten-line test type: embeds `release.Store`, counts `puts`, calls `onFirstPut` after the first successful `Put`. `snapshotPass` is whatever `registrySnapshotLoop` calls per tick; if the loop body is inline, extract it as part of this task.)
+
+```go
+// internal/relay/serve_test.go
+func TestServeRefusesAMismatchedRelayBox(t *testing.T) {
+ cfg := testConfig(t) // the existing helper
+ cfg.Box = "bea-sailing"
+ cfg.Publishable = func(label string) bool { return label == "annie-welding" }
+ var logged []string
+ err := Serve(ctx(t), cfg, func(f string, a ...any) { logged = append(logged, fmt.Sprintf(f, a...)) })
+ // Serve returns when ctx ends; assert no heartbeat object was written
+ // and the mismatch was logged once.
+ if _, _, err := cfg.Store.Get(context.Background(), HeartbeatKey("bea-sailing")); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("a relay whose box label this machine does not hold must publish no heartbeat")
+ }
+ if n := countContaining(logged, "not publishing"); n != 1 {
+ t.Fatalf("mismatch logged %d times, want once", n)
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `GOFLAGS=-buildvcs=false go test ./internal/host/ ./internal/relay/ -run 'DecideIdentity|StatusStoreTarget|DiscardsAForeignSeed|AbortsMidSequence|MismatchedRelayBox' -v`
+Expected: FAIL, undefined symbols and signature mismatches.
+
+- [ ] **Step 3: Implement, in this order**
+
+a. `statusStoreTarget(getenv)` in `agent.go` next to `ResolveStatusBucket` (`:2602`): `Bucket: ResolveStatusBucket(getenv)`, `Prefix: resolveStatusPrefix(getenv)`, `Dir: getenv("CARLOS_DEPLOYMENT_DIR")` when the bucket is empty.
+
+b. `decideIdentityIn(ctx, dir, store, target, id, probes, now)` in `agent.go`: builds `b := status.CollectBox(nil, probes, now)` with `b.Label, b.BoxID = id.Label, id.ID`, calls `status.DecideLabel`, then `writeIdentity(dir, identityFile{BoxID: id.ID, Label: id.Label, Verdict: d.Verdict, DecidedAt: now, Store: target})` whatever the verdict (unknown included, so a stale file goes stale honestly), and returns the decision. `decideIdentity` is the `dataDir()` wrapper. For `id.Fleet` it writes `VerdictFleet` and skips `DecideLabel`. For `id.ID == ""` it writes nothing and returns `status.VerdictUnknown` with an error naming the unreadable machine id.
+
+c. **Arm order** in `RunAgent`: before `go statusLoop(reg)` (`:482`), open the status store once (the same `openStatusStore` call `statusLoop` makes), compute `id := boxIdentity(os.Getenv, readMachineID())` and `target := statusStoreTarget(os.Getenv)`, and call `decideIdentity` synchronously with up to three attempts a second apart. Log the verdict once. A directory deployment (`errNoStatusStore`) writes `identityFile{Verdict: status.VerdictOwned, Store: target}` directly with a log line `agent: no status store, so this is a one-box deployment: label <label> owned by configuration`. Only then start the loops.
+
+d. **`statusLoop`**: replace the `lease.Acquire` then `statusTick` order (`:2865-2882`) with: `d, err := decideIdentity(...)` first; if `d.Verdict != status.VerdictOwned` (and not fleet), skip the election and the tick, call `refuseTransition(d)` (Task 5), and continue. Otherwise acquire the lease and run `statusTick` as today, but `statusTick` no longer calls `WriteBox` for a platform box: the decision already wrote the snapshot. Give `statusTick` a `wrote bool` parameter: `false` on fleet boxes, where it calls `status.WriteFleetBox` after `box.Label = labelOverride; box.BoxID = ""`.
+
+e. **Edges loop** (`edgesFileLoop`, `:626`): `id := boxIdentity(...)`; `ident := edgesFile{Box: id.Label, BoxID: id.ID, ...}`; the store becomes `gated(openDeployStore(...), id, target)`; `seedEdgesFileGate(ctx, s, box, boxID string)` returns nil when the seeded file carries a non-empty `BoxID != boxID`. In the loop, keep `prevPublishable bool`; when `Publishable` flips false to true, set `last = nil` (republish next pass). An `ErrNotPublishable` from `edgesFilePass` is logged once per transition, not per pass.
+
+f. **Metrics, alerts, registry snapshot, usage, logship, errship, analytics**: at each arm (`metrics_loop.go:799`, `snapshot_loop.go:205`, `usage_loop.go:72`, `logship.go:39` and its errship twin, the analytics flush), replace `box := metricsBoxLabel(...)` with `id := boxIdentity(os.Getenv, readMachineID()); box := id.Label` and wrap the store: `s = gated(s, id, statusStoreTarget(os.Getenv))`. The `agent.go:244,245,477,480,569` sinks take `boxIdentity(...).Label` instead of `status.Label(readMachineID())`. `metricsBoxLabel` and `BoxLabel` stay as thin wrappers returning `boxIdentity(...).Label` so nothing else breaks; `edge.WithBoxLabel(id.Label)` at `:542`.
+
+g. **Config acks**: `configAckFunc(ctx, store, prefix)` wraps `store` with `gated(store, boxIdentity(...), statusStoreTarget(os.Getenv))`; `adopt` (`:3486`) gets the gate for free through the same function.
+
+h. **Relay**: `relay.Config` gains `Publishable func(label string) bool`; `Serve` checks it before `WriteHeartbeat` (`serve.go:343`) and `meter.Flush` (`:354`), skipping both and logging `relay: box %s: not publishing (this machine does not hold that label)` once per transition. `relay.go:138` sets `cfg.Publishable = func(label string) bool { return host.Publishable(label, host.StatusStoreTarget(os.Getenv)) }` (export `StatusStoreTarget`), and `cfg.Box = host.BoxLabel()` as today when unset.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `GOFLAGS=-buildvcs=false go test ./internal/host/ ./internal/relay/ ./internal/status/`
+Expected: PASS. The existing `TestMetricsBoxLabelUsesFleetIdentity` (`metrics_loop_test.go:879`) still passes through the wrapper.
+
+- [ ] **Step 5: Cross-compile check**
+
+Run: `GOOS=darwin GOARCH=arm64 go build ./... && GOOS=windows go build ./...`
+Expected: both build.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/host relay.go internal/relay
+git commit -m "host, relay: decide the label at arm and gate every label-keyed publisher (#212)"
+```
+
+---
+
+### Task 5: evidence, the refusal log lines, and the webhook event
+
+**Files:**
+- Create: `internal/status/identity.go` (evidence type and key), `internal/host/refuse.go`
+- Test: `internal/status/identity_test.go`, `internal/host/refuse_test.go`
+
+**Interfaces:**
+- Produces:
+
+```go
+// internal/status
+const IdentityPrefix = "status/identity/"
+func IdentityKey(boxID string) string // IdentityPrefix + boxID + ".json"
+type Evidence struct {
+ V int `json:"v"`
+ Label string `json:"label"`
+ BoxID string `json:"box_id"`
+ InstanceID string `json:"instance_id,omitempty"`
+ Verdict Verdict `json:"verdict"`
+ HolderBoxID string `json:"holder_box_id,omitempty"`
+ HolderInstanceID string `json:"holder_instance_id,omitempty"`
+ SeenAt time.Time `json:"seen_at"`
+}
+func WriteEvidence(ctx, s release.Store, e Evidence) error // putOverwrite, not gated: the refuser must be able to say so
+func LoadEvidence(ctx, s release.Store, now time.Time) ([]Evidence, bool, error) // unexpired only; complete=false on any skipped object
+func EvidenceExpired(e Evidence, now time.Time) bool // now - SeenAt > 3*SnapshotPeriod
+// internal/host
+type refuser struct { prev status.Verdict; deliver func(metrics.Event); logf func(string, ...any) }
+func (r *refuser) observe(ctx, s release.Store, d status.Decision, instanceID string, now time.Time)
+```
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/status/identity_test.go
+func TestEvidenceRoundTripAndExpiry(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ now := time.Now()
+ e := status.Evidence{V: 1, Label: "edsger-knitting", BoxID: status.BoxID("b"), InstanceID: "i-b",
+ Verdict: status.VerdictForeign, HolderBoxID: status.BoxID("a"), HolderInstanceID: "i-a", SeenAt: now}
+ if err := status.WriteEvidence(context.Background(), s, e); err != nil {
+ t.Fatal(err)
+ }
+ got, complete, err := status.LoadEvidence(context.Background(), s, now)
+ if err != nil || !complete || len(got) != 1 || got[0].HolderBoxID != e.HolderBoxID {
+ t.Fatalf("load = %+v %v %v", got, complete, err)
+ }
+ got, _, _ = status.LoadEvidence(context.Background(), s, now.Add(3*status.SnapshotPeriod+time.Second))
+ if len(got) != 0 {
+ t.Fatal("expired evidence must not be returned")
+ }
+ if _, err := s.Put(context.Background(), status.IdentityPrefix+"junk.json", []byte("{"), ""); err != nil {
+ t.Fatal(err)
+ }
+ _, complete, _ = status.LoadEvidence(context.Background(), s, now)
+ if complete {
+ t.Fatal("an unparsable evidence object makes the load incomplete")
+ }
+}
+```
+
+```go
+// internal/host/refuse_test.go
+func TestRefuserLogsAndDeliversOncePerTransition(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ var logs []string
+ var events []metrics.Event
+ r := &refuser{deliver: func(e metrics.Event) { events = append(events, e) },
+ logf: func(f string, a ...any) { logs = append(logs, fmt.Sprintf(f, a...)) }}
+ now := time.Now()
+ foreign := status.Decision{Verdict: status.VerdictForeign, Label: "edsger-knitting", BoxID: status.BoxID("b"),
+ HolderBoxID: status.BoxID("a"), HolderInstanceID: "i-a", HolderFresh: true, DecidedAt: now}
+ r.observe(context.Background(), s, foreign, "i-b", now)
+ r.observe(context.Background(), s, foreign, "i-b", now.Add(time.Minute))
+ if len(logs) != 1 || !strings.Contains(logs[0], "held by another machine") || !strings.Contains(logs[0], "i-a") {
+ t.Fatalf("logs = %q", logs)
+ }
+ if len(events) != 1 || events[0].Rule != "identity" || !events[0].Firing || events[0].Box != "edsger-knitting" {
+ t.Fatalf("events = %+v", events)
+ }
+ ev, _, _ := status.LoadEvidence(context.Background(), s, now.Add(time.Minute))
+ if len(ev) != 1 || ev[0].Verdict != status.VerdictForeign || !ev[0].SeenAt.Equal(now.Add(time.Minute)) {
+ t.Fatalf("evidence must be rewritten every tick: %+v", ev)
+ }
+ owned := foreign
+ owned.Verdict = status.VerdictOwned
+ r.observe(context.Background(), s, owned, "i-b", now.Add(2*time.Minute))
+ if len(events) != 2 || events[1].Firing {
+ t.Fatalf("clearing is a delivered transition: %+v", events)
+ }
+ if len(logs) != 2 || !strings.Contains(logs[1], "now holds") {
+ t.Fatalf("clearing is logged: %q", logs)
+ }
+ twin := foreign
+ twin.Verdict = status.VerdictTwin
+ r.observe(context.Background(), s, twin, "i-b", now.Add(3*time.Minute))
+ if !strings.Contains(logs[2], "share this machine id") {
+ t.Fatalf("twin text: %q", logs[2])
+ }
+ unproven := foreign
+ unproven.Verdict = status.VerdictUnproven
+ r.observe(context.Background(), s, unproven, "i-b", now.Add(4*time.Minute))
+ if !strings.Contains(logs[3], "carlos fleet retire edsger-knitting") {
+ t.Fatalf("unproven text: %q", logs[3])
+ }
+ for _, l := range logs {
+ if strings.Contains(l, status.BoxID("a")) || strings.Contains(l, status.BoxID("b")) {
+ t.Fatalf("log lines name instances and labels, never box ids: %q", l)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `GOFLAGS=-buildvcs=false go test ./internal/status/ ./internal/host/ -run 'Evidence|Refuser' -v`
+Expected: FAIL, undefined.
+
+- [ ] **Step 3: Implement**
+
+`internal/status/identity.go`: the type, `IdentityKey`, `WriteEvidence` (via `putOverwrite`), `LoadEvidence` (list the prefix, skip non-`.json`, `Get`, unmarshal, drop expired, `complete=false` on read or parse failure, cap at `maxFleetBoxKeys` like `loadBoxesReport`), `EvidenceExpired`.
+
+`internal/host/refuse.go`:
+
+```go
+package host
+
+// The refusing side of spec 2.3: say it once per transition, write the
+// evidence every tick, and fire the webhook through the metrics sinks.
+
+type refuser struct {
+ prev status.Verdict
+ deliver func(metrics.Event)
+ logf func(string, ...any)
+}
+
+func (r *refuser) observe(ctx context.Context, s release.Store, d status.Decision, instanceID string, now time.Time) {
+ if d.Verdict != status.VerdictOwned {
+ _ = status.WriteEvidence(ctx, s, status.Evidence{V: 1, Label: d.Label, BoxID: d.BoxID, InstanceID: instanceID,
+ Verdict: d.Verdict, HolderBoxID: d.HolderBoxID, HolderInstanceID: d.HolderInstanceID, SeenAt: now})
+ }
+ if d.Verdict == r.prev {
+ return
+ }
+ switch d.Verdict {
+ case status.VerdictForeign:
+ r.logf("agent: label %s is held by another machine (instance %s); this box (instance %s) is not publishing. Fix the box that is new (box-identity spec 2.3; carlos fleet check-label shows both).", d.Label, orUnknown(d.HolderInstanceID), orUnknown(instanceID))
+ case status.VerdictTwin:
+ r.logf("agent: label %s: two machines share this machine id (instances %s and %s); fix the one that is new.", d.Label, orUnknown(d.HolderInstanceID), orUnknown(instanceID))
+ case status.VerdictUnproven:
+ r.logf("agent: cannot prove label %s is ours (no instance id to compare); if it is, carlos fleet retire %s frees it.", d.Label, d.Label)
+ case status.VerdictOwned:
+ if r.prev != "" {
+ r.logf("agent: label %s: this box now holds it.", d.Label)
+ }
+ }
+ firing := d.Verdict != status.VerdictOwned && d.Verdict != status.VerdictUnknown
+ if r.prev != "" || firing {
+ r.deliver(metrics.Event{Rule: "identity", Firing: firing, Box: d.Label})
+ }
+ r.prev = d.Verdict
+}
+
+func orUnknown(s string) string {
+ if s == "" {
+ return "unknown"
+ }
+ return s
+}
+```
+
+Wire it: `statusLoop` owns one `refuser{deliver: func(e metrics.Event) { sinks.deliver(ctx, rules, time.Now(), e) }, logf: log.Printf}` built from `buildAlertSinks(ctx, os.Getenv, ...)` (`alertsink.go:209`) and `metrics.RulesFromEnv`, and calls `observe` after every decision with `cachedInstanceID()`. The evidence store is the status store **ungated** (the refuser must be able to write evidence).
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: the same command. Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/status/identity.go internal/status/identity_test.go internal/host/refuse.go internal/host/refuse_test.go internal/host/agent.go
+git commit -m "status, host: evidence objects and the refusal transition (#212)"
+```
+
+---
+
+### Task 6: console readers, eligibility only
+
+**Files:**
+- Modify: `internal/console/instancesplacement.go:38-60` (view gains `BoxID`), `:93-168` (`readLiveEdgesSnapshot`)
+- Test: `internal/console/instancesplacement_test.go`, `internal/edgesfile/fold_test.go`
+
+**Interfaces:**
+- Consumes: Task 1 `edgesfile`/`status` `BoxID`; Task 5 `status.LoadEvidence`.
+- Produces: `edgesFilePlacementView.BoxID string json:"box_id"`; `edgesSnapshot.Contested []string` (labels), folded into `BoxesComplete`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/console/instancesplacement_test.go
+func TestLiveEdgesContestedPairIsIncompleteEvidence(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ now := time.Now()
+ writeSnapshot(t, s, status.Box{GeneratedAt: now, Label: "edsger-knitting", BoxID: status.BoxID("a")})
+ writeEdges(t, s, "edsger-knitting", `{"v":1,"box":"edsger-knitting","box_id":"`+status.BoxID("b")+`","eip":"1.2.3.4","placement":"uk","hosts":{}}`)
+ snap, err := readLiveEdgesSnapshot(context.Background(), s, now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if snap.BoxesComplete {
+ t.Fatal("a snapshot and an edges file written by two machines must be incomplete evidence")
+ }
+ if len(snap.Contested) != 1 || snap.Contested[0] != "edsger-knitting" {
+ t.Fatalf("contested = %v", snap.Contested)
+ }
+}
+
+func TestLiveEdgesAgreeingPairIsLive(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ now := time.Now()
+ writeSnapshot(t, s, status.Box{GeneratedAt: now, Label: "annie-welding", BoxID: status.BoxID("a")})
+ writeEdges(t, s, "annie-welding", `{"v":1,"box":"annie-welding","box_id":"`+status.BoxID("a")+`","eip":"1.2.3.4","placement":"ie","hosts":{}}`)
+ snap, _ := readLiveEdgesSnapshot(context.Background(), s, now)
+ if !snap.BoxesComplete || !snap.Files[0].Fresh {
+ t.Fatalf("agreeing pair must be live and complete: %+v", snap)
+ }
+}
+
+func TestLiveEdgesOldObjectsJoinByLabelAsToday(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ now := time.Now()
+ writeSnapshot(t, s, status.Box{GeneratedAt: now, Label: "annie-welding"})
+ writeEdges(t, s, "annie-welding", `{"v":1,"box":"annie-welding","eip":"1.2.3.4","placement":"ie","hosts":{}}`)
+ snap, _ := readLiveEdgesSnapshot(context.Background(), s, now)
+ if !snap.BoxesComplete || !snap.Files[0].Fresh {
+ t.Fatal("objects without box_id keep today's label join")
+ }
+}
+
+func TestLiveEdgesEvidenceContestsALabelEvenWithNoFiles(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ now := time.Now()
+ _ = status.WriteEvidence(context.Background(), s, status.Evidence{V: 1, Label: "edsger-knitting", BoxID: status.BoxID("b"),
+ Verdict: status.VerdictForeign, SeenAt: now})
+ snap, _ := readLiveEdgesSnapshot(context.Background(), s, now)
+ if snap.BoxesComplete {
+ t.Fatal("evidence must be read before the empty-files early return")
+ }
+}
+
+func TestLiveEdgesUnreadableEvidenceIsIncomplete(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ now := time.Now()
+ writeSnapshot(t, s, status.Box{GeneratedAt: now, Label: "annie-welding", BoxID: status.BoxID("a")})
+ writeEdges(t, s, "annie-welding", `{"v":1,"box":"annie-welding","box_id":"`+status.BoxID("a")+`","eip":"1.2.3.4","hosts":{}}`)
+ _, _ = s.Put(context.Background(), status.IdentityPrefix+"junk.json", []byte("{"), "")
+ snap, _ := readLiveEdgesSnapshot(context.Background(), s, now)
+ if snap.BoxesComplete {
+ t.Fatal("a failed evidence read is incomplete evidence")
+ }
+}
+```
+
+```go
+// internal/edgesfile/fold_test.go
+func TestEdgeIPsAndDeploymentRoutesIgnoreBoxIDDisagreement(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ now := time.Now()
+ writeBox(t, s, status.Box{GeneratedAt: now, Label: "edsger-knitting", BoxID: status.BoxID("a")}) // the existing helper
+ writeFile(t, s, "edsger-knitting", `{"v":1,"box":"edsger-knitting","box_id":"`+status.BoxID("b")+`","eip":"5.5.5.5","hosts":{"shop.example":{"role":"home"}}}`)
+ if ips := edgesfile.EdgeIPs(context.Background(), s, nil, now); !slices.Contains(ips, "5.5.5.5") {
+ t.Fatal("a contested file's EIP is still an edge: the union only widens")
+ }
+ if !edgesfile.DeploymentRoutes(context.Background(), s, now)["shop.example"] {
+ t.Fatal("a contested file's hostnames stay taken: occupancy is never weakened")
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `GOFLAGS=-buildvcs=false go test ./internal/console/ ./internal/edgesfile/ -run 'LiveEdges|IgnoreBoxIDDisagreement' -v`
+Expected: the console tests FAIL (`Contested` undefined; the evidence test finds `BoxesComplete` true); the fold test PASSES already (it pins behaviour that must not change).
+
+- [ ] **Step 3: Implement**
+
+In `edgesFilePlacementView` add `BoxID string json:"box_id"`. In `readLiveEdgesSnapshot`, before `if len(snap.Files) == 0`:
+
+```go
+ evidence, evComplete, evErr := status.LoadEvidence(ctx, s, now)
+ if evErr != nil || !evComplete {
+ snap.BoxesComplete = false
+ if _, said := livenessSaid.LoadOrStore("evidence", true); !said {
+ log.Printf("console: could not read every object under %s (%v), so no region outside the home is automatically eligible until it reads; said once", status.IdentityPrefix, evErr)
+ }
+ }
+ for _, e := range evidence {
+ snap.Contested = append(snap.Contested, e.Label)
+ }
+ if len(snap.Files) == 0 {
+ snap.BoxesComplete = snap.BoxesComplete && evErr == nil && evComplete && len(evidence) == 0
+ return snap, nil
+ }
+```
+
+(Initialise `snap.BoxesComplete = true` before this block rather than inside the early return.) Then, after the `known`/`live` maps are built, also build `idByLabel := map[string]string{}` from `b.Label -> b.BoxID` for boxes with a `BoxID`, and in the files loop:
+
+```go
+ if f := snap.Files[i]; f.BoxID != "" && idByLabel[f.Box] != "" && f.BoxID != idByLabel[f.Box] {
+ snap.Files[i].Fresh = false
+ snap.BoxesComplete = false
+ snap.Contested = append(snap.Contested, f.Box)
+ if _, said := livenessSaid.LoadOrStore("contested:"+f.Box, true); !said {
+ log.Printf("console: box %q: its edges file and its status snapshot were written by two machines, so the label is contested and no region outside the home is automatically eligible; said once", f.Box)
+ }
+ }
+```
+
+`edgesfile.EdgeIPs` and `DeploymentRoutes` are not touched.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: the same command plus `go test ./internal/console/ -run Placement`. Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/instancesplacement.go internal/console/instancesplacement_test.go internal/edgesfile/fold_test.go
+git commit -m "console: a contested label is incomplete evidence for automatic eligibility (#212)"
+```
+
+---
+
+### Task 7: the lease holder's observation file and the per-label fleet alert
+
+**Files:**
+- Create: `internal/status/fleetidentity.go`, `internal/status/fleetidentity_test.go`
+- Modify: `internal/host/agent.go` (`statusTick` publish branch, `:2938` after `BuildFleet`), `internal/console/alertwatch.go:97-145`
+- Test: `internal/console/alertwatch_test.go`
+
+**Interfaces:**
+- Produces:
+
+```go
+// internal/status
+const FleetObservationKey = "status/identity/fleet.json"
+const FleetAlertKey = "alerts/fleet/identity.json"
+type LabelObservation struct {
+ BoxID string `json:"box_id"`
+ GeneratedAt time.Time `json:"generated_at"`
+ ContestedUntil time.Time `json:"contested_until,omitempty"`
+}
+type FleetObservations struct {
+ V int `json:"v"`
+ Labels map[string]LabelObservation `json:"labels"`
+}
+// ObserveWriters applies rule 3 to boxes against prev and returns the next
+// record; it never shortens an existing ContestedUntil.
+func ObserveWriters(prev FleetObservations, boxes []Box, now time.Time) FleetObservations
+// IdentityAlert builds alerts/fleet/identity.json: one rule per label and
+// condition: "foreign:<label>", "twin:<label>", "unproven:<label>", "contested:<label>".
+func IdentityAlert(ev []Evidence, obs FleetObservations, contested []string, now time.Time) metrics.AlertState
+```
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/status/fleetidentity_test.go
+func TestObserveWritersFlagsAChangeWithinFreshnessOnly(t *testing.T) {
+ now := time.Now()
+ prev := status.FleetObservations{V: 1, Labels: map[string]status.LabelObservation{
+ "edsger-knitting": {BoxID: status.BoxID("a"), GeneratedAt: now.Add(-time.Minute)},
+ "annie-welding": {BoxID: status.BoxID("c"), GeneratedAt: now.Add(-2 * time.Hour)},
+ }}
+ next := status.ObserveWriters(prev, []status.Box{
+ {GeneratedAt: now, Label: "edsger-knitting", BoxID: status.BoxID("b")}, // changed while fresh
+ {GeneratedAt: now, Label: "annie-welding", BoxID: status.BoxID("d")}, // previous was stale: a legitimate successor? No: still a change, but after staleness, so not contested
+ }, now)
+ if cu := next.Labels["edsger-knitting"].ContestedUntil; !cu.Equal(now.Add(3 * status.SnapshotPeriod)) {
+ t.Fatalf("contested_until = %v", cu)
+ }
+ if !next.Labels["annie-welding"].ContestedUntil.IsZero() {
+ t.Fatal("a change after the previous object went stale is not a writer change")
+ }
+ // never shortened
+ far := now.Add(24 * time.Hour)
+ prev.Labels["edsger-knitting"] = status.LabelObservation{BoxID: status.BoxID("a"), GeneratedAt: now, ContestedUntil: far}
+ next = status.ObserveWriters(prev, []status.Box{{GeneratedAt: now.Add(time.Minute), Label: "edsger-knitting", BoxID: status.BoxID("b")}}, now.Add(time.Minute))
+ if !next.Labels["edsger-knitting"].ContestedUntil.Equal(far) {
+ t.Fatal("an existing contested_until is never shortened")
+ }
+ // disappearance (an old sibling overwrote): box_id "" while fresh
+ next = status.ObserveWriters(status.FleetObservations{V: 1, Labels: map[string]status.LabelObservation{
+ "edsger-knitting": {BoxID: status.BoxID("a"), GeneratedAt: now}}},
+ []status.Box{{GeneratedAt: now.Add(time.Minute), Label: "edsger-knitting"}}, now.Add(time.Minute))
+ if next.Labels["edsger-knitting"].ContestedUntil.IsZero() {
+ t.Fatal("box_id disappearing while fresh is a writer change")
+ }
+}
+
+func TestIdentityAlertIsPerLabel(t *testing.T) {
+ now := time.Now()
+ st := status.IdentityAlert(
+ []status.Evidence{{Label: "edsger-knitting", Verdict: status.VerdictForeign, SeenAt: now}, {Label: "bea-sailing", Verdict: status.VerdictUnproven, SeenAt: now}},
+ status.FleetObservations{V: 1, Labels: map[string]status.LabelObservation{"annie-welding": {ContestedUntil: now.Add(time.Hour)}}},
+ []string{"cal-hiking"}, now)
+ if st.Box != "fleet" || st.V != 1 {
+ t.Fatalf("state = %+v", st)
+ }
+ for _, rule := range []string{"foreign:edsger-knitting", "unproven:bea-sailing", "contested:annie-welding", "contested:cal-hiking"} {
+ if !st.Rules[rule].Firing {
+ t.Fatalf("rule %s must fire: %+v", rule, st.Rules)
+ }
+ }
+ if len(st.Rules) != 4 {
+ t.Fatalf("exactly four rules, got %d", len(st.Rules))
+ }
+ clear := status.IdentityAlert(nil, status.FleetObservations{V: 1}, nil, now)
+ if len(clear.Rules) != 0 || clear.UpdatedAt.IsZero() {
+ t.Fatal("no conditions is an all-clear object with a fresh UpdatedAt")
+ }
+}
+```
+
+```go
+// internal/console/alertwatch_test.go
+func TestAlertPassReadsTheFleetIdentityObject(t *testing.T) {
+ fx := newAlertFixture(t, nil) // the existing fixture
+ now := time.Now()
+ st := status.IdentityAlert([]status.Evidence{{Label: "edsger-knitting", Verdict: status.VerdictForeign, SeenAt: now}}, status.FleetObservations{V: 1}, nil, now)
+ body, _ := json.Marshal(st)
+ if _, err := fx.store.Put(context.Background(), status.FleetAlertKey, body, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.srv.alertPass(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if len(fx.sent) != 1 || !strings.Contains(fx.sent[0].subject, "foreign:edsger-knitting") {
+ t.Fatalf("sent = %+v", fx.sent)
+ }
+ if strings.Contains(fx.sent[0].body, status.BoxID("a")) {
+ t.Fatal("the mail must not carry a box id")
+ }
+ // a stale object is all-clear
+ st.UpdatedAt = now.Add(-4 * status.SnapshotPeriod)
+ body, _ = json.Marshal(st)
+ _, etag, _ := fx.store.Get(context.Background(), status.FleetAlertKey)
+ _, _ = fx.store.Put(context.Background(), status.FleetAlertKey, body, etag)
+ if err := fx.srv.alertPass(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if len(fx.sent) != 2 || fx.sent[1].subject == fx.sent[0].subject {
+ t.Fatalf("a stale fleet object clears the rule: %+v", fx.sent)
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `GOFLAGS=-buildvcs=false go test ./internal/status/ ./internal/console/ -run 'ObserveWriters|IdentityAlert|FleetIdentityObject' -v`
+Expected: FAIL, undefined.
+
+- [ ] **Step 3: Implement**
+
+`internal/status/fleetidentity.go`: `ObserveWriters` iterates `boxes`; for each label with a previous observation, `changed := prev.BoxID != b.BoxID` and `prevFresh := b.GeneratedAt.Sub(prev.GeneratedAt) <= 3*SnapshotPeriod`; when both, `ContestedUntil = max(prev.ContestedUntil, now.Add(3*SnapshotPeriod))`; otherwise carry `prev.ContestedUntil` forward; always set `BoxID`, `GeneratedAt` to the current object's. Labels not seen this tick are carried forward unchanged. `IdentityAlert` builds `metrics.AlertState{V: 1, Box: "fleet", UpdatedAt: now, Rules: map[string]metrics.RuleState{}}` with `Firing: true, FiredAt: seenAt` per condition; `contested:<label>` for every `obs.Labels[l].ContestedUntil.After(now)` and every entry of `contested`.
+
+`statusTick`'s publish branch (`agent.go:2938`, after `BuildFleet`): read `FleetObservationKey` (`Get`; `os.ErrNotExist` is an empty record; any other error or parse failure logs once and skips rule 3 this tick, `prev` empty and **no write**), `next := ObserveWriters(prev, boxes, now)` where `boxes` is `status.LoadBoxes`'s result (reuse the slice `BuildFleet` loaded: add a `BuildFleetFrom(boxes, ...)` or have `BuildFleet` return the boxes; smallest change is to call `LoadBoxes` once in `statusTick` and pass the slice to a new `BuildFleetFromBoxes`). Load evidence (`LoadEvidence`), compute `contested` from the same `box_id` mismatch rule as the console (extract `status.ContestedLabels(boxes, files)` is more than needed; the holder reads edges files only through `edgesfile`, which `internal/status` cannot import; so the holder's `contested` input is evidence plus observations only, and the console's rule 1 stays console-side). Then `if held.Err() == nil { Put(FleetObservationKey, next, etag) }` ignoring `ErrCASConflict` with a log line, and `putOverwrite(FleetAlertKey, IdentityAlert(...))`. Transitions in the alert object compared with the previous object's `Rules` are delivered through the same `sinks.deliver` as Task 5, one `metrics.Event{Rule: name, Firing, Box: "fleet"}` each.
+
+`alertwatch.go:alertPass`: after the `alerts/boxes/` loop, `Get(status.FleetAlertKey)`; `os.ErrNotExist` is nothing to do; parse; if `now.Sub(st.UpdatedAt) > 3*status.SnapshotPeriod` treat every rule as `Firing: false`; then run the same per-rule `alertRule` loop with `box = "fleet"`. `alertMessage` already names `box` and `rule` only; assert in the test that the body has no `box_id`.
+
+- [ ] **Step 4: Add the far-future `contested_until` case (Review Focus 4)**
+
+Already in `TestObserveWritersFlagsAChangeWithinFreshnessOnly` ("never shortened"). Add one assertion that `IdentityAlert` with `ContestedUntil` in the past does not fire `contested:` for that label.
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `GOFLAGS=-buildvcs=false go test ./internal/status/ ./internal/console/ ./internal/host/`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/status/fleetidentity.go internal/status/fleetidentity_test.go internal/host/agent.go internal/console/alertwatch.go internal/console/alertwatch_test.go
+git commit -m "status, console: the lease holder's writer observations and a per-label identity alert (#212)"
+```
+
+---
+
+### Task 8: `carlos fleet check-label` and `carlos fleet retire`
+
+**Files:**
+- Modify: `fleets.go:30-121` (usage and dispatch)
+- Create: `fleets_identity.go`, `fleets_identity_test.go`
+- Modify: `cli.go:3418-3440` (`printLiveBoxes` shows `contested`), `internal/console/view.go:830-877` and `templates/fleet.html:100-120` (a `contested` chip on the Hosts table)
+
+**Interfaces:**
+- Consumes: Task 2 `status.BoxID`, `status.Label`; Task 5 `status.LoadEvidence`; `release.DeleteIfMatch` (`internal/release/conditional_delete.go:28`); `host.ResolveStatusBucket`.
+- Produces: `func checkLabel(ctx, s release.Store, machineID string, now time.Time, w io.Writer) (exit int)`; `func retireLabel(ctx, s release.Store, label string, now time.Time, w io.Writer) error`; `HostRow.Contested bool`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// fleets_identity_test.go
+func TestCheckLabelFreeOwnAndForeign(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ now := time.Now()
+ var out bytes.Buffer
+ if code := checkLabel(context.Background(), s, "00000000000000000000000000000018", now, &out); code != 0 {
+ t.Fatalf("a free label exits 0, got %d: %s", code, out.String())
+ }
+ holder := status.CollectBox(nil, status.Probes{MachineID: "00000000000000000000000000000018", InstanceID: "i-a"}, now)
+ if err := status.WriteBox(context.Background(), s, holder); err != nil {
+ t.Fatal(err)
+ }
+ out.Reset()
+ if code := checkLabel(context.Background(), s, "00000000000000000000000000000018", now, &out); code != 0 {
+ t.Fatalf("our own label exits 0, got %d: %s", code, out.String())
+ }
+ out.Reset()
+ if code := checkLabel(context.Background(), s, "0000000000000000000000000000003c", now, &out); code != 1 {
+ t.Fatalf("a label held by another machine exits 1, got %d: %s", code, out.String())
+ }
+ if !strings.Contains(out.String(), "edsger-knitting") || !strings.Contains(out.String(), "i-a") || !strings.Contains(out.String(), "fresh") {
+ t.Fatalf("the report names the label, the holder's instance and freshness: %s", out.String())
+ }
+}
+
+func TestRetireLabelRefusesFreshAndDeletesStaleConditionally(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ now := time.Now()
+ ctx := context.Background()
+ holder := status.CollectBox(nil, status.Probes{MachineID: "a", InstanceID: "i-a"}, now)
+ if err := status.WriteBox(ctx, s, holder); err != nil {
+ t.Fatal(err)
+ }
+ edges := []byte(`{"v":1,"box":"` + holder.Label + `","eip":"1.2.3.4","hosts":{}}`)
+ if _, err := s.Put(ctx, edgesfile.Prefix+holder.Label+".json", edges, ""); err != nil {
+ t.Fatal(err)
+ }
+ var out bytes.Buffer
+ if err := retireLabel(ctx, s, holder.Label, now, &out); err == nil {
+ t.Fatal("a fresh snapshot must refuse")
+ }
+ later := now.Add(4 * status.SnapshotPeriod)
+ if err := retireLabel(ctx, s, holder.Label, later, &out); err != nil && !errors.Is(err, release.ErrConditionalDeleteUnsupported) {
+ t.Fatalf("stale: %v", err)
+ }
+ // DirStore has no conditional delete: the verb must refuse rather than
+ // fall back, and leave both objects. The S3 path is exercised by the
+ // fake below.
+ if _, _, err := s.Get(ctx, "status/boxes/"+holder.Label+".json"); err != nil {
+ t.Fatal("without conditional delete nothing is deleted")
+ }
+ fake := &condStore{Store: s} // implements DeleteIfMatch by comparing the etag it is given with Get's
+ out.Reset()
+ if err := retireLabel(ctx, fake, holder.Label, later, &out); err != nil {
+ t.Fatalf("retire: %v\n%s", err, out.String())
+ }
+ if _, _, err := s.Get(ctx, "status/boxes/"+holder.Label+".json"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("snapshot must be gone")
+ }
+ if _, _, err := s.Get(ctx, edgesfile.Prefix+holder.Label+".json"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("edges file must be gone")
+ }
+}
+
+func TestRetireLabelWithNoEdgesFileDeletesTheSnapshotAndSaysSo(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ now := time.Now()
+ holder := status.CollectBox(nil, status.Probes{MachineID: "a", InstanceID: "i-a"}, now)
+ _ = status.WriteBox(context.Background(), s, holder)
+ var out bytes.Buffer
+ if err := retireLabel(context.Background(), &condStore{Store: s}, holder.Label, now.Add(4*status.SnapshotPeriod), &out); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "edges file: already gone") {
+ t.Fatalf("out = %s", out.String())
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `GOFLAGS=-buildvcs=false go test . -run 'CheckLabel|RetireLabel' -v`
+Expected: FAIL, undefined.
+
+- [ ] **Step 3: Implement `fleets_identity.go`**
+
+`checkLabel`: `label, id := status.Label(machineID), status.BoxID(machineID)`; print `label <label>, box_id <id>`; `Get` the snapshot and the edges file; print for each: absent, or `box_id`, `instance_id` (snapshot only), `fresh`/`stale` (snapshot, via `BoxStale`), `eip` (edges only); load evidence naming the label and print each entry's verdict and instance ids. Exit 0 when both objects are absent or every present object's `box_id` equals `id`; exit 1 otherwise (foreign, or an object with no `box_id`: unproven). Store resolution for the real command is `host.ResolveStatusBucket` and `release.NewS3Store`, the `statusLive` recipe (`cli.go:3389`).
+
+`retireLabel`: `Get` both keys, keeping etags; if the snapshot parses and `!BoxStale(..., now, SnapshotPeriod)` return an error `label <label> has a fresh snapshot (instance <id>); a live writer holds it, refusing`; print both objects' `box_id`/`instance_id`; `release.DeleteIfMatch` each present object on its etag, snapshot first; print `edges file: already gone` when absent; an `ErrConditionalDeleteUnsupported` is returned as is (never a plain `Delete`). The claim check (spec §4) is a comment and a TODO-free note: `internal/hostclaim` is not on main; when it lands, `retireLabel` gains `hostclaim.References(ctx, s, boxID)` before deleting. Write that sentence in the function's doc comment, not as a code placeholder.
+
+Dispatch in `fleets.go`: `case "check-label": runFleetsCheckLabel(args[1:])`, `case "retire": runFleetsRetire(args[1:])`; usage lines:
+
+```
+ check-label <machine-id>
+ show who holds the label this machine id gives; exit 1 if another machine does
+ retire <label> delete a stale label's snapshot and edges file so a refused box can take it
+```
+
+`printLiveBoxes`: load evidence in `statusLive` and pass a `contested map[string]bool`; a contested row prints ` CONTESTED` after the label. `HostRows` gains `Contested bool` from the same evidence load (nil store or a failed load leaves it false), and `fleet.html`'s Hosts row gets `{{if .Contested}}<span class="chip chip-stale">contested</span>{{end}}` in the Host cell.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `GOFLAGS=-buildvcs=false go test . ./internal/console/ -run 'CheckLabel|RetireLabel|HostRows|Fleet'`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add fleets.go fleets_identity.go fleets_identity_test.go cli.go internal/console/view.go internal/console/templates/fleet.html
+git commit -m "carlos fleet check-label and retire; contested chip on the operator fleet page (#212)"
+```
+
+---
+
+### Task 9: privacy fixtures
+
+**Files:**
+- Modify: `internal/status/dryrun.go:62-70` (needles), `:182` (fixture), `:336-456` (scan)
+- Test: `internal/status/dryrun_internal_test.go`, `internal/status/status_test.go` (FleetBox), `internal/console/apiinstances_test.go`, `internal/console/alertwatch_test.go`
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+// internal/status/status_test.go
+func TestFleetBoxNeverCarriesBoxID(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ now := time.Now()
+ b := status.CollectBox(nil, status.Probes{MachineID: "m", InstanceID: "i-4242"}, now)
+ if err := status.WriteBox(context.Background(), s, b); err != nil {
+ t.Fatal(err)
+ }
+ f, err := status.BuildFleet(context.Background(), s, nil, now, status.SnapshotPeriod)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := json.Marshal(f)
+ for _, needle := range []string{b.BoxID, "i-4242", "box_id", "instance_id"} {
+ if strings.Contains(string(body), needle) {
+ t.Fatalf("fleet.json carries %q", needle)
+ }
+ }
+}
+```
+
+```go
+// internal/status/dryrun_internal_test.go
+func TestDryRunNeedlesIncludeIdentity(t *testing.T) {
+ // The fixture machine id must produce a BoxID the scan can look for,
+ // and the dry run must create one evidence object so status/identity/
+ // is exercised as a private home.
+ if !slices.Contains(dryRunMetricsNeedles, BoxID(dryRunMachineID)) {
+ t.Fatal("the fixture BoxID must be a needle")
+ }
+ if !slices.Contains(dryRunMetricsNeedles, dryRunInstanceID) {
+ t.Fatal("the fixture instance id must be a needle")
+ }
+ var out bytes.Buffer
+ if err := DryRun(t.TempDir(), &out); err != nil {
+ t.Fatalf("dry run: %v\n%s", err, out.String())
+ }
+ if !strings.Contains(out.String(), "status/identity/") {
+ t.Fatal("the dry run must publish and scan an evidence object")
+ }
+}
+```
+
+```go
+// internal/console/apiinstances_test.go
+func TestInstanceListNeverCarriesBoxID(t *testing.T) {
+ // Use the existing list fixture that seeds one record and one box
+ // snapshot; add BoxID and Metrics.InstanceID to the seeded snapshot.
+ fx := newInstancesFixture(t)
+ fx.seedBox(status.Box{GeneratedAt: time.Now(), Label: "annie-welding", BoxID: status.BoxID("m"), Metrics: &status.BoxMetrics{InstanceID: "i-4242"}})
+ body := fx.list(t)
+ for _, needle := range []string{status.BoxID("m"), "i-4242", "box_id"} {
+ if strings.Contains(body, needle) {
+ t.Fatalf("instances response carries %q", needle)
+ }
+ }
+}
+```
+
+The alert mail assertion is already in Task 7's `TestAlertPassReadsTheFleetIdentityObject`.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `GOFLAGS=-buildvcs=false go test ./internal/status/ ./internal/console/ -run 'NeverCarriesBoxID|NeedlesIncludeIdentity' -v`
+Expected: `TestFleetBoxNeverCarriesBoxID` PASSES (pins), the dry run test FAILS (no such needles, no evidence object), the instances test FAILS only if the fixture helpers are missing (write them from the existing list test's shape).
+
+- [ ] **Step 3: Implement**
+
+`dryrun.go`: `const dryRunMachineID = "ip-10-0-0-7"`, `const dryRunInstanceID = "i-42424242424242424"`; set `InstanceID: dryRunInstanceID` in the fixture `Probes`; append `BoxID(dryRunMachineID)` and `dryRunInstanceID` to `dryRunMetricsNeedles`. In `DryRun`, after `WriteBox`, write one evidence object: `WriteEvidence(ctx, s, Evidence{V: 1, Label: box.Label, BoxID: box.BoxID, InstanceID: dryRunInstanceID, Verdict: VerdictForeign, HolderBoxID: BoxID("other"), HolderInstanceID: "i-other", SeenAt: now})`. In the scan loop, treat `strings.HasPrefix(key, IdentityPrefix)` like `status/boxes/` for the two identity needles (required in `status/boxes/`, allowed in `status/identity/`, banned everywhere else), and add a `identityKeySeen` positive check mirroring `boxKeySeen`.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `GOFLAGS=-buildvcs=false go test ./internal/status/ ./internal/console/`
+Expected: PASS, including the existing `TestDryRun*`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/status/dryrun.go internal/status/dryrun_internal_test.go internal/status/status_test.go internal/console/apiinstances_test.go
+git commit -m "status: the dry run proves box_id and instance_id stay out of fleet.json and the page (#212)"
+```
+
+---
+
+### Task 10: simulation and the operator runbook
+
+**Files:**
+- Create: `internal/status/decide_sim_test.go`
+- Modify: `README.md` (the "Fleet status" section, after the `CARLOS_FLEET_BOX` bullet near `:614`)
+
+- [ ] **Step 1: Write the simulation**
+
+```go
+// internal/status/decide_sim_test.go
+package status_test
+
+// Spec 7: random fleets with injected collisions, simultaneous starts and
+// clones, asserting that at most one machine writes a label while its
+// object is fresh and that no label is ever reassigned without an
+// operator. Old-release writers (WriteBox bypassed with a plain put) are
+// the stated exception and are asserted to be detected, not refused.
+func TestDecideLabelSimulation(t *testing.T) {
+ seeds := 200
+ if testing.Short() {
+ seeds = 20
+ }
+ for seed := 0; seed < seeds; seed++ {
+ t.Run(fmt.Sprint(seed), func(t *testing.T) {
+ rng := rand.New(rand.NewSource(int64(seed)))
+ s := release.NewDirStore(t.TempDir())
+ now := time.Unix(1_700_000_000, 0)
+ type machine struct{ id, instance string; old bool }
+ n := 2 + rng.Intn(6)
+ var ms []machine
+ for i := 0; i < n; i++ {
+ ms = append(ms, machine{id: fmt.Sprintf("m%d-%d", seed, i), instance: fmt.Sprintf("i-%d-%d", seed, i), old: rng.Intn(5) == 0})
+ }
+ // force a collision: give the last machine the first's label by reusing the known pair
+ ms[0].id, ms[n-1].id = "00000000000000000000000000000018", "0000000000000000000000000000003c"
+ if rng.Intn(4) == 0 { // a clone
+ ms[n-1].id = ms[0].id
+ }
+ holder := map[string]string{} // label -> instance that currently holds it (new writers only)
+ for tick := 0; tick < 40; tick++ {
+ now = now.Add(time.Minute)
+ rng.Shuffle(len(ms), func(i, j int) { ms[i], ms[j] = ms[j], ms[i] })
+ for _, m := range ms {
+ b := boxFor(m.id, m.instance, now)
+ if m.old {
+ put(t, s, b.Label, status.Box{GeneratedAt: now, Label: b.Label, Metrics: &status.BoxMetrics{InstanceID: m.instance}})
+ continue
+ }
+ d, _ := status.DecideLabel(context.Background(), s, b, now)
+ switch d.Verdict {
+ case status.VerdictOwned:
+ if h, ok := holder[b.Label]; ok && h != m.instance {
+ t.Fatalf("tick %d: label %s moved from %s to %s without an operator", tick, b.Label, h, m.instance)
+ }
+ holder[b.Label] = m.instance
+ case status.VerdictForeign, status.VerdictTwin, status.VerdictUnproven:
+ raw, _, _ := s.Get(context.Background(), "status/boxes/"+b.Label+".json")
+ var cur status.Box
+ _ = json.Unmarshal(raw, &cur)
+ if cur.Metrics != nil && cur.Metrics.InstanceID == m.instance && cur.BoxID == b.BoxID {
+ t.Fatalf("tick %d: %s refused its own object", tick, m.instance)
+ }
+ }
+ }
+ }
+ })
+ }
+}
+```
+
+- [ ] **Step 2: Run it**
+
+Run: `GOFLAGS=-buildvcs=false go test ./internal/status/ -run Simulation -v`
+Expected: PASS. If a seed fails, the failure names the tick and the machines; fix `classify`, not the test.
+
+- [ ] **Step 3: README**
+
+Add under the fleet status section, after the `CARLOS_FLEET_BOX` bullet:
+
+```markdown
+- Box identity (#212). Every platform box carries a private `box_id`
+ (a digest of its machine id) in its snapshot and its edges file, and
+ refuses to publish under a label another machine holds. A refusing box
+ logs `label <label> is held by another machine`, publishes evidence
+ under `status/identity/`, and the operator fleet page shows the label
+ as contested. To fix a collision: stop the new box's agent, run
+ `rm /etc/machine-id && systemd-machine-id-setup && reboot` on it, wait
+ fifteen minutes for the old objects to go stale, then
+ `carlos fleet retire <label>`. Before a new box joins, run
+ `carlos fleet check-label $(cat /etc/machine-id)` against the status
+ bucket. Design: docs/superpowers/specs/2026-10-06-box-identity-detect-design.md.
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/status/decide_sim_test.go README.md
+git commit -m "status: label-ownership simulation; README: the collision runbook (#212)"
+```
+
+---
+
+## Self-review
+
+**Spec coverage.** §2.1 table: Task 2. §2.2 identity file, `Publishable`, `gatedStore`, entry points, fleet verdict, `CARLOS_RELAY_BOX`, edges gate reset and foreign seed: Tasks 3 and 4. §2.3 log texts, evidence, webhook, transitions: Task 5; freeing a label: Task 8. §2.4 evidence shape and expiry: Task 5. §2.5 race: Task 2 (`TestDecideLabelSimultaneousStartOneWins`) and directory deployments: Task 4 step 3c. §2.6: no code; Task 6 pins `EdgeIPs`/`DeploymentRoutes` unchanged. §2.7 `check-label`: Task 8. §3 rules 1 and 2: Task 6; rule 3 and the alert object: Task 7. §4 `ClaimEligible`: Task 3 (the claim itself is not on main; `retire`'s claim check is a documented follow-up in Task 8). §7 privacy fixtures: Task 9; simulation: Task 10. §8 runbook: Task 10. Gap: none found.
+
+**Placeholders.** `retireLabel`'s claim check is a doc-comment sentence, not code; `snapshotPass` and the fixture helpers (`writeSnapshot`, `writeEdges`, `writeBox`, `writeFile`, `newTestRegistry`, `newAlertFixture`, `newInstancesFixture`, `condStore`, `flipStore`, `envFunc`, `testConfig`) are named after the helpers the existing test files already contain; where a name differs, use the existing one, and write `condStore` and `flipStore` (ten lines each) in the test file that uses them.
+
+**Type consistency.** `status.Decision`, `status.Verdict`, `host.StoreTarget`, `host.BoxIdentity`, `host.gated(s, id BoxIdentity, target)`, `status.Evidence`, `status.FleetObservations`, `status.IdentityAlert` are used with the same names and parameter orders in Tasks 2 to 9. Task 3's test first wrote `gated(inner, "annie-welding", ...)` and was corrected to the `BoxIdentity` form; the implementation uses that form.
+
+**Review Focus.** 1 and 2 are rows in Task 2's table; 3 is `TestPublishableRefusesMissingOrForeignShapedFile` in Task 3; 4 is the "never shortened" assertion plus the past-`ContestedUntil` assertion in Task 7; 5 is `TestRetireLabelWithNoEdgesFileDeletesTheSnapshotAndSaysSo` in Task 8.
diff --git a/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md b/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md
index f9bceb720..f67ce24f6 100644
--- a/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md
+++ b/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md
@@ -1,19 +1,24 @@
# Box identity, Option B: detect and refuse, no key migration
**Date:** 2026-10-06
-**Revision:** 2
+**Revision:** 3
- Rev 1 (7ff690c5) went to Astra (GPT-6, via codex); the findings are in
- `CODEX-FINDINGS-BOX-IDENTITY-B-R1.md`: seven P1s, five P2s, all checked
- against the code and accepted. Changes are marked "(codex B-N)" and §10
- records each disposition. Rev 2 has not been reviewed.
+ `CODEX-FINDINGS-BOX-IDENTITY-B-R1.md`: seven P1s, five P2s, accepted.
+ Changes marked "(codex B-N)".
+- Rev 2 (8c362f12) went back; the findings are in
+ `CODEX-FINDINGS-BOX-IDENTITY-B-R2.md`: four P1s, six P2s, one P3,
+ accepted. Changes marked "(codex B2-N)". §10 records every disposition.
+- **Rev 3 records Paul's choice of Option B (2026-10-06) and four rulings
+ taken by default for him to override (§9).** Rev 3 has not been
+ reviewed.
**Discussion:** amadan carlos/platform #212
**Sibling of:** `2026-10-06-box-identity-collisions-design.md` (Option A,
-migrate the keys), rev 3, whose third Astra round
-(`CODEX-FINDINGS-BOX-IDENTITY-R3.md`) still found four P1s in the
-key-migration protocol. §6 compares the two and recommends one.
-**Status:** draft, for Astra's second round, then Paul's.
+migrate the keys), superseded by this spec as the recommendation on
+2026-10-06; it stays as the record and its round-3 P1s stay open.
+**Plan:** `docs/superpowers/plans/2026-10-06-box-identity-detect.md`.
+**Status:** chosen by Paul; rev 3 awaits Astra's third round.
## 1. What Option B is
@@ -25,18 +30,15 @@ per-box key stays as it is. Three things are added:
bucket-private, never in fleet.json or any public or member-facing
surface. It is the proof of which machine wrote the object.
2. A writer that finds its label's object **written by another machine**
- does not overwrite it, fresh or stale. It refuses to publish, latches,
- alerts with instructions, and takes no claim work. A label is never
- reassigned automatically; an operator frees it (codex B-1).
+ does not overwrite it, fresh or stale. It refuses to publish anything
+ under the label, alerts with instructions, and takes no claim work. A
+ label is never reassigned automatically; an operator frees it.
3. The host lifecycle claim names a box by `box_id`, not by the label
- (§4). The reference is bound to one machine by construction, which is
- what claim rev 6.16 asks for, and it does not depend on who holds a
- label.
+ (§4): bound to one machine by construction, as claim rev 6.16 asks.
-A collision therefore costs the colliding box, not the fleet: the first
-writer keeps the label and keeps working, the second is loud and
-withdrawn until an operator acts. That is the trade against Option A,
-where both boxes keep working and the migration carries the risk.
+A collision costs the colliding box, not the fleet. The first writer
+keeps the label and keeps working; the second is loud and withdrawn until
+an operator acts.
## 2. The writer rule
@@ -44,96 +46,155 @@ where both boxes keep working and the migration carries the risk.
The arbiter is the status snapshot: `statusTick` writes it unconditionally
every tick (`internal/host/agent.go:2905`) and `status.BoxStale` defines
-its freshness. The decision is made by `status.DecideLabel`, called by the
-agent at arm **before any per-box publisher starts** (codex B-4) and again
-on every status tick. It reads the label's object and returns one verdict:
+its freshness. `status.DecideLabel` is called by the agent at arm, before
+any per-box publisher starts, and again on every status tick. It reads
+the label's object and returns one verdict:
| Object under our label | Verdict |
|---|---|
| not found | **owned**, after a create-only `Put` (`ifMatch ""`) succeeds. A CAS conflict re-reads and re-decides; it never re-Puts with a fresh etag without re-reading the body (that re-Put, `snapshot.go:749-760`, is the overwrite this spec removes) |
| `box_id` equals ours, and `metrics.instance_id` equals ours or either is empty | **owned**, replace with the etag |
-| `box_id` equals ours, both `instance_id`s non-empty and different | **twin**: two machines with one machine id (codex B-2). Refuse |
-| `box_id` non-empty and different, fresh or stale | **foreign**. Refuse. Staleness does not free a label (codex B-1) |
-| `box_id` absent (an old-release writer, or this box before it rolled), `instance_id` equal and non-empty | **owned** |
-| `box_id` absent, both `instance_id`s non-empty and different | **foreign**. Refuse |
-| `box_id` absent, either `instance_id` empty, object fresh | **unproven**. Refuse and wait: the writer may be an old-release sibling that cannot refuse, or this box before it rolled |
-| `box_id` absent, either `instance_id` empty, object stale | **unproven**, unless the label's edges file carries our non-empty EIP, in which case the stale object is this box's own pre-roll snapshot and the verdict is **owned**. Two machines never share an EIP. No EIP evidence: stay unproven and say so; an operator frees the label (codex B-3) |
+| `box_id` equals ours, both instance ids non-empty and different | **twin**: two machines with one machine id. Refuse |
+| `box_id` non-empty and different, fresh or stale | **foreign**. Refuse. Staleness does not free a label |
+| `box_id` absent (an old-release writer, or this box before it rolled), instance ids equal and non-empty | **owned** |
+| `box_id` absent, both instance ids non-empty and different | **foreign**. Refuse |
+| `box_id` absent, either instance id empty, fresh or stale | **unproven**. Refuse. There is no machine-bound evidence to promote it: an EIP is a separate resource that a replacement instance can inherit (`infra/modules/region-host/main.tf:503`) and an edges file persists without refresh (`edgesfile.go:491`), so neither proves the object is ours (codex B2-1). An operator frees the label (§2.3) |
| body does not parse, read fails, CAS exhausted (three attempts) | **unknown**. No write; retry next tick |
-Nothing is ever promoted to owned by reading back its own bytes: the
-only writes are the create-only `Put` and a replace of an object already
-proved ours (codex B-3). `owned` is recorded only after that write
-succeeds (codex B-8).
+Nothing is ever promoted to owned by reading back its own bytes: the only
+writes are the create-only `Put` and a replace of an object already proved
+ours. `owned` is recorded only after that write succeeds.
Fleet boxes (`agent.go:2926`, operator-chosen label, unique per fleet by
-`registry.ErrFleetBoxActive`) publish no `box_id` and skip the decision.
-A box whose machine id is unreadable (`probes.go:153`) has no `box_id` and
-no identity: it is **unidentified**, publishes nothing keyed by its label,
-takes no claim work, and logs once an hour (codex B-2). `Label("")` is no
-longer a thing any box publishes under.
+`registry.ErrFleetBoxActive`) publish no `box_id` and skip the decision;
+their identity is the fleet label, not the machine, and §2.2 says what
+the gate does for them (codex B2-9). A box whose machine id is unreadable
+(`probes.go:153`) has no `box_id` and no identity: it is **unidentified**,
+publishes nothing keyed by its label, takes no claim work, and logs once
+an hour. `Label("")` is no longer a thing any platform box publishes
+under.
+
+On the flagship, Tito and dev every box has an instance id, so the
+unproven row is reached only by hardware no cloud owns.
### 2.2 The gate
-The verdict, its time, the label and the `box_id` are held in memory and
-written to `<dataDir>/identity.json` (codex B-5), because not every
-publisher lives in the agent process. `host.Ownership()` reads it and
-answers **publishable** only when the verdict is owned and decided within
-three snapshot periods (`status.SnapshotPeriod`, the `BoxStale` rule). A
-missing or stale file is not publishable: a box that cannot re-prove its
-label for fifteen minutes stops writing under it, so an outage on one box
-cannot leave two machines writing one label.
+The verdict is held in memory and written to `<dataDir>/identity.json`,
+because not every publisher lives in the agent process:
-Every write or prune under a label-keyed key goes through the gate, from
-startup (codex B-4), in every process (codex B-5). The entry points, each
-of which today derives the label from `metricsBoxLabel`, `BoxLabel` or
-`status.Label(readMachineID())`:
+```json
+{"v": 1, "box_id": "...", "label": "edsger-knitting", "verdict": "owned",
+ "decided_at": "2026-10-06T10:00:00Z",
+ "store": {"bucket": "carlos-flagship-deploy", "prefix": ""}}
+```
+
+`host.Publishable(label string, target StoreTarget) bool` reads it and
+answers true only when (codex B2-2):
+
+- the file's `box_id` equals `status.BoxID(readMachineID())` of the
+ **calling** process (the file is this machine's, not a copy);
+- the file's `label` equals the label the caller is about to publish
+ under, exactly;
+- the file's `store` equals the caller's bucket and prefix (one
+ arbitration namespace per publication namespace, §2.6);
+- the verdict is owned and `decided_at` is within three snapshot periods.
+
+A missing, unparsable or stale file answers false. A box that cannot
+re-prove its label for fifteen minutes stops writing under it, so an
+outage on one box cannot leave two machines writing one label. **Ruling
+R3** (§9): this is deliberate, and metrics and logs stop too.
+
+Fleet boxes write the same file with `verdict: "fleet"` and their fleet
+label; `Publishable` treats `fleet` as owned for that label. Their
+uniqueness is the registry's, not the machine's.
+
+The gate is enforced at **every mutation**, not at loop entry (codex
+B2-5): `host.gatedStore` wraps a `release.Store` and checks `Publishable`
+before each `Put` and `Delete`, returning `ErrNotPublishable`, so a
+multi-write operation (the registry snapshot's generation put, current
+put, sidecar put and prune, `snapshot_loop.go:104-130`; `putReplacing`'s
+retries, `:221`) aborts at the next call when the verdict changes. Reads
+and lists are not gated. In-flight mutations are seconds long; a label is
+freed by an operator only after fifteen minutes of staleness (§2.3), so
+none can land after reassignment.
+
+The entry points, each of which today derives the label from
+`metricsBoxLabel`, `BoxLabel` or `status.Label(readMachineID())`, and
+each of which publishes through a `gatedStore`:
| Process | Entry point | Key |
|---|---|---|
-| agent | `statusTick` (`agent.go:2905`); the lease election at `agent.go:2865` moves after the decision, so a non-owner never becomes the fleet's publisher | `status/boxes/<label>.json` |
+| agent | `statusTick` (`agent.go:2905`); the lease election (`agent.go:2865`) moves after the decision, so a non-owner never becomes the fleet's publisher | `status/boxes/<label>.json` |
| agent | `edgesFilePass` (`edgesfile.go:273`) | `control/edges/<label>.json` |
| agent | `metricsFlush`, `alertTick` (`metrics_loop.go:162,444`) | `metrics/boxes/<label>/`, `alerts/boxes/<label>.json` |
| agent | `registrySnapshotLoop` (`snapshot_loop.go:205`) | `control/registry-snapshot/<label>.*`, `control/registry-snapshots/<label>/` |
| agent | `usageFlush` (`usage_loop.go:72`) | usage objects stamped with the box |
-| agent | `logshipLoop` and `chunkPrune`, `errshipLoop`, the analytics meter's flush (`agent.go:244,245,477,480,569`; `logship.go:71,161`) | `apps/<sqid>/<app>/{logs,errors,analytics}/<label>/` |
+| agent | `logshipLoop` and `chunkPrune`, `errshipLoop`, the analytics flush (`agent.go:244,245,477,480,569`; `logship.go:92,161`) | `apps/<sqid>/<app>/{logs,errors,analytics}/<label>/` |
| agent, and root-side `adopt` | `configAckFunc` (`agent.go:954`, `agent.go:3486`) | `control/config-ack/<label>/<host>.json` |
| `relay serve` | heartbeat and meter flush (`relay.go:138`, `internal/relay/serve.go:343,354`) | `control/relay-ack/<label>.json`, `metrics/relay/<label>/` |
+`relay serve` accepts `CARLOS_RELAY_BOX` (`serve.go:152`). It publishes
+only when `Publishable(cfg.Box, target)` is true, which requires the
+override to equal the label this machine owns; a mismatched override is
+logged once and the relay publishes nothing (codex B2-2).
+
A publisher that is not publishable skips its write and keeps its state:
the sinks buffer to their caps, the sampler keeps its ring, the edges
-loop keeps its fingerprint. Serving traffic is untouched: the edge, the
-activator and the reconcile pass do not depend on these publishers.
+loop keeps its fingerprint. **Ruling R2** (§9): a withdrawn box ships
+nothing under any segment, and drops at its caps. Serving traffic is
+untouched: the edge, the activator and the reconcile pass do not depend
+on these publishers.
Transitions **to** owned reset the edges gate: `*last` is cleared so the
next pass republishes, and `seedEdgesFileGate` (`edgesfile.go:529`)
discards a seeded file whose `box_id` is not ours and never inherits its
-EIP for the blank-probe fallback (`edgesfile.go:478`) (codex B-6).
+EIP for the blank-probe fallback (`edgesfile.go:478`).
-### 2.3 Refusal
+### 2.3 Refusal, and how a label is freed
On foreign, twin or unproven:
- the box logs, once per transition, with the instructions. Foreign:
`label <label> is held by another machine (instance <id>); this box
- (instance <id>) is not publishing. If this box is the new one:
- rm /etc/machine-id && systemd-machine-id-setup && reboot; its label will
- change. If the holder is retired: carlos fleet retire <label>.` Twin:
- `two machines share this machine id; regenerate it on the one that is
- new.` Unproven: `cannot prove this label is ours (no instance id to
- compare); if it is, carlos fleet retire <label> frees it.` The holder is
- never told to regenerate: its label is referenced by its own objects and
- history, and the refuser has none;
+ (instance <id>) is not publishing. Fix the box that is new (§2.3 of the
+ box-identity spec; carlos fleet check-label shows both).` Twin: `two
+ machines share this machine id; fix the one that is new.` Unproven:
+ `cannot prove label <label> is ours (no instance id to compare); if it
+ is, carlos fleet retire <label> frees it.`
- the box writes the evidence object (§2.4) every tick while refusing, and
delivers one `metrics.Event{Rule: "identity"}` through the metrics
loop's sinks (`metrics_loop.go:510`) on the transition, so
`CARLOS_ALERT_WEBHOOK` fires from the box that knows;
- the claim's arm and every claim step refuse (§4);
- every tick re-runs §2.1. The verdict changes only when the object
- changes: an operator's `carlos fleet retire <label>` deletes both
- objects (after printing what it found and refusing while the claim
- references the holder's `box_id`), or the box's own label changes
- (machine id regenerated, restart). Clearing is a transition, logged and
- delivered the same way.
+ changes or this box's label changes.
+
+**Freeing a label** is the operator's, in this order (codex B2-4). The
+alert names the holder (the object's writer) and the refuser; the
+operator decides which machine is the newcomer, normally the one with no
+history under the label:
+
+1. Stop the newcomer's agent (`systemctl stop carlos-agent`), so nothing
+ writes under the label from it. If the newcomer holds the label and
+ the established box is the refuser, this is the step that lets the
+ object go stale.
+2. Regenerate the newcomer's machine id: `rm /etc/machine-id &&
+ systemd-machine-id-setup && reboot`. It comes back under a new label
+ and a new `box_id`, decides that label at arm, and publishes.
+3. Once the contested label's objects are stale (fifteen minutes after
+ step 1), `carlos fleet retire <label>` deletes `status/boxes/<label>.json`
+ and `control/edges/<label>.json` with `release.DeleteIfMatch` on the
+ etags it read, after printing both objects' `box_id` and `instance_id`.
+ It refuses while either object is fresh (no `--force`: a fresh object
+ has a live writer), and it refuses while the claim names the holder's
+ `box_id` in any open operation, which is the claim's own retirement to
+ resolve first.
+4. The refuser's next tick finds not-found, creates, and is owned.
+
+The holder is never told to regenerate: an established box's `box_id` is
+in its claim history and its label is in its objects' history; the
+newcomer has neither. `carlos fleet check-label <machine-id>` (§2.7)
+prints what each side holds so the operator can tell them apart.
### 2.4 Evidence
@@ -146,11 +207,10 @@ On foreign, twin or unproven:
```
Keyed by the writer's own id, so two colliding boxes never share an
-evidence key, and written in the **status store** beside the snapshots,
-so whichever store a reader loads snapshots from also holds the evidence
-(codex B-9). Written every tick while the verdict is not owned; an entry
-older than three snapshot periods is expired and ignored. The prefix is
-under `status/` but is not one of the three keys the edge serves
+evidence key, written in the status store beside the snapshots (§2.6).
+Written every tick while the verdict is not owned; an entry older than
+three snapshot periods is expired and ignored. The prefix is under
+`status/` but is not one of the three keys the edge serves
(`internal/edge/status.go:54`).
### 2.5 Two new boxes start at once with an empty key
@@ -160,66 +220,105 @@ Both read not-found and both `Put` with `ifMatch ""`. On S3 that is
succeeds; the other gets `ErrCASConflict`, re-reads, finds a fresh object
with a foreign `box_id`, and refuses. The decision happens at arm, before
any other publisher runs, so the loser writes nothing at all. S3 is the
-arbitrating backend. `DirStore` enforces CAS under one process-wide mutex
-(`store.go:255-262`) and is a one-box, one-process shape; it is not
-claimed to arbitrate two processes (codex B-8). A deployment with no
-status store (`CARLOS_DEPLOYMENT_DIR`, `agent.go:2717`) is one box by
-construction: its verdict is owned by configuration, logged once, and the
-gate is open (codex B-9).
-
-### 2.6 What refusal does not do
-
-It does not withdraw routing (codex B-7). Steering (`steering.go:119`)
-and the home map (`homemap.go:273`) read edges files as they do today and
-do not consume §3's verdicts; Option B changes nothing there. What holds
-instead: a refuser has written nothing under the label since it refused,
-and before that only as owner, so the edges file in the bucket is the
-holder's. The one exception is the roll: an old-release sibling cannot
-refuse and overwrites the holder's objects with its own; routing then
-follows the old box, which is a real edge, until it rolls (§5). When a
-label's edges file flips between two machines' host sets, the steering
-converger sees one box's records replaced by another's and its shrink
-guard (`route53.go:209`) may refuse a pass; that is today's collision
-behaviour and this spec does not make it worse, only shorter.
+arbitrating backend. `DirStore` enforces CAS under a mutex **per handle**
+(`store.go:258`; codex B2-11), so it arbitrates writers that share one
+handle, which is what the tests use, and a one-box deployment needs no
+arbitration. A deployment with no status store (`CARLOS_DEPLOYMENT_DIR`,
+`agent.go:2717`) is one box by construction: its verdict is owned by
+configuration, logged once, and the gate is open.
+
+### 2.6 One arbitration namespace, and what refusal does not do
+
+Every writer that shares a publication namespace (the deployment bucket's
+label-keyed keys) must decide its label in the **same status store**
+(bucket and prefix), or two boxes could each be owned in their own
+namespace and still collide in the shared one (codex B2-6). The identity
+file records the store (§2.2); `carlos fleet check-label` prints it. The
+console reads snapshots, evidence and the fleet observation file from
+`cfg.Releases` (`instancesplacement.go:85`, `alertwatch.go:106`), so the
+supported shape for §3's rules is the one every deployment runs today:
+status store equals deployment bucket at an empty prefix. A split status
+bucket (`CARLOS_STATUS_BUCKET`) keeps today's degradation (the console
+sees no snapshots and no evidence, says so once, and automatic
+eligibility counts nothing outside the home); this spec does not wire a
+second store into the console.
+
+Refusal does not withdraw routing (codex B-7, B2-10). Steering
+(`steering.go:119`) and the home map (`homemap.go:273`) read edges files
+as they do today and consume none of §3's verdicts; Option B changes
+nothing there. The edges file in the bucket is the holder's: a refuser
+has written nothing under the label since it refused, and before that
+only as owner. Exceptions, which are costs and not protocol (§6.2): an
+old-release sibling cannot refuse and overwrites the holder's objects
+with its own during a roll, and routing then follows the old box; and
+when a label's host set flips between two machines the steering
+converger's shrink guard (`route53.go:209`) may refuse a pass, which is
+today's collision behaviour, now shorter.
+
+### 2.7 `carlos fleet check-label` (ruling R1, §9)
+
+`carlos fleet check-label <machine-id>` runs from an operator's machine
+with bucket credentials, like `carlos status -live`. It prints the label
+and `box_id` the machine id gives, then reads `status/boxes/<label>.json`
+and `control/edges/<label>.json` and prints each object's `box_id`,
+`instance_id`, freshness and store, and any evidence under
+`status/identity/` naming the label. Exit 0 when the label is free or
+held by this machine id; exit 1 when it is held by another machine or
+unproven. The provisioning recipe runs it after a new box's first boot
+(the machine id exists then; `cat /etc/machine-id` over SSM) and before
+the agent is bootstrapped, so a collision is found before the box serves
+anything.
## 3. Readers
Readers add two stateless rules and one durable one. Keys and joins do
-not change.
-
-1. **Disagreeing objects.** Where a snapshot and an edges file share a
- label and both carry `box_id`, they must be equal. A mismatch makes
- the pair **contested**: not live for `edgesfile.EdgeIPs` and
- `DeploymentRoutes` (`fold.go:84,158`), `BoxesComplete=false` for
- `readLiveEdgesSnapshot` (`instancesplacement.go:93`). Objects without
- `box_id` join by label as today.
+not change, and **hostname occupancy is never weakened** (codex B2-3).
+
+1. **Disagreeing objects, for eligibility only.** Where a snapshot and an
+ edges file share a label and both carry `box_id`, they must be equal.
+ A mismatch makes the pair **contested** for `readLiveEdgesSnapshot`
+ (`instancesplacement.go:93`): `BoxesComplete=false`, so no region
+ outside the home is automatically eligible. `edgesfile.EdgeIPs` and
+ `DeploymentRoutes` (`fold.go:46,125`) are **unchanged**: a contested
+ file whose label has a fresh snapshot still contributes its EIP (the
+ union only widens, which is safe) and still reserves its hostnames
+ (`deploymentRouteTaken`, `apidomains.go:1282`, and the alias pass,
+ `aliases.go:117`, read absence as "not taken", so dropping a contested
+ file would let an attach steal an operator-minted route). Objects
+ without `box_id` join by label as today.
2. **Evidence.** `readLiveEdgesSnapshot` reads `status/identity/` from
- the store it reads snapshots from, **before** its empty-files early
- return (`instancesplacement.go:98`); a failed read is incomplete
- evidence (codex B-10). An unexpired entry naming a label makes that
- label contested.
+ `cfg.Releases` **before** its empty-files early return
+ (`instancesplacement.go:98`); a failed read is incomplete evidence. An
+ unexpired entry naming a label makes that label contested.
3. **Writer change.** The status lease holder keeps
`status/identity/fleet.json`: per label, the last `box_id` and
- `generated_at` it saw and a `contested_until`. It reads the file at
- the start of each publish and writes it at the end, under the status
- lease, so the record survives the lease moving between boxes
- (codex B-10; the lease is released after every publish,
- `agent.go:2879`). A `box_id` that changes, appears or disappears while
- the previous object was still fresh at the new object's
- `generated_at` sets `contested_until` three periods ahead. The console
- reads the same file under rule 2.
+ `generated_at` it saw and a `contested_until`. At the start of each
+ publish it reads the file (a failed or unparsable read means no
+ writer-change detection this tick, logged once; rules 1 and 2 still
+ run); at the end it writes it with `ifMatch` on the etag it read, after
+ `held.Err()` says the lease is still held (`lease.go:170`), and a CAS
+ conflict or a lost lease skips the write, leaving the other
+ publisher's record standing (codex B2-7). A `box_id` that changes,
+ appears or disappears while the previous object was still fresh at the
+ new object's `generated_at` sets `contested_until` three periods
+ ahead; a later write never shortens an existing `contested_until`. The
+ console reads the same file under rule 2.
The lease holder folds all three into one singly-owned
-`alerts/fleet/identity.json` (`metrics.AlertState`, `Box: "fleet"`, rules
-`foreign`, `twin`, `unproven`, `contested`), written every publish tick,
-firing or not. The console's alert watcher (`alertwatch.go:97`) reads it
-alongside `alerts/boxes/*.json`, treats an object older than three
-periods as clear, and mails operators holding a grant; with none the
-transition is logged and dropped (`alertwatch.go:194`, amadan #192). The
-mail body (`alertMessage`, `alertwatch.go:285`) names the label and the
-rule, never a `box_id` or `instance_id`; those are on the operator fleet
-page and in `carlos status -live`, which show a `contested` chip and
-both machines' instance ids.
+`alerts/fleet/identity.json` (`metrics.AlertState`, `Box: "fleet"`),
+written every publish tick, firing or not. Rules are **per label**
+(codex B2-8): `foreign:<label>`, `twin:<label>`, `unproven:<label>`,
+`contested:<label>`, so a second label going foreign while the first is
+still foreign is its own transition for the watcher's `(box, rule)`
+tracking (`alertwatch.go:187`). The console's alert watcher
+(`alertwatch.go:97`) reads this one key alongside `alerts/boxes/*.json`,
+treats an object older than three periods as clear, and mails operators
+holding a grant; with none the transition is logged and dropped
+(`alertwatch.go:194`, amadan #192). The mail body (`alertMessage`,
+`alertwatch.go:285`) names the label and the rule, never a `box_id` or
+`instance_id`; those are on the operator fleet page and in
+`carlos status -live`, which show a `contested` chip and both machines'
+instance ids. **Ruling R4** (§9): fleet.json says nothing.
## 4. The host lifecycle claim
@@ -227,210 +326,238 @@ Claim rev 6.16 (`design-replica-ownership`,
`2026-10-02-host-lifecycle-claim-design.md:238-246`) says box references
are opaque identifiers compared by exact equality, each naming exactly one
machine, and that how a box obtains one, and refuses claim work when it
-cannot prove uniqueness, is this design's job. The answer (codex B-1):
+cannot prove uniqueness, is this design's job. The answer:
- the reference is `box_id`. It is a function of the machine id alone, so
it names exactly one machine without any arbitration, survives a label
- being freed and reassigned, and is what `generation.go:31`'s
- `op.exec == box` compares, what `claim.go:132`'s inventory is keyed by,
- and what `delete.go:73`'s `box:<b>` obligations name. A label is a
- display name and is never a claim reference;
-- a box takes or continues claim work only when it is identified
- (non-empty `box_id`), not a twin, and **publishable** (§2.2). Foreign
- and unproven boxes refuse because they are withdrawn from the fleet's
- view, not because their reference is in doubt: nothing can reach them
- through the home map, and nobody can see them on the fleet page;
+ being freed, and is what `generation.go:31`'s `op.exec == box`
+ compares, what `claim.go:132`'s inventory is keyed by, and what
+ `delete.go:73`'s `box:<b>` obligations name. A label is a display name
+ and is never a claim reference;
+- `host.ClaimEligible()` is true only when the box is identified
+ (non-empty `box_id`), not a twin, and `Publishable` for its label.
+ Foreign and unproven boxes refuse because they are withdrawn from the
+ fleet's view (no snapshot, no edges file of their own, no metrics), not
+ because their reference is in doubt;
- a box whose verdict leaves owned mid-operation drops its execution
lease and lets the claim's recovery owner take over, as for any lost
lease;
-- twins (one machine id on two machines) share a `box_id` and would be one
- box to the claim. §2.1 detects them by `instance_id` and refuses the
- second; a pair with no instance ids cannot be told apart. Machine-id
- uniqueness is a prerequisite the image recipe enforces, and this spec
- makes no clone-safety promise beyond that detection.
+- twins share a `box_id` and would be one box to the claim. §2.1 detects
+ them by `instance_id` and refuses the second; a pair with no instance
+ ids cannot be told apart. Machine-id uniqueness is a prerequisite (§8)
+ and this spec makes no clone-safety promise beyond that detection;
+- fleet boxes are outside the claim today; if that changes, their
+ reference is `fleet/<fleet>/<label>`, unique by the registry.
-`carlos fleet retire <label>` refuses while any claim references the
-holder's `box_id`; retiring a box the claim still names is the claim's
-own retirement procedure, not a label operation.
+`carlos fleet retire <label>` refuses while any open claim operation
+names the holder's `box_id`.
## 5. Mixed versions and rollback
+Guarantees, stated per state (codex B2-10):
+
+- **Every writer on this release.** At most one machine writes under a
+ label while its object is fresh; no label is reassigned without an
+ operator; a non-owner writes nothing under any label-keyed key; every
+ collision is in the evidence within one period. Clones with no
+ instance ids are the stated exception.
- **Old writer, new reader.** No `box_id`: join by label, as today. Rule 3
sees `box_id` disappear if an old sibling overwrites a new box's object,
and reports it.
- **New writer, old reader.** The field is additive and ignored.
- **Old sibling colliding with a new box.** The old box cannot refuse and
overwrites every tick; the new box sees a fresh object with no `box_id`
- and a different `instance_id`, refuses, latches, alerts. Detection is
- on the new side only; refusal waits for the old box to roll, when both
- run §2.1 and whichever object is in the bucket decides (codex B-12).
- Today both overwrite silently.
+ and a different `instance_id`, refuses and alerts. Detection is on the
+ new side only. When the old box rolls, both run §2.1: the object in the
+ bucket is the old box's and now carries its `box_id`, so the old box is
+ owned and the new box stays foreign. The repair is §2.3, and it needs
+ all four steps: regenerating the newcomer's machine id alone leaves its
+ old object under the label, which the other box refuses forever
+ (codex B2-4).
- **An established holder during the roll.** The one way a production
- holder loses its label is an old-release sibling overwriting it, as
- above. It then refuses, withdraws its publishers, and keeps serving. Its
- claim references are unaffected (they are `box_id`), its edges file is
- replaced by the sibling's, and its routing follows the sibling until the
- operator fixes the new box (codex B-12).
+ holder loses its label is an old-release sibling overwriting it. It
+ then refuses, withdraws its publishers, keeps serving, keeps its claim
+ references (they are `box_id`), and its routing follows the sibling
+ until §2.3 is run.
- **Rollback.** An old-release box has no check and overwrites blindly, as
- today. No object moved, so there is nothing to sweep and no key shape
- to reconcile. `identity.json` in `dataDir` and the evidence prefix are
- additive; an old reader never consults them. The cost of rolling back is
- losing the detection.
+ today. No object moved, so there is nothing to sweep. `identity.json`,
+ the evidence prefix and the fleet files are additive; an old reader
+ never consults them. The cost of rolling back is losing the detection.
- **Steering** is untouched (§2.6).
## 6. Findings, costs and the comparison
-### 6.1 Which findings disappear, which remain
+### 6.1 Which Option A findings disappear, which remain
| Finding | Under Option B |
|---|---|
| R1-1, R2-3, R3-4: steering `SetIdentifier`, renames, shrink guard | **gone.** No identifier changes |
-| R1-2, R2-1, R3-1: cleanup or sweep deletes a live writer's object | **gone.** Nothing is deleted except by an operator |
+| R1-2, R2-1, R3-1: cleanup or sweep deletes a live writer's object | **gone.** Nothing is deleted except by an operator, after staleness |
| R1-3, R2-2, R3-2, R3-3, R3-5: rollback duplicates, aliases, effective-file choice, orphans | **gone.** One key shape, one object per label |
| R1-4: the mixed-shape join | **gone** |
-| R1-5, R3-6: the other label-keyed families | **covered by the gate**, not fixed: a non-owner writes none of them, so the owner's are not overwritten |
-| R1-6, R2-7: architecture page | **gone.** One box per label publishes; the refuser's machine shows as an unclaimed instance |
+| R1-5, R3-6: the other label-keyed families | **covered by the gate**: a non-owner writes none of them |
+| R1-6, R2-7: architecture page | **gone.** One box per label publishes |
| R1-7, R2-6: alert object ownership and webhook delivery | **remain**, same design (§2.3, §3) |
| R1-8: step 2 privacy in `apps/` keys | **gone.** No key changes |
-| R1-9, R2-4: claim references and twins | **remain**, answered: `box_id` is the reference (§4) |
+| R1-9, R2-4: claim references and twins | **answered**: `box_id` is the reference (§4) |
| R2-5: twin evidence reaching the publisher | **remains**, same mechanism (§2.4) |
-| B-1 to B-12 | answered in this revision (§10); not yet re-reviewed |
### 6.2 Residual risk and cost under Option B
- A colliding box is out of multi-region routing, fleet status, metrics,
- logs and claim work until an operator acts. Its own edge serves its
- hosts locally. On the flagship a new box collides with one of four
- labels with probability 4 in 1,600 per box added; Tito 6 in 1,600.
- Because the decision is made at arm, a new box fails at its first
- start, before it has served anything.
-- During the roll, an old-release sibling can displace an established
- holder (§5). The holder keeps serving and keeps its claim references;
- its routing follows the sibling until the new box is fixed. The repair
- is the same as for a new box: regenerate the new box's machine id.
-- An unproven label (no instance ids to compare, and no EIP evidence)
- needs an operator to free it. On the flagship and Tito every box has an
- instance id, so this is a roll-time wait of one staleness window at
- most for a cloud box, and an operator step only for hardware no cloud
- owns.
+ logs and claim work until §2.3 is run. Its own edge serves its hosts
+ locally. On the flagship a new box collides with one of four labels
+ with probability 4 in 1,600 per box added; Tito 6 in 1,600. With
+ `check-label` in the provisioning recipe, it is found before the box
+ serves anything.
+- During a roll, an old-release sibling can displace an established
+ holder (§5). The holder keeps serving and its claim references; its
+ routing follows the sibling until §2.3 is run, which includes stopping
+ and re-identifying the newcomer.
+- An unproven label needs an operator to free it. On the flagship, Tito
+ and dev every box has an instance id; this is hardware no cloud owns.
- Retired boxes' objects still linger, as today; a new box that collides
with a retired label is refused until `carlos fleet retire`.
- A box that cannot read its status store for fifteen minutes stops
- publishing under its label (§2.2). Today it would keep trying; the
- difference is only that it also stops its other label-keyed publishers.
+ every label-keyed publisher (ruling R3).
- Steering and the home map do not consume the verdicts (§2.6).
+- A split status bucket is outside §3's supported shape (§2.6).
-Operator cost: one alert; for a new box, `rm /etc/machine-id &&
-systemd-machine-id-setup && reboot`; for a retired holder, `carlos fleet
-retire <label>`. No runbook step on roll, none on rollback. How long a
-repair takes depends on who is on call; the spec does not estimate it.
+Operator cost: one alert; the four steps of §2.3 for a collision; one
+`check-label` per new box. No runbook step on roll, none on rollback.
### 6.3 Comparison
| | Option A: migrate the keys | Option B: detect and refuse |
|---|---|---|
| What it fixes | Both colliding boxes keep working; the label becomes cosmetic | The collision is loud and deterministic; one box works, the other is withdrawn until fixed |
-| Residual risk | Four open P1s after three rounds, all protocol: writer retirement, cross-object cleanup, effective-file choice for steering, the rename exemption in N. Deletes production objects. Rollback needs a runbook | A withdrawn box; a holder displaced by an old sibling during the roll; unproven labels on off-cloud hardware; routing not withdrawn. Rev 2 answers seven P1s and is unreviewed |
-| Changes to objects, keys, DNS | Two key shapes for two releases, a sweep, a DNS identifier flip, step 2 for six more key families | Two additive fields, one new private prefix, one local file |
-| Code touched | `status`, `edgesfile`, `host`, `console`, `edge`, `steering`, CLI; two releases minimum | `status` (decide), `host` (gate at twelve entry points, identity file), `relay` and `adopt` (read the file), `console` (two reader rules, evidence file, alert), CLI `fleet retire`; one release |
-| Review | Astra has twice asked for an explicit migration state machine and the DNS boundary; both are open | One round done; rev 2 is a judgment that the remaining risk is operational, which the next round tests |
-| Flagship (4 boxes, no collision today) | A roll with a sweep, an N+1 DNS flip, a rollback runbook | A roll; nothing moves; an alert and a withdrawn box if a future box collides |
-| Tito (6 boxes) | the same, on a customer's fleet, plus `alert_seen` migration in step 2 | the same as the flagship |
-| dev (1 box, directory store possible) | the flip and sweep run for nothing | owned by configuration; nothing happens |
-
-### 6.4 Recommendation
-
-**Option B.** It removes the silent failure #212 describes with two
-additive fields and a gate, moves nothing, and its residual costs are
-operational and visible: a new box that fails loudly at first start, and
-a displaced holder only while a roll is in progress. Option A buys two
-colliding boxes both working, at the price of a deletion-and-rename
-protocol that three rounds have not closed. Option A's spec stays on the
-branch as the record and as the design to return to if the fleet ever
-grows to where a withdrawn box is not an acceptable answer.
+| Residual risk | Four open P1s after three rounds, all protocol. Deletes production objects. Rollback needs a runbook | A withdrawn box; a displaced holder during a roll; unproven labels off-cloud; routing not withdrawn; a four-step repair |
+| Changes to objects, keys, DNS | Two key shapes for two releases, a sweep, a DNS identifier flip, step 2 for six more families | Two additive fields, one private prefix, two fleet files, one local file |
+| Code touched | `status`, `edgesfile`, `host`, `console`, `edge`, `steering`, CLI; two releases minimum | `status` (decide), `host` (gate at twelve entry points, identity file), `relay` and `adopt` (read the file), `console` (two reader rules, two fleet files, alert), CLI (`check-label`, `retire`); one release |
+| Review | Astra twice asked for a migration state machine and the DNS boundary; both open | Two rounds; rev 3 unreviewed; remaining risk is operational |
+| Flagship (4 boxes, no collision today) | roll with sweep, N+1 DNS flip, rollback runbook | a roll; nothing moves; an alert and a withdrawn box if a future box collides |
+| Tito (6 boxes) | the same, on a customer's fleet | the same as the flagship |
+| dev (1 box) | the flip and sweep run for nothing | owned by configuration; nothing happens |
+
+### 6.4 Decision
+
+Paul chose Option B on 2026-10-06. Option A stays on the branch as the
+record and as the design to return to if the fleet ever grows to where a
+withdrawn box is not an acceptable answer.
## 7. Test plan
-- `DecideLabel`: every row of §2.1's table, including stale foreign
- (still foreign), stale unproven with and without EIP evidence, twin by
- instance id, malformed body and CAS exhaustion (unknown, no write);
- a CAS conflict re-reads and re-decides; owned is recorded only after a
- successful conditional write. Two writers against one `DirStore` handle
- from one start: exactly one owns, in both orders. Against S3 in the
- integration suite: two processes.
-- The gate: every entry point in §2.2's table skips when not publishable,
- from startup, in the agent, `adopt` and `relay serve`; publishable
- expires after three periods without a decision; a one-box directory
- deployment is open; transitions to owned reset the edges gate and
- `seedEdgesFileGate` discards a foreign seed and its EIP.
-- An unidentified box (empty machine id) publishes nothing and refuses
- claim work.
+- `DecideLabel`: every row of §2.1's table, including stale foreign and
+ stale unproven (both still refuse), twin by instance id, malformed body
+ and CAS exhaustion (unknown, no write); a CAS conflict re-reads and
+ re-decides; owned is recorded only after a successful conditional
+ write. Two writers against one `DirStore` handle from one start:
+ exactly one owns, in both orders.
+- The gate: `Publishable` false on a missing, stale, other-machine,
+ other-label or other-store file; `gatedStore` refuses every `Put` and
+ `Delete` and passes `Get` and `List`; every entry point in §2.2's table
+ publishes through it, in the agent, `adopt` and `relay serve`; a
+ mismatched `CARLOS_RELAY_BOX` publishes nothing; a one-box directory
+ deployment is open; a fleet box is open for its fleet label; the
+ registry snapshot aborts mid-sequence when the verdict changes;
+ transitions to owned reset the edges gate and `seedEdgesFileGate`
+ discards a foreign seed and its EIP.
+- An unidentified box publishes nothing and refuses claim work.
- Evidence: written each tick while not owned, keyed by the writer's id,
- in the status store, expiring after three periods; the fleet
- observation file survives a lease move.
-- Readers: rule 1 makes a disagreeing pair not live and incomplete; rule
- 2 runs before the empty-files return and a failed read is incomplete;
- rule 3 fires on a change within freshness, not after staleness; the
- fleet alert object is written firing and clear; the watcher mails and
- clears; the mail body carries no `box_id` or `instance_id`.
-- Privacy fixtures (codex B-11): distinct sentinels for `box_id` and
- `instance_id` in the dry run (`dryrun.go:62`), a collision evidence
- object created in the run, positive checks in `status/boxes/` and
- `status/identity/`, negative in fleet.json and index.html; unit tests
- at `fleet.go:172` (`FleetBox`), `apiinstances.go:327` (the instances
- response) and `alertwatch.go:285` (the mail) asserting neither field
- appears.
-- Claim: the arm gate refuses unidentified, twin and non-publishable
- boxes; `fleet retire` refuses while the claim names the holder.
+ expiring after three periods.
+- The fleet observation file: survives a lease move; a conditional write
+ loses to a concurrent writer and the record stands; a lost lease skips
+ the write; `contested_until` is never shortened; an unreadable file
+ disables rule 3 for the tick and nothing else.
+- Readers: rule 1 makes a disagreeing pair incomplete for eligibility and
+ leaves `EdgeIPs` and `DeploymentRoutes` unchanged (a contested file's
+ EIP is in the set and its hostnames are taken); rule 2 runs before the
+ empty-files return and a failed read is incomplete; rule 3 fires on a
+ change within freshness, not after staleness; the fleet alert object
+ carries per-label rules and the watcher mails each label's transition;
+ the mail body carries no `box_id` or `instance_id`.
+- Privacy fixtures: distinct sentinels for `box_id` and `instance_id` in
+ the dry run (`dryrun.go:62`), a collision evidence object created in the
+ run, positive checks in `status/boxes/` and `status/identity/`,
+ negative in fleet.json and index.html; unit tests at `fleet.go:172`
+ (`FleetBox`), `apiinstances.go:327` (the instances response) and
+ `alertwatch.go:285` (the mail) asserting neither field appears.
+- CLI: `check-label` exits 0 for free and own, 1 for foreign and
+ unproven, and prints both sides; `retire` refuses a fresh object,
+ deletes stale ones conditionally, refuses on an etag change.
+- Claim: `ClaimEligible` false for unidentified, twin and non-publishable.
- Mixed versions: old-shape objects join as today; new-shape objects are
byte-compatible for the fields an old reader uses.
- Simulation: random fleets with injected collisions, simultaneous starts,
- clones, rolls, rollbacks and one-box store outages, asserting that at
- most one machine writes a label while its object is fresh, that no
- label is ever reassigned without an operator, that a non-owner writes
- nothing under any label-keyed key, and that every collision is in the
- evidence within one period.
-
-## 8. Rollout
-
-1. Confirm the image recipes clear `/etc/machine-id`.
-2. Land on main; cut a release; roll boxes in any order; roll the
- console. Nothing moves, so there is no order and no sweep. Expect one
- staleness window of silence from any box whose own pre-roll snapshot
- has no instance id (none on the flagship or Tito).
-3. Add the alert texts and the two repairs to the operator runbook and
- the provisioning recipe.
-4. Record §4 on the claim branch as the answer to rev 6.16's open
- reference: `box_id`.
-
-## 9. Open questions for Paul
-
-1. Option B or Option A (§6.4)?
-2. Should a new box's provisioning check its label against the fleet
- before it joins (`carlos fleet check-label <machine-id>`), so the
- collision is found before the box is built?
-3. A withdrawn box buffers logs to cap and ships nothing. Acceptable for
- the time until it is fixed, or should it ship under a temporary
- `<label>-<4 hex>` segment?
-4. A box that cannot read its status store for fifteen minutes now stops
- every label-keyed publisher, not only status. Is that the right
- posture, or should metrics and logs keep flowing on the last verdict?
-5. Should fleet.json say anything when a label is contested? This spec
- keeps it silent, as for every other operator-only condition.
+ clones, rolls, rollbacks and one-box store outages, asserting §5's
+ first bullet for every writer on this release and the stated exceptions
+ for the others.
+
+## 8. Prerequisite and rollout
+
+**Machine-id uniqueness, verified 2026-10-06 (read-only, no host
+touched).** The flagship root (`carlosframework/carloku-infrastructure`
+at ec9587b), Tito's root (`tito/carlos-infrastructure` at 99fec15) and
+dev (`carlosframework/platform-infrastructure` at 841e94b) all build
+boxes from `infra/modules/region-host`, whose AMI is Debian's own
+`debian-13-arm64-*` image from account `136693071363`
+(`variables.tf:131-141`, `main.tf:14-24`); no root overrides
+`ami_owner` or `ami_name_pattern`, and no root builds a custom AMI or a
+snapshot-derived one. Their `host-init.sh` first-boot scripts (byte-
+identical between IE and the templated regions, differing only in the
+SSM region) never touch `/etc/machine-id`. Debian's cloud images ship
+`/etc/machine-id` empty and systemd generates it on first boot, so every
+instance launched from them has its own. The flagship's four distinct
+labels are the observable consequence. The HA ladder's instance recovery
+(`main.tf:507`) keeps the root volume and so the machine id, which is
+correct: it is the same machine. An EBS-snapshot clone would carry the
+machine id with it; no root does that today, and §2.1's twin row is the
+detection if one ever does. The rollout step is to keep it so: any future
+custom AMI must clear `/etc/machine-id`.
+
+1. Land on main; cut a release; roll boxes in any order; roll the
+ console. Nothing moves, so there is no order and no sweep.
+2. Add `carlos fleet check-label` to the provisioning recipe, and §2.3's
+ four steps and the alert texts to the operator runbook.
+3. Record §4 on the claim branch as the answer to rev 6.16's open
+ reference: `box_id`, and `host.ClaimEligible()` as the gate.
+
+## 9. Rulings
+
+Taken by default on 2026-10-06 so the plan can be written; Paul may
+override any of them.
+
+| | Ruling | Why |
+|---|---|---|
+| R1 | `carlos fleet check-label <machine-id>` is added and the provisioning recipe runs it before bootstrapping the agent (§2.7) | a collision found before the box serves anything costs nothing |
+| R2 | a withdrawn box buffers logs, errors and analytics to its caps and ships nothing; no temporary `<label>-<4 hex>` segment (§2.2) | a temporary segment is a second identity to migrate off later; the box is fixed in minutes |
+| R3 | a box that cannot re-prove its label for fifteen minutes stops every label-keyed publisher, metrics and logs included (§2.2) | the conservative choice: one machine writing a label at a time is the invariant, and a box that cannot read the store cannot know it still holds the label |
+| R4 | fleet.json stays silent on contested labels (§3) | every other operator-only condition is silent there, and the public feed carries no condition a reader could act on |
## 10. Review dispositions
| Finding | Severity | Disposition |
|---|---|---|
-| B-1 takeover violates the claim's one-machine contract | P1 | accepted. No automatic reassignment, fresh or stale; the claim references `box_id`, not the label; `fleet retire` refuses while the claim names the holder (§1, §2.1, §4) |
-| B-2 twin detection bypassed; empty machine id manufactures an identity | P1 | accepted. Equal `box_id` with differing instance ids is a twin and refuses; an unreadable machine id is unidentified and publishes nothing (§2.1) |
-| B-3 unproven becomes owned by reading its own write | P1 | accepted. Unproven never writes; a stale unproven object is taken only on EIP evidence, else an operator frees it (§2.1) |
-| B-4 startup and expiry gating | P1 | accepted. Decision at arm before any publisher; publishable expires after three periods; the election follows the decision (§2.1, §2.2) |
-| B-5 cross-process publishers | P1 | accepted. `<dataDir>/identity.json` read by `relay serve` and `adopt`; the entry-point inventory replaces "eight loops" (§2.2) |
+| B-1 takeover violates the claim's one-machine contract | P1 | accepted. No automatic reassignment; the claim references `box_id` (§1, §2.1, §4) |
+| B-2 twin detection bypassed; empty machine id manufactures an identity | P1 | accepted. Equal `box_id` with differing instance ids is a twin; an unreadable machine id publishes nothing (§2.1) |
+| B-3 unproven becomes owned by reading its own write | P1 | accepted. Unproven never writes (§2.1) |
+| B-4 startup and expiry gating | P1 | accepted. Decision at arm before any publisher; publishable expires after three periods (§2.1, §2.2) |
+| B-5 cross-process publishers | P1 | accepted. `<dataDir>/identity.json`; the entry-point inventory (§2.2) |
| B-6 stale edges after regaining ownership | P1 | accepted. Transitions to owned reset the gate; a foreign seed and its EIP are discarded (§2.2) |
-| B-7 refusal does not withdraw routing | P1 | accepted as a stated limit, not a protocol: steering and the home map are unchanged; the invariant and its one exception (an old sibling during the roll) are written down with the shrink-guard note (§2.6) |
-| B-8 remaining branches and the CAS guarantee | P2 | accepted. The table has every branch; S3 arbitrates, `DirStore` is one process; owned only after a successful write (§2.1, §2.5) |
-| B-9 directory deployments and split status stores | P2 | accepted. Owned by configuration with no status store; evidence lives in the status store beside the snapshots (§2.4, §2.5) |
-| B-10 writer-change evidence does not survive a publisher change | P2 | accepted. Durable `status/identity/fleet.json` under the lease; the console reads evidence before the early return and fails closed on a read error (§3) |
-| B-11 privacy fixtures | P2 | accepted. Sentinels, a collision object in the dry run, and boundary tests at the three projections (§7) |
-| B-12 the comparison understates B's costs | P2 | accepted. Timing and round estimates removed; displacement during the roll, unproven labels and routing stated as costs; "undetected" corrected to "detected, not refused" (§5, §6.2, §6.3) |
+| B-7 refusal does not withdraw routing | P1 | accepted as a stated limit (§2.6) |
+| B-8 remaining branches and the CAS guarantee | P2 | accepted (§2.1, §2.5) |
+| B-9 directory deployments and split status stores | P2 | accepted (§2.5, §2.6) |
+| B-10 writer-change evidence does not survive a publisher change | P2 | accepted. Durable fleet observation file (§3) |
+| B-11 privacy fixtures | P2 | accepted (§7) |
+| B-12 the comparison understates B's costs | P2 | accepted (§5, §6.2, §6.3) |
+| B2-1 EIP equality does not establish ownership | P1 | accepted. The EIP branch is removed; stale unproven stays unproven; an operator frees it (§2.1, §2.3) |
+| B2-2 the gate can authorise publication under another label | P1 | accepted. `Publishable(label, target)` binds the calling machine's `box_id`, the exact label and the store; a mismatched `CARLOS_RELAY_BOX` publishes nothing (§2.2) |
+| B2-3 excluding contested routes disables hostname occupancy | P1 | accepted. Rule 1 applies to eligibility only; `EdgeIPs` and `DeploymentRoutes` are unchanged (§3) |
+| B2-4 the mixed-version repair leaves the displaced holder refused | P1 | accepted. The four-step freeing procedure: stop, regenerate, `retire` after staleness, reclaim (§2.3, §5) |
+| B2-5 in-flight revocation | P2 | accepted. `gatedStore` checks every `Put` and `Delete`; the staleness barrier before freeing (§2.2) |
+| B2-6 split stores | P2 | accepted. One arbitration namespace per publication namespace, recorded in the identity file; the supported console shape stated (§2.6) |
+| B2-7 fleet observation file failure and lease loss | P2 | accepted. Conditional write after `held.Err()`, skip on conflict or loss, never shorten `contested_until`, unreadable disables rule 3 only (§3) |
+| B2-8 the fleet alert loses the label | P2 | accepted. Per-label rule names (§3) |
+| B2-9 fleet boxes and the gate | P2 | accepted. `verdict: "fleet"`, open for the fleet label; the claim reference if ever needed (§2.2, §4) |
+| B2-10 limits and guarantees disagree | P2 | accepted. §5 states guarantees per state; §4 no longer claims unreachability (§4, §5) |
+| B2-11 DirStore mutex is per handle | P3 | accepted (§2.5) |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-BOX-IDENTITY-B-R2.md b/docs/superpowers/specs/CODEX-FINDINGS-BOX-IDENTITY-B-R2.md
new file mode 100644
index 000000000..63a5e2d81
--- /dev/null
+++ b/docs/superpowers/specs/CODEX-FINDINGS-BOX-IDENTITY-B-R2.md
@@ -0,0 +1,74 @@
+Reviewed revision 2 at `8c362f12`, against the worktree’s `04cd2d78` code and the requested claim branches. No files changed or tests run.
+
+Round-1 dispositions:
+
+| Finding | Round-2 assessment |
+|---|---|
+| B-1 | Label-reference reuse is closed by using `box_id`, subject to the stated machine-ID uniqueness prerequisite. |
+| B-2 | The non-empty instance-ID twin check and unidentified-machine refusal are explicit. |
+| B-3 | **Not closed:** the replacement EIP proof is unsound. |
+| B-4 | Startup and expiry requirements are explicit; revocation of in-flight publication remains unspecified. |
+| B-5 | Publisher coverage is substantially corrected; authorization is not bound to each publisher’s actual label. |
+| B-6 | Clearing the edges fingerprint and rejecting foreign seeds close the original cache defect. |
+| B-7 | Routing withdrawal is expressly excluded, but later claims still contradict that limit. |
+| B-8 | S3 arbitration is correct. The DirStore mutex description remains inaccurate. |
+| B-9 | Directory operation is addressed; split-store reader wiring and arbitration scope remain incomplete. |
+| B-10 | Durable history addresses publisher changes; failure and lease-loss handling remain unspecified. |
+| B-11 | The proposed privacy fixtures cover the identified projection boundaries. |
+| B-12 | Improved, but the repair procedure and several guarantees remain overstated. |
+
+1. **P1 — EIP equality does not establish snapshot ownership.**
+ **Claim:** [Spec:60](/home/paulca/amadan.net/carlos/platform-design-box-identity/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md:60) promotes stale, unproven snapshots to owned using an edges address.
+ **Evidence:** The EIP is a separate resource attached to the current instance ([main.tf:503](/home/paulca/amadan.net/carlos/platform-design-box-identity/infra/modules/region-host/main.tf:503)); replacing an instance can preserve that address. Edges files also persist without timestamp refresh ([edgesfile.go:491](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/edgesfile.go:491)). A replacement with a colliding label can therefore acquire its predecessor’s label automatically. Independently, the snapshot and edges object may have different legacy writers.
+ **Fix:** Keep this branch unproven. Require explicit operator resolution or machine-bound evidence; an address is insufficient.
+
+2. **P1 — The shared gate can authorize publication under another label.**
+ **Claim:** [Spec:77](/home/paulca/amadan.net/carlos/platform-design-box-identity/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md:77) authorizes publication from a recent owned verdict.
+ **Evidence:** `CARLOS_RELAY_BOX` supplies an independent label ([serve.go:152](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/relay/serve.go:152)); [relay.go:137](/home/paulca/amadan.net/carlos/platform-design-box-identity/relay.go:137) preserves that override. Ownership of label A would therefore authorize relay writes under B. The file predicate also omits comparison with the reader’s current machine identity and destination.
+ **Fix:** Bind authorization to the current machine, exact publication label, and store/prefix. Reject unmatched relay overrides and identity files from another machine or publication scope.
+
+3. **P1 — Excluding contested routes disables a hostname ownership safeguard.**
+ **Claim:** [Spec:190](/home/paulca/amadan.net/carlos/platform-design-box-identity/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md:190) removes disagreeing pairs from `DeploymentRoutes`.
+ **Evidence:** An absent hostname means “not taken” at [apidomains.go:1282](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/apidomains.go:1282). The same map protects box-side alias reconciliation ([aliases.go:117](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/domains/aliases.go:117)). Meanwhile §2.6 deliberately preserves serving and routing. Identity disagreement would remove protection for an existing operator-created route.
+ **Fix:** Separate placement eligibility from hostname occupancy. Preserve contested hostname reservations, or explicitly refuse affected attaches and alias materialization until ownership is resolved.
+
+4. **P1 — The mixed-version repair leaves the displaced holder permanently refused.**
+ **Claim:** [Spec:307](/home/paulca/amadan.net/carlos/platform-design-box-identity/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md:307) says regenerating the newcomer’s machine ID repairs displacement.
+ **Evidence:** A new holder A can be overwritten by old B through [WriteBox:749](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/status/snapshot.go:749). B then upgrades and stamps its `box_id`. Regenerating B moves it to another label but leaves B’s old snapshot behind. Under spec:56, A refuses that foreign ID forever, including after staleness.
+ **Fix:** Specify a recovery sequence that quiesces B, resolves its claim obligations, safely retires the abandoned label, and lets A reclaim it. Regeneration alone is insufficient; this requires a roll/rollback runbook.
+
+5. **P2 — Publication revocation needs an in-flight contract.**
+ **Claim:** [Spec:86](/home/paulca/amadan.net/carlos/platform-design-box-identity/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md:86) says every write and prune is gated.
+ **Evidence:** A registry snapshot performs several writes and deletes after entry ([snapshot_loop.go:104](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/snapshot_loop.go:104)); `putReplacing` rereads and retries independently at line 221. An entry-point check does not prevent completion after ownership changes.
+ **Fix:** Define checks at mutation/retry boundaries, cancellation or draining on refusal, and the retirement barrier that prevents reassignment while old mutations remain in flight.
+
+6. **P2 — Split-store support remains incomplete.**
+ **Claim:** [Spec:148](/home/paulca/amadan.net/carlos/platform-design-box-identity/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md:148) colocates evidence with snapshots.
+ **Evidence:** The console explicitly does not read `CARLOS_STATUS_BUCKET` ([instancesplacement.go:85](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/instancesplacement.go:85)); alert watching reads `s.cfg.Releases` ([alertwatch.go:106](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/alertwatch.go:106)). Colocation alone does not connect either reader. Different status namespaces can also independently grant ownership while writers share deployment keys.
+ **Fix:** Specify reader store/prefix wiring and require one arbitration namespace for every shared publication namespace.
+
+7. **P2 — Durable observation updates need failure and lease-loss rules.**
+ **Claim:** [Spec:201](/home/paulca/amadan.net/carlos/platform-design-box-identity/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md:201) relies on writing `fleet.json` “under the status lease.”
+ **Evidence:** The existing status path acquires, publishes, then releases without checking lease health during publication ([agent.go:2865](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/agent.go:2865)). `Held` exposes renewal failure through `Err` and `Lost` ([lease.go:169](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/lease/lease.go:169)).
+ **Fix:** Define unreadable-history behavior, conditional observation updates, conflict merging that preserves `contested_until`, and rejection of writes after lease loss. Test a delayed former publisher against its successor.
+
+8. **P2 — The fleet alert shape loses the affected label.**
+ **Claim:** [Spec:212](/home/paulca/amadan.net/carlos/platform-design-box-identity/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md:212) uses `Box:"fleet"` and four rule names, while promising mail naming each label.
+ **Evidence:** `RuleState` contains only booleans, timestamps and a numeric value ([alert.go:92](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/metrics/alert.go:92)). The watcher tracks firing transitions by box/rule ([alertwatch.go:187](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/console/alertwatch.go:187)). A second foreign label appearing while the first remains foreign produces no new transition.
+ **Fix:** Define per-label/per-rule alert state and delivery tracking, retaining the exclusion of private IDs from mail.
+
+9. **P2 — Fleet boxes skip arbitration without receiving a usable gate verdict.**
+ **Claim:** [Spec:68](/home/paulca/amadan.net/carlos/platform-design-box-identity/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md:68) skips their ownership decision; §2.2 admits only a recent owned verdict.
+ **Evidence:** Fleet metrics and usage still publish under their configured label ([metrics_loop.go:729](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/metrics_loop.go:729), [usage_loop.go:72](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/host/usage_loop.go:72)).
+ **Fix:** Explicitly define the fleet gate exemption or alternative authority, its scope, and how unidentified fleet machines interact with claim eligibility.
+
+10. **P2 — The stated limits and test guarantees still disagree.**
+ **Claim:** §4 says nothing reaches refusers through the home map; §6.2 says they are outside multi-region routing; §7 asserts single-writer behavior through rolls and rollbacks.
+ **Evidence:** [Spec:170](/home/paulca/amadan.net/carlos/platform-design-box-identity/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md:170) explicitly preserves routing, matching [homemap.go:273](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/edge/homemap.go:273). Old `WriteBox` continues unconditional replacement.
+ **Fix:** State guarantees separately for fully upgraded writers, mixed versions, rollback, and unresolved clones. Test the documented exceptions. Retain routing persistence as an accepted limit without claiming withdrawal elsewhere.
+
+11. **P3 — DirStore’s mutex is per handle, not process-wide.**
+ **Evidence:** [Spec:163](/home/paulca/amadan.net/carlos/platform-design-box-identity/docs/superpowers/specs/2026-10-06-box-identity-detect-design.md:163) repeats the misleading code comment; the mutex belongs to each `DirStore` instance ([store.go:258](/home/paulca/amadan.net/carlos/platform-design-box-identity/internal/release/store.go:258)).
+ **Fix:** Say arbitration is supported through one shared handle. The proposed test already uses that scope.
+
+**Verdict: not ready. P1s: unsound EIP ownership proof; gate authorization under another label; contested routes losing hostname protection; incomplete mixed-version recovery.**
\ No newline at end of file