carlos / platform Public
S3 Change Index 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: Stop every box and the console from re-walking the whole deployment bucket: writers bump a per-scope generation in control/changes.json, readers re-read only what moved, a 30-minute backstop walk bounds staleness.
Architecture: The bump lives inside the two concrete stores (S3Store, DirStore), enabled by deployment openers. A process-wide release.ChangeWatch polls the index with a conditional GET. provision.RecordView keeps the record families the walk reads and exposes immutable snapshots as a read-through release.Store, so Reconcile, ReconcileSidecars, the moves pass, steering and the console sweeps run unchanged over a snapshot. A release.ReadGate gates the per-app GET loops on generations plus a maximum age.
Tech Stack: Go, AWS SDK v2 S3 (existing client in internal/release), DirStore for tests, MinIO in the delivered-contract test.
Spec: docs/superpowers/specs/2026-10-21-s3-change-index-design.md (rev 3, Astra-accepted). Section numbers below (§N) refer to it.
Global Constraints
- Index key
control/changes.json, body{"schema":1,"entries":{"<scope>":<uint64>}}; generations only increase; entries never removed in v1 (§3.1). - Scopes exactly per §3.2:
apps/<a>/<b>(except data prefixeslogs,errors,analytics,runs,metrics),accounts/<a>,control/certs,control/edges; nothing else. - Bump after a successful mutation only; at most 4 attempts with jitter inside a 2s budget detached from the caller's cancellation; a failed bump is logged and never fails the write (§3.3).
- Backstop interval 30 minutes, jittered ±10% (R3). Gated reads have a maximum successful-read age equal to the backstop (§5).
- Readers armed only by
CARLOS_CHANGE_INDEX=read(boxhost.env, console environment). Unset means exactly today's behavior. Writers always bump once shipped (§7). - A store without
ConditionalGetter+ETagLister, a denied index read, or a malformed index means "unknown": walk as today, never "empty and unchanged" (§3.4). - Core only: no AWS SDK outside
internal/release's existing S3 client (§6). - Lock order:
w.pass→reconcileMu→RecordView.mu;RecordView.muis a leaf (§4.3). - Tests: run only touched packages locally with
-run; CI runs the full suite. LeaveGOCACHEat its default. The simulation runs niced, one 3000-seed sweep per revision. - No tenant names in code, tests or comments (use
shop,example.com). - Every new top-level test stands alone:
make testis about to splitinternal/consoleand the root package across separatego testprocesses, so no test may depend on another having run first. Root-package e2e tests callbuildCarlos(t)and never write to the shared read-only binary it returns. internal/hosttests callt.Parallel()unless they set env (t.Setenv), cwd, umask, log output or package vars, or reachedge.SecureUnixListen. Tasks 8 and 9 set the package varsarmedChangeWatch/armedRecordView(andCARLOS_CHANGE_INDEX), so those tests stay serial; tests that only build their ownfastDeps,genCacheor gates callt.Parallel().- Any user-facing text (release notes for R4) goes through the copy-review skill before it is written.
Review Focus
- A deployment with no
control/changes.jsonyet (first arm, fresh bucket): the firstRefreshmust rebuild by walking, and the view must never publish as empty-and-complete. Test in Task 6. - A malformed or future-schema index (hand edit, newer writer): readers treat it as unusable and walk; writers do not overwrite an unknown schema. Tests in Tasks 1 and 4.
- A mixed fleet: an armed reader while an old binary writes a record without bumping. The record appears after the next
Rebuild, and no route is retired in between. Tests in Tasks 6 and 7. - An app purge deleting hundreds of keys under one scope: bumps stay bounded (one per mutation, each a single CAS), the view drops the app once, nothing is retired before the purge's own records are gone. Test in Task 2 (bump count) and Task 6 (view).
- Index read denied by IAM (role lacks
GetObjecton the key):ChangeWatch.Usable()stays false,ReadGate.Dueis always true, every loop behaves as today. Tests in Tasks 4 and 9.
---
Task 1: Change scope and the index document
Files:
- Create:
internal/release/changeindex.go - Test:
internal/release/changeindex_test.go
Interfaces:
- Produces: -
const ChangeIndexKey = "control/changes.json"-type ChangeIndex struct { Schema int \json:"schema"\; Entries map[string]uint64 \json:"entries"\}-func ChangeScope(key string) (scope string, ok bool)-func ReadChangeIndex(ctx context.Context, s Store) (ChangeIndex, string, error)— absent object returnsChangeIndex{Schema: 1, Entries: {}}, etag"", nil; schema ≠ 1 returnsErrChangeIndexSchema. -var ErrChangeIndexSchema = errors.New("release: change index schema unsupported")-func bumpChangeIndex(ctx context.Context, get func(context.Context, string) ([]byte, string, error), put func(context.Context, string, []byte, string) (string, error), scopes ...string) error— unexported; takes raw get/put so the store hook (Task 2) can call it without recursion.
- [ ] Step 1: Write the failing tests
func TestChangeScope(t *testing.T) {
cases := map[string]string{
"apps/bab/shop/instances/a.example.com.json": "apps/bab/shop",
"apps/bab/shop/config-gen.json": "apps/bab/shop",
"apps/bab/shop/channels/canary/x": "apps/bab/shop",
"apps/bab/shop/sidecars.json": "apps/bab/shop",
"accounts/bab/account.json": "accounts/bab",
"accounts/bab/telemetry.json": "accounts/bab",
"control/certs/example.com": "control/certs",
"control/edges/box-a.json": "control/edges",
}
for k, want := range cases {
got, ok := ChangeScope(k)
if !ok || got != want { t.Errorf("ChangeScope(%q) = %q,%v want %q", k, got, ok, want) }
}
for _, k := range []string{
"apps/bab/shop/logs/box/2026-10-21-10/x", "apps/bab/shop/errors/x",
"apps/bab/shop/analytics/x", "apps/bab/shop/runs/r1/request.json",
"apps/bab/shop/metrics/x", "apps/bab/shop", "apps/bab/", "apps/",
ChangeIndexKey, "control/serial", "control/edge-mesh-key",
"control/edge-mesh-key.next", "releases/x", "status/boxes/a.json",
"fleets/f/apps/bab/shop/config.json",
} {
if s, ok := ChangeScope(k); ok { t.Errorf("ChangeScope(%q) = %q, want none", k, s) }
}
}
func TestBumpChangeIndexCreatesThenIncrements(t *testing.T) // DirStore: two bumps of "apps/bab/shop" -> 2; other entry untouched
func TestBumpChangeIndexRetriesOnConflict(t *testing.T) // put fails with ErrCASConflict twice, then succeeds -> nil, final gen 1
func TestBumpChangeIndexGivesUpAfterFourAttempts(t *testing.T) // 4 conflicts -> non-nil error, exactly 4 puts
func TestBumpChangeIndexRefusesUnknownSchema(t *testing.T) // existing {"schema":2,...} -> ErrChangeIndexSchema, no put
func TestReadChangeIndexAbsentIsEmpty(t *testing.T) // Entries non-nil, len 0, etag ""- [ ] Step 2: Run to verify they fail
Run: go test ./internal/release/ -run 'TestChangeScope|TestBumpChangeIndex|TestReadChangeIndex' -count=1 Expected: FAIL (undefined: ChangeScope)
- [ ] Step 3: Implement
ChangeScope,ReadChangeIndex,bumpChangeIndexininternal/release/changeindex.go.ChangeScoperequiresapps/<a>/<b>/<rest>with all three non-empty segments and<rest>'s first segment not in the data set;accounts/<a>/<rest>with both non-empty.bumpChangeIndexapplies all scopes in one read-modify-write; conflict iserrors.Is(err, ErrCASConflict); jitter 20–120ms between attempts; it does not create its own context (Task 2 passes the detached one).
- [ ] Step 4: Run to verify they pass (same command). Expected: PASS
- [ ] Step 5: Commit
git commit -m "release: change scopes and the change index document (#336)"
---
Task 2: Bump inside the concrete stores
Files:
- Modify:
internal/release/store.go(S3Storestruct:681,DirStorestruct:342,Put:414/:787,PutFrom:456/:820,Delete:660/:970) - Modify:
internal/release/conditional_delete.go(DeleteIfMatch:63/:86) - Test:
internal/release/changeindex_store_test.go
Interfaces:
- Consumes: Task 1's
ChangeScope,bumpChangeIndex. - Produces: -
func (s *S3Store) EnableChangeIndex(),func (d *DirStore) EnableChangeIndex()-func NewDeploymentS3Store(ctx context.Context, bucket string) (*S3Store, error)andfunc NewDeploymentS3StoreIn(ctx context.Context, bucket, region string) (*S3Store, error)— the S3 constructors plusEnableChangeIndex(). -var changeIndexBumpBudget = 2 * time.Second(var for tests).
- [ ] Step 1: Write the failing tests (all on
DirStorewith the index enabled)
func TestStoreBumpsAfterEveryMutation(t *testing.T) {
// Put, PutFrom, Delete, DeleteIfMatch on "apps/bab/shop/instances/h.json"
// each raise entries["apps/bab/shop"] by exactly 1.
}
func TestStoreDoesNotBumpFailedMutation(t *testing.T) // Put with stale ifMatch -> ErrCASConflict, gen unchanged
func TestStoreDoesNotBumpUnscopedKeys(t *testing.T) // logs/, control/serial, the index key itself -> no index object
func TestStoreBumpFailureDoesNotFailWrite(t *testing.T) // index object is a directory (unwritable) -> Put returns nil, record written
func TestStoreBumpDetachedFromCallerCancel(t *testing.T)// ctx cancelled right after Put returns from the backend -> bump still lands
func TestStoreWithoutEnableNeverBumps(t *testing.T)
func TestStoreCapabilitiesPreserved(t *testing.T) {
d := NewDirStore(t.TempDir()); d.EnableChangeIndex()
var s Store = d
_, ok1 := s.(ConditionalDeleter); _, ok2 := s.(PutStreamer); _, ok3 := s.(Opener)
_, ok4 := s.(SizeLister); _, ok5 := s.(PageLister); _, ok6 := s.(ModLister)
if !(ok1 && ok2 && ok3 && ok4 && ok5 && ok6) { t.Fatal("index-enabled DirStore lost an extension") }
}
func TestPurgeBumpsBounded(t *testing.T) // 300 deletes under one app -> gen 300, each bump one CAS (count puts on a counting wrapper of the raw backend)- [ ] Step 2: Run to verify they fail
Run: go test ./internal/release/ -run 'TestStore(Bumps|DoesNot|BumpFailure|BumpDetached|WithoutEnable|Capabilities)|TestPurgeBumps' -count=1 Expected: FAIL (undefined: EnableChangeIndex)
- [ ] Step 3: Implement. Add an unexported
changeIndex boolfield to both structs. Split each mutation into the existing body (renamedputRaw,putFromRaw,deleteRaw,deleteIfMatchRaw) and a public method that calls the raw one and, on success and when enabled andChangeScopematches, callsbumpChangeIndexwithgetRaw/putRawundercontext.WithTimeout(context.WithoutCancel(ctx), changeIndexBumpBudget), logging a failure asrelease: change index bump %s: %v. The struct literal&release.S3Store{Client:..., Bucket:...}elsewhere must still compile (exported fields unchanged).
- [ ] Step 4: Run to verify they pass (same command, plus
go test ./internal/release/ -count=1for regressions). Expected: PASS
- [ ] Step 5: Commit
git commit -m "release: concrete stores bump the change index after each mutation (#336)"
---
Task 3: Read extensions and their forwarding
Files:
- Modify:
internal/release/store.go(new interfaces besideModLister:186-207; methods on both stores;prefixStoreforwarding:113-217) - Modify:
internal/hostops/mutation_boundary.go(forwarding surface:108-130) - Modify: the MinIO delivered-contract test (
grep -rn TestDeliveredContractMinio) - Test:
internal/release/readext_test.go,internal/hostops/mutation_boundary_test.go
Interfaces:
- Produces: -
type ConditionalGetter interface { GetIfChanged(ctx context.Context, key, etag string) (body []byte, newETag string, changed bool, err error) }—etag == ""behaves asGet; unchanged returnsnil, etag, false, nil; absent returns anos.ErrNotExist-wrapped error. -type KeyETag struct { Key, ETag string }-type ETagLister interface { ListETags(ctx context.Context, prefix string) ([]KeyETag, error) }— sorted by key; ETags in the same formGetreturns. - Both on*S3Storeand*DirStore;prefixStoreandmutationBoundaryforward both (boundary returnsErrContractwhen the raw store lacks one, matchingListMod).
- [ ] Step 1: Write the failing tests
func TestGetIfChangedDirStore(t *testing.T) // put k; GetIfChanged(k,"") -> changed, etag e; GetIfChanged(k,e) -> !changed, body nil; put again; GetIfChanged(k,e) -> changed
func TestGetIfChangedAbsent(t *testing.T) // errors.Is(err, os.ErrNotExist)
func TestListETagsMatchesGet(t *testing.T) // for each listed key, ETag == etag from Get
func TestPrefixStoreForwardsReadExtensions(t *testing.T) // NewPrefixStore(dir,"fleets/f/") answers both, keys stripped
func TestBoundaryForwardsReadExtensions(t *testing.T) // in hostopsIn the MinIO contract test add: GetIfChanged returns changed=false for the current ETag (S3 304), and ListETags ETags equal Get ETags.
- [ ] Step 2: Run to verify they fail
Run: go test ./internal/release/ ./internal/hostops/ -run 'GetIfChanged|ListETags|ForwardsReadExtensions' -count=1 Expected: FAIL
- [ ] Step 3: Implement. S3:
GetObjectwithIfNoneMatch: etag; a 304 surfaces from the SDK as a smithy API error with HTTP status 304 — map it tochanged=false.ListETagspagesListObjectsV2and keepsKey/ETagexactly asGetreports them (same quoting). DirStore: compute the ETag the way itsGetdoes.
- [ ] Step 4: Run to verify they pass (same command). Expected: PASS. MinIO contract runs in CI.
- [ ] Step 5: Commit
git commit -m "release: conditional GET and ETag listings, forwarded through prefix stores and the hostops boundary (#336)"
---
Task 4: ChangeWatch and ReadGate
Files:
- Create:
internal/release/changewatch.go - Test:
internal/release/changewatch_test.go
Interfaces:
- Consumes: Tasks 1 and 3.
- Produces: -
const ChangeIndexBackstop = 30 * time.Minute(R3; here soprovision,host,edgeandconsoleshare it without import cycles) -type ChangeWatch struct(safe for concurrent use) -func NewChangeWatch(s Store) *ChangeWatch-func (w *ChangeWatch) Poll(ctx context.Context) (moved []string, err error)— conditional GET ofChangeIndexKey; returns scopes whose generation rose (or appeared) since the last successful poll; on 304 returnsnil, nil; any error, an unknown schema, or a store lackingConditionalGettermarks the watch unusable and returns the error. -func (w *ChangeWatch) Gen(scope string) (gen uint64, ok bool)—okfalse while unusable. -func (w *ChangeWatch) Observed() (map[string]uint64, bool)— copy of the last good entries. -func (w *ChangeWatch) Usable() bool-type ReadGate struct;func NewReadGate(w *ChangeWatch, maxAge time.Duration) *ReadGate-func (g *ReadGate) Due(scope string, now time.Time) (gen uint64, due bool)— due when the watch is nil or unusable, the scope was never marked, its last mark failed, its generation moved past the marked one, ornowis at leastmaxAgepast the last successful mark. -func (g *ReadGate) Mark(scope string, gen uint64, now time.Time, ok bool)— records the generation captured byDuebefore the read.
- [ ] Step 1: Write the failing tests
func TestChangeWatchFirstPollReportsAll(t *testing.T) // index with 2 entries -> moved has both
func TestChangeWatchUnchangedIs304(t *testing.T) // second poll, no writes -> moved nil, err nil; counting store shows GetIfChanged with etag
func TestChangeWatchReportsOnlyMoved(t *testing.T)
func TestChangeWatchAbsentIndexIsUsableEmpty(t *testing.T) // absent -> Usable true, Observed empty
func TestChangeWatchDeniedIsUnusable(t *testing.T) // store whose Get of the key fails with access denied -> Usable false
func TestChangeWatchUnknownSchemaIsUnusable(t *testing.T)
func TestChangeWatchWithoutExtensionIsUnusable(t *testing.T)
func TestReadGateDue(t *testing.T) // table: unusable->due; never marked->due; marked ok same gen within age->!due; gen moved->due; age>=maxAge->due; last mark failed->due- [ ] Step 2: Run to verify they fail
Run: go test ./internal/release/ -run 'TestChangeWatch|TestReadGate' -count=1 Expected: FAIL
- [ ] Step 3: Implement in
changewatch.go. Absent index is usable with empty entries (Review Focus 1 is handled byRecordView, not here).
- [ ] Step 4: Run to verify they pass. Expected: PASS
- [ ] Step 5: Commit
git commit -m "release: ChangeWatch polls the change index; ReadGate gates reads on generation and age (#336)"
---
Task 5: Every deployment opener enables the index
Files:
- Modify: deployment-bucket openers among the
release.NewS3Store/NewS3StoreIncall sites:cli.go:1957,2067,3591,cmd/console/main.go:633(the deployment bucket;:333is the database bucket),internal/host/agent.go:4368,4462,internal/edge/edge.go:3407,internal/host/relocatepass.go:251,680,internal/host/activation_command_linux.go:67,92,206,283,internal/ops/ops.go:339,internal/vet/vet.go:226,internal/host/seedwiring.go:102. Classify each by reading it; switch deployment ones toNewDeploymentS3Store[In]. - Modify:
internal/host/boxpublish.goonly if its test shows the concrete assertion:259failing (it should not: the type is still*release.S3Store). - Create:
internal/release/openers_test.go
Interfaces:
- Consumes: Task 2's
NewDeploymentS3Store[In].
- [ ] Step 1: Write the failing test
TestS3StoreOpenersClassified: parse every non-test.gofile in the module withgo/parser, find calls torelease.NewS3Store,release.NewS3StoreIn(and the package-internalNewS3Storeoutsidestore.go), and require eachfile:functo appear in a tableotherBucketOpeners map[string]string(value: the reason, for example"database bucket","hibernate data bucket"). Deployment openers must callNewDeploymentS3Store[In]instead, so they never appear.
- [ ] Step 2: Run to verify it fails
Run: go test ./internal/release/ -run TestS3StoreOpenersClassified -count=1 Expected: FAIL listing the unclassified call sites
- [ ] Step 3: Switch each deployment opener to
NewDeploymentS3Store[In]and list the rest inotherBucketOpenerswith a reason.
- [ ] Step 4: Box publication witness
TestBoxPublishThroughDeploymentOpenerininternal/host:openDeployStoreEnvagainst aDirStore-backed fake is out of reach, so assert at the type level thatNewDeploymentS3Store's result satisfies the*release.S3Storeassertion used atboxpublish.go:259(compile-timevar _ *release.S3Store = mustDeploy()plus a runtime check through the sameopened.(*release.S3Store)expression).
- [ ] Step 5: Run
go test ./internal/release/ ./internal/host/ -run 'TestS3StoreOpenersClassified|TestBoxPublishThroughDeploymentOpener' -count=1andgo build ./.... Expected: PASS, build clean.
- [ ] Step 6: IAM check. Read
infra/modules/deployment-buckets/main.tfandinfra/modules/region-hostpolicies: confirm every writing role (box, console) is alloweds3:GetObjectands3:PutObjectoncontrol/changes.jsonand no bucket-policy Deny covers it. If anything denies it, add an allow and atests/*.tftest.hclassertion in that module. Record the result (and the same check owed on the Tito infrastructure repo) in the branch's #336 thread.
- [ ] Step 7: Commit
git commit -m "Deployment openers enable the change index; opener classification test (#336)"
---
Task 6: RecordView and its snapshot store
Files:
- Create:
internal/provision/recordview.go,internal/provision/recordview_store.go - Test:
internal/provision/recordview_test.go
Interfaces:
- Consumes: Tasks 1, 3, 4 (including
release.ChangeIndexBackstop); existingknownAccounts(reconcile.go:1760),listAppSubtree(:2022),ManifestKey/RecordsPrefix(provision.go:321),SidecarsPrefix(sidecar.go:122),release.ReadAppTombstone's key,domains.AccountManifestSqid. - Produces: -
type RecordView structwithmu sync.Mutex(leaf lock) -func NewRecordView(live release.Store, watch *release.ChangeWatch) *RecordView-func (v *RecordView) Refresh(ctx context.Context) error— §4.2 undermu:watch.Poll, then re-read every scope withapplied < observedor new; an unusable watch, or a view never built, runsRebuildinstead. -func (v *RecordView) Rebuild(ctx context.Context) error— §4.4 undermu: readwatch.Observed()(after aPoll) first, then walk everything, publish, setapplied= the pre-walk generations. -func (v *RecordView) Snapshot() *ViewSnapshot— atomic load; nil before the first publication. -type ViewSnapshot struct { Epoch uint64; Complete bool /* plus unexported cached families */ }-func (sn *ViewSnapshot) Store(live release.Store) release.Store— read-through store, below. -func ScopeApp(scope string) (acct, app string, ok bool)— parsesapps/<a>/<b>.
Cached families (what a snapshot answers; everything else goes to live):
List("accounts/")andList("accounts/<a>/"): complete key listings per account, kept fromListETags("accounts/<a>/")on each account-scope re-read.Get("accounts/<a>/account.json"): cached body or not-found.ListDirs("apps/"),ListDirs("apps/<a>/"): accounts and apps that have at least one in-scope app scope;BucketShapedListDirs()returns true solistAppSubtreewalks.Listofapps/<a>/<b>/instances/andapps/<a>/<b>/sidecars/: complete, fromListETags.Getof each record key under those prefixes, ofapps/<a>/<b>/deleted.json(trash marker),instances.jsonandsidecars.json: cached body, cached not-found (anos.ErrNotExist-wrapped error), or the cached read error.- Every
Put/PutFrom/Delete/DeleteIfMatchgoes tolive, and the key, and every listing prefix it falls under, is answered fromlivefor the rest of that snapshot store's life (read-your-writes and CAS retries). - Optional extensions not listed above: forwarded to
live.
- [ ] Step 1: Write the failing tests (DirStore with the index enabled,
ChangeWatchover it)
func TestViewMatchesWalk(t *testing.T) // 3 accounts, 5 apps, records/sidecars/manifests/trash: ListAllRecordsStrict over sn.Store(live) == over live
func TestViewRefreshRereadsOnlyMovedApp(t *testing.T) // counting store: after one record Put in shop, Refresh lists only apps/bab/shop/{instances,sidecars}/ and GETs only the changed key + that app's three manifests/marker
func TestViewRefreshNoChangeIsOneGet(t *testing.T) // counting store: exactly one GetIfChanged
func TestViewRetriesFailedScopeOn304(t *testing.T) // inject one List failure for shop after its bump; next Refresh (index 304) re-reads shop; Complete false until then (codex 4)
func TestViewFirstRefreshWithoutIndexRebuilds(t *testing.T) // no control/changes.json, records exist -> snapshot has them, Complete true (Review Focus 1)
func TestViewUnusableWatchRebuilds(t *testing.T) // malformed index -> Refresh walks (Review Focus 2)
func TestViewBypassWriteAppearsAfterRebuild(t *testing.T) // record written with index disabled -> absent after Refresh, present after Rebuild (Review Focus 3)
func TestViewPurgeDropsApp(t *testing.T) // delete every key under shop -> ListDirs("apps/bab/") no longer has shop (Review Focus 4)
func TestViewEpochsIncrease(t *testing.T)
func TestViewBackstopCannotOverwriteNewer(t *testing.T) // codex 2 interleaving: Rebuild blocked mid-walk by a hook; concurrent Refresh must wait on mu; final snapshot has the record
func TestSnapshotStoreReadYourWrites(t *testing.T) // Put via sn.Store, then Get/List return live state
func TestSnapshotStoreErrorsMatchLive(t *testing.T) // not-found Get of a cached-family key: errors.Is(err, os.ErrNotExist)
func TestSnapshotStoreOtherKeysGoLive(t *testing.T) // Get of config.json / List("apps/bab/shop/channels/") hit live- [ ] Step 2: Run to verify they fail
Run: go test ./internal/provision/ -run 'TestView|TestSnapshotStore' -count=1 Expected: FAIL
- [ ] Step 3: Implement
recordview.go(state,Refresh,Rebuild, publication withatomic.Pointer[ViewSnapshot]) andrecordview_store.go(the read-through store).RebuildreusesknownAccountsandlistAppSubtreeoverlive(andListETagsto skip unchanged bodies already cached); an app-scope re-read usesListETagson both prefixes andGetIfChangedfor the three per-app objects. Incomplete stays incomplete until every scope withapplied < observedsucceeds.
- [ ] Step 4: Run to verify they pass (same command). Expected: PASS
- [ ] Step 5: Commit
git commit -m "provision: RecordView keeps the record families and serves snapshots as a read-through store (#336)"
---
Task 7: Invariant simulation and mutation checks
Files:
- Create:
internal/provision/recordview_sim_test.go
Interfaces:
- Consumes: Task 6.
- [ ] Step 1: Write the simulation
TestRecordViewSimulation(seeded by-seedand-seedsflags, default 200 in CI, skipped under-short): random actors on aDirStore— writers that bump, writers that bypass (EnableChangeIndexoff), writers whose bump fails (injected), incrementalRefresh,Rebuild, and a consumer that takes snapshots under a fakew.passand records epochs. Check after every step: (a) after aRefreshthat observed generation g for a scope, every bumped write to that scope at ≤ g is in the snapshot; (b) afterRebuild,ListAllRecordsStrict(sn.Store(live))equalsListAllRecordsStrict(live); (c) snapshot epochs only increase and no scope's contents revert; (d) a snapshot withComplete == falsenever returns a nil error fromListAllRecordsStrict; (e) a failed scope is re-read on the nextRefreshwith the index unchanged. The seed set must include the three named interleavings in §8 (codex 1, 2, 4) as fixed seeds or scripted cases.
- [ ] Step 2: Run
nice -n 19 go test ./internal/provision/ -run TestRecordViewSimulation -seeds 3000 -count=1. Expected: PASS
- [ ] Step 3: Mutation checks in a scratch copy of the tree under
$TMPDIR(never in the worktree): apply each mutation, run the sweep, confirm it FAILS, discard the copy. Mutations: bump before write (Task 2); recordappliedfor failed scopes (Task 6); skip the pre-walk index read inRebuild; publish the rebuild outsidemu; consume a handed-over snapshot instead of taking one underw.pass. Record the five results in the commit message.
- [ ] Step 4: Commit
git commit -m "provision: RecordView invariant simulation; five mutation checks fail as they must (#336)"
---
Task 8: Box agent reads through the view
Files:
- Modify:
internal/host/agent.go(reconcileSerializedWith:1569, fast-converge deps andfastTick:2537,fullPass:2787,instancesLoop:3358, agent start-up wiring) - Modify:
internal/host/movespass.go(movesPass:1060),internal/host/movesoffline.go(refreshOfflineView:241callers),internal/host/steering_loop.go:100 - Modify:
internal/appconfig/syncexec.go(new option beside:751-810) - Create:
internal/host/changeindex.go - Test:
internal/host/changeindex_test.go,internal/appconfig/syncexec_test.go
Interfaces:
- Consumes: Tasks 4, 6.
- Produces: -
func changeIndexArmed(getenv func(string) string) bool—getenv("CARLOS_CHANGE_INDEX") == "read"-var armedChangeWatch atomic.Pointer[release.ChangeWatch],var armedRecordView atomic.Pointer[provision.RecordView](set once at start-up when armed; nil means today's behavior everywhere) -func viewStore(ctx context.Context, live release.Store) (s release.Store, epoch uint64, ok bool)—RefreshthenSnapshot().Store(live);okfalse when disarmed, so callers uselive. -func recordViewBackstopLoop(ctx context.Context, v *provision.RecordView)—Rebuildat start and everyrelease.ChangeIndexBackstop±10%, then runsfullPass(unrestricted). -func appconfig.WithRouteFilter(keep func(registry.Route) bool) SyncOption-fastDeps.scopedPass func(ctx context.Context, moved []string)-movesWorker.lastEpoch uint64
- [ ] Step 1: Write the failing tests
func TestReconcileUsesViewWhenArmed(t *testing.T) // armed, counting live store: two reconcileSerializedWith calls with no writes -> zero List calls on live
func TestReconcileWalksWhenDisarmed(t *testing.T) // disarmed -> today's List calls (regression guard)
func TestMovesPassTakesSnapshotUnderPass(t *testing.T) // codex 1: hook pauses a pass after Reconcile; a second pass runs moves at epoch N+1; first pass's moves then sees epoch >= N+1 (never older)
func TestMovesPassRefusesOlderEpoch(t *testing.T) // forced older snapshot -> pass skipped with a log line, no move step taken
func TestFastTickIndexOnlyRunsScopedPass(t *testing.T) // serial unchanged, index moved for shop -> scopedPass called with ["apps/bab/shop"], fullPass not called
func TestScopedPassTouchesOnlyMovedApp(t *testing.T) // codex 10 witness: registry hosts shop and cafe; bump shop; counting store sees no key under apps/bab/cafe/
func TestSerialChangeStillRunsFullPass(t *testing.T)
func TestInstancesLoopFloorIsRefreshWhenArmed(t *testing.T) // nothing changed -> one GetIfChanged on live, no List
func TestSyncExecWithRouteFilter(t *testing.T) // in appconfig: filter excluding cafe -> no read of cafe's bundle- [ ] Step 2: Run to verify they fail
Run: go test ./internal/host/ ./internal/appconfig/ -run 'TestReconcileUsesView|TestReconcileWalksWhenDisarmed|TestMovesPass(Takes|Refuses)|TestFastTickIndexOnly|TestScopedPass|TestSerialChangeStill|TestInstancesLoopFloor|TestSyncExecWithRouteFilter' -count=1 Expected: FAIL
- [ ] Step 3: Implement. - Start-up: when armed, open the watch on the deployment store, build the view, store both pointers, start
recordViewBackstopLoop. A fleet box (store door) leaves itself disarmed: its watch would be unusable and everyRefreshwould fall back to a full walk anyway. -reconcileSerializedWith: insidereconcileMu,s2, _, ok := viewStore(ctx, s); passs2(ors) torefreshOfflineView,provision.Reconcileandprovision.ReconcileSidecars. -movesPass: insidew.pass, takeviewStore; refuse and logagent: moves pass: snapshot epoch %d older than %d consumedif the epoch is lower thanw.lastEpoch; setw.lastEpoch. -steeredRoutesFromRecordscallers passviewStore's store. -fastTick: when the watch is armed,moved, err := watch.Poll(ctx)every tick; a serial change keepsfullPass; otherwise non-emptymoved(after the 5s clamp) runsscopedPass(ctx, moved). -scopedPass:reconcileSerialized,telemetryPass,runMovesPass,runRunsPass, aliases as infullPass, thenrelease.PassFilterwithextrakeeping routes whose(Account, App)is inScopeApp(moved), thensyncExecSerializedwithappconfig.WithRouteFilteron the same set. -instancesLoop: unchanged structure; its reconcile now goes through the view, so a quiet floor costs one conditional GET.
- [ ] Step 4: Run to verify they pass (same command), then
go test ./internal/host/ -count=1 -run 'Moves|Reconcile|FastTick|Steer'for regressions. Expected: PASS
- [ ] Step 5: Commit
git commit -m "host: reconcile, sidecars, moves and steering read through the record view; index-only changes run a scoped pass (#336)"
---
Task 9: Box GET loops gated by generation and age
Files:
- Modify:
internal/host/agent.go(genPollPass:1720,configGenLoop:1979, thePassFiltercall:2628andfastMembers:2214) - Modify:
internal/host/telemetry_pass.go:58 - Test:
internal/host/readgate_test.go
Interfaces:
- Consumes: Task 4's
ReadGate, Task 8'sarmedChangeWatch. - Produces:
genCachegains agate *release.ReadGate;fastDepsgainscanaryGate *release.ReadGate;telemetryPassuses a packagetelemetryGateplus a per-pass account cache. All gates areNewReadGate(armedChangeWatch.Load(), release.ChangeIndexBackstop); with a nil watch every read is due (today's behavior).
- [ ] Step 1: Write the failing tests
func TestConfigGenSkipsUnmovedApp(t *testing.T) // armed, two ticks, no bump -> one GET of config-gen.json (the first)
func TestConfigGenReadsMovedApp(t *testing.T)
func TestConfigGenReadsAtMaxAge(t *testing.T) // fake clock past 30m -> GET (codex 9)
func TestConfigGenRetriesAfterError(t *testing.T)
func TestCanaryPointerGated(t *testing.T) // canary route stays a member; pointer GET only on move or age
func TestTelemetryAccountReadOncePerPass(t *testing.T) // 3 apps in one account -> one GET of accounts/<a>/telemetry.json
func TestTelemetryGatedWithAgeBound(t *testing.T) // codex 9
func TestGatesDueWhenWatchUnusable(t *testing.T) // Review Focus 5: watch denied -> every read due on every tick- [ ] Step 2: Run to verify they fail
Run: go test ./internal/host/ -run 'TestConfigGen|TestCanaryPointerGated|TestTelemetry(Account|Gated)|TestGatesDueWhenWatchUnusable' -count=1 Expected: FAIL
- [ ] Step 3: Implement. Each read calls
gen, due := gate.Due(scope, now); skip when not due; after the readgate.Mark(scope, gen, now, err == nil). For canaries, gate the pointer read inside thePassFiltermembership forcanary/*routes only; stable routes keep today's path.
- [ ] Step 4: Run to verify they pass (same command). Expected: PASS
- [ ] Step 5: Commit
git commit -m "host: config-gen, canary pointers and telemetry read only on change or at the backstop age (#336)"
---
Task 10: Edge cert mirror and home map
Files:
- Modify:
internal/edge/certsync.go(certMirrorstruct,Refresh:622, loop:411) - Modify:
internal/edge/homemap.go(Refresh:320,refreshHosts:264) - Modify: the edge's wiring in
internal/edge/edge.goto pass the process's watch (nil when disarmed) - Test:
internal/edge/certsync_test.go,internal/edge/homemap_test.go
Interfaces:
- Consumes: Tasks 3, 4.
- Produces:
certMirror.watch *release.ChangeWatch,homeMap.watch *release.ChangeWatch; each keeps the last successful full rebuild time.
- [ ] Step 1: Write the failing tests
func TestCertMirrorSkipsWhenUnmoved(t *testing.T) // armed, two polls, no bump -> second poll: no List, no Get
func TestCertMirrorFetchesOnlyChanged(t *testing.T) // one cert rewritten -> ListETags + exactly one Get
func TestCertMirrorDropsRemovedKey(t *testing.T) // deleted challenge token disappears from the snapshot
func TestCertMirrorFullRebuildAtBackstop(t *testing.T)
func TestCertMirrorBootRefreshUnchanged(t *testing.T) // boot path still a full read
func TestHomeMapKeysPolledIndependently(t *testing.T) // codex 8: rotate control/edge-mesh-key, control/edges unchanged -> new key loaded within one poll
func TestHomeMapHeartbeatOnUnchangedPoll(t *testing.T) // RefreshAttempted advances when hosts are skipped- [ ] Step 2: Run to verify they fail
Run: go test ./internal/edge/ -run 'TestCertMirror|TestHomeMap(Keys|Heartbeat)' -count=1 Expected: FAIL
- [ ] Step 3: Implement. Cert mirror: when the watch is usable and
control/certshas not moved and the last full rebuild is younger thanrelease.ChangeIndexBackstop, the poll does nothing; when it moved,ListETags(certPrefix), GET new or changed keys, drop keys absent from the listing; otherwise today's fullRefresh. Home map: same rule forrefreshHostsoncontrol/edges;refreshKeysandhealth.RefreshAttemptedrun on every poll as today.
- [ ] Step 4: Run to verify they pass (same command). Expected: PASS
- [ ] Step 5: Commit
git commit -m "edge: cert mirror and home map re-read only what moved; mesh keys keep their own poll (#336)"
---
Task 11: Console sweeps
Files:
- Modify:
cmd/console/main.go(arm watch and view whenCARLOS_CHANGE_INDEX=read; start the backstop loop) - Modify:
internal/console/server.go(Server fields),internal/console/runsweep.go(:62-114),internal/console/seedsweep.go(:140-165),internal/console/store.go(new query besideRecordActivity:2591) - Test:
internal/console/runsweep_test.go,internal/console/seedsweep_test.go,internal/console/store_test.go
Interfaces:
- Consumes: Tasks 4, 6.
- Produces: -
Server.recordView *provision.RecordView(nil when disarmed) -func (s *Store) AppsWithActionSince(action string, since time.Time) ([]AppRef, error)withtype AppRef struct{ Sqid, App string }— distinct(account sqid, app)from theaudittable whereaction = ?and the time column ≥since. -const orphanCandidateWindow = 48 * time.Hour,const orphanFullSweepEvery = 24 * time.Hour,Server.lastOrphanFull time.Time
- [ ] Step 1: Write the failing tests
func TestRunSweepReadsViewWhenArmed(t *testing.T) // counting releases store: no List of apps/ across two sweeps with no writes
func TestOrphanHousekeepingListsOnlyRecentRunApps(t *testing.T) // shop has run-start 1h ago, cafe none -> ListDirs only under apps/bab/shop/runs/ (codex R2-1)
func TestOrphanHousekeepingFullSweepDaily(t *testing.T) // fake clock past 24h -> every app with records is swept
func TestAppsWithActionSince(t *testing.T) // rows older than since excluded; distinct pairs
func TestSeedSweepSkipsWalkWithoutSnapshots(t *testing.T)// no snapshots -> no strict live walk; abandonment still sees a Seed record via the view
func TestSeedSweepWalksStrictWithSnapshots(t *testing.T) // snapshots present -> live ListAllRecordsStrict after the snapshot listing, as today- [ ] Step 2: Run to verify they fail
Run: go test ./internal/console/ -run 'TestRunSweepReadsView|TestOrphanHousekeeping|TestAppsWithActionSince|TestSeedSweep(Skips|Walks)' -count=1 Expected: FAIL
- [ ] Step 3: Implement.
runSweepPasstakes its record store fromrecordView.Refresh+Snapshot().Store(live)when armed. Housekeeping builds its app set fromAppsWithActionSince("run-start", now-48h)unlessnow-lastOrphanFull >= 24h, in which case it uses every app with records and setslastOrphanFull. Seed sweep: when the snapshot listing is empty and the view is armed, read records from the view snapshot; otherwise keep the strict live walk exactly where it is today (afterseedsPassBetweenListings).
- [ ] Step 4: Run to verify they pass (same command). Expected: PASS
- [ ] Step 5: Commit
git commit -m "console: run sweep reads the record view; orphan housekeeping narrows to recent runs; seed sweep skips the walk with nothing to delete (#336)"
---
Task 12: Census overlay, release note, rollout notes
Files:
- Create: the plan14 root-reads census overlay for every touched covered package (recipe in the
litestream-284memory and #280),census-ruling-s3-change-index - Modify:
README.mdrollout/arming section (where release recipes live),CHANGELOGor release-notes location the repo uses - Test: the census test package (
internal/provision's root-reads census test)
- [ ] Step 1: Run the census test
go test ./internal/provision/ -run RootRead -count=1. Expected: FAIL naming changed packages/units. - [ ] Step 2: Merge main first, then generate the overlay so it loads after the latest one on main (as of 2026-10-21 the overlays from
ci-speed-activatorandsecurelisten-337; check main's latest when you get there), with the scratch-test recipe (rootReadProduction,loadRootReadInventory,rootReadLegacyFingerprint,rootReadLegacySeal), add it, and post the census-ruling request on #280. - [ ] Step 3: Run the census test again. Expected: PASS
- [ ] Step 4: Release note for R4 (old CLIs in direct-bucket mode converge at the 30-minute backstop until updated) and the arming steps from §7: draft the strings, run them through the copy-review skill, apply Paul's edits verbatim.
- [ ] Step 5: Full local gate on touched packages
go test ./internal/release/ ./internal/provision/ ./internal/host/ ./internal/edge/ ./internal/console/ ./internal/appconfig/ ./internal/hostops/ -count=1(host and console with-runfilters per the targeted-tests rule if the full packages are too heavy),go vet ./..., and the cross-compile check (GOOS=darwin go build ./...,GOOS=windows go build ./...). Expected: PASS - [ ] Step 5b: Claim invariant. If any console test added here swaps process-wide state (a package var, log output, a client transport) or uses
t.Setenvinside aTestClaimConsoleMutationInvariantfixture, list it serial (claimSerialDoorsor the probe'sserialfield) and callclaimMustRunAlone(t, ...)before the swap. Task 11 adds no mutation doors, so this is a check, not expected work. - [ ] Step 6: Shard weights. If any new console or root-package test takes several seconds, refresh
.amadan/test-weights.tsvwith.amadan/test-weights.sh(CI runs.amadan/test-gate.sh, which shards console 8 ways and root 3 ways). Put no CI-only script underscripts/: the root-read census counts every.shoutside dot directories. - [ ] Step 7: Merge main, commit and push; wait for CI green on the tip (one
=== <job>section per job; failures listed under FAILED: at the end).