- Status: done
- Date: 2026-06-29
- Specs touched:
APP_LIFECYCLE.md# reconciliation pass (the drift list gained the pending-recreate case)
Closes #268. The manifest-config-block.md entry (#264) left a documented gap, reprised by RebindMail: SetConfig/RebindMail are brain-commits-first — they write the store + override/.env, then compose up -d a running instance. If that follow-up compose up fails, the job reports failure and the committed override/.env already hold the desired env, so a container that fell over (or a brain restart) converges via the reconcile pass's "no containers" branch. But a container that kept running stayed on its old env, because reconcile re-created an already-running container only on resource-limit drift, never on env drift. The gap "spans config + mail" and was tracked nowhere until #268. This slice makes the reconcile pass converge it for both, in one place.
internal/store/(store.go) — apending_recreateboolean column oninstances(INTEGER NOT NULL DEFAULT 0, in theCREATE TABLE, the idempotent ALTER-TABLE migration list,instanceColumns, andscan), surfaced asInstance.PendingRecreate.SetInstancePendingRecreate(id, bool)sets/clears it (ErrNotFoundon a missing row, same shape asSetServiceIdentity).Createis untouched — new rows default to0.internal/lifecycle/(lifecycle.go) — two helpers.recreateRunning(ctx, inst)replaces the barecompose upin the running-instance edit path: on acompose upfailure it marks the instancepending_recreate(the brain has already committed the override/.env, so the marker is the reconstructible intent), on success it clears the marker.clearPendingRecreate(inst)is the shared no-op-if-clear clear. TheReconcilealready-up branch now recreates when the resource-limit policy drifted orinst.PendingRecreateis set — onecompose up -dconverges both (env is read at container-create) — and clears the marker on success; therestore()rewind is guarded to the resource-stanza patch (restore != nil) while the marker, not a file rewind, is what makes the env recreate retryable. The "no containers" branch clears the marker after it brings a drifted instance up, so a pending instance whose container also fell over is satisfied there without a redundant recreate on a later pass.internal/lifecycle/(config.go,mail.go) —SetConfigandRebindMailcallrecreateRunninginstead of their owncompose up; their doc comments now describe the real convergence boundary (the reconcile pass brings a stranded running container to the new env via the pending-recreate marker) rather than the old "stays stale until the user retries".internal/lifecycle/(lifecycle.go,Start) —Start's ownComposeUpapplies whatever the override/.env currently hold, which satisfies an owed recreate just as well as reconcile's would; it now callsclearPendingRecreateon success too (a self-review catch, not just reconcile's already-up/no-containers branches), so a Stop→Start cycle on a pending instance doesn't leave a stale marker for the next reconcile pass to redundantly retry against an already-converged container.- Tests —
store:TestSetInstancePendingRecreate(default-false, Get + List round-trip, clear,ErrNotFound).lifecycle(pending_recreate_test.go): a failedSetConfigmarks then a successful retry clears; a failedRebindMailmarks; reconcile recreates + clears a pending running instance and does not re-create it on a second (converged) pass; a failed reconcile recreate leaves the marker set; the "no containers" branch clears the marker;Startclears the marker and a subsequent reconcile does not re-recreate; a clean install + healthy reconcile never set it.
APP_LIFECYCLE.md's reconciliation list now carries the pending-recreate drift case as a fourth bullet, alongside running-no-containers / stopped-with-containers / orphans. The mechanism is the spec's own "every state-changing op records 'I am about to apply this change' before issuing it" pattern (line 89) made concrete for env-restamping edits: the marker is set at the failed edit, and reconcile re-applies desired. It is the brain-commits-first / "host is reconstructible" posture from CLAUDE.md # Load-bearing decisions — the marker persists in SQLite so the running container converges on the next startup pass.
- Startup-pass cadence.
Reconcileruns once at brain startup (cmd/brain/main.go), not on a timer, so a stranded running container converges on the next brain restart, not seconds after the failed edit. This matches the issue's framing ("low-severity, self-healing-on-restart edge") and the spec's imperative, no-reconciler-loop stance; a periodic reconcile is out of scope and unspecced. - No store-write fault coverage. The three
slog.Warnbranches that fire only whenSetInstancePendingRecreateitself errors (a SQLite write failure) are uncovered — the lifecycle fakes back onto a real SQLite store with no fault injection, the same reason the adjacentrestore()-error andSetServiceIdentityExec-error logs are untested. All new logic (mark-on-failure, clear-on-success, reconcile retry, no-churn, no-containers clear, store round-trip +ErrNotFound) is covered. architecture.mdunchanged. The issue suggested recording the policy indocs/architecture.mdtoo, but its only reconcile mention is the one-linelifecycleresponsibility cell, which already reads "reconcile pass" at the right altitude; the convergence policy lives inAPP_LIFECYCLE.md(which owns the drift list) and here.
- Nothing required for #268. If a future change adds a periodic reconcile, the marker mechanism already drives convergence on every pass, not just startup.
- Any future env-restamping op (beyond config/mail) gets convergence for free by routing its running-instance recreate through
recreateRunning.