Skip to content

sbx-mgr: add the persisted state-transition table and update_state_from (no callers) - #388

Merged
ac-pplx merged 1 commit into
ac/sbxmgr-state-write-pr0-testsfrom
ac/sbxmgr-state-write-pr1-helper
Oct 14, 2026
Merged

ac-pplx merged 1 commit into
ac/sbxmgr-state-write-pr0-testsfrom
ac/sbxmgr-state-write-pr1-helper

Conversation

@ac-pplx

@ac-pplx ac-pplx commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #372 (the PR 0 characterization tests). Base: ac/sbxmgr-state-write-pr0-tests. Review #372 first; this PR's diff is one commit on top of it.

All of this concerns sbx-manager's node-local SQLite sandboxes.state, not the control-plane Postgres public.sandboxes.lifecycle.

Coordination. The D2 Stop/Kill change (#396) was closed, so nothing touches state.rs or this allowlist's Stop entries. This PR does not edit state.rs.

What merging does

Adds db/state_write.rs.

  • StateSet: a bitset over the 18 lifecycle states. EMPTY, ALL, of, with, without, contains, is_empty, iter.

  • Edge / EdgeKind / EDGES: 73 data-only edge constants, each with a from: StateSet, a to: SandboxState, a kind and a doc comment naming its writer functions and callers. Fields are private. SandboxState::can_transition_to stays the RPC-admission machine (D1).

  • update_state_from(conn, id, edge: &'static Edge) -> StateWriteOutcome (plan §4.1: &edges::PAUSE_ROLLBACK). A call site names a table edge and cannot invent one. It returns one of:

    • Applied { from }
    • Refused { found, refusal: FromState | Quarantined | CleanupOwned }
    • Missing

    Its behavior:

    • It refuses KnownRace edges and inserts with InvalidQuery, since a known race disappears when its caller is converted. Targets update_state rejects (quarantined, failed) are InvalidQuery too.
    • It applies update_state's quarantine and cleanup-ownership guards (§1.4) through generic_state_write_refusal. That function now lives in state_write.rs and returns StateWriteRefusal directly; update_state calls it with no behavior change.
    • It then writes with AND state IN (<edge.from>) in the UPDATE itself.
    • Precedence. The generic guards answer first, so every write update_state refuses is refused for the same reason. Converting an update_state caller (PRs 2, 3, 5) therefore changes only rows outside the edge's from-set.
    • Note for PR 4. A guard refusal does not say whether the row was also outside the from-set: a warm start's starting/restoring rows with a current activation episode answer CleanupOwned. PR 4 must branch on edge.from().contains(found), not on the refusal tag.
    • Invariant error. If the UPDATE matches no row although the row was read in a source state, it returns StatementChangedRows(0), not FromState (plan §4).
    • Side effects are update_state's: it clears failure_reason, the restore columns and non-matching receipts, and a deleting write fails active teardown jobs.
    • Inside a caller's transaction it runs in that transaction. In autocommit it opens its own IMMEDIATE transaction, so the guard reads, the write and the job failure commit together. It never nests one.
    • A #[cfg(test)] update_state_from_states(conn, id, from, to) lets tests sweep arbitrary from-sets.
    • No production callers.

Updates the sbx-mgr AGENTS.md writer rule. A new transition calls update_state_from with its edge, or adds a specialized CAS writer; no new update_state callers. Record the edge in src/db/state_write.rs, and add any new disagreement with can_transition_to, with a reason, to ALLOWLIST in src/db/tests/state_write_table.rs. Nothing checks the table against the writers; the &'static Edge argument does that at call sites.

Edge inventory

The list was built from the code at this head: every SET state/INSERT INTO sandboxes and every update_state/update_state_best_effort/superseding caller (24 call sites), not from the plan's §2 table. These rules shaped it:

  • A specialized CAS writer's from-set is its SQL predicate, narrowed to the states its production callers reach.
  • A generic update_state caller's edge lists the state(s) the caller relies on. Its stale-state overwrites are separate KnownRace edges.
  • Inserts have an empty from-set.
  • Same-state rewrites (e.g. publish_stopped_artifact over stopped, record_cleanup_episode_failure over cleanup_failed) and row deletion are not edges. A test enforces that no edge lists its own target as a source.
  • Test-only writers are excluded: mark_quarantined, mark_starting_quarantined, mark_restoring_quarantined, begin_activation, mark_stopping_superseding_activation and mark_deleting_superseding_activation.
Kind Edges (from, to) pairs Inserts
Request 14 49 2
Completion 16 16 0
Compensation 9 9 0
Recovery 28 47 4
KnownRace 6 17 0
Total 73 138 (104 distinct) 6
Request (14)
Edge From → to Writers
CREATE_INSERT insert → creating insert_sandbox_with_cleanup_episode_and_route_evidence (create, pool clone), transfer::reserve_import
COLD_START_BEGIN created, stopped, suspended → starting begin_activation_with_route_evidence(ColdStart)
RESTORE_BEGIN suspended → restoring begin_activation_with_route_evidence(Restore) from start_from_checkpoint
WARM_START_RESTAGE created, stopped → suspended try_start_from_warm_snapshot: update_state(Suspended)
CROSS_NODE_RESTORE_INSERT insert → restoring insert_restoring_sandbox_row_from_snapshot → restore_adopt_orphan_or_insert_sandbox_in_transaction
PAUSE_BEGIN started → pausing pause, suspend auto-pause: update_state(Pausing)
RESUME_BEGIN paused → resuming begin_resident_resume_with_policy; resume_after_wake_lock (no policy): update_state(Resuming)
CHECKPOINT_BEGIN started, paused → checkpointing do_checkpoint, create_template_base_snapshot: update_state(Checkpointing)
SUSPEND_BEGIN paused → suspending begin_suspend, begin_teardown_suspend
STOP_ADMISSION all but creating, stopping, deleting, quarantined → stopping admit_lifecycle_cleanup_fenced → update_state_superseding_activation_in_transaction (Stop, Kill, auto-lifecycle Stop). Stop resolves its target only while fc.pid is live, and admission does not re-check it. So created/stopped/suspended are reached only in the gate-to-fence window or with a leaked runtime, and creating never is. Kill takes only started/paused
AUTO_LIFECYCLE_STOP_SUSPENDED suspended → stopped run_due_auto_lifecycle: update_state(Stopped); bypasses every RPC (kept as Request, and its allowlist reason says so)
DELETE_ADMISSION all but deleting → deleting admit_lifecycle_cleanup_fenced → superseding writer (Delete, auto-lifecycle Delete)
EVICTION_CLAIM stopped, suspending, suspended, restoring, quarantined → deleting the five claim_*_sandbox_for_eviction writers
POOL_CLONE_DISPOSAL_REARM quarantined → cleanup_required rearm_prepared_clone_disposal (Delete of a quarantined pool clone)
Completion (16)
Edge From → to Writers
CREATE_COMPLETE creating → created create_with_id: update_state(Created)
CREATE_BASE_SNAPSHOT_COMPLETE created → suspended create_with_id base-snapshot mode: update_state(Suspended)
CLONE_OR_IMPORT_COMPLETE_SUSPENDED creating → suspended complete_pool_clone, complete_import
IMPORT_COMPLETE_STOPPED creating → stopped complete_import
START_COMPLETE starting → started mark_start_succeeded
RESTORE_COMPLETE_STARTED / _PAUSED restoring → started / paused mark_restore_succeeded
CROSS_NODE_RESTORE_PUBLISHED restoring → suspended publish_restored_suspended_artifact, publish_restored_suspended_without_artifact
RESTORED_DISK_AS_NEW restoring → stopped publish_restored_disk_as_new
PAUSE_COMPLETE pausing → paused pause, suspend auto-pause: update_state(Paused)
RESUME_COMPLETE resuming → started restore_resident_resume_state(Started) → rollback_activation_state
CHECKPOINT_COMPLETE_STARTED / _PAUSED checkpointing → started / paused restore_checkpoint_state (finish_checkpoint_success); template snapshot update_state(Paused)
SUSPEND_COMPLETE suspending → suspended complete_row_intent_cleanup_for_job(MarkSuspended)
PROCESS_SNAPSHOT_ROLLBACK_RESTAGE stopped → suspended full process-snapshot rollback: update_state(Suspended)
STOP_COMPLETE stopping → stopped complete_row_intent_cleanup_for_job(MarkStopped), publish_stopped_artifact
Compensation (9)
Edge From → to Writers
START_ROLLBACK_CREATED / _STOPPED / _SUSPENDED starting → created / stopped / suspended rollback_starting_episode
RESTORE_ROLLBACK restoring → suspended rollback_restoring_episode
PAUSE_ROLLBACK pausing → started pause and suspend auto-pause update_state(Started); update_state_best_effort(Started) (connect failure, pause_with_reconciliation)
RESUME_ROLLBACK resuming → paused restore_resident_resume_state(Paused)
CHECKPOINT_ROLLBACK_STARTED / _PAUSED checkpointing → started / paused restore_checkpoint_state_best_effort; template snapshot update_state_best_effort(Paused)
SUSPEND_ROLLBACK suspending → paused rollback_suspending_to_paused (suspend snapshot path)
Recovery (28)
Edge From → to Writers
ACTIVATION_CLEANUP_REQUEST creating, created, starting, suspended, restoring → cleanup_required request_cleanup_episode (activation cleanup guard, startup, cleanup recovery); created/suspended when create's guard fires after its own write
CLEANUP_HANDOFF starting, stopping, deleting, suspending, restoring, cleanup_failed → cleanup_required mark_cleanup_required, mark_cleanup_required_if_no_intent[_in_state]
SUSPEND_ARTIFACT_CLEANUP suspending → cleanup_required pin_suspended_artifact_for_cleanup
CLEANUP_FAILURE stopping, deleting, suspending, cleanup_required → cleanup_failed mark_cleanup_failed, record_cleanup_episode_failure
CLEANUP_COMPLETE_CREATED / _STOPPED / _SUSPENDED cleanup_required, cleanup_failed → created / stopped / suspended complete_cleanup_episode_for_job, complete_row_intent_cleanup_for_job → apply_cleanup_state; publish_stopped_artifact
CLEANUP_QUARANTINE cleanup_required, cleanup_failed → quarantined quarantine_cleanup_episode_for_job; apply_cleanup_state (Quarantine, or MarkSuspended without artifact)
IN_FLIGHT_QUARANTINE stopping, pausing, resuming, checkpointing → quarantined mark_quarantined_best_effort, mark_quarantined_from_state
SUSPEND_QUARANTINE suspending → quarantined mark_suspending_quarantined; row-intent MarkSuspended without artifact
STARTUP_RESIDENT_QUARANTINE started, paused, resuming, checkpointing → quarantined settle_startup_resident(Quarantined)
ORPHAN_QUARANTINE_INSERT insert → quarantined insert_quarantined_sandbox_in_transaction
PAUSE_FAILED_QUARANTINE_RECOVERY_STARTED / _PAUSED quarantined → started / paused recover_pause_failed_quarantine
QUARANTINE_CLEANUP_ADOPTION quarantined → cleanup_required adopt_quarantine_cleanup_episode
ORPHAN_RESTORE_ADOPTION quarantined → restoring restore_adopt_orphan_or_insert_sandbox_in_transaction (conflict branch)
INTERRUPTED_CREATE_RECOVERY creating → created recover_interrupted_create: update_state(Created)
UNLAUNCHED_START_RECOVERY_STOPPED / _CREATED starting → stopped / created recover_unlaunched_start: update_state
INTERRUPTED_PAUSE_RECOVERY_STARTED / _PAUSED pausing → started / paused recover_interrupted_pause_state
STARTUP_RESIDENT_STARTED resuming, checkpointing → started settle_startup_resident(Started)
STARTUP_RESIDENT_PAUSED resuming → paused settle_startup_resident(Paused)
STARTUP_SUSPEND_ROLLBACK suspending → paused rollback_suspending_to_paused (startup suspend recovery)
INTERRUPTED_RESTORE_RECOVERY restoring → suspended recover_interrupted_startup_states: update_state(Suspended); publish_orphan_quarantine_recovered_artifact
ORPHAN_CLEANUP_INSERT / ORPHAN_SUSPENDED_INSERT / ORPHAN_RESTORE_INSERT insert → cleanup_required / suspended / restoring orphan materialization: insert_sandbox, insert_recovered_suspended_sandbox_in_transaction, restore_adopt_orphan_or_insert_sandbox_in_transaction
KnownRace (6)
Edge From → to Writer and race
PAUSE_ROLLBACK_AFTER_STOP stopped → started pause rollback after a concurrent Stop (§3 finding 1)
PAUSE_COMPLETE_AFTER_STOP stopped → paused pause completion after Stop (finding 1)
RESUME_BEGIN_AFTER_STOP stopped → resuming policy-free resume after Stop (finding 2)
WARM_START_UNVALIDATED creating, starting, started, stopping, pausing, paused, resuming, checkpointing, suspending, restoring, cleanup_required, cleanup_failed → suspended warm start (finding 3). An empty-checkpoint Start { from_warm } rejects only failed before try_start_from_warm_snapshot, so the reach is every state except failed and suspended that the generic guards admit. No concurrency is needed. Rows with a current activation episode answer CleanupOwned
PROCESS_SNAPSHOT_ROLLBACK_AFTER_STOP stopping → suspended full process-snapshot rollback (finding 5); only in Stop's gate-to-fence window or with a leaked runtime, since the rollback's own stop leaves no runtime
TEMPLATE_SNAPSHOT_COMPLETE_AFTER_STOP stopped → paused template snapshot completion (finding 6)

Finding 4 (create completion over stopping) is unreachable. Stop requires a live runtime, a row still creating or created during its create has none, and Delete's deleting row is refused by the generic guard. Its two edges are dropped.

What the counts say

Convention. An edge enters or leaves a transient state when its target or any of its sources is one of the nine SandboxState::is_transient states. A pair qualifies when either end is transient.

  • In-flight edges. 55 of the 73 edges qualify: 9 of 9 compensations, 14 of 16 completions, 18 recoveries, 11 requests and 3 of 6 known races. At pair level, 108 of 138 pairs qualify. Under the stricter convention (target transient, or every source transient), 50 edges qualify, including 2 known races.

  • Episode-bound edges. 17 edges create, require or complete an activation cleanup episode:

    • create/clone/import: CREATE_INSERT, CREATE_COMPLETE, CREATE_BASE_SNAPSHOT_COMPLETE, CLONE_OR_IMPORT_COMPLETE_SUSPENDED, IMPORT_COMPLETE_STOPPED;
    • activation: COLD_START_BEGIN, RESTORE_BEGIN, START_COMPLETE, RESTORE_COMPLETE_STARTED/_PAUSED, START_ROLLBACK_CREATED/_STOPPED/_SUSPENDED, RESTORE_ROLLBACK;
    • hand-off: ACTIVATION_CLEANUP_REQUEST, QUARANTINE_CLEANUP_ADOPTION, POOL_CLONE_DISPOSAL_REARM.

    Five more cleanup edges (CLEANUP_FAILURE, CLEANUP_COMPLETE_* ×3, CLEANUP_QUARANTINE) serve both episodes and row intents.

  • Recovery is the largest kind. 28 edges: 47 pairs plus 4 inserts.

  • Targets. failed is the only state no writer targets. It is only a source, of Stop and Delete admission. Every other state is targeted.

  • Admission-machine edges no writer implements. can_transition_to allows 56 state-changing pairs, and 8 have no writer: started → suspending (suspend pauses first), and → quarantined from creating, created, starting, stopped, deleting, suspended and restoring.

  • Admission-machine edges only non-request writers implement. 21 allowed pairs are written only by non-request edges:

    • 10 completions;
    • 9 quarantine entries;
    • 2 for the cleanup retry loop.

    27 allowed pairs are written by a request edge.

  • Written pairs the machine forbids. The table writes 56 pairs that can_transition_to forbids.

    • 17 come from request edges:
      • 12 from Stop admission: 6 live transient sources (starting, pausing, resuming, checkpointing, suspending, restoring); 3 runtime owners (cleanup_required, cleanup_failed, failed); and 3 settled sources (created, stopped, suspended) reached only in the gate-to-fence window or with a leaked runtime.
      • creating → deleting.
      • warm-start created/stopped → suspended.
      • auto-lifecycle suspended → stopped.
      • the clone re-arm.
    • 39 come only from completion, compensation, recovery or known-race edges. 9 of those pairs exist only as known races: stopped → started/paused/resuming, and → suspended from started, stopping, pausing, paused, resuming and checkpointing.

Differences from the plan's §2 table, found in code

  • settle_startup_resident does not write paused → started or started → paused at this head. It writes resuming → started/paused, checkpointing → started and quarantines.
  • quarantined → quarantined (reconfirm) has no production caller: every production quarantine writer passes an expected non-quarantined state.

Parity allowlist

db/tests/state_write_table.rs classifies every state-changing pair on which the edge table and can_transition_to disagree. It asserts that the classification equals the checked-in ALLOWLIST of 85 entries, each with a one-line reason:

Class Meaning Entries
RequestForbidden a Request edge writes it; can_transition_to forbids it 17
WriteForbidden only non-request edges write it; forbidden 39
AllowedNotRequested allowed; only non-request edges write it 21
AllowedUnwritten allowed; no writer 8

The Stop entries carry the runtime-gate reasons above; suspended → stopped says that auto-lifecycle bypasses every RPC.

A new writer edge, a changed from-set, or a changed state machine fails CI until the allowlist records it. On failure, the test prints the unlisted entries in allowlist syntax and the stale ones.

Tests

  • Equivalence (update_state_from.rs).

    • What it runs: sbx-mgr: characterize the state writers before the state-write helper (tests only) #372's grids, seeds, receipt variants and whole-database diffs, unchanged and not copied. update_state_matrix.rs now runs its sweeps over a Writer.
    • What it proves: update_state_from_states(ALL), both standalone and inside a caller's transaction, gives the same outcome and the same diff as update_state on every cell. That covers all 18×18 pairs, the five cleanup shapes, both receipt kinds, the quarantine episode and the missing row.
    • Applied { from } and Refused { found } must name the seeded state.
  • From-set refusal. It covers every (from, to) pair on a plain row, with both receipt kinds, standalone and in a transaction, using four from-sets:

    • every other state, and no state: these must return Refused { found: from, FromState } with every row unchanged;
    • only the row's state, and every state: these are the accepting controls. They must return Applied { from } with update_state's exact diff.

    Guard refusals must not depend on the from-set. Rejected targets (quarantined, failed) must return InvalidQuery with nothing changed.

  • Edge binding. For every writable edge × every source state on a plain row, update_state_from(&edge) applies exactly when the edge's from-set contains the row's state and the generic guards (sbx-mgr: characterize the state writers before the state-write helper (tests only) #372's PLAIN grid) admit it, with the exact row diff. Otherwise it refuses with every row unchanged.

  • Refused edges. Every KnownRace, insert and quarantine/failed-target edge (16 in all) is InvalidQuery with every row unchanged. The 6 known races are tried over a source state the generic guards admit. The 6 inserts have no source, and every PLAIN cell for the 4 quarantine-target edges is X, so those 10 are tried over started.

  • Invariant. A temp RAISE(IGNORE) trigger stands in for SQL that drifted from the from-set: a source-state row whose write matches nothing returns StatementChangedRows(0), row unchanged.

  • Autocommit atomicity. A temp trigger makes the jobs update fail. A standalone deleting write then returns the error, and the state write is rolled back too.

  • Table. The parity test above, plus a test that no edge lists its own target as a source.

Mutation proof

The mutations ran one at a time, each from a single backup, and every SQL ?N stayed bound. Each was restored and touched before the next.

# Mutation Failing tests
1 Drop the from-set predicate (AND ?4 IS NOT NULL) from-set refusal, edge binding
2 Drop the quarantine guard both matrix sweeps, both equivalence sweeps, from-set refusal, edge binding
3 Drop the cleanup-episode guard matrix shapes sweep, equivalence shapes sweep
4 Drop the delete_row-intent guard matrix shapes, equivalence shapes, from-set refusal, edge binding
5 Drop the stopping/cleanup revival guard matrix shapes, equivalence shapes, from-set refusal, edge binding
6 Open IMMEDIATE even inside a caller's transaction both equivalence sweeps, from-set refusal
7 Skip fail_active on deleting equivalence shapes, from-set refusal, edge binding, autocommit atomicity
8 Remove the suspended → stopped allowlist entry parity
9 Add (Created, Paused) to can_transition_to parity
10 Bind StateSet::ALL instead of edge.from() edge binding
11 Accept KnownRace edges refused edges
12 Accept insert edges refused edges
13 Report a missed source-state write as FromState invariant
14 Drop the autocommit IMMEDIATE wrapper autocommit atomicity

Guards 2–5 live in the shared generic_state_write_refusal, so each mutation fails both writers' tests.

Instructions for the converting PRs (2–5)

  • Refresh reasons, not just classes. The parity test compares each pair's class, not its reason text. When a PR deletes a KnownRace edge, a pair that is still written by another edge keeps its class, so the test stays green while its reason goes stale. Re-read the mixed reasons that name a race:
    • creating, starting, restoring, cleanup_required and cleanup_failed → suspended (they name the warm start);
    • stopping → suspended (warm start and process-snapshot rollback);
    • stopped → paused (pause and template-snapshot completion).
  • Retire the kind with the last race. known_race_insert_and_rejected_target_edges_are_refused asserts that at least one KnownRace edge exists. It fails when the last one is deleted, which forces the PR that does so to retire EdgeKind::KnownRace and the test's race branch.

Decisions made without an operator

  • Request kind. Request includes node-initiated admissions: the checkpoint scheduler, auto-lifecycle and eviction act as clients.
  • One edge per writer purpose. An edge has one to. When the same pair serves two purposes, each gets its own edge, e.g. CHECKPOINT_COMPLETE_PAUSED (Completion) and CHECKPOINT_ROLLBACK_PAUSED (Compensation).
  • Guard precedence. The generic guards answer before the from-set so that refusals keep update_state's reason (see Precedence above).
  • Predicate form. The from-set predicate is state IN (SELECT value FROM json_each(?4)). This has precedent in cleanup_jobs.rs. update_state's SQL quarantine predicate is not duplicated: the shared Rust guard answers first, and a duplicate predicate would be untestable.
  • Warm-start race reach. WARM_START_UNVALIDATED lists the full reach. I traced start_sandbox_with_authority's early returns for an empty-checkpoint from_warm Start with restore_artifact_as_new = None: target lookup, interrupted-restore action, the suspend-checkpoint and restore-as-new branches, and the non-empty-checkpoint refusal. None of them gates on state before try_start_from_warm_snapshot except the failed check. With restore_artifact_as_new set, interrupted_restore_action branches on restoring, and the reattach branch can answer a live started sandbox before warm staging. The 12-state inventory, which covers the None case, is unchanged.
  • Finding 4. Its two edges are dropped rather than kept as unreachable, so the table lists only reachable writes.
  • AUTO_LIFECYCLE_STOP_SUSPENDED stays Request, matching the module's documented convention that node-initiated admissions are requests. Its allowlist reason says that it bypasses every RPC.
  • Allowlist layout. The allowlist lives in the test file under #[rustfmt::skip], which has precedent in routes/public_docs/compatibility/tests.rs, so it stays one line per entry.

Validation

Review rounds 1 and 2 are folded into this one commit. The final round changed only a test comment and this body; that pass re-ran fmt, workspace clippy (0 warnings) and one SPACE_CI_REQUIRE_TEST_DEPS=1 sbx-mgr nextest run (1622/1622, 0 dependency skips).

Check Result
cargo fmt --all -- --check, scripts/check-rust-error-policy.sh pass
cargo clippy --workspace --locked --all-targets --all-features -- -D warnings pass (0 warnings)
cargo arch pass. By archcheck's counting rule, state_transitions.rs was 1163 code lines at 8f12bd6 (already over its 1156 cap), 1182 at d9162b7, and is 1140 at this head; the cap is lowered from 1156 to 1140. state_write.rs is under the 1000-line limit
pre-commit (Gitleaks) on the staged change pass
SPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --locked -p pplx-space-sbx-mgr --retries 0 × 3 1622/1622 passed each run; 0 dependency skips (empty skip log ×3); 1 skipped = the #[ignore] privileged transfer_directory::tests::distinct_service_uids_can_exchange_downloads, unrelated
Mutation proof (14 mutations above) all 14 caught, all restored
Public-safety review of the staged diff no findings in the reviewed scope
CI (head 2fc6457) green: all 13 checks pass, including Rust tests, clippy and format
Kind not run: no production callers

🤖 Generated with Claude Code

@ac-pplx
ac-pplx force-pushed the ac/sbxmgr-state-write-pr1-helper branch 2 times, most recently from f27c281 to 2fc6457 Compare October 13, 2026 19:47
@ac-pplx
ac-pplx marked this pull request as ready for review October 13, 2026 19:56
@kumarpallav-pplx
kumarpallav-pplx requested review from shrikant-pplx and zibi-pplx and removed request for zibi-pplx October 13, 2026 19:56
@ac-pplx
ac-pplx added this pull request to stack #401 October 13, 2026 21:14
@kumarpallav-pplx
kumarpallav-pplx requested review from nikhilgupta-pplx and the-gigi-pplx and removed request for shrikant-pplx October 13, 2026 21:18
@the-gigi-pplx

Copy link
Copy Markdown
Collaborator

Review — 3 findings

Medium

  1. crates/libs/sbx/mgr/server/src/db/state_write.rs:377 — five edges that update_state_from accepts can never apply through it.
    PAUSE_FAILED_QUARANTINE_RECOVERY_STARTED, PAUSE_FAILED_QUARANTINE_RECOVERY_PAUSED, QUARANTINE_CLEANUP_ADOPTION, POOL_CLONE_DISPOSAL_REARM and ORPHAN_RESTORE_ADOPTION all have the from-set {quarantined}. generic_state_write_refusal runs generic_writer_transition_blocked first (db.rs:355), and that refuses every move out of quarantined except the one can_transition_to allows, → deleting. So each of these edges returns Refused { Quarantined } on every row. They pass the writable() filter (tests/update_state_from.rs:110), and an_edge_writes_its_target_only_from_its_sources accepts "always refused" as their expected result, so nothing flags them. I ran a probe in a scratch tree: for every writable() edge, seed a plain row in each source state and call update_state_from. Exactly these five returned Applied zero times and Refused { Quarantined } once each.
    Their real writers are specialized CAS writers, so nothing is broken today. But the &'static Edge argument is meant to make an edge from the table safe to pass, and a future call such as update_state_from(&edges::PAUSE_FAILED_QUARANTINE_RECOVERY_STARTED) would compile and then quietly never recover anything. I'd make update_state_from return InvalidQuery for an edge whose from-set the quarantine guard always refuses, the way it already does for KnownRace edges and inserts, and move them out of writable() into the refused-edges test. The alternative is a marker such as a specialized_only flag on the edge. Either works; what matters is that the test asserts the refusal and doesn't just tolerate it.

Low

  1. crates/libs/sbx/mgr/server/src/db/state_write.rs:608 — the UPDATE body is a verbatim copy of update_state's (state_transitions.rs:134): failure_reason, the three restore_* columns and the six transition_receipt_* CASEs. Only the WHERE differs. The equivalence sweeps catch a drift only in columns the fixtures seed, so a new receipt or restore column added to one writer and not the other would get past them. A shared SET-clause constant, or having update_state delegate to write_state_from with StateSet::ALL plus its own quarantine predicate, would leave one copy.
  2. crates/libs/sbx/mgr/server/src/db/state_transitions.rs:123 — update_state folds StateWriteRefusal::FromState into the CleanupOwned arm, with the comment that the guards never return it. That's true today (state_write.rs:477), but it's held up by a comment, not by the type. If the shared guard ever returns FromState, update_state reports SupersededByCleanupEpisode and logs "cleanup owns the transition", and nothing fails to compile. A narrower return type for generic_state_write_refusal (just Quarantined | CleanupOwned, wrapped into StateWriteRefusal by update_state_from) would remove the impossible arm.

Verified against 2fc64571ca5b8cb808ea6a55a28688c72ff4e3ba, reviewed as the one commit on top of PR #372's head 8f12bd65. Reproduced locally: I ran the PR's state-write tests (state_write_table, update_state_from, update_state_matrix, state_writer_refusal) with SPACE_CI_REQUIRE_TEST_DEPS=1, and all 28 passed. I ran the dead-edge probe for finding 1 in the same run, in a scratch tree, and then discarded it.

I also checked and dropped two candidates:

  • Missing vs SupersededByCleanupEpisode on a deleted row that still has an episode row. It can't happen: all four production DELETE FROM sandboxes sites call delete_sandbox_child_rows in the same transaction.
  • StateSet::ALL being a literal. A new state outside it would fail the "every state" from-set sweep.

I didn't re-verify the edge inventory's per-writer from-sets or the count tables in the description against every call site.

@the-gigi-pplx the-gigi-pplx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. 3 non-blocking findings in the review comment above.

…om (no callers)

Add db/state_write.rs for the node-local SQLite sandboxes.state column:

- StateSet, a bitset over the 18 lifecycle states.
- EDGES: 73 data-only edge constants (from-set, target, kind) built from
  every production writer at this head, each naming its writer functions.
  Kinds are Request, Completion, Compensation, Recovery and KnownRace (the
  stale or unvalidated writes generic update_state callers can make).
  SandboxState::can_transition_to stays the RPC-admission machine.
- update_state_from(conn, id, &'static Edge) -> Applied { from } |
  Refused { found, refusal } | Missing. Call sites name a table edge;
  KnownRace and insert edges are refused. It applies update_state's
  quarantine and cleanup-ownership guards, now generic_state_write_refusal
  in this module, then writes with the edge's from-set predicate in the
  UPDATE itself. A missed write from a source state is an invariant error.
  It runs in a caller's transaction or opens its own IMMEDIATE one in
  autocommit, never a nested one. It has no production callers yet.

update_state calls the moved guard; its behavior is unchanged, and
state_transitions.rs shrinks below its archcheck cap, which is lowered.

Tests:
- With every state expected, the writer matches update_state on every
  cell of the existing outcome matrix and its whole-database diffs,
  standalone and inside a transaction, by running the matrix's own grids
  and sweeps against both writers.
- A from-set without the row's state refuses with every row unchanged, for
  every (from, to) pair, with accepting controls; every writable edge
  writes only from its own sources; refused edge kinds change nothing.
- The invariant error and the autocommit atomicity of a deleting write and
  its teardown-job failure are pinned with temporary triggers.
- Every disagreement between the edge table and can_transition_to (85
  pairs) is in a checked-in allowlist with a reason, so a new one fails.

The sbx-mgr AGENTS.md writer rule now points new transitions at
update_state_from with an edge, or a specialized compare-and-set writer.
@ac-pplx
ac-pplx force-pushed the ac/sbxmgr-state-write-pr1-helper branch from 2fc6457 to 8646f74 Compare October 14, 2026 03:11
@ac-pplx
ac-pplx added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit b18051f Oct 14, 2026
20 checks passed
@ac-pplx
ac-pplx deleted the ac/sbxmgr-state-write-pr1-helper branch October 14, 2026 03:30
ac-pplx added a commit that referenced this pull request Oct 14, 2026
Every write of sbx-mgr's node-local SQLite `sandboxes.state` now goes
through `update_state_from` with an explicit edge, or through a named
specialized writer. Nothing here touches the control-plane Postgres
`public.sandboxes.lifecycle`.

The three remaining db-internal `update_state` callers:

- The `deleting` re-entry inside `update_state` had no production caller:
  Stop and Delete admission always write inside their own transaction.
  `update_state_from` already opens an `IMMEDIATE` transaction in
  autocommit for every target, so the deletion and the teardown jobs it
  fails still commit together.
- `begin_resident_resume_with_policy` writes `edges::RESUME_BEGIN`
  (`paused` -> `resuming`) inside its policy transaction. The edge now
  refuses a non-paused row, so the separate state pre-check is gone; the
  generation fence, the lock and the transaction are unchanged.
- Stop and Delete admission
  (`update_state_superseding_activation_in_transaction`) cannot use an
  exact edge without changing behavior. Admission re-admits a row that is
  already in its target: a `stopping` row for a repeated Stop of a live
  runtime, and a `deleting` row for a repeated Delete. No edge lists its
  own target. The write therefore moves to `write_cleanup_admission_state`,
  a private writer for only those two targets. It writes over every state
  the generic guards admit, using `update_state_from`'s statement, guards
  and warnings, as admission always has. `STOP_ADMISSION` and
  `DELETE_ADMISSION` keep listing the transitions it makes.

`update_state`, `SandboxStateWriteOutcome`, the legacy
`require_state_write` and `CleanupCompletionError::StateWriteNotApplied`
are deleted. The guard types collapse: `db::StateWriteGuard { Quarantined,
CleanupOwned }`, `generic_state_write_refusal` returns
`Option<StateWriteGuard>`, and `StateWriteRefusal` is
`FromState | Guard(StateWriteGuard)`. That removes the unreachable
"guards never answer FromState" arm. `SandboxStateWriteNotApplied` becomes
`Guard(StateWriteGuard) | Missing` and renders the same client-visible text
byte for byte. `into_status`, checkpoint admission and cleanup admission
all render through it.

Tests:

- The `update_state` outcome matrix already ran through `update_state_from`
  with every state expected, standalone and in a transaction (#388). Only
  its `update_state` arm is dropped, so every guard cell stays pinned. The
  guard tests in `db/tests.rs` now target production edges, and the #438
  status tests keep their literal texts without the legacy comparison.
- New: Delete admission from every source state. It admits every plain row,
  a `deleting` row included, except a `suspending` row whose suspend has
  not reached the cluster. Nothing pinned Delete re-admission before.
- Test-only callers use an edge where production has one, `force_state`
  where only the state matters, and an explicit-source
  `update_state_from_states` for the two `started` -> `restoring` seeds.
- The five copies of the Stop fixture (superseding write, `mark_stopped`
  intent, row-intent completion) are now `admit_stop`, `complete_stop` and
  `stop` in `db/tests/state_write_fixtures.rs`.

Review comments from the stack's approval, folded in:

- `update_state_from_best_effort` logs a missing row with its own message,
  instead of "superseded".
- `require_state_write_from` takes `context: &str`, as `into_status` does.
- Pause's comment on `mark_quarantined_best_effort` now says it also
  answers false on a poisoned connection or a database error.
- A refused startup recovery write names the edge, the found state and the
  refusal, instead of printing the outcome's `Debug` form. A missing row
  has its own error.
- Auto-lifecycle Stop gains a case for a Delete admitted mid-write: a
  `deleting` row with a current `delete_row` intent, which the
  cleanup-ownership guard refuses and the Stop skips.
- Suspend's guest-prep rollback refusal and auto-pause refusal still answer
  `Internal`. Both are unreachable under suspend's fence and locks, and a
  comment now says so.
- Warm start's doc says why a guarded `suspended` row stays `Aborted`, and
  that answering a transient row with `FailedPrecondition` is deliberate.
- Checkpoint admission renders through `SandboxStateWriteNotApplied`, not
  the legacy outcome. A missing-row case pins its text alongside the
  existing guard literals.

The sbx-mgr AGENTS.md writer rule is updated, and the archcheck cap for
`db/state_transitions.rs` is lowered to its new size.
ac-pplx added a commit that referenced this pull request Oct 14, 2026
Every write of sbx-mgr's node-local SQLite `sandboxes.state` now goes
through `update_state_from` with an explicit edge, or through a named
specialized writer. Nothing here touches the control-plane Postgres
`public.sandboxes.lifecycle`.

The three remaining db-internal `update_state` callers:

- The `deleting` re-entry inside `update_state` had no production caller:
  Stop and Delete admission always write inside their own transaction.
  `update_state_from` already opens an `IMMEDIATE` transaction in
  autocommit for every target, so the deletion and the teardown jobs it
  fails still commit together.
- `begin_resident_resume_with_policy` writes `edges::RESUME_BEGIN`
  (`paused` -> `resuming`) inside its policy transaction. The edge now
  refuses a non-paused row, so the separate state pre-check is gone; the
  generation fence, the lock and the transaction are unchanged.
- Stop and Delete admission
  (`update_state_superseding_activation_in_transaction`) cannot use an
  exact edge without changing behavior. Admission re-admits a row that is
  already in its target: a `stopping` row for a repeated Stop of a live
  runtime, and a `deleting` row for a repeated Delete. No edge lists its
  own target. The write therefore moves to `write_cleanup_admission_state`,
  a private writer for only those two targets. It writes over every state
  the generic guards admit, using `update_state_from`'s statement, guards
  and warnings, as admission always has. `STOP_ADMISSION` and
  `DELETE_ADMISSION` keep listing the transitions it makes.

`update_state`, `SandboxStateWriteOutcome`, the legacy
`require_state_write` and `CleanupCompletionError::StateWriteNotApplied`
are deleted. The guard types collapse: `db::StateWriteGuard { Quarantined,
CleanupOwned }`, `generic_state_write_refusal` returns
`Option<StateWriteGuard>`, and `StateWriteRefusal` is
`FromState | Guard(StateWriteGuard)`. That removes the unreachable
"guards never answer FromState" arm. `SandboxStateWriteNotApplied` becomes
`Guard(StateWriteGuard) | Missing` and renders the same client-visible text
byte for byte. `into_status`, checkpoint admission and cleanup admission
all render through it.

Tests:

- The `update_state` outcome matrix already ran through `update_state_from`
  with every state expected, standalone and in a transaction (#388). Only
  its `update_state` arm is dropped, so every guard cell stays pinned. The
  guard tests in `db/tests.rs` now target production edges, and the #438
  status tests keep their literal texts without the legacy comparison.
- New: Delete admission from every source state. It admits every plain row,
  a `deleting` row included, except a `suspending` row whose suspend has
  not reached the cluster. Nothing pinned Delete re-admission before.
- Test-only callers use an edge where production has one, `force_state`
  where only the state matters, and an explicit-source
  `update_state_from_states` for the two `started` -> `restoring` seeds.
- The five copies of the Stop fixture (superseding write, `mark_stopped`
  intent, row-intent completion) are now `admit_stop`, `complete_stop` and
  `stop` in `db/tests/state_write_fixtures.rs`.

Review comments from the stack's approval, folded in:

- `update_state_from_best_effort` logs a missing row with its own message,
  instead of "superseded".
- `require_state_write_from` takes `context: &str`, as `into_status` does.
- Pause's comment on `mark_quarantined_best_effort` now says it also
  answers false on a poisoned connection or a database error.
- A refused startup recovery write names the edge, the found state and the
  refusal, instead of printing the outcome's `Debug` form. A missing row
  has its own error.
- Auto-lifecycle Stop gains a case for a Delete admitted mid-write: a
  `deleting` row with a current `delete_row` intent, which the
  cleanup-ownership guard refuses and the Stop skips.
- Suspend's guest-prep rollback refusal and auto-pause refusal still answer
  `Internal`. Both are unreachable under suspend's fence and locks, and a
  comment now says so.
- Warm start's doc says why a guarded `suspended` row stays `Aborted`, and
  that answering a transient row with `FailedPrecondition` is deliberate.
- Checkpoint admission renders through `SandboxStateWriteNotApplied`, not
  the legacy outcome. A missing-row case pins its text alongside the
  existing guard literals.

The sbx-mgr AGENTS.md writer rule is updated, and the archcheck cap for
`db/state_transitions.rs` is lowered to its new size.
ac-pplx added a commit that referenced this pull request Oct 14, 2026
Every write of sbx-mgr's node-local SQLite `sandboxes.state` now goes
through `update_state_from` with an explicit edge, or through a named
specialized writer. Nothing here touches the control-plane Postgres
`public.sandboxes.lifecycle`.

The three remaining db-internal `update_state` callers:

- The `deleting` re-entry inside `update_state` had no production caller:
  Stop and Delete admission always write inside their own transaction.
  `update_state_from` already opens an `IMMEDIATE` transaction in
  autocommit for every target, so the deletion and the teardown jobs it
  fails still commit together.
- `begin_resident_resume_with_policy` writes `edges::RESUME_BEGIN`
  (`paused` -> `resuming`) inside its policy transaction. The edge now
  refuses a non-paused row, so the separate state pre-check is gone; the
  generation fence, the lock and the transaction are unchanged.
- Stop and Delete admission
  (`update_state_superseding_activation_in_transaction`) cannot use an
  exact edge without changing behavior. Admission re-admits a row that is
  already in its target: a `stopping` row for a repeated Stop of a live
  runtime, and a `deleting` row for a repeated Delete. No edge lists its
  own target. The write therefore moves to `write_cleanup_admission_state`,
  a private writer for only those two targets. It writes over every state
  the generic guards admit, as admission always has, using
  `update_state_from`'s statement and guards. `update_state`'s guard
  warnings move with it; their text and fields are unchanged, but the log
  target is now `db::state_write`. `STOP_ADMISSION` and `DELETE_ADMISSION`
  keep listing the transitions it makes. One behavior change: a row whose
  state label this binary cannot parse, reachable only after a downgrade or
  with a corrupt row, now fails Stop admission instead of being overwritten
  with `stopping`. Delete already failed earlier, in `get_sandbox`.

`update_state`, `SandboxStateWriteOutcome`, the legacy
`require_state_write` and `CleanupCompletionError::StateWriteNotApplied`
are deleted. The guard types collapse: `db::StateWriteGuard { Quarantined,
CleanupOwned }`, `generic_state_write_refusal` returns
`Option<StateWriteGuard>`, and `StateWriteRefusal` is
`FromState | Guard(StateWriteGuard)`. That removes the unreachable
"guards never answer FromState" arm. `SandboxStateWriteNotApplied` becomes
`Guard(StateWriteGuard) | Missing` and renders the same client-visible text
byte for byte. `into_status`, checkpoint admission and cleanup admission
all render through it.

Tests:

- The `update_state` outcome matrix already ran through `update_state_from`
  with every state expected, standalone and in a transaction (#388). Only
  its `update_state` arm is dropped, so every guard cell stays pinned. The
  guard tests in `db/tests.rs` now target production edges, and the #438
  status tests keep their literal texts without the legacy comparison.
- New: admission's same-state write. Re-admitting a `stopping` or
  `deleting` row clears the failure reason and another target's receipt and
  advances `updated_at`. For `deleting`, it also fails the active teardown
  jobs. The Stop sweep now pins the full client text for `deleting` and
  `quarantined` rows.
- New: Delete admission from every source state. It admits every plain row,
  a `deleting` row included, except a `suspending` row whose suspend has
  not reached the cluster. Nothing pinned Delete re-admission before.
- Test-only callers use an edge where production has one, `force_state`
  where only the state matters, and an explicit-source
  `update_state_from_states` for the two `started` -> `restoring` seeds.
- The five copies of the Stop fixture (superseding write, `mark_stopped`
  intent, row-intent completion) are now `admit_stop`, `complete_stop` and
  `stop` in `db/tests/state_write_fixtures.rs`.

Review comments from the stack's approval, folded in:

- `update_state_from_best_effort` logs a missing row with its own message,
  instead of "superseded".
- `require_state_write_from` takes `context: &str`, as `into_status` does.
- Pause's comment on `mark_quarantined_best_effort` now says it also
  answers false on a poisoned connection or a database error.
- A refused startup recovery write names the edge, the found state and the
  refusal, instead of printing the outcome's `Debug` form. A missing row
  has its own error.
- Auto-lifecycle Stop gains a case for a Delete admitted mid-write: a
  `deleting` row with a current `delete_row` intent, which the
  cleanup-ownership guard refuses and the Stop skips.
- Suspend's guest-prep rollback refusal and auto-pause refusal still answer
  `Internal`. Both are unreachable under suspend's fence and locks, and a
  comment now says so.
- Warm start's doc says why a guarded `suspended` row stays `Aborted`, and
  that answering a transient row with `FailedPrecondition` is deliberate.
- Checkpoint admission renders through `SandboxStateWriteNotApplied`, not
  the legacy outcome. A missing-row case pins its text alongside the
  existing guard literals.

The sbx-mgr AGENTS.md writer rule is updated, and the archcheck cap for
`db/state_transitions.rs` is lowered to its new size.
ac-pplx added a commit that referenced this pull request Oct 14, 2026
Every write of sbx-mgr's node-local SQLite `sandboxes.state` now goes
through `update_state_from` with an explicit edge, or through a named
specialized writer. Nothing here touches the control-plane Postgres
`public.sandboxes.lifecycle`.

The three remaining db-internal `update_state` callers:

- The `deleting` re-entry inside `update_state` had no production caller:
  Stop and Delete admission always write inside their own transaction.
  `update_state_from` already opens an `IMMEDIATE` transaction in
  autocommit for every target, so the deletion and the teardown jobs it
  fails still commit together.
- `begin_resident_resume_with_policy` writes `edges::RESUME_BEGIN`
  (`paused` -> `resuming`) inside its policy transaction. The edge now
  refuses a non-paused row, so the separate state pre-check is gone; the
  generation fence, the lock and the transaction are unchanged.
- Stop and Delete admission
  (`update_state_superseding_activation_in_transaction`) cannot use an
  exact edge without changing behavior. Admission re-admits a row that is
  already in its target: a `stopping` row for a repeated Stop of a live
  runtime, and a `deleting` row for a repeated Delete. No edge lists its
  own target. The write therefore moves to `write_cleanup_admission_state`,
  a private writer for only those two targets. It writes over every state
  the generic guards admit, as admission always has, using
  `update_state_from`'s statement and guards. `update_state`'s guard
  warnings move with it; their text and fields are unchanged, but the log
  target is now `db::state_write`. `STOP_ADMISSION` and `DELETE_ADMISSION`
  keep listing the transitions it makes. One behavior change: a row whose
  state label this binary cannot parse, reachable only after a downgrade or
  with a corrupt row, now fails Stop admission instead of being overwritten
  with `stopping`. Delete already failed earlier, in `get_sandbox`.

`update_state`, `SandboxStateWriteOutcome`, the legacy
`require_state_write` and `CleanupCompletionError::StateWriteNotApplied`
are deleted. The guard types collapse: `db::StateWriteGuard { Quarantined,
CleanupOwned }`, `generic_state_write_refusal` returns
`Option<StateWriteGuard>`, and `StateWriteRefusal` is
`FromState | Guard(StateWriteGuard)`. That removes the unreachable
"guards never answer FromState" arm. `SandboxStateWriteNotApplied` becomes
`Guard(StateWriteGuard) | Missing` and renders the same client-visible text
byte for byte. `into_status`, checkpoint admission and cleanup admission
all render through it.

Tests:

- The `update_state` outcome matrix already ran through `update_state_from`
  with every state expected, standalone and in a transaction (#388). Only
  its `update_state` arm is dropped, so every guard cell stays pinned. The
  guard tests in `db/tests.rs` now target production edges, and the #438
  status tests keep their literal texts without the legacy comparison.
- New: admission's same-state write. Re-admitting a `stopping` or
  `deleting` row clears the failure reason and another target's receipt and
  advances `updated_at`. For `deleting`, it also fails the active teardown
  jobs. The Stop sweep now pins the full client text for `deleting` and
  `quarantined` rows.
- New: Delete admission from every source state. It admits every plain row,
  a `deleting` row included, except a `suspending` row whose suspend has
  not reached the cluster. Nothing pinned Delete re-admission before.
- Test-only callers use an edge where production has one, `force_state`
  where only the state matters, and an explicit-source
  `update_state_from_states` for the two `started` -> `restoring` seeds.
- The five copies of the Stop fixture (superseding write, `mark_stopped`
  intent, row-intent completion) are now `admit_stop`, `complete_stop` and
  `stop` in `db/tests/state_write_fixtures.rs`.

Review comments from the stack's approval, folded in:

- `update_state_from_best_effort` logs a missing row with its own message,
  instead of "superseded".
- `require_state_write_from` takes `context: &str`, as `into_status` does.
- Pause's comment on `mark_quarantined_best_effort` now says it also
  answers false on a poisoned connection or a database error.
- A refused startup recovery write names the edge, the found state and the
  refusal, instead of printing the outcome's `Debug` form. A missing row
  has its own error.
- Auto-lifecycle Stop gains a case for a Delete admitted mid-write: a
  `deleting` row with a current `delete_row` intent, which the
  cleanup-ownership guard refuses and the Stop skips.
- Suspend's guest-prep rollback refusal and auto-pause refusal still answer
  `Internal`. Both are unreachable under suspend's fence and locks, and a
  comment now says so.
- Warm start's doc says why a guarded `suspended` row stays `Aborted`, and
  that answering a transient row with `FailedPrecondition` is deliberate.
- Checkpoint admission renders through `SandboxStateWriteNotApplied`, not
  the legacy outcome. A missing-row case pins its text alongside the
  existing guard literals.

The sbx-mgr AGENTS.md writer rule is updated, and the archcheck cap for
`db/state_transitions.rs` is lowered to its new size.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants