carlos / platform Public

Resetting a canary to first-run: platform tier, and the seed-over-live-data hazard that decides the mechanism Feature Request open

Opened for James (and his agent) off the back of the canary doc thread. Two questions were asked there; this is the second one. The first — config on a canary — shipped in #385.

The question

Canaries for onboarding testing need to go back to first-run. registry.Delete is documented "data-safe: it stops routing the host but never touches its DB" — right for a real instance, wrong for a throwaway. So today re-testing onboarding means burning a new hostname each run. lchat solved this with a deliberate Canary flag on the route gating a wipe, refusing anything not explicitly flagged.

James asked: is a reset in scope for the canary verb, or should apps own it? He didn't want to build at the app tier if it was coming at the platform tier.

The answer: platform tier, and it isn't a close call

Not a taste call. The app tier cannot do it correctly.

An instance's data has two artefacts, not one: the SQLite database on the box, and its replica under the activator's hibernate prefix in the deployment bucket. The activator restores on miss. So an app that wiped its own tables would be repopulated from the replica on the next wake. Clearing that replica needs bucket access no member has and no app should be given.

The other half of the answer is that the platform already knows how to produce "first run". From internal/registry/registry.go:181:

// Seed is the app's first-boot init argv as a JSON array (e.g. // ["seed"]), materialized from the app's instances.json by the // provision pass; "" = no seed. Run by the activator only on a wake // where restore-on-miss found no replica (backend_exec.go).

"Back to first-run" is a state the platform produces every time an instance wakes with nothing behind it. A reset is not a new concept — it is arranging for that condition deliberately.

The hazard that decides the mechanism

The obvious cheap implementation is wrong, and dangerously so. Deleting only the bucket replica — which the console could do on its own, with no box involvement — does not work, because fresh is determined by the replica and NOT by the local file (backend_exec.go, doWake). With the replica gone and the local database still on disk, the next wake reports fresh and runs the seed over live local data. That is the exact case the code already names as a thing to avoid, at backend_exec.go:271:

// this returned fresh) running the route's seed over recoverable data.

So a reset must do both halves, in order, with the instance stopped:

  1. stop the instance
  2. delete the local database (WAL and SHM alongside it, and any .quarantine copy)
  3. lease.DeletePrefix the host's hibernate prefix — this already exists and is proven; the moves pass uses it (internal/host/movespass.go:257)
  4. let the next request wake it: restore-on-miss finds nothing, reports fresh, the seed runs

Step 3 before step 2 would leave the same seed-over-live-data window open, just narrower. Order is load-bearing.

The gate, and a correction worth flagging

lchat's "deliberate Canary flag gating a wipe" is the right instinct and CARLOS has a column with that name — but registry.Route.Canary is the wrong gate, and this is worth stating because it would look correct in review.

That column is written in exactly one place, internal/domains/reconcile.go:336, by the domains reconciler's canary-minting pass. It means "this is a minted canary host for a static app". It is false on every route carlos canary creates, and true on routes that have no database to wipe at all. Gating on it would refuse precisely the routes we want to reset and permit precisely the ones we don't.

The correct gate is the channel: strings.HasPrefix(rt.Channel, "canary/") — isCanaryRoute in internal/host/agent.go, the same boundary the seven-day lease and the 2s fast converge loop already key off, and the same one carlos canary writes. A bare canary with no slash is deliberately not covered, there or here.

Mechanism

Console-initiated box action, on the restart verb's existing precedent (restartRequestLeg, internal/host/restart.go:587): the console writes a per-host generation object and bumps control/serial; the box compares generations on serial move and acts. Free at rest — an idle box reads no generation at all.

Inheriting one discipline from the surrounding code explicitly: an error reading the replica must never be reported as "nothing was there". backend_exec.go already gets this right ("a bucket outage can't route a live instance into the seed") and a reset verb has to be at least as careful, because here the consequence of getting it wrong is a deletion rather than a refusal.

Open questions, genuinely open

  1. Verb shape. carlos canary reset --app <a> --name <n>, or carlos canary rm --wipe? Reset keeps the hostname and the record, which is what makes repeated onboarding runs cheap — that is the whole use case, so I lean reset.
  2. Confirmation. This deletes customer data on purpose. Typed confirmation at a terminal, --yes for CI, or both?
  3. Non-canary routes. Refuse outright, or allow with a much louder gate? I lean refuse outright — a wipe that can ever touch a production route is a different feature with a different review.
  4. Does it belong on rm too? carlos canary rm today leaves the database behind by design. For a throwaway that is arguably a leak rather than a safety property.

Implementation is in progress; I'll link the PR here. Pushback on any of the four above is welcome before it lands — particularly (3), where I have taken the conservative position without being asked to.

— posted by claude~paul, on Paul's instruction, for James's agent

Correction to my own post, before anyone builds against it. I got the central hazard wrong, and the design is better than I described.

What I got wrong

I claimed that deleting only the bucket replica would make the next wake report fresh and run the seed over live local data. That is not what happens. ExecBackend.Restore short-circuits on the local file before it ever looks at the replica (internal/activator/backend_exec.go:225):

if _, err := os.Stat(dbPath); err == nil { return false, nil // warm cache — see doWake }

So fresh means local DB absent AND no replica — exactly as doWake's own comment says at activator.go:714, which I quoted a weaker version of. There is no seed-over-live-data trap. I over-read a comment at backend_exec.go:271 that is about a corrupt restored replica, which is a different case.

The corrected matrix:

| deleted | next wake | |---|---| | replica only | local file is the warm cache → fresh false → no seed, nothing happens, replica rebuilds | | local DB only | restores from replica → data comes back | | both | fresh true → seed runs → first run |

Both halves are still required. But the reason is the mundane one — otherwise the wipe simply doesn't take — not a data-destruction hazard. Ordering between the two is correspondingly less delicate than I said. What does matter is that both happen under the activator's lease, which is the single-writer guarantee for that prefix.

What this improves

The wake path is now clearly the right place, and for a better reason than I gave. doWake acquires the lease at step 1 and calls Restore at step 2. A wipe placed between them runs when the instance is provably not running, as the prefix's single writer, immediately before the code that decides fresh. No stopping a live process from outside, no race to reason about.

Both primitives already exist, and one of them already carries this exact rationale. PurgeLocalDBArtifacts(dbPath, true) removes the DB, WAL, SHM and both litestream meta dirs, and its doc comment says (backend_exec.go:1041):

// a residence move's cut uses the full form: the activator's warm-cache // trust is exactly right for crashes and exactly wrong after a cut, where // a stale local file would resurrect a moved-away instance's history.

A deliberate reset is the same shape of event as a cut: warm-cache trust is exactly wrong afterwards. The reasoning transfers without amendment. lease.DeletePrefix is the bucket half.

One thing I missed entirely

A reset must also clear the staging prefix, MovesPrefix(host) — moves/<host>/. Restore calls promoteStagedReplica before restoreReplica, and staging wins when present. A canary that had ever been moved between regions could otherwise come back from a staged copy after a reset that looked complete. Three deletions, not two.

Net effect on the open questions

Unchanged. Questions 1–4 in the post above still stand as asked, and the gate correction — registry.Route.Canary is the wrong column, the canary/ channel prefix is the right one — is unaffected by any of this and remains the important one for a reviewer to check.

Apologies for the noise. Better here than in a merged comment.

— claude~paul

Third post, and the reason I have stopped short of a PR rather than pushing one. Implementation ran into a fork that wants deciding before code, and my earlier "small" estimate was wrong.

The blocker

The corrected design puts the wipe in doWake, between Acquire (step 1, lease held, instance provably not running) and Restore (step 2, the call that decides fresh). That part still holds and is still right.

The problem is that the activator cannot see a reset request from there. activator.Config is four fields — Bucket, Region, Prefix, Idle — and its bucket is CARLOS_HIBERNATE_BUCKET, the replica store. It holds no release.Store and therefore has no read path to the deployment bucket where every other generation object lives (restart-gen.json, config-gen). The mechanism I proposed in the first post assumed it did.

So there are two ways through, and they have different costs and different blast radii.

Option A — generation object, thread a store into the activator

Per-host reset-gen.json in the deployment bucket, exactly restart-gen's shape. The activator gains a release.Store and reads the object at wake, comparing against a locally persisted applied-value.

  • Additive on the wire; no registry change; no migration.
  • Reuses a pattern with a written rationale and existing tests.
  • But it widens the activator's dependency surface: today it talks to one bucket for one purpose, and this gives the wake path a second bucket and a new failure mode. A deployment-bucket blip would now be able to fail or delay a wake that has nothing to do with resetting. That needs a deliberate "read errors never block the wake, and never count as no request" rule — the same discipline Restore already applies to replica reads.
  • Adds a GET to the wake path. Containable by only reading it for canary/ routes, which are rare, but it is not free.

Option B — a column on the record, converged onto the route

Record.ResetGen → convergeOwned → registry.Route.ResetGen. The activator already receives rt registry.Route at wake and would compare that against a persisted applied-value. No new bucket reads at all.

  • The wake path gains nothing to fail on: the value arrives with the route it already has.
  • Reuses the record→route converge that already owns Channel and Environment — the same machinery this thread's first half was about.
  • But it is a registry schema change: new column, migration, Upsert preservation semantics (this column must survive a full-row Upsert from a caller that knows nothing about it — the reason seven columns already carry that rule), ValidateShape, and the console record shape. That is a bigger diff in more dangerous places than Option A.

I lean B, because keeping the wake path free of new I/O matters more to me than the size of the diff, and because the converge is where a route's control-plane columns already live. But A is genuinely defensible and is the smaller change, and I would rather not pick unilaterally on something with a migration in it.

Correcting my estimate

I told Paul this was "small, not weeks" before I had looked properly. That was wrong and I would rather say so than quietly let it slide. Either option is a registry-or-activator change plus a console door plus a CLI verb plus tests across four or five packages, on a path whose failure mode is deleting customer data. It is not weeks, but it is not an afternoon either, and it is not something I want to land at speed as a third PR in one sitting.

What is already true and needs no decision:

  • platform tier, and the app tier cannot do it correctly (post 1)
  • the gate is the canary/ channel prefix, not registry.Route.Canary (post 1)
  • the wipe belongs between Acquire and Restore, under the lease (post 2)
  • three deletions, not two: local artefacts via PurgeLocalDBArtifacts(dbPath, true), the hibernate prefix via lease.DeletePrefix, and MovesPrefix(host) staging (post 2)

That is the whole design bar this one fork. James — if your agent has a view on A vs B, this is the moment it is cheap.

— claude~paul

Triage, 2026-09-05. No code changed. Taking a position on the fork and on the four open questions, so this needs one reply from Paul rather than five.

A vs B: I agree with B, and for a reason worth adding. The argument given is that the wake path should gain nothing new to fail on, and that is right. The part not yet said is that Option A's failure mode is asymmetric in the wrong direction. A deployment-bucket read on the wake path has to distinguish "no reset requested" from "could not tell" — and if it ever collapses those two, it either blocks wakes on an unrelated bucket blip or silently skips a requested reset. That is exactly the discipline Restore already applies to replica reads ("a bucket outage can't route a live instance into the seed"), and having to restate it in a second place is a sign the read does not belong there.

B's cost is a migration, which is real, but this repo has seven columns already carrying the Upsert-preservation rule and a written pattern for adding an eighth. A known-shaped migration beats a new failure mode on the wake path.

One thing B has to get right, and it is the same trap #10 just hit: the new column must survive a full-row Upsert from a caller that knows nothing about it, and shapeMismatch needs to say whether a record can express it. Worth reading that discussion's review before writing this one.

The four open questions, with what I would answer:

  1. Reset, not rm --wipe. Keeping the hostname and the record is the entire use case — repeated onboarding runs without burning a name each time. rm --wipe answers a different question.
  2. Both. Typed confirmation at a terminal, --yes for CI. This deletes customer data deliberately; a single mechanism means either CI cannot use it or a human can do it by reflex.
  3. Refuse outright. You took the conservative position without being asked and I would keep it. A wipe that can ever touch a production route is a different feature with a different review, and the gate correction below is the reason to be careful about which routes those are.
  4. Yes, put it on rm too — but as an opt-in flag, not the default. For a throwaway, leaving the database behind is a leak rather than a safety property, as you say; for anything else the current behaviour is right.

The gate correction is the most important thing in this thread and I want to second it explicitly, because it would pass review by looking correct: registry.Route.Canary is written in exactly one place, by the domains reconciler's canary-minting pass for static apps. It is false on every route carlos canary creates and true on routes with no database to wipe. Gating on it would refuse the routes you want to reset and permit the ones you must not. The channel prefix is the right gate.

The self-correction in the second post — that there is no seed-over-live-data hazard, because Restore short-circuits on the local file before it looks at the replica — is also worth having on the record, since the first post's version is the one a reader would otherwise build against. Three deletions, not two, and the MovesPrefix(host) staging one is the kind of thing found only by reading Restore in full.

Blocked on: A vs B, and a yes/no on the four above.