Skip to content

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

Closed
ac-pplx wants to merge 3 commits into
ac/sbxmgr-state-write-pr0-testsfrom
ac/sbxmgr-sw-pr1b-refusal-status
Closed

ac-pplx wants to merge 3 commits into
ac/sbxmgr-state-write-pr0-testsfrom
ac/sbxmgr-sw-pr1b-refusal-status

Conversation

@ac-pplx

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

Copy link
Copy Markdown
Contributor

Stacked on #388. Each update_state_from conversion stacked 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 rebase onto this PR and 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. update_state_from_best_effort keeps its own expectation until a caller uses 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 rebasing siblings.

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

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

… (tests only)

Pin today's behavior of sbx-mgr's writers of the node-local SQLite
`sandboxes.state` (and their cleanup-episode and `sandbox_quarantine_*`
companion rows), so the planned `update_state_from` helper and its caller
conversions can prove they are behavior-preserving. Nothing here touches the
control-plane Postgres `public.sandboxes.lifecycle`.

- `update_state` outcome matrix: every (from, to) pair of the 18 lifecycle
  states, for a plain row, a `delete_row` intent, an armed create episode,
  an armed cold-start episode and a requested episode. Each pair runs once
  with a transition receipt for the target and once with a receipt for
  another state. The matrix also covers a row the production quarantine
  writer quarantined and a missing row. Each case asserts the outcome and the
  whole-database diff. The expected outcomes are literal grids indexed by a
  wildcard-free match over `SandboxState`, not derived from
  `can_transition_to`.
- Whole-database accepted-shape diffs for each specialized writer that still
  has a production caller, keyed by primary key and diffed column by column,
  so a later move onto a shared helper must stay column-identical. Seeds carry
  a failure reason where production can, a transition receipt, a runtime with
  its spaced endpoint proof, route identity, artifact pins and an active
  teardown-suspend job.
- Direct tests, with the accepting case and each refusing predicate in its own
  case, for restore_checkpoint_state,
  update_state_superseding_activation_in_transaction,
  settle_startup_resident, restore_adopt_orphan_or_insert_sandbox,
  adopt_quarantine_cleanup_episode and pin_suspended_artifact_for_cleanup.
- Stop's cleanup admission function from every source state, with and without
  a persisted intent: admitted from 16 states, refused from `deleting` and
  `quarantined`. `creating`, `created`, `stopped` and `suspended` are
  admission-only. The Stop RPC selects only a live runtime, and from every
  state it admits nothing without one.

The new DB modules live under `db/tests/`. The Stop module is wired by one
`#[cfg(test)] mod` line in `service/cleanup.rs` and builds its service with
`SandboxManagerServiceOptions::for_test_in`. Known races in today's writers
are documented in the pull request, not asserted.

No production code changes.
…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-sw-pr1b-refusal-status branch from 7e2e41c to db2ca8b Compare October 14, 2026 00:39
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.
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.

1 participant