Skip to content

sbx-mgr: one refusal-to-status mapping for update_state_from - #438

Merged
ac-pplx merged 1 commit into
mainfrom
ac/sbxmgr-sw-pr1b-refusal-status
Oct 14, 2026
Merged

ac-pplx merged 1 commit into
mainfrom
ac/sbxmgr-sw-pr1b-refusal-status

Conversation

@ac-pplx

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

Copy link
Copy Markdown
Contributor

Stacked on main. Stack, bottom to top: #438 → #418 → #413 → #416 → #421 → #417 → #414. #372 and #388 have merged to main.

Replaces #424, which closed automatically when its base branch merged.

Each update_state_from conversion first written on #388 wrote its own StateWriteOutcome → Status helper, with different names, message text and policies. This adds one shared mapping and one best-effort writer in service/cleanup_support.rs, with no production callers. The conversions, now stacked on this PR, delete their copies. The legacy require_state_write for update_state stays unchanged.

API

  • require_state_write_from(result, context) -> Result<(), Status> is the primary mapping.
  • state_write_result(outcome) -> Result<SandboxState, StateWriteRejected> is the typed form. It returns the overwritten state, or one of FromState { found }, Guard { refusal: StateWriteGuard, found } and Missing. into_status(context) turns a rejection into a status.
    • A caller that answers one case differently (for example warm start with FailedPrecondition) matches that case and passes the rest to into_status.
    • Guard carries found for callers that decide by edge.from().contains(found).
  • update_state_from_best_effort(db, sandbox_id, edge) -> Option<StateWriteOutcome> is update_state_best_effort for update_state_from, with a wildcard-free match.
    • It logs with the same message text and field names, except that outcome is logged with Debug (?outcome), where the legacy function uses Display.
    • Only Applied marks the sandbox dirty.
    • None means a poisoned connection (failure signalled) or a database error.

Messages

Outcome Code Message
Applied { from } — Ok(()) (state_write_result: Ok(from))
Refused { refusal: FromState, found } Aborted {context}: sandbox state write was superseded: sandbox is {found}
Refused { refusal: Quarantined, .. } Aborted {context}: sandbox state write was not applied: blocked_by_quarantine
Refused { refusal: CleanupOwned, .. } Aborted {context}: sandbox state write was not applied: superseded_by_cleanup_episode
Missing Aborted {context}: sandbox state write was not applied: missing
database error Internal {context}: {error}

The guard, missing and database-error rows are byte-for-byte what require_state_write returns for update_state, at 8f12bd6 and now.

Dead-code expectations

Outside tests the new items are #[cfg_attr(not(test), expect(dead_code, ...))]. Adopting require_state_write_from leaves several expectations unfulfilled at once: the function, StateWriteRejected, StateWriteGuard, state_write_result and the impl. unfulfilled_lint_expectations is warn-by-default and an error only under CI's clippy -D warnings, so the adopting conversion must drop those attributes: #418, the first conversion above this PR, drops them. update_state_from_best_effort keeps its own expectation until a caller uses it: #421 drops it.

Deferred: StateWriteGuard duplicates StateWriteRefusal::{Quarantined, CleanupOwned}, and the legacy SandboxStateWriteOutcome is a third copy. The fix nests them: generic_state_write_refusal -> Option<StateWriteGuard> and StateWriteRefusal::Guard(StateWriteGuard). PR 6, which retires the legacy enum, does that, so this PR doesn't churn #388's types under the conversions stacked above it.

Tests (service/cleanup_support/tests.rs)

  • Wildcard-free matches over every StateWriteOutcome × StateWriteRefusal × state assert the typed rejection and the exact code and message. The refusal list itself comes from a wildcard-free successor match, so a new variant does not compile until it is swept.
  • On the same row, the new mapping and the update_state caller path return the same status, and every row stays unchanged. The rows are a quarantined row, a stopping row revived to started, a cleanup_required row with a delete_row intent, and a missing row. The row's own state is in the from-set, so the guard is what refuses. The messages are also checked against literals copied from 8f12bd6.
  • The best-effort writer:
    • applies a write and marks only that sandbox dirty;
    • returns the refusal and changes nothing for a from-state refusal (stopped), a cleanup-owned row and a missing row;
    • returns None and changes nothing on a database error.
  • A poisoned connection returns None and changes nothing. It also wakes the dirty-outbox worker, which exits on the poisoned connection and fails its critical-task guard. The test pauses the clock and only yields, so the worker's poll timer cannot fire and only the signal can wake it.
  • No assertions on log fields, severity or text.

Validation of the restacked stack

The stack was restacked linearly on main (b18051f) after #372 and #388 merged: #438 → #418 → #413 → #416 → #421 → #417 → #414. The per-PR validation further down ran on this PR's pre-restack head.

This PR's head (bf43fd5): pull CI is green. fmt, workspace clippy --all-targets --all-features -D warnings and cargo arch (0 required findings) all pass. SPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --locked -p pplx-space-sbx-mgr --retries 0 passed 1634/1634, with 0 dependency skips. Kind Pull CI ran on #417, which contains this PR: all 4 Firecracker smoke shards passed.

The top of the stack (#414 @ 9a0a251, which contains this PR):

Check Result
sbx-mgr nextest ×3 1650/1650 each, 0 dependency skips
Whole-workspace nextest 8985/9021 passed, and every API crate passed. The 36 failures are all outside sbx-mgr. 34 fail identically on main: spaced-guest and spaced-host-cli process tests (the test box can't switch identity) and one net-gateway test. The other 2 are common-grpc retry-deadline tests that pass on rerun on both heads.
The stack's 30 new or changed tests, ×30 900/900
cargo test -p pplx-space-sbx-mgr (shared process) 1650/1650
Mutation spot-check on the top head #417's quarantine-miss mutation and #418's warm-start admission mutation are both caught
Kind, fresh cluster; the deployed sbx-mgr binary is from this head smoke-kind-e2e.sh passed. smoke-kind-typed.sh all: native 23/23; ECW 76 passed, 2 infra errors. local.ecw_template_build_from_oci_boots passed, and so did the leader-election brownout.
The 2 ECW infra errors node_group_boundary_schedules and node_selector_extras_render_and_enforce. Both scenarios restart the API Deployment, and the same failure family appears on main in an A/B on the same cluster. In the failed runs the kill never reached sbx-mgr.
Lifecycle race soak on Kind 11 concurrent operation pairs × 40 iterations, on the stack and on main on the same fresh cluster, checking that sbx-mgr's SQLite sandboxes.state, control-plane Postgres lifecycle and the VM agree. No change-induced violation: across 880 sandboxes, no quarantine, panic, restart or stuck state. Every violation reproduces on main with the same signature: an ECW pause that loses to Stop answers 503 instead of 409; after Suspend vs Delete, the node row can outlive the control-plane row until the next eviction pass (pass timing, not the build); and a refused quarantine write after Pause vs Delete is still logged. The soak's 0–200 ms API-level offsets did not reproduce the interleavings this stack closes, so the unit-level race tests remain the direct evidence for those.

Validation on the pre-restack head

Check Result
cargo fmt --all -- --check pass
cargo clippy --workspace --locked --all-targets -- -D warnings pass
cargo arch pass, 0 required findings; the only advisories are on files this PR does not touch
SPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run -p pplx-space-sbx-mgr ×3 1630/1630 passed each run. The 1 skip is the #[ignore] root-only distinct_service_uids_can_exchange_downloads; 0 dependency skips
Mutation proof (20 mutants: every mapping arm swapped, both status codes, the from-state text, the database-error code, a swallowed rejection, found dropped from Guard, and each best-effort dirty, return and poison-signal arm) 20/20 killed
Kind not run: no production callers

🤖 Generated with Claude Code

Each update_state_from conversion wrote its own StateWriteOutcome-to-Status
helper, with different names, message text and policies. Add one shared
mapping and best-effort writer in service/cleanup_support.rs, with no
production callers; the conversions adopt them and delete their copies.

- require_state_write_from(result, context) -> Result<(), Status>. A
  from-state refusal is Aborted "{context}: sandbox state write was
  superseded: sandbox is {found}". A quarantine or cleanup-ownership refusal
  and a missing row keep the exact Aborted status require_state_write
  returns for update_state (blocked_by_quarantine,
  superseded_by_cleanup_episode, missing). A database error is Internal
  "{context}: {error}", as before.
- state_write_result(outcome) -> Result<SandboxState, StateWriteRejected> is
  the typed form. It returns the overwritten state, or one of FromState
  { found }, Guard { refusal, found } and Missing, with into_status(context).
  A caller that answers one case differently, such as warm start with
  FailedPrecondition, matches it and passes the rest on. Guard carries
  found for callers that decide by edge.from().contains(found).
- update_state_from_best_effort(db, id, edge) -> Option<StateWriteOutcome>
  is update_state_best_effort for update_state_from, with a wildcard-free
  match. It logs refusals and errors with that function's messages and
  field names, but logs outcome with Debug where the legacy used Display.
  Only an applied write marks the sandbox dirty. None means a poisoned
  connection or a database error.

The legacy require_state_write is unchanged. Outside tests the new items
expect dead_code. Adopting require_state_write_from leaves several of these
expectations unfulfilled at once: the function, StateWriteRejected,
StateWriteGuard, state_write_result and the impl. unfulfilled_lint_expectations
is warn-by-default and an error under CI's clippy -D warnings, so the
adopting conversion must drop those attributes.

Tests:
- Wildcard-free matches cover every StateWriteOutcome and StateWriteRefusal
  and assert each one's typed rejection and exact code and message. The
  refusal list is built from such a match.
- The same row gets the same status from the new mapping and from the
  update_state caller path, and every row stays unchanged, for: a
  quarantined row, a stopping row revived to started, a cleanup row with a
  delete_row intent, and a missing row. The row's own state is expected, and
  the messages match literals copied from 8f12bd6.
- The best-effort writer applies a write and marks only that sandbox dirty.
  A refusal and a database error return their result and change nothing. A
  poisoned connection is None, changes nothing, and wakes the dirty-outbox
  worker, which exits on the poisoned connection and fails its critical-task
  guard. The test pauses the clock so the worker's poll timer cannot fire.

The sbx-mgr AGENTS.md writer rule names both helpers and the override.

@nikhilgupta-pplx nikhilgupta-pplx left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approve. Mapping, typed rejection and best-effort writer match the legacy update_state paths (guard/missing/DB-error text is byte-identical; dirty only on Applied; poison still signals). No production callers, and the dead-code handoff to #418/#421 is consistent.

Non-blocking nits:

  • update_state_from_best_effort logs Missing as "superseded" (inherited from the legacy writer); worth a distinct message while this is new.
  • require_state_write_from takes &'static str while into_status takes &str; minor inconsistency.

@ac-pplx
ac-pplx added this pull request to the merge queue Oct 14, 2026
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.
@the-gigi-pplx

Copy link
Copy Markdown
Collaborator

Review — 2 findings

Low

  1. crates/libs/sbx/mgr/server/src/service/cleanup_support/tests.rs:357 — best_effort_database_error_is_none_and_changes_nothing never reaches a database error.
    It passes a KnownRace edge, so update_state_from returns InvalidQuery from its precondition check (db/state_write.rs:554) before it touches SQLite. The Err arm is covered, but by the path that rejects a caller's mistake, and in production that mistake logs the same warn as a transient SQLite failure ("failed to update sandbox state"). Later in the stack the edge becomes ORPHAN_SUSPENDED_INSERT, the same pattern again. A trigger that fails inside SQLite itself would keep the test meaningful once every edge passed in is a valid one.
  2. crates/libs/sbx/mgr/server/src/service/cleanup_support/tests.rs:42 — the literals cite 8f12bd6, which no branch of this repo contains.
    It's the pre-squash head of PR sbx-mgr: characterize the state writers before the state-write helper (tests only) #372, reachable only through that PR's ref, so git show 8f12bd6 in a clone fails. a7e700bc, PR sbx-mgr: characterize the state writers before the state-write helper (tests only) #372's squash on main, has the same require_state_write, SandboxStateWriteNotApplied message and as_str labels, so citing it keeps the provenance checkable. The commit message and PR body cite the same sha.

What I checked and found sound:

  • require_state_write_from's guard, missing and database-error statuses are the same text and code as require_state_write's. Both format "{context}: {SandboxStateWriteNotApplied}" over the same as_str labels, and both map a database error to Internal "{context}: {error}".
  • state_write_result and into_status have no wildcard arm, and refusals() builds its list from a wildcard-free successor match, so a new outcome or refusal fails to compile until it's handled.
  • update_state_from_best_effort drops the connection guard before mark_sandbox_dirty, as update_state_best_effort does, and only Applied marks the sandbox dirty. snapshot covers every table, so the refusal cases also show that no dirty ref is written.
  • connection_or_signal_failure calls notify_one, which stores a permit, so the poisoned-connection test doesn't depend on the worker already waiting when the signal fires.
  • Nothing in the repo searches the outcome log field, so the Display-to-Debug change the body mentions has no consumer to break.

Verified against bf43fd5b1b77. Reproduced locally: cargo clippy -p pplx-space-sbx-mgr --all-targets --all-features -D warnings (clean) and cargo nextest run -p pplx-space-sbx-mgr -p pplx-space-db-test-fixtures --all-features (1645 passed, 0 failed) at this head; two mutations: having update_state_from_best_effort lock the connection directly instead of connection_or_signal_failure fails best_effort_write_over_a_poisoned_connection_signals_the_failure, and mapping CleanupOwned to StateWriteGuard::Quarantined in state_write_result fails both mapping tests, so neither guard is vacuous.

@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. 2 non-blocking findings in the review comment above.

Merged via the queue into main with commit 3a1b471 Oct 14, 2026
20 checks passed
@ac-pplx
ac-pplx deleted the ac/sbxmgr-sw-pr1b-refusal-status branch October 14, 2026 20:52
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.

3 participants