carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1# Seeded Instances, Plan 4: Placing a Seed on First Wake Implementation Plan
2
3> **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.
4
5**Goal:** On a nested box with claims off, an instance whose record carries a placeable seed starts from that seed's snapshot on its first wake, never from an empty database, and the seed becomes `placed` only after the host's own replica exists.
6
7**Architecture:** Reconcile carries a record's seed onto its registry row as a new column, `seed_source` (a `seeds.RowSeed`), dispatches on the seed's state before any other per-record handling, mints a `--from` row only nested and only with claims off, never stamps the manifest's seed command on a seeded row, and finishes an `aborted` seed that no box swapped (delete this seed's snapshot, CAS to `rolled-back`). The activator's `ExecBackend.Restore` gains a fallback beside the staging fallback: for a row whose seed is `ready`, `swapped` or `placed`, it restores normally when the host has a replica of its own, keeps a local database only when its `<db>.seed` marker names this seed, and otherwise purges, fetches the snapshot into the instance directory, checks length, sha256 and `CheckDBContext`, writes the marker and renames the file into place. After replication starts, `doWake` waits for the first upload (`Replicator.SyncAndWait`) and the host package CASes the record to `placed` and deletes `seeds/<host>/<id>/`.
8
9**Tech Stack:** Go 1.25, modernc sqlite registry, `internal/instancefs` (openat2, Linux), litestream v0.5.14 replica client (`lease`), `release.S3Store`.
10
11**Spec:** `docs/superpowers/specs/2026-10-08-seeded-instances-design.md` rev 7: §3.2's dispatch table (codex 13) for every state except an import's `ready` (Plan 7), §3.4 the plain path (codex 8, R2-1), §6.3 the box half for placement. Index: `2026-10-08-seeded-instances-0-index.md`. Copy deck rows SD-N14 to SD-N19.
12
13## Global Constraints
14
15- Every user-facing string (CLI output, errors, console messages, docs prose) is copy-reviewed by Paul before it is written into code; implementation tasks use only strings from the copy deck. Copy voice: no em dashes; full stops and new sentences instead.
16- CI cross-compiles darwin and windows: linux-only code needs `//go:build linux` plus a `!linux` stub.
17- Local test commands: targeted tests (`go test ./internal/<pkg>/ -run TestX`), not the full suite in a loop. Inside the sandbox the default Go build cache may be read-only; if so use `GOCACHE=$TMPDIR/gocache` re-exported per command. Socket tests need a short TMPDIR (`TMPDIR=/tmp/g`). The local gate may need `GOFLAGS=-buildvcs=false`.
18- Heavy loops run under `nice`; no -race full suite until the end of a plan.
19- Production use needs nested boxes; the claim-armed path is refused here and left to #250 (spec §3.4).
20- Commits end with `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
21- No new file in this series imports `internal/moves`. New sentences go in new files (`internal/activator/seedplace.go`), never in a file listed in `movesScanFiles` (`backend_exec.go`, `activator.go`'s neighbours `registry.go`, `reconcile.go`, `provision.go`, `lease.go`, `agent.go` are listed).
22- When a task wires a deck row, append its id to `seededWiredIDs` in `approved_seeded_copy_test.go` in the same commit.
23- Write each deck sentence as exactly one string literal or one format string holding the row's text, with `%s` or `%d` at each `<placeholder>` and nothing else: no prefix, no trailing `\n`, no leading spaces. The gate's matcher (`movesRowSpoken`, `approved_moves_copy_test.go:811`) anchors both ends. Print with `fmt.Fprintln(w, sentence)` or a `" %s\n"` format around a separately built sentence.
24- `instancefs` is Linux-only (`instancefs_other.go` returns `errors.ErrUnsupported`); the placement code compiles everywhere and its tests carry `//go:build linux`.
25
26## Review Focus
27
281. A wake that cannot tell whether the host has a replica of its own (a bucket error) must fail, never fall through to the snapshot or to an empty database: placing over a replica that exists would fork the host's lineage. Test in Task 3 (`TestSeededRestoreFailsWhenTheReplicaCannotBeListed`).
292. A local database left by an earlier, aborted seed of the same host (marker names another ID) or with no marker must be purged with its `-wal`, `-shm` and litestream metadata before the snapshot is placed. Test in Task 3 (`TestSeededRestorePurgesAnotherSeedsDatabase`).
303. The manifest's seed command must never run for a seeded row, whatever `Restore` reports: the row's `seed` column stays empty, and `Restore` never returns `fresh` for a placeable seed. Tests in Task 5 (`TestReconcileMintsAFromSeedNestedWithoutTheSeedCommand`) and Task 3.
314. A `ready` `--from` record on a flat or claim-armed box must not be minted at all, so nothing can wake it there. Test in Task 5 (`TestReconcileDoesNotMintASeedFlatOrClaimed`).
325. When the `placed` CAS fails after the first upload, the next wake (which now finds the host's own replica) must finish it: CAS and snapshot delete, idempotently. Test in Task 4 (`TestSeedPlacedIsFinishedOnTheNextWake`).
33
34---
35
36## File structure
37
38| path | responsibility |
39|---|---|
40| `internal/registry/registry.go` | `Route.SeedSource`, column `seed_source`, `SetSeedSource` |
41| `internal/release/store.go` | `NewS3StoreIn(ctx, bucket, region)` |
42| `internal/lease/check.go` | `HasReplica` |
43| `internal/activator/seedplace.go` | `SeedObjects`, `SeedPlacementError`, `seededRestore`, `SeedPlaced` |
44| `internal/activator/seedplace_linux_test.go` | the placement tests |
45| `internal/activator/backend_exec.go` | `ExecBackend.Seeds`, `ClaimArmed`, `OnSeedPlaced`; `Restore` dispatches |
46| `internal/activator/backend_unit.go` | `SplitBackend.SeedPlaced` |
47| `internal/activator/activator.go` | `doWake` calls `SeedPlaced` after replication starts |
48| `internal/activator/logsink.go` | `memberSafeWakeReason` passes a `SeedPlacementError` sentence through |
49| `internal/provision/seedreconcile.go` | `SeedHooks`, `seedAction`, `finishAbortedSeed` |
50| `internal/provision/reconcile.go` | the dispatch call, the seed column on mint and converge |
51| `internal/host/seedwiring.go` | `storeSeedObjects`, `seedPlacedHook`, `armSeedHooks` |
52| `internal/host/agent.go` | arming beside the staging-fallback mirror |
53
54---
55
56### Task 1: The registry's `seed_source` column
57
58**Files:**
59- Modify: `internal/registry/registry.go` (`Route` struct after `HoldOp`; `migrate` `:524`; `routeCols` `:776`; `scanRoute` `:783`; `Upsert` `:977`)
60- Test: `internal/registry/registry_test.go` (`TestUpsertRoundTripsAllColumns` `:64`), `internal/registry/seedsource_test.go`
61
62**Interfaces:**
63- Produces: `Route.SeedSource string` (column `seed_source TEXT NOT NULL DEFAULT ''`; `seeds.RowSeed`'s encoding, `""` for none); `(*Registry).SetSeedSource(host, v string) error` (errors when no row matched). `Upsert`'s conflict path keeps a non-empty existing value when the incoming one is empty, like `seed`. Not in `Fingerprint`: the edge does not route on it. In `SnapshotFingerprint` only when set.
64
65- [ ] **Step 1: Write the failing tests**
66
67In `TestUpsertRoundTripsAllColumns`, add `SeedSource: `{"id":"sd_aaaaaaaaaaaaaaaaaaaaaaaaaa","kind":"replica","state":"ready"}`,` to `want`.
68
69`internal/registry/seedsource_test.go`:
70
71```go
72package registry
73
74import "testing"
75
76func TestSeedSourceColumn(t *testing.T) {
77 reg := openTestRegistry(t)
78 const col = `{"id":"sd_aaaaaaaaaaaaaaaaaaaaaaaaaa","kind":"replica","state":"ready"}`
79 if err := reg.Upsert(Route{Host: "c.example.com", Kind: "instance", SeedSource: col}); err != nil {
80 t.Fatal(err)
81 }
82 // A re-add that knows nothing of seeds keeps the column.
83 if err := reg.Upsert(Route{Host: "c.example.com", Kind: "instance"}); err != nil {
84 t.Fatal(err)
85 }
86 if got, _ := reg.Get("c.example.com"); got.SeedSource != col {
87 t.Fatalf("after a seedless Upsert: %q", got.SeedSource)
88 }
89 placed := `{"id":"sd_aaaaaaaaaaaaaaaaaaaaaaaaaa","kind":"replica","state":"placed"}`
90 if err := reg.SetSeedSource("c.example.com", placed); err != nil {
91 t.Fatal(err)
92 }
93 if got, _ := reg.Get("c.example.com"); got.SeedSource != placed {
94 t.Fatalf("after SetSeedSource: %q", got.SeedSource)
95 }
96 if err := reg.SetSeedSource("nope.example.com", placed); err == nil {
97 t.Fatal("SetSeedSource on no row: want an error")
98 }
99 before, _ := reg.Fingerprint()
100 _ = reg.SetSeedSource("c.example.com", col)
101 if after, _ := reg.Fingerprint(); after != before {
102 t.Fatal("the seed column moved the edge's fingerprint")
103 }
104}
105```
106
107- [ ] **Step 2: Run the tests to verify they fail**
108
109Run: `go test ./internal/registry/ -run 'TestUpsertRoundTripsAllColumns|TestSeedSourceColumn' -count=1`
110Expected: FAIL to compile: `unknown field SeedSource`.
111
112- [ ] **Step 3: Add the column in its four places**
113
114- `Route`, after `HoldOp`:
115
116```go
117 // SeedSource is the record's seed as this row needs it
118 // (seeds.RowSeed's JSON, "" for none), written by the provision pass
119 // (seeded-instances spec §3.4): the activator's restore fallback reads
120 // it, never the record. Upsert keeps a non-empty value over an empty
121 // one; SetSeedSource converges it. Not in Fingerprint.
122 SeedSource string
123```
124
125- `migrate`'s ALTER list, after `hold_op`: `` `ALTER TABLE routes ADD COLUMN seed_source TEXT NOT NULL DEFAULT ''`, ``
126- `routeCols`: append `, seed_source` after `hold_op`, and update its comment's count from 47 to 48.
127- `scanRoute`: append `&t.SeedSource` after `&t.HoldOp`.
128- `Upsert`: add one `?` to `VALUES` (48), append `t.SeedSource` to the argument list after `t.HoldOp`, and add to the `ON CONFLICT … DO UPDATE SET` list: `seed_source=CASE WHEN excluded.seed_source = '' THEN routes.seed_source ELSE excluded.seed_source END,` (before `args=`).
129- `SnapshotFingerprint` (`:2657`) hashes every `routeCols` column; give `seed_source` `hold_op`'s rule there (hashed only when non-empty, tagged `seed_source=`), so a registry with no seeded rows digests exactly as before. Add a case to the snapshot digest's existing stability test (find it with `grep -n SnapshotFingerprint internal/registry/*_test.go`) asserting an unseeded registry's digest is unchanged by the migration.
130
131Then:
132
133```go
134// SetSeedSource converges a row's seed column from its record.
135func (r *Registry) SetSeedSource(host, v string) error {
136 res, err := r.sql.Exec(`UPDATE routes SET seed_source = ? WHERE host = ?`, v, host)
137 if err != nil {
138 return err
139 }
140 n, err := res.RowsAffected()
141 if err != nil {
142 return err
143 }
144 if n == 0 {
145 return fmt.Errorf("no route for host %q", host)
146 }
147 return nil
148}
149```
150
151- [ ] **Step 4: Run the registry package**
152
153Run: `go test ./internal/registry/ -count=1`
154Expected: PASS.
155
156- [ ] **Step 5: Commit**
157
158```bash
159git add internal/registry/registry.go internal/registry/registry_test.go internal/registry/seedsource_test.go
160git commit -m "registry: the seed_source column (Plan 4 Task 1)
161
162Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
163```
164
165### Task 2: `lease.HasReplica` and `release.NewS3StoreIn`
166
167**Files:**
168- Modify: `internal/lease/check.go` (add `HasReplica`), `internal/release/store.go:687` (add `NewS3StoreIn`)
169- Test: `internal/lease/hasreplica_test.go`, `internal/release/store_test.go`
170
171**Interfaces:**
172- Produces: `lease.HasReplica(ctx context.Context, bucket, prefix, region string) (bool, error)`: true when any LTX file exists at any level 0 to 9 under `prefix`; an error when the listing fails. `release.NewS3StoreIn(ctx context.Context, bucket, region string) (*S3Store, error)`: `NewS3Store` with the AWS region pinned when `region != ""`.
173
174- [ ] **Step 1: Write the failing tests**
175
176`internal/lease/hasreplica_test.go`. The listing itself is a small function over anything with litestream's `LTXFiles`, so the test needs no S3:
177
178```go
179package lease
180
181import (
182 "context"
183 "errors"
184 "testing"
185
186 "github.com/superfly/ltx"
187)
188
189type fakeLister struct {
190 levels map[int][]*ltx.FileInfo
191 err error
192 asked []int
193}
194
195func (f *fakeLister) LTXFiles(_ context.Context, level int, _ ltx.TXID, _ bool) (ltx.FileIterator, error) {
196 f.asked = append(f.asked, level)
197 if f.err != nil {
198 return nil, f.err
199 }
200 return ltx.NewFileInfoSliceIterator(f.levels[level]), nil
201}
202
203func TestHasReplicaIn(t *testing.T) {
204 ctx := context.Background()
205 empty := &fakeLister{}
206 if has, err := hasReplicaIn(ctx, empty); has || err != nil {
207 t.Fatalf("empty = %v, %v", has, err)
208 }
209 if len(empty.asked) != 10 {
210 t.Fatalf("levels asked = %v, want 0 to 9", empty.asked)
211 }
212 snap := &fakeLister{levels: map[int][]*ltx.FileInfo{9: {{Level: 9, MinTXID: 1, MaxTXID: 7, Size: 4096}}}}
213 if has, err := hasReplicaIn(ctx, snap); !has || err != nil {
214 t.Fatalf("a snapshot at level 9 = %v, %v", has, err)
215 }
216 broken := &fakeLister{err: errors.New("listing failed")}
217 if has, err := hasReplicaIn(ctx, broken); has || err == nil {
218 t.Fatalf("a failed listing = %v, %v; want an error, never false", has, err)
219 }
220}
221```
222
223In `internal/release/store_test.go`:
224
225```go
226func TestNewS3StoreInPinsTheRegion(t *testing.T) {
227 t.Setenv("AWS_REGION", "us-east-1")
228 t.Setenv("AWS_ACCESS_KEY_ID", "k")
229 t.Setenv("AWS_SECRET_ACCESS_KEY", "s")
230 s, err := NewS3StoreIn(context.Background(), "b", "eu-west-1")
231 if err != nil {
232 t.Fatal(err)
233 }
234 if got := s.Client.Options().Region; got != "eu-west-1" {
235 t.Fatalf("region = %q, want eu-west-1", got)
236 }
237}
238```
239
240- [ ] **Step 2: Run the tests to verify they fail**
241
242Run: `go test ./internal/lease/ -run TestHasReplicaIn -count=1; go test ./internal/release/ -run TestNewS3StoreIn -count=1`
243Expected: FAIL to compile.
244
245- [ ] **Step 3: Write both**
246
247In `internal/lease/check.go`:
248
249```go
250// HasReplica reports whether s3://bucket/prefix holds any LTX file at any
251// level: the seed fallback's "has this host a replica of its own" (seeded
252// instances spec §3.4 step 1). A listing error is returned, never read as
253// "no replica".
254func HasReplica(ctx context.Context, bucket, prefix, region string) (bool, error) {
255 region, err := resolveRegion(ctx, region)
256 if err != nil {
257 return false, err
258 }
259 return hasReplicaIn(ctx, newReplicaClient(bucket, prefix, region))
260}
261
262// ltxLister is the one replica-client method HasReplica needs.
263type ltxLister interface {
264 LTXFiles(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error)
265}
266
267func hasReplicaIn(ctx context.Context, client ltxLister) (bool, error) {
268 for level := 0; level <= litestream.SnapshotLevel; level++ {
269 itr, err := client.LTXFiles(ctx, level, 0, false)
270 if err != nil {
271 return false, err
272 }
273 found := itr.Next()
274 ierr := itr.Err()
275 cerr := itr.Close()
276 if ierr != nil {
277 return false, ierr
278 }
279 if cerr != nil {
280 return false, cerr
281 }
282 if found {
283 return true, nil
284 }
285 }
286 return false, nil
287}
288```
289
290(`litestream.SnapshotLevel` is 9 in v0.5.14; add the `litestream` and `github.com/superfly/ltx` imports if `check.go` lacks them.)
291
292In `internal/release/store.go`, refactor `NewS3Store`'s body into `newS3Store(ctx, bucket string, opts ...func(*awsconfig.LoadOptions) error)` passing `opts` to `awsconfig.LoadDefaultConfig(ctx, opts...)`, keep `NewS3Store(ctx, bucket)` as `return newS3Store(ctx, bucket)`, and add:
293
294```go
295// NewS3StoreIn is NewS3Store with the bucket's region pinned: a box's or a
296// console's data bucket is named with its region beside it
297// (CARLOS_HIBERNATE_REGION), and the default chain's region may be
298// another. An empty region is NewS3Store.
299func NewS3StoreIn(ctx context.Context, bucket, region string) (*S3Store, error) {
300 if region == "" {
301 return newS3Store(ctx, bucket)
302 }
303 return newS3Store(ctx, bucket, awsconfig.WithRegion(region))
304}
305```
306
307- [ ] **Step 4: Run the tests to verify they pass**
308
309Run: `go test ./internal/lease/ -run TestHasReplicaIn -count=1 && go test ./internal/release/ -run 'TestNewS3Store' -count=1`
310Expected: PASS.
311
312- [ ] **Step 5: Commit**
313
314```bash
315git add internal/lease/check.go internal/lease/hasreplica_test.go internal/release/store.go internal/release/store_test.go
316git commit -m "lease.HasReplica and release.NewS3StoreIn for the seed fallback (Plan 4 Task 2)
317
318Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
319```
320
321### Task 3: The restore fallback
322
323**Files:**
324- Create: `internal/activator/seedplace.go`, `internal/activator/seedplace_linux_test.go`
325- Modify: `internal/activator/backend_exec.go:611` (`Restore` dispatches; the existing body becomes `restorePlain`), the `ExecBackend` struct (fields `Seeds`, `ClaimArmed`)
326- Modify: `internal/activator/logsink.go:49` (`memberSafeWakeReason`)
327
328**Interfaces:**
329- Consumes: `seeds.ParseRowSeed`, `seeds.RowSeed.Placeable`, `lease.HasReplica`, `lease.CheckDBContext`, `instancefs.OpenBeneath`, `instancefs.ReadBeneathLimit`, `instancefs.WriteAtomic`, `instancefs.RenameBetween`, `instancefs.RemoveBeneath`, `PurgeLocalDBArtifacts`, `b.pathsFor(rt)`, `LayoutNested`, `b.Cfg.Bucket`, `b.Cfg.hibernatePrefix(host)`, `b.Cfg.Region`.
330- Produces: `type SeedObjects interface{ Open(ctx context.Context, host, id string) (io.ReadCloser, error) }`; `ExecBackend.Seeds SeedObjects`; `ExecBackend.ClaimArmed bool`; `type SeedPlacementError struct{ Sentence string; Err error }`; `SeedMarkerSuffix = ".seed"`; package vars `hasOwnReplica = lease.HasReplica`, `checkSeedDB = lease.CheckDBContext`.
331
332The fallback's order (spec §3.4):
333
3341. Claim armed: refuse (SD-N18). Not nested (`pathsFor(rt).layout != LayoutNested`): refuse (SD-N17).
3352. The host has a replica of its own (`hasOwnReplica`): restore plainly; if the seed is not yet `placed`, finish placement best-effort (Task 4's `finishPlacement`); never report fresh. A listing error fails the wake.
3363. A local database whose `<db>.seed` names this seed's ID: keep it; replication starts in `doWake`.
3374. Otherwise purge the local database, its `-wal` and `-shm`, its marker and litestream's metadata; fetch the snapshot to `.seed-<id>.tmp` in the instance directory, hashing as it writes; refuse a length or sha256 that differs (SD-N14); run `CheckDBContext` (SD-N15); write the marker, then rename the file onto the database path (SD-N19 on failure). A read failure is SD-N16.
338
339- [ ] **Step 1: Write the failing tests**
340
341`internal/activator/seedplace_linux_test.go`:
342
343```go
344//go:build linux
345
346package activator
347
348import (
349 "bytes"
350 "context"
351 "crypto/sha256"
352 "encoding/hex"
353 "errors"
354 "io"
355 "os"
356 "path/filepath"
357 "strings"
358 "testing"
359
360 "github.com/carlosframework/platform/internal/registry"
361 "github.com/carlosframework/platform/internal/seeds"
362)
363
364type fakeSeedObjects struct {
365 body map[string][]byte // id -> snapshot
366 err error
367 opens int
368}
369
370func (f *fakeSeedObjects) Open(_ context.Context, host, id string) (io.ReadCloser, error) {
371 f.opens++
372 if f.err != nil {
373 return nil, f.err
374 }
375 b, ok := f.body[id]
376 if !ok {
377 return nil, os.ErrNotExist
378 }
379 return io.NopCloser(bytes.NewReader(b)), nil
380}
381
382// seededRoute is nestedBackend's row with a seed column for a snapshot
383// made by writeMinimalSQLite.
384func seededRoute(t *testing.T, b *ExecBackend, rt registry.Route, state string) (registry.Route, *fakeSeedObjects, string) {
385 t.Helper()
386 id, _ := seeds.NewID()
387 snap := filepath.Join(t.TempDir(), "snap.db")
388 if err := writeMinimalSQLite(snap); err != nil {
389 t.Fatal(err)
390 }
391 body, _ := os.ReadFile(snap)
392 sum := sha256.Sum256(body)
393 rt.SeedSource = seeds.RowSeed{ID: id, Kind: seeds.KindReplica, SHA256: hex.EncodeToString(sum[:]), Bytes: int64(len(body)), State: state}.Encode()
394 objs := &fakeSeedObjects{body: map[string][]byte{id: body}}
395 b.Seeds = objs
396 return rt, objs, id
397}
398
399func noOwnReplica(t *testing.T) {
400 t.Helper()
401 old := hasOwnReplica
402 hasOwnReplica = func(context.Context, string, string, string) (bool, error) { return false, nil }
403 t.Cleanup(func() { hasOwnReplica = old })
404}
405
406func TestSeededRestorePlacesTheSnapshot(t *testing.T) {
407 noOwnReplica(t)
408 b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
409 rt, objs, id := seededRoute(t, b, rt, seeds.StateReady)
410 fresh, err := b.Restore(context.Background(), rt)
411 if err != nil || fresh {
412 t.Fatalf("Restore = fresh %v, %v; want placed, never fresh", fresh, err)
413 }
414 if objs.opens != 1 {
415 t.Fatalf("snapshot opened %d times", objs.opens)
416 }
417 marker, err := os.ReadFile(rt.DBPath + SeedMarkerSuffix)
418 if err != nil || strings.TrimSpace(string(marker)) != id {
419 t.Fatalf("marker = %q, %v; want the seed id", marker, err)
420 }
421 if err := checkSeedDB(context.Background(), rt.DBPath); err != nil {
422 t.Fatalf("the placed database does not check: %v", err)
423 }
424 // A second wake (a crash after the rename, before the first upload)
425 // keeps the placed file and does not fetch again.
426 if fresh, err := b.Restore(context.Background(), rt); err != nil || fresh || objs.opens != 1 {
427 t.Fatalf("resume: fresh %v err %v opens %d", fresh, err, objs.opens)
428 }
429}
430
431func TestSeededRestorePurgesAnotherSeedsDatabase(t *testing.T) {
432 noOwnReplica(t)
433 b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
434 rt, _, id := seededRoute(t, b, rt, seeds.StateSwapped)
435 os.MkdirAll(filepath.Dir(rt.DBPath), 0o700)
436 os.WriteFile(rt.DBPath, []byte("an aborted import's data"), 0o600)
437 os.WriteFile(rt.DBPath+"-wal", []byte("w"), 0o600)
438 os.WriteFile(rt.DBPath+"-shm", []byte("s"), 0o600)
439 os.WriteFile(rt.DBPath+SeedMarkerSuffix, []byte("sd_bbbbbbbbbbbbbbbbbbbbbbbbbb\n"), 0o600)
440 meta := filepath.Join(filepath.Dir(rt.DBPath), ".activator-"+filepath.Base(rt.DBPath)+"-litestream")
441 os.MkdirAll(meta, 0o700)
442 if _, err := b.Restore(context.Background(), rt); err != nil {
443 t.Fatal(err)
444 }
445 for _, p := range []string{rt.DBPath + "-wal", rt.DBPath + "-shm", meta} {
446 if _, err := os.Stat(p); !os.IsNotExist(err) {
447 t.Errorf("%s survived the purge", p)
448 }
449 }
450 if m, _ := os.ReadFile(rt.DBPath + SeedMarkerSuffix); strings.TrimSpace(string(m)) != id {
451 t.Fatalf("marker = %q, want %s", m, id)
452 }
453 if b, _ := os.ReadFile(rt.DBPath); bytes.Equal(b, []byte("an aborted import's data")) {
454 t.Fatal("the other seed's database stood in for this one")
455 }
456}
457
458func TestSeededRestoreRefusesABadSnapshot(t *testing.T) {
459 noOwnReplica(t)
460 for _, c := range []struct {
461 name string
462 mangle func(*fakeSeedObjects, string)
463 want string
464 }{
465 {"short", func(f *fakeSeedObjects, id string) { f.body[id] = f.body[id][:100] }, "length or sha256"},
466 {"wrong bytes", func(f *fakeSeedObjects, id string) { f.body[id] = bytes.Repeat([]byte{'x'}, len(f.body[id])) }, "length or sha256"},
467 {"unreadable", func(f *fakeSeedObjects, id string) { f.err = errors.New("bucket down") }, "could not be read"},
468 } {
469 t.Run(c.name, func(t *testing.T) {
470 b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
471 rt, objs, id := seededRoute(t, b, rt, seeds.StateReady)
472 c.mangle(objs, id)
473 fresh, err := b.Restore(context.Background(), rt)
474 var spe *SeedPlacementError
475 if fresh || !errors.As(err, &spe) || !strings.Contains(spe.Sentence, c.want) {
476 t.Fatalf("Restore = fresh %v, %v; want a %q refusal", fresh, err, c.want)
477 }
478 if _, err := os.Stat(rt.DBPath); !os.IsNotExist(err) {
479 t.Fatal("a refused snapshot left a database at the row's path")
480 }
481 })
482 }
483}
484
485func TestSeededRestoreRefusesANonDatabase(t *testing.T) {
486 noOwnReplica(t)
487 b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
488 id, _ := seeds.NewID()
489 body := []byte("not sqlite at all, but the right length and hash")
490 sum := sha256.Sum256(body)
491 rt.SeedSource = seeds.RowSeed{ID: id, Kind: seeds.KindImport, SHA256: hex.EncodeToString(sum[:]), Bytes: int64(len(body)), State: seeds.StateSwapped}.Encode()
492 b.Seeds = &fakeSeedObjects{body: map[string][]byte{id: body}}
493 _, err := b.Restore(context.Background(), rt)
494 var spe *SeedPlacementError
495 if !errors.As(err, &spe) || !strings.Contains(spe.Sentence, "integrity check") {
496 t.Fatalf("Restore = %v; want the integrity refusal", err)
497 }
498}
499
500func TestSeededRestoreRefusesClaimAndFlat(t *testing.T) {
501 noOwnReplica(t)
502 b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
503 rt, _, _ = seededRoute(t, b, rt, seeds.StateReady)
504 b.ClaimArmed = true
505 var spe *SeedPlacementError
506 if _, err := b.Restore(context.Background(), rt); !errors.As(err, &spe) || !strings.Contains(spe.Sentence, "claim control") {
507 t.Fatalf("claim-armed: %v", err)
508 }
509 b.ClaimArmed = false
510 flat := testExecBackend(t)
511 flat.Seeds = b.Seeds
512 frt := registry.Route{Host: rt.Host, Kind: "instance", Hibernate: true, SeedSource: rt.SeedSource,
513 Socket: filepath.Join(t.TempDir(), "x.sock")} // DBPath empty: the flat default
514 if _, err := flat.Restore(context.Background(), frt); !errors.As(err, &spe) || !strings.Contains(spe.Sentence, "nested") {
515 t.Fatalf("flat: %v", err)
516 }
517}
518
519func TestSeededRestoreFailsWhenTheReplicaCannotBeListed(t *testing.T) {
520 old := hasOwnReplica
521 hasOwnReplica = func(context.Context, string, string, string) (bool, error) { return false, errors.New("listing failed") }
522 defer func() { hasOwnReplica = old }()
523 b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
524 rt, objs, _ := seededRoute(t, b, rt, seeds.StateReady)
525 if fresh, err := b.Restore(context.Background(), rt); err == nil || fresh || objs.opens != 0 {
526 t.Fatalf("Restore = fresh %v, %v, opens %d; want a failed wake and no fetch", fresh, err, objs.opens)
527 }
528}
529
530func TestSeededRestoreUsesItsOwnReplicaOnceItHasOne(t *testing.T) {
531 old := hasOwnReplica
532 hasOwnReplica = func(context.Context, string, string, string) (bool, error) { return true, nil }
533 defer func() { hasOwnReplica = old }()
534 oldRestore := restoreReplica
535 restored := 0
536 restoreReplica = func(_ context.Context, dbPath, _, _, _ string) (bool, error) {
537 restored++
538 return true, writeMinimalSQLite(dbPath)
539 }
540 defer func() { restoreReplica = oldRestore }()
541 b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
542 rt, objs, _ := seededRoute(t, b, rt, seeds.StatePlaced)
543 if fresh, err := b.Restore(context.Background(), rt); err != nil || fresh || restored != 1 || objs.opens != 0 {
544 t.Fatalf("fresh %v err %v restored %d opens %d; want the own replica, not the seed", fresh, err, restored, objs.opens)
545 }
546}
547
548func TestMemberSafeWakeReasonPassesTheSeedSentence(t *testing.T) {
549 err := fmtWrap(seedRefusedFlat("canary.go.tito.io"))
550 if got := memberSafeWakeReason(err); got != "wake failed: canary.go.tito.io could not start from its seed: this box does not run instances nested." {
551 t.Fatalf("reason = %q", got)
552 }
553}
554
555func fmtWrap(err error) error { return errors.Join(errors.New("restore canary.go.tito.io"), err) }
556```
557
558- [ ] **Step 2: Run the tests to verify they fail**
559
560Run: `go test ./internal/activator/ -run 'TestSeeded|TestMemberSafeWakeReasonPassesTheSeedSentence' -count=1`
561Expected: FAIL to compile: `undefined: SeedMarkerSuffix`, `b.Seeds undefined`.
562
563- [ ] **Step 3: Add the fields and the dispatch**
564
565In the `ExecBackend` struct (`backend_exec.go`), beside `NestedOK`:
566
567```go
568 // Seeds reads a seed's snapshot from this region's data bucket
569 // (seeded-instances spec §3.4). Nil: no seed can be placed here, and a
570 // seeded row's wake fails rather than starting empty.
571 Seeds SeedObjects
572 // ClaimArmed is CARLOS_HOST_CLAIM on this box. The seed fallback is the
573 // plain path only; with claims armed it refuses (spec §3.4, #250).
574 ClaimArmed bool
575 // OnSeedPlaced records a seed placed (Task 4): the record's CAS to
576 // placed, then the snapshot's delete. Nil: not recorded here.
577 OnSeedPlaced func(ctx context.Context, account, app, host, id string) error
578```
579
580Rename the current `Restore` (`:611-701`) to `restorePlain`, unchanged, and add above it:
581
582```go
583func (b *ExecBackend) Restore(ctx context.Context, rt registry.Route) (bool, error) {
584 src, err := seeds.ParseRowSeed(rt.SeedSource)
585 if err != nil {
586 return false, fmt.Errorf("restore %s: %w", rt.Host, err)
587 }
588 if src != nil && src.Placeable() {
589 return b.seededRestore(ctx, rt, src)
590 }
591 return b.restorePlain(ctx, rt)
592}
593```
594
595- [ ] **Step 4: Write `internal/activator/seedplace.go`**
596
597```go
598package activator
599
600import (
601 "context"
602 "crypto/sha256"
603 "encoding/hex"
604 "errors"
605 "fmt"
606 "io"
607 "os"
608 "path/filepath"
609 "strings"
610
611 "github.com/carlosframework/platform/internal/instancefs"
612 "github.com/carlosframework/platform/internal/lease"
613 "github.com/carlosframework/platform/internal/registry"
614 "github.com/carlosframework/platform/internal/seeds"
615)
616
617// SeedObjects reads a seed's snapshot, seeds/<host>/<id>/snapshot.db, from
618// this box's own region data bucket.
619type SeedObjects interface {
620 Open(ctx context.Context, host, id string) (io.ReadCloser, error)
621}
622
623// SeedMarkerSuffix names the installation evidence beside a placed
624// database: <db>.seed holds the seed's id (spec §3.4 step 3, codex R2-1).
625const SeedMarkerSuffix = ".seed"
626
627// hasOwnReplica and checkSeedDB are seams for the tests.
628var (
629 hasOwnReplica = lease.HasReplica
630 checkSeedDB = lease.CheckDBContext
631)
632
633// SeedPlacementError is a wake the seed fallback failed or refused. Its
634// Sentence is the member's (SD-N14 to SD-N19): memberSafeWakeReason passes
635// it to carlos logs whole.
636type SeedPlacementError struct {
637 Sentence string
638 Err error
639}
640
641func (e *SeedPlacementError) Error() string { return e.Sentence }
642func (e *SeedPlacementError) Unwrap() error { return e.Err }
643
644func seedMismatch(host string) error {
645 return &SeedPlacementError{Sentence: fmt.Sprintf("%s could not start from its seed: the snapshot's length or sha256 does not match its record.", host)}
646}
647
648func seedNotDB(host string, err error) error {
649 return &SeedPlacementError{Sentence: fmt.Sprintf("%s could not start from its seed: the snapshot is not a database that passes an integrity check.", host), Err: err}
650}
651
652func seedUnread(host string, err error) error {
653 return &SeedPlacementError{Sentence: fmt.Sprintf("%s could not start from its seed: the snapshot could not be read. The next wake tries again.", host), Err: err}
654}
655
656func seedRefusedFlat(host string) error {
657 return &SeedPlacementError{Sentence: fmt.Sprintf("%s could not start from its seed: this box does not run instances nested.", host)}
658}
659
660func seedRefusedClaim(host string) error {
661 return &SeedPlacementError{Sentence: fmt.Sprintf("%s could not start from its seed: claim control is on for this box.", host)}
662}
663
664func seedPlaceFailed(host string, err error) error {
665 return &SeedPlacementError{Sentence: fmt.Sprintf("%s could not start from its seed: it could not be put in place. The next wake tries again.", host), Err: err}
666}
667
668// seededRestore is the plain-path seed fallback (spec §3.4). It never
669// reports fresh: a seeded row never runs the manifest's seed command, and
670// every failure fails the wake instead of falling through to an empty
671// database.
672func (b *ExecBackend) seededRestore(ctx context.Context, rt registry.Route, src *seeds.RowSeed) (bool, error) {
673 host := rt.Host
674 if b.ClaimArmed {
675 return false, seedRefusedClaim(host)
676 }
677 paths := b.pathsFor(rt)
678 if paths.layout != LayoutNested {
679 return false, seedRefusedFlat(host)
680 }
681 // Step 1: a replica of its own means this host was placed already.
682 has, err := hasOwnReplica(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host), b.Cfg.Region)
683 if err != nil {
684 return false, fmt.Errorf("seed fallback for %s: cannot tell whether it has a replica of its own: %w", host, err)
685 }
686 if has {
687 if _, err := b.restorePlain(ctx, rt); err != nil {
688 return false, err
689 }
690 if src.State != seeds.StatePlaced {
691 b.finishPlacement(ctx, rt, src)
692 }
693 return false, nil
694 }
695 dbPath := paths.dbPath
696 dir, base := filepath.Dir(dbPath), filepath.Base(dbPath)
697 if _, err := os.Stat(dbPath); err == nil {
698 if m, err := instancefs.ReadBeneathLimit(dir, base+SeedMarkerSuffix, 128); err == nil && strings.TrimSpace(string(m)) == src.ID {
699 return false, nil // this seed, placed by an interrupted first wake
700 }
701 } else if !errors.Is(err, os.ErrNotExist) {
702 return false, err
703 }
704 if b.Seeds == nil {
705 return false, seedUnread(host, errors.New("no seed reader on this box"))
706 }
707 // Anything else is purged: another seed's database, one with no
708 // marker, their WAL and SHM, the marker, litestream's metadata.
709 PurgeLocalDBArtifacts(dbPath, false)
710 for _, name := range []string{base, base + SeedMarkerSuffix} {
711 if err := instancefs.RemoveBeneath(dir, name); err != nil && !errors.Is(err, os.ErrNotExist) {
712 return false, seedPlaceFailed(host, err)
713 }
714 }
715 if err := os.MkdirAll(dir, 0o700); err != nil {
716 return false, seedPlaceFailed(host, err)
717 }
718 tmp := ".seed-" + src.ID + ".tmp"
719 _ = instancefs.RemoveBeneath(dir, tmp)
720 if err := b.fetchSeed(ctx, host, src, dir, tmp); err != nil {
721 _ = instancefs.RemoveBeneath(dir, tmp)
722 return false, err
723 }
724 if err := checkSeedDB(ctx, filepath.Join(dir, tmp)); err != nil {
725 _ = instancefs.RemoveBeneath(dir, tmp)
726 return false, seedNotDB(host, err)
727 }
728 // The marker first: a database at the row's path always has one.
729 if err := instancefs.WriteAtomic(dir, base+SeedMarkerSuffix, []byte(src.ID+"\n"), 0o600); err != nil {
730 _ = instancefs.RemoveBeneath(dir, tmp)
731 return false, seedPlaceFailed(host, err)
732 }
733 if err := instancefs.RenameBetween(dir, tmp, dir, base); err != nil {
734 _ = instancefs.RemoveBeneath(dir, tmp)
735 return false, seedPlaceFailed(host, err)
736 }
737 return false, nil
738}
739
740// fetchSeed copies the snapshot into dir/tmp, checking its length and
741// sha256 against the row as it writes.
742func (b *ExecBackend) fetchSeed(ctx context.Context, host string, src *seeds.RowSeed, dir, tmp string) error {
743 rc, err := b.Seeds.Open(ctx, host, src.ID)
744 if err != nil {
745 return seedUnread(host, err)
746 }
747 defer rc.Close()
748 f, err := instancefs.OpenBeneath(dir, tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
749 if err != nil {
750 return seedPlaceFailed(host, err)
751 }
752 h := sha256.New()
753 n, cerr := io.Copy(io.MultiWriter(f, h), io.LimitReader(rc, src.Bytes+1))
754 serr := f.Sync()
755 ferr := f.Close()
756 if cerr != nil {
757 return seedUnread(host, cerr)
758 }
759 if serr != nil || ferr != nil {
760 return seedPlaceFailed(host, errors.Join(serr, ferr))
761 }
762 if n != src.Bytes || hex.EncodeToString(h.Sum(nil)) != src.SHA256 {
763 return seedMismatch(host)
764 }
765 return nil
766}
767```
768
769`finishPlacement` is Task 4's; for this task add a stub in `seedplace.go` that does nothing (`func (b *ExecBackend) finishPlacement(context.Context, registry.Route, *seeds.RowSeed) {}`), replaced in Task 4.
770
771In `logsink.go`'s `memberSafeWakeReason`, first thing:
772
773```go
774 var seed *SeedPlacementError
775 if errors.As(err, &seed) {
776 return "wake failed: " + seed.Sentence
777 }
778```
779
780- [ ] **Step 5: Run the tests to verify they pass**
781
782Run: `go test ./internal/activator/ -run 'TestSeeded|TestMemberSafeWakeReason|TestExecRestore|TestStaging|TestRestore' -count=1`
783Expected: PASS.
784
785- [ ] **Step 6: Wire SD-N14 to SD-N19 and commit**
786
787Append `"SD-N14", "SD-N15", "SD-N16", "SD-N17", "SD-N18", "SD-N19"` to `seededWiredIDs`; run `go test . -run TestSeededWiredCopyIsApplied -count=1` (PASS).
788
789```bash
790git add internal/activator/seedplace.go internal/activator/seedplace_linux_test.go internal/activator/backend_exec.go internal/activator/logsink.go approved_seeded_copy_test.go
791git commit -m "activator: the seed fallback in Restore, marker, purge and checks (Plan 4 Task 3)
792
793Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
794```
795
796### Task 4: Placed after the first upload
797
798**Files:**
799- Modify: `internal/activator/seedplace.go` (`SeedPlaced`, `finishPlacement`)
800- Modify: `internal/activator/backend_unit.go:311` (`SplitBackend.SeedPlaced`)
801- Modify: `internal/activator/activator.go:1321-1330` (`doWake`, after `lease.Replicate`)
802- Test: `internal/activator/seedplace_linux_test.go`, `internal/activator/activator_test.go`
803
804**Interfaces:**
805- Consumes: `ExecBackend.OnSeedPlaced` (Task 3's field), `Rep`.
806- Produces: `(b *ExecBackend) SeedPlaced(ctx context.Context, rt registry.Route, rep Rep) error`; `(b *SplitBackend) SeedPlaced(...)`; unexported `seedPlacer` interface in `activator.go`.
807
808`SeedPlaced` does nothing for a row whose seed is absent, not placeable, or already `placed`. Otherwise it waits for the first upload (`SyncAndWait` on the replicator, when `rep` offers it) and calls `OnSeedPlaced`. `doWake` logs a failure and goes on serving: the next wake finds the host's own replica and `finishPlacement` records it then.
809
810- [ ] **Step 1: Write the failing tests**
811
812Append to `seedplace_linux_test.go`:
813
814```go
815type syncRep struct {
816 synced int
817 err error
818}
819
820func (r *syncRep) Err() error { return nil }
821func (r *syncRep) Close(context.Context) error { return nil }
822func (r *syncRep) SyncAndWait(context.Context) error { r.synced++; return r.err }
823
824func TestSeedPlacedWaitsForTheFirstUpload(t *testing.T) {
825 b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
826 rt, _, id := seededRoute(t, b, rt, seeds.StateReady)
827 rt.Account, rt.App = "bae", "titogo"
828 var calls []string
829 b.OnSeedPlaced = func(_ context.Context, acct, app, host, got string) error {
830 calls = append(calls, acct+"/"+app+"/"+host+"/"+got)
831 return nil
832 }
833 rep := &syncRep{}
834 if err := b.SeedPlaced(context.Background(), rt, rep); err != nil {
835 t.Fatal(err)
836 }
837 if rep.synced != 1 || len(calls) != 1 || calls[0] != "bae/titogo/"+rt.Host+"/"+id {
838 t.Fatalf("synced %d, calls %q", rep.synced, calls)
839 }
840 // A failed first upload records nothing.
841 rep = &syncRep{err: errors.New("upload failed")}
842 calls = nil
843 if err := b.SeedPlaced(context.Background(), rt, rep); err == nil || len(calls) != 0 {
844 t.Fatalf("after a failed sync: err %v calls %q", err, calls)
845 }
846 // Placed already, or no seed: nothing at all.
847 rt.SeedSource = strings.Replace(rt.SeedSource, `"state":"ready"`, `"state":"placed"`, 1)
848 rep = &syncRep{}
849 if err := b.SeedPlaced(context.Background(), rt, rep); err != nil || rep.synced != 0 || len(calls) != 0 {
850 t.Fatalf("placed row: err %v synced %d calls %q", err, rep.synced, calls)
851 }
852}
853
854func TestSeedPlacedIsFinishedOnTheNextWake(t *testing.T) {
855 old := hasOwnReplica
856 hasOwnReplica = func(context.Context, string, string, string) (bool, error) { return true, nil }
857 defer func() { hasOwnReplica = old }()
858 oldRestore := restoreReplica
859 restoreReplica = func(_ context.Context, dbPath, _, _, _ string) (bool, error) { return true, writeMinimalSQLite(dbPath) }
860 defer func() { restoreReplica = oldRestore }()
861 b, rt := nestedBackend(t, &fakeControl{}, &fakeUnit{})
862 rt, _, id := seededRoute(t, b, rt, seeds.StateReady) // the CAS was lost last wake
863 rt.Account, rt.App = "bae", "titogo"
864 var got []string
865 b.OnSeedPlaced = func(_ context.Context, _, _, _, seed string) error { got = append(got, seed); return nil }
866 if _, err := b.Restore(context.Background(), rt); err != nil {
867 t.Fatal(err)
868 }
869 if len(got) != 1 || got[0] != id {
870 t.Fatalf("finishPlacement calls = %q, want one for %s", got, id)
871 }
872}
873```
874
875In `activator_test.go`, add a doWake-level test on the package's `fakeBackend` (`activator_test.go:215`), wired the way `testActivator` (`:620`) wires it:
876
877```go
878// seedCountingBackend is fakeBackend plus the optional seed recorder.
879type seedCountingBackend struct {
880 fakeBackend
881 placed []string
882}
883
884func (b *seedCountingBackend) SeedPlaced(_ context.Context, rt registry.Route, _ Rep) error {
885 b.placed = append(b.placed, rt.Host)
886 return nil
887}
888
889func TestDoWakeRecordsSeedPlacementAfterReplication(t *testing.T) {
890 b := &seedCountingBackend{}
891 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
892 if err != nil {
893 t.Fatal(err)
894 }
895 a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, b, reg)
896 rt := testRoute("canary.example.com")
897 rt.SeedSource = seeds.RowSeed{ID: "sd_aaaaaaaaaaaaaaaaaaaaaaaaaa", Kind: seeds.KindReplica, State: seeds.StateReady}.Encode()
898 if err := reg.Upsert(rt); err != nil {
899 t.Fatal(err)
900 }
901 arrangeAwake(t, a, rt)
902 if len(b.placed) != 1 || b.placed[0] != rt.Host {
903 t.Fatalf("SeedPlaced calls = %q, want one, after replication started", b.placed)
904 }
905 if len(b.reps) != 1 {
906 t.Fatalf("replications = %d: SeedPlaced must follow Replicate", len(b.reps))
907 }
908}
909```
910
911(add the `seeds` import to `activator_test.go`).
912
913- [ ] **Step 2: Run the tests to verify they fail**
914
915Run: `go test ./internal/activator/ -run 'TestSeedPlaced|TestDoWakeRecordsSeedPlacement' -count=1`
916Expected: FAIL to compile: `b.SeedPlaced undefined`.
917
918- [ ] **Step 3: Write `SeedPlaced` and `finishPlacement`**
919
920Replace Task 3's stub in `seedplace.go`:
921
922```go
923// SeedPlaced records a seed placed once the host's own replica holds it
924// (spec §3.4 steps 4 and 5): it waits for the first upload, then the host
925// package CASes the record to placed and deletes seeds/<host>/<id>/. The
926// snapshot goes only after the replica exists, so a crash at any point
927// retries from the placed local file or the snapshot.
928func (b *ExecBackend) SeedPlaced(ctx context.Context, rt registry.Route, rep Rep) error {
929 src, err := seeds.ParseRowSeed(rt.SeedSource)
930 if err != nil || src == nil || !src.Placeable() || src.State == seeds.StatePlaced {
931 return err
932 }
933 if s, ok := rep.(interface{ SyncAndWait(context.Context) error }); ok {
934 if err := s.SyncAndWait(ctx); err != nil {
935 return fmt.Errorf("first upload of %s: %w", rt.Host, err)
936 }
937 }
938 if b.OnSeedPlaced == nil {
939 return errors.New("no seed recorder on this box")
940 }
941 return b.OnSeedPlaced(ctx, rt.Account, rt.App, rt.Host, src.ID)
942}
943
944// finishPlacement is SeedPlaced for a host whose own replica already
945// exists (a placed CAS lost on an earlier wake). Best effort: logged.
946func (b *ExecBackend) finishPlacement(ctx context.Context, rt registry.Route, src *seeds.RowSeed) {
947 if b.OnSeedPlaced == nil {
948 return
949 }
950 if err := b.OnSeedPlaced(ctx, rt.Account, rt.App, rt.Host, src.ID); err != nil {
951 log.Printf("activator: %s: seed %s not recorded as placed yet: %v", rt.Host, src.ID, err)
952 }
953}
954```
955
956(add `"log"` to the imports).
957
958In `backend_unit.go`, beside `AdmitConfig`:
959
960```go
961// SeedPlaced dispatches on Backing, like every other call. A unit tenant
962// never carries a seed.
963func (b *SplitBackend) SeedPlaced(ctx context.Context, rt registry.Route, rep Rep) error {
964 if p, ok := b.pick(rt).(seedPlacer); ok {
965 return p.SeedPlaced(ctx, rt, rep)
966 }
967 return nil
968}
969```
970
971In `activator.go`, beside the `Backend` interface:
972
973```go
974// seedPlacer is the optional half of a Backend that records a seed placed
975// after the first upload (seeded-instances spec §3.4).
976type seedPlacer interface {
977 SeedPlaced(ctx context.Context, rt registry.Route, rep Rep) error
978}
979```
980
981In `doWake`, immediately after the successful `lease.Replicate` (`w.rep = rep` and the error check), before step 6:
982
983```go
984 // 5b. A seeded row's first upload, then its record (spec §3.4 step 5).
985 // A failure here does not fail the wake: the instance is serving from
986 // the placed file, and the next wake records it (finishPlacement).
987 if p, ok := a.backend.(seedPlacer); ok {
988 if err := p.SeedPlaced(ctx, rt, rep); err != nil {
989 log.Printf("activator: %s: seed placement not recorded yet: %v", host, err)
990 }
991 }
992```
993
994- [ ] **Step 4: Run the tests to verify they pass**
995
996Run: `go test ./internal/activator/ -run 'TestSeed|TestDoWake|TestMemberSafe' -count=1`
997Expected: PASS.
998
999- [ ] **Step 5: Commit**
1000
1001```bash
1002git add internal/activator/
1003git commit -m "activator: record a seed placed after the first upload (Plan 4 Task 4)
1004
1005Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1006```
1007
1008### Task 5: Reconcile's seed dispatch
1009
1010**Files:**
1011- Create: `internal/provision/seedreconcile.go`
1012- Modify: `internal/provision/reconcile.go` (`ReconcileConfig` `:35`; `routeFor` `:425`; the record loop `:1072`; the owned branch's converge `:1232`)
1013- Test: `internal/provision/seedreconcile_test.go`
1014
1015**Interfaces:**
1016- Consumes: `seeds.*`, `SetRecordSeed`, `SeedRolledBack`, `SeedColumn`, `mintLayoutReader`.
1017- Produces: `type SeedHooks struct{ Data release.Store }` (Plan 7 adds fields); `ReconcileConfig.Seeds *SeedHooks`; unexported `seedAction(rec Record, hasRow bool, cfg ReconcileConfig) seedVerdict` with `seedOrdinary`, `seedSkip`, `seedFinishAbort`; `finishAbortedSeed(ctx, s release.Store, cfg ReconcileConfig, rec Record)`.
1018
1019The dispatch, before any other per-record handling (spec §3.2):
1020
1021| seed | verdict |
1022|---|---|
1023| none | ordinary (as today) |
1024| any, `cfg.Seeds == nil` | skip (a box not wired for seeds never mints one) |
1025| `aborted` | finish: delete `seeds/<host>/<id>/snapshot.db`, CAS `rolled-back` (Plan 7 puts the journal check first) |
1026| `rolled-back`, `reserving`, `awaiting-upload`, `uploaded`, `refused` | skip |
1027| `ready`, replica | ordinary, but only nested and only with claims off; otherwise skip and log |
1028| `ready`, import | skip (Plan 7 swaps) |
1029| `swapped`, `placed`, `committed` | ordinary |
1030
1031For every seeded record that is handled ordinarily, the manifest seed is `""` and known (the seed command never runs for a seeded row), and the row carries `SeedColumn(rec)`.
1032
1033- [ ] **Step 1: Write the failing tests**
1034
1035`internal/provision/seedreconcile_test.go`:
1036
1037```go
1038package provision
1039
1040import (
1041 "context"
1042 "errors"
1043 "os"
1044 "testing"
1045 "time"
1046
1047 "github.com/carlosframework/platform/internal/release"
1048 "github.com/carlosframework/platform/internal/seeds"
1049)
1050
1051func seededRecord(t *testing.T, s release.Store, sqid, kind, state string) Record {
1052 t.Helper()
1053 rec := acmeRecord(sqid)
1054 id, _ := seeds.NewID()
1055 rec.Seed = &SeedSource{ID: id, Kind: kind, State: state, SHA256: "a", Bytes: 1, By: "t", At: time.Now()}
1056 if err := CreateRecord(ctx, s, rec); err != nil {
1057 t.Fatal(err)
1058 }
1059 return rec
1060}
1061
1062func nestedSeedCfg(data release.Store) ReconcileConfig {
1063 return ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data",
1064 Layout: func(context.Context, release.Store) (string, error) { return release.LayoutModeNested, nil },
1065 Seeds: &SeedHooks{Data: data}}
1066}
1067
1068func TestReconcileMintsAFromSeedNestedWithoutTheSeedCommand(t *testing.T) {
1069 s, sqid := setup(t) // the manifest declares Seed ["seed"]
1070 rec := seededRecord(t, s, sqid, seeds.KindReplica, seeds.StateReady)
1071 reg := regWith(t)
1072 if err := Reconcile(ctx, s, reg, nestedSeedCfg(dirStore(t))); err != nil {
1073 t.Fatal(err)
1074 }
1075 got := mustGet(t, reg, rec.Host)
1076 if got.Seed != "" || got.DBPath != NestedDBPath("/data", rec.Host) || got.SeedSource != SeedColumn(rec) {
1077 t.Fatalf("row = seed %q db %q col %q", got.Seed, got.DBPath, got.SeedSource)
1078 }
1079 // The record moves on (placed), and the next pass converges the column
1080 // and still never stamps the seed command.
1081 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedPlace(rec.Seed.ID)); err != nil {
1082 t.Fatal(err)
1083 }
1084 if err := Reconcile(ctx, s, reg, nestedSeedCfg(dirStore(t))); err != nil {
1085 t.Fatal(err)
1086 }
1087 got = mustGet(t, reg, rec.Host)
1088 row, _ := seeds.ParseRowSeed(got.SeedSource)
1089 if got.Seed != "" || row == nil || row.State != seeds.StatePlaced {
1090 t.Fatalf("after placed: seed %q col %+v", got.Seed, row)
1091 }
1092}
1093
1094func TestReconcileDoesNotMintASeedFlatOrClaimed(t *testing.T) {
1095 s, sqid := setup(t)
1096 rec := seededRecord(t, s, sqid, seeds.KindReplica, seeds.StateReady)
1097 for name, cfg := range map[string]ReconcileConfig{
1098 "flat": {Region: "ie", Pools: []string{"tito"}, DataDir: "/data", Seeds: &SeedHooks{Data: dirStore(t)}},
1099 "claimed": func() ReconcileConfig { c := nestedSeedCfg(dirStore(t)); c.ClaimEnabled = true; return c }(),
1100 "unwired": {Region: "ie", Pools: []string{"tito"}, DataDir: "/data",
1101 Layout: func(context.Context, release.Store) (string, error) { return release.LayoutModeNested, nil }},
1102 } {
1103 reg := regWith(t)
1104 if err := Reconcile(ctx, s, reg, cfg); err != nil {
1105 t.Fatal(err)
1106 }
1107 if rt, _ := reg.Get(rec.Host); rt != nil {
1108 t.Errorf("%s: minted %+v", name, rt)
1109 }
1110 }
1111}
1112
1113func TestReconcileSkipsSeedsThatAreNotReady(t *testing.T) {
1114 for _, c := range []struct{ kind, state string }{
1115 {seeds.KindReplica, seeds.StateReserving},
1116 {seeds.KindImport, seeds.StateAwaitingUpload},
1117 {seeds.KindImport, seeds.StateUploaded},
1118 {seeds.KindImport, seeds.StateRefused},
1119 {seeds.KindImport, seeds.StateReady}, // Plan 7 swaps; here, nothing
1120 {seeds.KindReplica, seeds.StateRolledBack},
1121 } {
1122 s, sqid := setup(t)
1123 rec := seededRecord(t, s, sqid, c.kind, c.state)
1124 reg := regWith(t)
1125 if err := Reconcile(ctx, s, reg, nestedSeedCfg(dirStore(t))); err != nil {
1126 t.Fatal(err)
1127 }
1128 if rt, _ := reg.Get(rec.Host); rt != nil {
1129 t.Errorf("%s %s: minted %+v", c.kind, c.state, rt)
1130 }
1131 }
1132}
1133
1134func TestReconcileFinishesAnAbortedSeedWithNoJournal(t *testing.T) {
1135 s, sqid := setup(t)
1136 data := dirStore(t)
1137 rec := seededRecord(t, s, sqid, seeds.KindReplica, seeds.StateAborted)
1138 other, _ := seeds.NewID()
1139 mine := seeds.SnapshotKey(rec.Host, rec.Seed.ID)
1140 theirs := seeds.SnapshotKey(rec.Host, other)
1141 for _, k := range []string{mine, theirs} {
1142 if _, err := data.Put(ctx, k, []byte("db"), ""); err != nil {
1143 t.Fatal(err)
1144 }
1145 }
1146 reg := regWith(t)
1147 if err := Reconcile(ctx, s, reg, nestedSeedCfg(data)); err != nil {
1148 t.Fatal(err)
1149 }
1150 if _, _, err := data.Get(ctx, mine); !errors.Is(err, os.ErrNotExist) {
1151 t.Fatalf("this seed's snapshot: %v, want it deleted", err)
1152 }
1153 if _, _, err := data.Get(ctx, theirs); err != nil {
1154 t.Fatalf("another seed's snapshot was touched: %v", err)
1155 }
1156 got, err := GetRecord(ctx, s, rec.Account, rec.App, rec.Host)
1157 if err != nil || got.Seed.State != seeds.StateRolledBack {
1158 t.Fatalf("record = %+v, %v; want rolled-back", got.Seed, err)
1159 }
1160 // Idempotent: a second pass over a rolled-back record does nothing.
1161 if err := Reconcile(ctx, s, reg, nestedSeedCfg(data)); err != nil {
1162 t.Fatal(err)
1163 }
1164}
1165```
1166
1167
1168- [ ] **Step 2: Run the tests to verify they fail**
1169
1170Run: `go test ./internal/provision/ -run 'TestReconcile.*Seed|TestReconcileSkipsSeeds|TestReconcileFinishesAnAborted' -count=1`
1171Expected: FAIL to compile: `unknown field Seeds in struct literal of type ReconcileConfig`.
1172
1173- [ ] **Step 3: Write `internal/provision/seedreconcile.go`**
1174
1175```go
1176package provision
1177
1178import (
1179 "context"
1180 "errors"
1181 "log"
1182 "os"
1183
1184 "github.com/carlosframework/platform/internal/release"
1185 "github.com/carlosframework/platform/internal/seeds"
1186)
1187
1188// SeedHooks is what the provision pass needs to act on a seeded record
1189// (seeded-instances spec §3.2). Nil on ReconcileConfig: no seeded record is
1190// minted or acted on, which is the safe answer for a box that cannot place
1191// one.
1192type SeedHooks struct {
1193 // Data is this region's data bucket (seeds/ lives there).
1194 Data release.Store
1195}
1196
1197type seedVerdict int
1198
1199const (
1200 seedOrdinary seedVerdict = iota
1201 seedSkip
1202 seedFinishAbort
1203)
1204
1205// seedAction is §3.2's dispatch, run before any other per-record handling.
1206// hasRow is whether this box already holds a row for the host. nested is
1207// asked only for a mint, through the pass's lazy layout reader.
1208func seedAction(rec Record, hasRow bool, cfg ReconcileConfig, nested func() (bool, error)) seedVerdict {
1209 src := rec.Seed
1210 if src == nil {
1211 return seedOrdinary
1212 }
1213 if cfg.Seeds == nil {
1214 return seedSkip
1215 }
1216 switch src.State {
1217 case seeds.StateAborted:
1218 return seedFinishAbort
1219 case seeds.StateRolledBack, seeds.StateReserving, seeds.StateAwaitingUpload, seeds.StateUploaded, seeds.StateRefused:
1220 return seedSkip
1221 case seeds.StateReady:
1222 if src.Import() {
1223 return seedSkip // the swap is Plan 7's
1224 }
1225 if hasRow {
1226 return seedOrdinary
1227 }
1228 if cfg.ClaimEnabled {
1229 log.Printf("provision: %s: not minting a seeded instance: claim control is on for this box", rec.Host)
1230 return seedSkip
1231 }
1232 if ok, err := nested(); err != nil || !ok {
1233 log.Printf("provision: %s: not minting a seeded instance: this box does not mint nested (%v)", rec.Host, err)
1234 return seedSkip
1235 }
1236 return seedOrdinary
1237 case seeds.StateSwapped, seeds.StatePlaced, seeds.StateCommitted:
1238 return seedOrdinary
1239 }
1240 log.Printf("provision: %s: seed %s is in an unknown state %q; leaving it alone", rec.Host, src.ID, src.State)
1241 return seedSkip
1242}
1243
1244// finishAbortedSeed is §3.2's no-journal path for an aborted seed: delete
1245// this seed's snapshot (only this seed's id, codex R4-1), then CAS the
1246// record to rolled-back. Idempotent: an absent snapshot and a lost CAS are
1247// both done. The console finishes from rolled-back (Plan 8).
1248func finishAbortedSeed(ctx context.Context, s release.Store, cfg ReconcileConfig, rec Record) {
1249 if cfg.DryRun {
1250 log.Printf("provision: dry-run: would finish aborted seed %s of %s", rec.Seed.ID, rec.Host)
1251 return
1252 }
1253 key := seeds.SnapshotKey(rec.Host, rec.Seed.ID)
1254 if err := cfg.Seeds.Data.Delete(ctx, key); err != nil && !errors.Is(err, os.ErrNotExist) {
1255 log.Printf("provision: %s: delete aborted seed's snapshot: %v", rec.Host, err)
1256 return
1257 }
1258 if _, err := SetRecordSeed(ctx, s, rec.Account, rec.App, rec.Host, SeedRolledBack(rec.Seed.ID)); err != nil && !errors.Is(err, ErrSeedConflict) {
1259 log.Printf("provision: %s: mark seed %s rolled back: %v", rec.Host, rec.Seed.ID, err)
1260 return
1261 }
1262 log.Printf("provision: %s: aborted seed %s rolled back (no box had swapped it)", rec.Host, rec.Seed.ID)
1263}
1264```
1265
1266- [ ] **Step 4: Wire it into `Reconcile`**
1267
1268- `ReconcileConfig`, after `HoldWritten`:
1269
1270```go
1271 // Seeds is the seeded-instances wiring (seedreconcile.go). Nil: every
1272 // seeded record is left alone.
1273 Seeds *SeedHooks
1274```
1275
1276- `routeFor` (`:425`): add `SeedSource: SeedColumn(rec),` to the `registry.Route` literal.
1277- In the record loop, right after `existing, ok := byHost[rec.Host]` and before the `cfg.Hold` block:
1278
1279```go
1280 // A seed is dispatched before anything else (seeded-instances spec
1281 // §3.2, codex 13): an aborted one is finished, an unready one is
1282 // skipped, and a seeded row never carries the manifest's seed
1283 // command.
1284 if rec.Seed != nil {
1285 switch seedAction(rec, ok, cfg, mintLayout) {
1286 case seedSkip:
1287 continue
1288 case seedFinishAbort:
1289 finishAbortedSeed(ctx, s, cfg, rec)
1290 continue
1291 }
1292 seed, seedIsKnown = "", true
1293 }
1294```
1295
1296- In the owned branch, after the `SetSeed` converge (`:1232-1240`):
1297
1298```go
1299 if col := SeedColumn(rec); existing.SeedSource != col {
1300 if cfg.DryRun {
1301 log.Printf("provision: dry-run: would converge %s seed source", rec.Host)
1302 } else if err := reg.SetSeedSource(rec.Host, col); err != nil {
1303 log.Printf("provision: converge seed source on %s: %v", rec.Host, err)
1304 }
1305 }
1306```
1307
1308- [ ] **Step 5: Run the tests to verify they pass**
1309
1310Run: `go test ./internal/provision/ -run 'TestReconcile|TestSeed' -count=1`
1311Expected: PASS, including every existing reconcile test (an unseeded record mints exactly as before: `SeedColumn` is `""`).
1312
1313- [ ] **Step 6: Commit**
1314
1315```bash
1316git add internal/provision/seedreconcile.go internal/provision/seedreconcile_test.go internal/provision/reconcile.go
1317git commit -m "provision: reconcile dispatches on the seed state first (Plan 4 Task 5)
1318
1319Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1320```
1321
1322### Task 6: The box's wiring
1323
1324**Files:**
1325- Create: `internal/host/seedwiring.go`, `internal/host/seedwiring_test.go`
1326- Modify: `internal/host/agent.go` (beside the staging-fallback mirror, `:404-410`; `reconcileSerialized` `:1131`)
1327
1328**Interfaces:**
1329- Consumes: `release.NewS3StoreIn`, `openDeployStore(ctx)`, `claims.Writes.Enabled`, `provision.SetRecordSeed`, `provision.SeedPlace`, `provision.GetRecord`, `provision.ErrSeedConflict`.
1330- Produces: `type storeSeedObjects struct{ s release.Store }` implementing `activator.SeedObjects`; `seedPlacedHook(deploy, data release.Store) func(ctx context.Context, acct, app, host, id string) error`; `var armedSeedHooks atomic.Pointer[provision.SeedHooks]`; `armSeedHooks(h *provision.SeedHooks)`.
1331
1332- [ ] **Step 1: Write the failing tests**
1333
1334`internal/host/seedwiring_test.go`:
1335
1336```go
1337package host
1338
1339import (
1340 "context"
1341 "errors"
1342 "io"
1343 "os"
1344 "testing"
1345 "time"
1346
1347 "github.com/carlosframework/platform/internal/provision"
1348 "github.com/carlosframework/platform/internal/release"
1349 "github.com/carlosframework/platform/internal/seeds"
1350)
1351
1352func TestSeedPlacedHookCASesThenDeletesOnlyThisSeed(t *testing.T) {
1353 ctx := context.Background()
1354 deploy, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
1355 id, _ := seeds.NewID()
1356 other, _ := seeds.NewID()
1357 rec := provision.Record{Schema: 1, Host: "canary.go.tito.io", Slug: "canary", App: "titogo", Account: "bae",
1358 Channel: "stable", CreatedAt: time.Now().UTC(),
1359 Seed: &provision.SeedSource{ID: id, Kind: seeds.KindReplica, State: seeds.StateReady, By: "t", At: time.Now().UTC()}}
1360 if err := provision.CreateRecord(ctx, deploy, rec); err != nil {
1361 t.Fatal(err)
1362 }
1363 for _, k := range []string{seeds.SnapshotKey(rec.Host, id), seeds.SnapshotKey(rec.Host, other)} {
1364 data.Put(ctx, k, []byte("db"), "")
1365 }
1366 hook := seedPlacedHook(deploy, data)
1367 if err := hook(ctx, "bae", "titogo", rec.Host, id); err != nil {
1368 t.Fatal(err)
1369 }
1370 got, _ := provision.GetRecord(ctx, deploy, "bae", "titogo", rec.Host)
1371 if got.Seed.State != seeds.StatePlaced {
1372 t.Fatalf("state = %s", got.Seed.State)
1373 }
1374 if _, _, err := data.Get(ctx, seeds.SnapshotKey(rec.Host, id)); !errors.Is(err, os.ErrNotExist) {
1375 t.Fatalf("this seed's snapshot: %v", err)
1376 }
1377 if _, _, err := data.Get(ctx, seeds.SnapshotKey(rec.Host, other)); err != nil {
1378 t.Fatalf("another seed's snapshot: %v", err)
1379 }
1380 // Again (a retried wake): done, no error.
1381 if err := hook(ctx, "bae", "titogo", rec.Host, id); err != nil {
1382 t.Fatalf("idempotent: %v", err)
1383 }
1384 // Another id than the record's: refused, nothing deleted.
1385 data.Put(ctx, seeds.SnapshotKey(rec.Host, other), []byte("db"), "")
1386 if err := hook(ctx, "bae", "titogo", rec.Host, other); err == nil {
1387 t.Fatal("placing another seed's id succeeded")
1388 }
1389 if _, _, err := data.Get(ctx, seeds.SnapshotKey(rec.Host, other)); err != nil {
1390 t.Fatal("a refused placement deleted a snapshot")
1391 }
1392}
1393
1394func TestStoreSeedObjectsOpensTheSnapshotKey(t *testing.T) {
1395 ctx := context.Background()
1396 data := release.NewDirStore(t.TempDir())
1397 id, _ := seeds.NewID()
1398 data.Put(ctx, seeds.SnapshotKey("h.example.com", id), []byte("db-bytes"), "")
1399 rc, err := storeSeedObjects{data}.Open(ctx, "h.example.com", id)
1400 if err != nil {
1401 t.Fatal(err)
1402 }
1403 b, _ := io.ReadAll(rc)
1404 rc.Close()
1405 if string(b) != "db-bytes" {
1406 t.Fatalf("read %q", b)
1407 }
1408}
1409```
1410
1411- [ ] **Step 2: Run the tests to verify they fail**
1412
1413Run: `go test ./internal/host/ -run 'TestSeedPlacedHook|TestStoreSeedObjects' -count=1`
1414Expected: FAIL to compile.
1415
1416- [ ] **Step 3: Write `internal/host/seedwiring.go`**
1417
1418```go
1419package host
1420
1421import (
1422 "context"
1423 "errors"
1424 "fmt"
1425 "io"
1426 "os"
1427 "sync/atomic"
1428
1429 "github.com/carlosframework/platform/internal/provision"
1430 "github.com/carlosframework/platform/internal/release"
1431 "github.com/carlosframework/platform/internal/seeds"
1432)
1433
1434// storeSeedObjects reads seed snapshots from this box's data bucket.
1435type storeSeedObjects struct{ s release.Store }
1436
1437func (o storeSeedObjects) Open(ctx context.Context, host, id string) (io.ReadCloser, error) {
1438 op, ok := o.s.(release.Opener)
1439 if !ok {
1440 return nil, errors.New("the data bucket store cannot stream")
1441 }
1442 rc, _, err := op.Open(ctx, seeds.SnapshotKey(host, id))
1443 return rc, err
1444}
1445
1446// seedPlacedHook is ExecBackend.OnSeedPlaced (seeded-instances spec §3.4
1447// step 5): the record's CAS to placed, then the delete of this seed's
1448// snapshot, never another's. A record already placed (or committed) with
1449// this id is a retried wake: the delete still runs.
1450func seedPlacedHook(deploy, data release.Store) func(ctx context.Context, acct, app, host, id string) error {
1451 return func(ctx context.Context, acct, app, host, id string) error {
1452 if deploy == nil || data == nil {
1453 return errors.New("seed recording is not wired on this box")
1454 }
1455 _, err := provision.SetRecordSeed(ctx, deploy, acct, app, host, provision.SeedPlace(id))
1456 if errors.Is(err, provision.ErrSeedConflict) {
1457 rec, gerr := provision.GetRecord(ctx, deploy, acct, app, host)
1458 if gerr != nil {
1459 return gerr
1460 }
1461 if rec.Seed == nil || rec.Seed.ID != id || (rec.Seed.State != seeds.StatePlaced && rec.Seed.State != seeds.StateCommitted) {
1462 return fmt.Errorf("seed %s of %s: %w", id, host, err)
1463 }
1464 } else if err != nil {
1465 return err
1466 }
1467 if err := data.Delete(ctx, seeds.SnapshotKey(host, id)); err != nil && !errors.Is(err, os.ErrNotExist) {
1468 return err
1469 }
1470 return nil
1471 }
1472}
1473
1474// armedSeedHooks is what reconcileSerialized hands the provision pass
1475// (ReconcileConfig.Seeds). Nil until the activator is armed with a data
1476// bucket.
1477var armedSeedHooks atomic.Pointer[provision.SeedHooks]
1478
1479func armSeedHooks(h *provision.SeedHooks) { armedSeedHooks.Store(h) }
1480```
1481
1482- [ ] **Step 4: Arm it in `agent.go`**
1483
1484Beside the staging-fallback mirror (`agent.go:404`), after `eb.Mirror = m`:
1485
1486```go
1487 // Seeded instances (seeded-instances spec §3.4): the data bucket
1488 // for snapshots, the record store for the placed CAS, and the
1489 // claim flag that makes the fallback refuse. Best-effort like the
1490 // mirror: a box that cannot build them places no seed, and a
1491 // seeded row's wake fails instead of starting empty.
1492 eb.ClaimArmed = claims.Writes.Enabled
1493 if data, err := release.NewS3StoreIn(context.Background(), cfg.Bucket, cfg.Region); err != nil {
1494 log.Printf("agent: seed reader: %v (no seed can be placed on this box)", err)
1495 } else if deploy, err := openDeployStore(context.Background()); err != nil {
1496 log.Printf("agent: seed recorder: %v (no seed can be placed on this box)", err)
1497 } else {
1498 eb.Seeds = storeSeedObjects{data}
1499 eb.OnSeedPlaced = seedPlacedHook(deploy, data)
1500 armSeedHooks(&provision.SeedHooks{Data: data})
1501 }
1502```
1503
1504In `reconcileSerialized` (`:1131`), before `cfg = withMovesHolds(cfg)`:
1505
1506```go
1507 if cfg.Seeds == nil {
1508 cfg.Seeds = armedSeedHooks.Load()
1509 }
1510```
1511
1512- [ ] **Step 5: Run the tests to verify they pass**
1513
1514Run: `go test ./internal/host/ -run 'TestSeedPlacedHook|TestStoreSeedObjects|TestReconcileSerialized|TestAgent' -count=1`
1515Expected: PASS.
1516
1517- [ ] **Step 6: Commit**
1518
1519```bash
1520git add internal/host/seedwiring.go internal/host/seedwiring_test.go internal/host/agent.go
1521git commit -m "host: wire seed placement into the activator and the provision pass (Plan 4 Task 6)
1522
1523Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
1524```
1525
1526### Task 7: Plan gate
1527
1528- [ ] **Step 1: Cross-compile and run the touched packages under -race**
1529
1530Run:
1531```bash
1532GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./... && \
1533nice go test -race ./internal/registry/ ./internal/lease/ ./internal/release/ ./internal/activator/ ./internal/provision/ ./internal/host/ . -count=1
1534```
1535Expected: builds clean; tests PASS; the moves copy gate passes (`go test . -run TestMoves`).
1536
1537---
1538
1539## Self-review
1540
1541- Spec coverage: §3.4 steps 1 to 5 and "Failures" (Tasks 3, 4); "the manifest's seed command is never run for a seeded row" (Task 5 blanks the column; Task 3 never reports fresh); "the snapshot is deleted only after the host's own replica exists" (Task 4: `SyncAndWait` before the CAS and delete); refusal with claim control armed and on a flat box (Tasks 3 and 5); §3.2's dispatch for every state, with an import's `ready` and the journal-first branch left to Plan 7; §3.1's seed-ID-scoped delete (Tasks 5 and 6).
1542- §9 box placement tests: each failure fails the wake (Task 3), purge of another seed's database (Task 3), crash after rename resumes from the local file (Task 3's second `Restore`), crash before rename retries from the snapshot (the `.tmp` is removed and refetched), delete only after the first upload (Task 4), claim refusal (Task 3).
1543- Multi-box regions: any box holding a `--from` row may place it, under the wake lease; finishing an aborted `--from` seed is idempotent on every box.
1544