| 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. |