Repository navigation
api: ask the substrate for capabilities instead of matching on it - #368
Merged
Merged
Conversation
ac-pplx
force-pushed
the
ac/substrate-capabilities-api
branch
2 times, most recently
from
October 13, 2026 07:39
b4ea64d to
e5862f7
Compare
ac-pplx
marked this pull request as ready for review
October 13, 2026 15:42
Shared api code outside the substrate module asked "which substrate is this?" (a `Substrate::Direct`/`Substrate::Kubernetes` match, or a comparison of `managed_by()` with "k8s"/"space") where it meant "does this substrate support X?". Name those questions once. `SubstrateAdapter` gains a required `capabilities()` that returns a small `Copy` `SubstrateCapabilities`, one constant per substrate (`SubstrateCapabilities::DIRECT`, `SubstrateCapabilities::KUBERNETES`), forwarded by `impl SubstrateAdapter for Substrate`. Its fields, each read by a site converted here: - `caller_completes_claimed_restore` (Direct false, Kubernetes true): every restore-shaped wake claims `restoring` before its node call and leaves `restoring -> started` to the caller's completion CAS. The wake completion's published-suspend fence and the wake-cancellation shield rely on exactly that. Direct does not guarantee it: its worker-local wake leaves its pre-wake lifecycle (`suspended`, or `restoring` kept by an earlier lease restore) for the completion CAS to advance, and its lease restore claims `restoring` but writes `started` itself. The shield doc and two `lifecycle.rs` comments that said Direct restores never leave `suspended` are corrected. - `node_addressing` (`NodeAddressing::DirectEndpoint` / `SubstrateNodeId`): whether the fork artifact copy can reuse the source's active node target. Replaces the executor's private `CopyNodeEndpointRequirement`; it must match what the substrate's `resolve_node_api_endpoint` requires. - `registers_nodes_via_admin_api` (Direct true, Kubernetes false): the admin node registry routes (create, update, delete, template pull and delete). `require_direct_substrate` becomes `require_admin_node_registry`; the 501 response is unchanged. - `resolves_cold_create_shape` (Direct false, Kubernetes true): the control plane resolves a cold Firecracker create's shape, from a pool for a fallthrough create and otherwise from the template's build shape for values the request left unset. - `supports_node_pool_placement` (Direct false, Kubernetes true): folds in the trait method of that name, whose only caller was the admin create route. Behavior-preserving: every field has the value the replaced branch had for each substrate. Row-level checks of a row's `managed_by` stay where they are, as do `managed_by()`'s return type, the Kubernetes-only redrive hydration in `wake_completion`, and the console `/me` substrate label. Existing tests change only where a signature did: the cold-create shape test passes capabilities instead of a `managed_by` string, the fork copy target tests name `NodeAddressing`, and the two hand-written `SubstrateAdapter` test doubles implement `capabilities()`. New behavioural tests, one per field without existing coverage except `node_addressing`, each run against a real Direct substrate and (with the `kubernetes` feature) the store-backed Kubernetes test substrate, both wrapped in `Substrate`: - the wake completion fence chosen from the running substrate keeps a `suspended` row on Kubernetes and advances it to `started` on Direct; - deleting an unknown node through the admin route reaches the registry lookup (404) on Direct and is refused with the unchanged 501 on Kubernetes; - with placement enabled, a node-pool-pinned admin create is refused (400) on Direct and accepted on Kubernetes with the pin persisted in the queued create. The two admin tests run in a child test process that pins `SPACE_K8S_CONTROL_PLANE` off, so `AppState::from_pool` builds a Direct substrate whatever the host environment sets.
ac-pplx
force-pushed
the
ac/substrate-capabilities-api
branch
from
October 13, 2026 17:33
e5862f7 to
251e4c7
Compare
zibi-pplx
approved these changes
Oct 14, 2026
zibi-pplx
left a comment
Collaborator
There was a problem hiding this comment.
Approving. I traced all six sites, and each substrate gets exactly the behavior it had before:
- the wake fence and the cancellation shield;
- fork node addressing;
- the node-registry 501;
- the cold-create shape;
- admin placement.
Making capabilities() required with no default is the right call. The branch merges cleanly with main. None of the suggestions below blocks merging.
Suggestions
node_addressingduplicates whatresolve_node_api_endpointrequires.- The field (
substrate/mod.rs:2166) restates what each substrate'sresolve_node_api_endpointneeds.direct.rs:4175readsdirect_endpoint;kubernetes/adapter/node_eligibility.rs:224requiressubstrate_node_id. - Nothing ties the two together. As the PR notes, flipping the field fails no test.
- Options: make it a trait method next to
resolve_node_api_endpoint(e.g.node_api_target_is_complete), or add one test per substrate that resolves a target carrying only the declared field. Direct's resolve is pure.
- The field (
caller_completes_claimed_restorenames one protocol, but on Direct the two consumers arefalsefor different reasons.- The worker-local wake keeps
suspendedas its baseline. The lease restore has already writtenstarted. - A related window, which predates this PR and is worth an issue:
- In the Restore arm on Direct,
complete_direct_restore_leasewritesstarted. complete_wake_lifecyclethen runs withDirectRestoreBaseline($2 = FALSE,lifecycle.rs:393-412).- So a suspend that fully finishes in between is overwritten back to
started.
- In the Restore arm on Direct,
- The window is narrow and not a regression. The rewritten comment above that update (
lifecycle.rs:1884-1899) describes the Direct restore path but doesn't flag the window.
- The worker-local wake keeps
- The new wake test mostly repeats an existing one.
wake_completion_fence_follows_the_running_substratelargely re-coverswake_completion_cas_outcome_for_every_prior_lifecycle(same file,:48).- It calls
for_capabilitiesitself, so the Wake and Restore call sites stay uncovered. - Per the space-code-quality test policy, running
[DIRECT, KUBERNETES]throughfor_capabilitiesinside the existing table test would be leaner.
Nits
substrate/mod.rs:2174: "Without it, the requested shape goes to the node unchanged" isn't true for Direct one-shot creates. Those drop vcpu/memory, because sbx-mgr'sCreateRequesthas no shape fields. "the control plane passes the requested shape through unchanged" is accurate.wake_completion_postgres_tests.rs:71-72still says "a direct restore keepssuspendedas the baseline". That contradicts the corrected comments: it's the worker-local wake that keeps it.- Missing from the "left for later" list:
lifecycle.rs:1225(delete_observed_sandbox) defaults a missing or unparseable nodemanaged_bytoNodeManagedBy::Space. - The body says "five api-crate branches", but the site table lists six sites, covered by five fields.
ac-pplx
added a commit
that referenced
this pull request
Oct 14, 2026
Add a "Direct Substrate Contract" section to the substrate AGENTS.md and a module doc on direct.rs, so agents changing the substrate, or shared code that branches on it, know what Direct is for and where its contract ends. The AGENTS.md section holds the rules: - when the api selects Direct (no `kubernetes` feature, or SPACE_K8S_CONTROL_PLANE not truthy) and that a Kubernetes init failure is an error, not a fallback; - implement new lifecycle operations for Kubernetes; Direct returns Unsupported unless the operation is part of the create, command, inspect and delete flow in docs/first-sandbox.md; - make new SubstrateAdapter operations required methods, so the compiler demands each substrate's implementation and the forwarding arm in `impl SubstrateAdapter for Substrate`; only a "nothing to do" hook that shared code calls on every path keeps a default, and it still needs its arm; - where shared code must differ, add a SubstrateCapabilities field, set it in DIRECT and KUBERNETES, and read it through capabilities() instead of matching the substrate (#368); - scope queries to a substrate's own rows by binding the value (`substrate.managed_by()`, or `SubstrateKind::Kubernetes` in a Kubernetes-only loop) rather than a 'k8s' literal (#373); - keep writes to the Direct-only tables in direct.rs and the Direct node registry; - don't cite a reconciler as the retry path for a failed Direct operation, and don't change a known gap as a side effect of other work; - which test fixtures to use: Direct only for tests under `direct/` or substrate-independent behavior, `kubernetes::tests` helpers for shared tests, `scripted_test_doubles.rs` inside `kubernetes/`, and that both compile only with `--features kubernetes`. The direct.rs module doc records what Direct does today, so it is reviewed with the code: which lifecycle operations it implements, which it rejects as Unsupported (node-pool placement, forks, cluster pool configs), its no-op trait methods, its Direct-only admin routes, how admin drain/cordon actions fail on a Direct node, that the cluster controller starts its drain, orphan, failed-row repair and node-sync loops only for Kubernetes so nothing retries a failed Direct retire, and two gaps that succeed silently: one-shot creates drop custom vcpu/memory, and retire_execution does not enqueue the stopped-disk backup the trait documents. Correct the comment in Direct retire_execution that named the failed-cleanup pass as its retry path: that pass selects only 'k8s' rows, so a failed Direct retire is not retried. Make the rules reachable from the code they govern: add the `controllers/substrate/CLAUDE.md` symlink, widen the pointer in crates/libs/api/AGENTS.md, and point the cluster-controller and internal-api AGENTS.md files at the section, with the cluster controller's binding rule for `managed_by` scoping stated there. Documentation and comments only; no code, schema, wire or configuration change.
ac-pplx
added a commit
that referenced
this pull request
Oct 14, 2026
Add a "Direct Substrate Contract" section to the substrate AGENTS.md and a module doc on direct.rs, so agents changing the substrate, or shared code that branches on it, know what Direct is for and where its contract ends. The AGENTS.md section holds the rules: - when the api selects Direct (no `kubernetes` feature, or SPACE_K8S_CONTROL_PLANE not truthy) and that a Kubernetes init failure is an error, not a fallback; - implement new lifecycle operations for Kubernetes; Direct returns Unsupported unless the operation is one of the calls examples/first_sandbox.py makes (create, command session, list/inspect, delete); - make new SubstrateAdapter operations required methods, so the compiler demands each substrate's implementation and the forwarding arm in `impl SubstrateAdapter for Substrate`; only a "nothing to do" hook that shared code calls on every path keeps a default, and it still needs its arm; - where shared code needs a static fact about the substrate, add a SubstrateCapabilities field, set it in DIRECT and KUBERNETES, and read it through capabilities() instead of matching the substrate; where the substrate must act, add a required method (#368); - in new SQL, scope to a substrate's rows by binding the value (`substrate.managed_by()` for the running substrate's rows, or a fixed `SubstrateKind::Kubernetes`/`::Space` for one substrate's rows) instead of a 'k8s'/'space' literal; existing literals are debt (#373); - keep writes to the Direct-only tables in direct.rs and the Direct node registry; - don't cite a cluster-controller reconciler as the retry path for a failed Direct execution retire or repair, and don't change a known gap as a side effect of other work; - existing substrate matches, Direct-table joins in shared code and Unsupported trait defaults are debt, not precedent; - which test fixtures to use: Direct only for tests under `direct/` or substrate-independent behavior, `kubernetes::tests` helpers for shared tests, `scripted_test_doubles.rs` inside `kubernetes/`, and that both compile only with `--features kubernetes`. The direct.rs module doc records what Direct does today, so it is reviewed with the code: how it reaches workers, which lifecycle operations it implements, which it rejects as Unsupported (node-pool placement, forks, cluster pool configs), its no-op trait methods, its Direct-only admin routes, how admin drain/cordon actions fail on a Direct node, and which cluster-controller loops are Kubernetes-only: the drain, orphan, failed-row repair and node-sync loops never run for Direct, while the public-operation dispatcher runs for both substrates and retries transient operation failures. Nothing retries a failed Direct retire. It lists two gaps that succeed silently: one-shot create does not forward requested vCPU or memory (disk size is forwarded), and retire_execution does not enqueue the stopped-disk backup the trait documents. Correct the comment in Direct retire_execution that named the failed-cleanup pass as its retry path: that pass selects only 'k8s' rows, so a failed Direct retire is not retried. Correct the `resolves_cold_create_shape` field doc, which said that without the capability the requested shape reaches the node unchanged: Direct's one-shot create cannot carry vCPU or memory. Make the rules reachable from the code they govern: add the `controllers/substrate/CLAUDE.md` symlink, widen the pointer in crates/libs/api/AGENTS.md, and point the cluster-controller and internal-api AGENTS.md files at the section, with the cluster controller's binding rule for `managed_by` scoping stated there, including `tombstone_retention`'s fixed scope to Direct tombstones. Documentation and comments only; no code, schema, wire or configuration change. Names from #373 (`SubstrateKind`) exist on main only once #373 merges, so this lands after it.
ac-pplx
added a commit
that referenced
this pull request
Oct 14, 2026
Add a "Direct Substrate Contract" section to the substrate AGENTS.md and a module doc on direct.rs, so agents changing the substrate, or shared code that branches on it, know what Direct is for and where its contract ends. The AGENTS.md section holds the rules: - when the api selects Direct (no `kubernetes` feature, or SPACE_K8S_CONTROL_PLANE not truthy) and that a Kubernetes init failure is an error, not a fallback; - implement new lifecycle operations for Kubernetes; Direct returns Unsupported unless the operation is one of the calls examples/first_sandbox.py makes (create, command session, list/inspect, delete); - make new SubstrateAdapter operations required methods, so the compiler demands each substrate's implementation and the forwarding arm in `impl SubstrateAdapter for Substrate`; only a "nothing to do" hook that shared code calls on every path keeps a default, and it still needs its arm; - where shared code needs a static fact about the substrate, add a SubstrateCapabilities field, set it in DIRECT and KUBERNETES, and read it through capabilities() instead of matching the substrate; where the substrate must act, add a required method (#368); - in new SQL, scope to a substrate's rows by binding the value (`substrate.managed_by()` for the running substrate's rows, or a fixed `SubstrateKind::Kubernetes`/`::Space` for one substrate's rows) instead of a 'k8s'/'space' literal; existing literals are debt (#373); - keep writes to the Direct-only tables in direct.rs and the Direct node registry; - don't cite a cluster-controller reconciler as the retry path for a failed Direct execution retire or repair, and don't change a known gap as a side effect of other work; - existing substrate matches, Direct-table joins in shared code and Unsupported trait defaults are debt, not precedent; - which test fixtures to use: Direct only for tests under `direct/` or substrate-independent behavior, `kubernetes::tests` helpers for shared tests, `scripted_test_doubles.rs` inside `kubernetes/`, and that both compile only with `--features kubernetes`. The direct.rs module doc records what Direct does today, so it is reviewed with the code: how it reaches workers, which lifecycle operations it implements, which it rejects as Unsupported (node-pool placement, forks, cluster pool configs), its no-op trait methods, its Direct-only admin routes, how admin drain/cordon actions fail on a Direct node, and which cluster-controller loops are Kubernetes-only: the drain, orphan, failed-row repair and node-sync loops never run for Direct, while the public-operation dispatcher runs for both substrates and retries transient operation failures. Nothing retries a failed Direct retire. It lists two gaps that succeed silently: one-shot create does not forward requested vCPU or memory (disk size is forwarded), and retire_execution does not enqueue the stopped-disk backup the trait documents. Correct the comment in Direct retire_execution that named the failed-cleanup pass as its retry path: that pass selects only 'k8s' rows, so a failed Direct retire is not retried. Correct the `resolves_cold_create_shape` field doc, which said that without the capability the requested shape reaches the node unchanged: Direct's one-shot create cannot carry vCPU or memory. Make the rules reachable from the code they govern: add the `controllers/substrate/CLAUDE.md` symlink, widen the pointer in crates/libs/api/AGENTS.md, and point the cluster-controller and internal-api AGENTS.md files at the section, with the cluster controller's binding rule for `managed_by` scoping stated there, including `tombstone_retention`'s fixed scope to Direct tombstones. The Direct-table rule names archcheck's `pattern_ratchet.direct_tables` rule (#358) as its enforcement, advisory today. The SQL-scoping rule also covers returning early in Rust when a fixed-scope statement may run under another substrate (#373), and names the shared `routable_execution_or_non_k8s` fragment as remaining debt. The Direct retire wording now says the sweeper's failed-cleanup retry does nothing unless the running substrate is Kubernetes, matching the code after #373. Documentation and comments only; no code, schema, wire or configuration change.
This was referenced Oct 14, 2026
ac-pplx
added a commit
that referenced
this pull request
Oct 16, 2026
…ce test into its table Follow-ups to the review of #368 (`SubstrateCapabilities`). Tests and comments only; no production behavior changes. - `node_addressing_names_the_field_endpoint_resolution_reads` (`substrate/tests.rs`) runs each substrate's `resolve_node_api_endpoint` on a target carrying only the field its `capabilities().node_addressing` names (it resolves) and on one carrying only the other field (NotFound). Before, flipping either constant's `node_addressing` failed no test. It follows the shape of `node_drain_capability_matches_set_node_schedulability` and covers Kubernetes when built with the `kubernetes` feature. - `wake_completion_cas_outcome_for_every_prior_lifecycle` now runs `SubstrateCapabilities::DIRECT` and `::KUBERNETES` through `PublishedSuspendFence::for_capabilities` instead of naming the fences, and asserts each running substrate's `capabilities()` returns its constant. That absorbs `wake_completion_fence_follows_the_running_substrate`, which is removed; its `suspended` row outcomes are table rows. - `wake_completion_outcome_maps_to_the_wake_response` (lifecycle characterization) also wakes a Direct sandbox from `suspended`, with the simulated resident suspended to the shared directory, across the same mid-flight lifecycle moves. With no move it ends `started` / `on_node`, which pins the Wake arm's fence choice: hard-coding `PublishedSuspendFence::Enforced` there leaves the row `suspended`. - Comments: the table test credits Direct's `suspended` baseline to the worker-local wake, not a direct restore. The Restore arm states the known Direct gap: Direct's suspend writes no `suspending` claim, only `suspended` after its node call, so a `suspended` landing between the lease restore's `started` write and the completion CAS is overwritten back to `started`; and because the lease write is not guarded on lifecycle, a `suspended` written after the restore placed the row `on_node` (a span that, for a remote restore, includes the source cleanup call) is overwritten by the lease write itself. `delete_observed_sandbox` states why defaulting a missing node `managed_by` to `Space` is harmless: neither substrate's delete path reads it, only `role` and the address fields.
ac-pplx
added a commit
that referenced
this pull request
Oct 16, 2026
…ce test into its table Follow-ups to the review of #368 (`SubstrateCapabilities`). Tests and comments only; no production behavior changes. - `node_addressing_names_the_field_endpoint_resolution_reads` (`substrate/tests.rs`) runs each substrate's `resolve_node_api_endpoint` on a target carrying only the field its `capabilities().node_addressing` names (it resolves) and on one carrying only the other field (NotFound). Before, flipping either constant's `node_addressing` failed no test. It follows the shape of `node_drain_capability_matches_set_node_schedulability` and covers Kubernetes when built with the `kubernetes` feature. - `wake_completion_cas_outcome_for_every_prior_lifecycle` now runs `SubstrateCapabilities::DIRECT` and `::KUBERNETES` through `PublishedSuspendFence::for_capabilities` instead of naming the fences, and asserts each running substrate's `capabilities()` returns its constant. That absorbs `wake_completion_fence_follows_the_running_substrate`, which is removed; its `suspended` row outcomes are table rows. - `wake_completion_outcome_maps_to_the_wake_response` (lifecycle characterization) also wakes a Direct sandbox from `suspended`, with the simulated resident suspended to the shared directory, across the same mid-flight lifecycle moves. With no move it ends `started` / `on_node`, which pins the Wake arm's fence choice: hard-coding `PublishedSuspendFence::Enforced` there leaves the row `suspended`. - Comments: the table test credits Direct's `suspended` baseline to the worker-local wake, not a direct restore, and says only Direct's side of the Wake arm's fence choice is pinned outside the race. The Restore arm states the known Direct gap, which the Wake arm's lease-restore fallback shares: Direct's suspend writes no `suspending` claim, only `suspended` after its node call, so a `suspended` landing between the lease restore's `started` write and the completion CAS is overwritten back to `started`; and because the lease write is not guarded on lifecycle, a `suspended` written after the restore placed the row `on_node` (a span that, for a remote restore, includes the source cleanup call) is overwritten by the lease write itself. `delete_observed_sandbox` states why defaulting a missing node `managed_by` to `Space` is harmless: neither substrate's delete path reads it, only `role` and the address fields.
This was referenced Oct 16, 2026
kavishkartha05
pushed a commit
to kavishkartha05/SPACE
that referenced
this pull request
Oct 17, 2026
…ce test into its table (perplexityai#597) Follow-ups to the review of perplexityai#368 (`SubstrateCapabilities`). Tests and comments only; no production behavior changes. - `node_addressing_names_the_field_endpoint_resolution_reads` (`substrate/tests.rs`) runs each substrate's `resolve_node_api_endpoint` on a target carrying only the field its `capabilities().node_addressing` names (it resolves) and on one carrying only the other field (NotFound). Before, flipping either constant's `node_addressing` failed no test. It follows the shape of `node_drain_capability_matches_set_node_schedulability` and covers Kubernetes when built with the `kubernetes` feature. - `wake_completion_cas_outcome_for_every_prior_lifecycle` now runs `SubstrateCapabilities::DIRECT` and `::KUBERNETES` through `PublishedSuspendFence::for_capabilities` instead of naming the fences, and asserts each running substrate's `capabilities()` returns its constant. That absorbs `wake_completion_fence_follows_the_running_substrate`, which is removed; its `suspended` row outcomes are table rows. - `wake_completion_outcome_maps_to_the_wake_response` (lifecycle characterization) also wakes a Direct sandbox from `suspended`, with the simulated resident suspended to the shared directory, across the same mid-flight lifecycle moves. With no move it ends `started` / `on_node`, which pins the Wake arm's fence choice: hard-coding `PublishedSuspendFence::Enforced` there leaves the row `suspended`. - Comments: the table test credits Direct's `suspended` baseline to the worker-local wake, not a direct restore, and says only Direct's side of the Wake arm's fence choice is pinned outside the race. The Restore arm states the known Direct gap, which the Wake arm's lease-restore fallback shares: Direct's suspend writes no `suspending` claim, only `suspended` after its node call, so a `suspended` landing between the lease restore's `started` write and the completion CAS is overwritten back to `started`; and because the lease write is not guarded on lifecycle, a `suspended` written after the restore placed the row `on_node` (a span that, for a remote restore, includes the source cleanup call) is overwritten by the lease write itself. `delete_observed_sandbox` states why defaulting a missing node `managed_by` to `Space` is harmless: neither substrate's delete path reads it, only `role` and the address fields.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What merging does
Shared api code outside the substrate module stops asking "which substrate is this?" where it means "does this substrate support X?".
SubstrateAdaptergets one required method,capabilities(), which returns a smallCopystruct,SubstrateCapabilities. There is one constant per substrate, and the five api-crate branches this phase covers read their answers from it. Production behavior, wire formats, schema, metrics and configuration are unchanged: every field holds, for each substrate, the value the branch it replaces produced. Three behavioural tests cover the sites that had no coverage, and comments that misdescribed Direct's restore lifecycle are corrected.This is phase 3, PR 1 of the plan to keep the Direct/Kubernetes split out of shared code. The cluster controller, the routability/redrive behavior, the row-level checks and making
Substrate's variants private are left for later PRs.The struct
crates/libs/api/src/controllers/substrate/mod.rs:DIRECTKUBERNETEScaller_completes_claimed_restoresuspendedorrestoring) claimsrestoringbefore its node call and leavesrestoring → startedto the caller's completion CAS. The caller can then fence asuspendedrow at completion as a newer suspend, and must shield the CAS against request cancellation.falsetruenode_addressingNodeApiTargetfield addresses a node; must match what the substrate'sresolve_node_api_endpointrequiresDirectEndpointSubstrateNodeIdregisters_nodes_via_admin_apitruefalseresolves_cold_create_shapefalsetruesupports_node_pool_placementfalsetrueWhy
caller_completes_claimed_restoreisfalsefor Direct. Direct rows do becomerestoring, so the reason is not that they never do. Direct doesn't guarantee that every restore-shaped wake claimsrestoringfirst and leaves the completion to the caller:restore_execution_with_candidates. That path claimsrestoringunder its own lease (claim_direct_restore_lease) and writesstarteditself (complete_direct_restore_lease) before returning.wake_sandbox) doesn't touch lifecycle. It leaves the pre-wake lifecycle for the caller's completion CAS to advance:suspended, orrestoringkept by an earlier lease restore that released its lease, timed out or failed to persist its receipt.So on Direct, a
suspendedrow at completion can be the wake's own baseline, which the completion must advance, not fence. The shield's doc and twolifecycle.rscomments said Direct "never flips the row out ofsuspended" or "doesn't touch lifecycle at all"; they now describe both Direct paths. A cancelled Direct wake leavessuspended,started, or arestoringrow that a later restore reclaims once any lease expires.DirectSubstrate::capabilities()returnsSubstrateCapabilities::DIRECTandKubernetesSubstrate::capabilities()returnsSubstrateCapabilities::KUBERNETES.impl SubstrateAdapter for Substratehas a forwarding arm forcapabilities(), which the new tests exercise (they call it onSubstrate).supports_node_pool_placement()used to be a trait method (defaultfalse, overridden totrueby Kubernetes, forwarded bySubstrate). Its one caller, the admin create route, converted cleanly, so the method folds into the struct.SandboxController::supports_node_pool_placement()becomesSandboxController::substrate_capabilities().Site table
All paths are under
crates/libs/api/src/.controllers/sandbox/lifecycle.rsPublishedSuspendFence(Wake/ImplicitWake and Restore arms)match substrate { Direct => DirectRestoreBaseline, Kubernetes => Enforced }for_capabilities(caps):if caps.caller_completes_claimed_restore { Enforced } else { DirectRestoreBaseline }false→DirectRestoreBaselinetrue→Enforcedcontrollers/sandbox/wake_completion.rsshield_applies(same two arms)restore_shaped && match substrate { Direct => false, Kubernetes => true }restore_shaped && caps.caller_completes_claimed_restorefalsetruefork_operation_executor.rscopy-target completenesscopy_node_endpoint_requirement(&substrate):Direct => DirectEndpoint, Kubernetes => SubstrateNodeId(privateCopyNodeEndpointRequirement)substrate.capabilities().node_addressing(NodeAddressing, same two variants)DirectEndpointSubstrateNodeIdroutes/admin/node_maintenance.rscreate_node,update_node,delete_node,pull_node_template,delete_node_templaterequire_direct_substrate:managed_by() != "space"→ 501require_admin_node_registry:!caps.registers_nodes_via_admin_api→ the same 501 and message"space"→ allowed /true"k8s"→ 501 /falsemanaged_by()is"space"exactly for Directcontrollers/sandbox/queries.rsresolve_cold_create_resourcesColdCreateShapeInput { managed_by },managed_by != "k8s"→ requested shapeColdCreateShapeInput { capabilities },!caps.resolves_cold_create_shape→ requested shape"space"→ early return /false"k8s"→ pool or template shape /truemanaged_by()is"k8s"exactly for Kubernetesroutes/admin/sandboxes.rscreate_sandboxsandbox_controller.supports_node_pool_placement()sandbox_controller.substrate_capabilities().supports_node_pool_placementfalse(trait default)true(override)Left as they are, deliberately:
routes/admin/node_maintenance.rsupdate_node'snode.managed_by == NodeManagedBy::Spacetests the row, not the running substrate.routes/session_auth.rsConsoleSubstrate::from_managed_by(the console/melabel) needs a typed substrate kind, and none is reachable here without one of three changes, each rejected:managed_by()'s string, which adds a fallible step that the current total mapping doesn't have;managed_by()'s return type, which this PR must not do;It becomes a one-line
Fromonce the siblingSubstrateKindwork lands andmanaged_by()returns it.Coverage by mutation
For each field, I flipped its value in one substrate's constant, ran
cargo nextest run --locked -p pplx-space-api --all-features --retries 0 --no-fail-fast(Postgres and Redis started,SPACE_CI_REQUIRE_TEST_DEPS=1), recorded what failed, then reverted. The unmutated baseline in the same scratch copy passed 1734/1734.caller_completes_claimed_restoreKUBERNETEStrue → falsecontrollers::sandbox::lifecycle::wake_completion_postgres_tests::wake_completion_fence_follows_the_running_substrate(new)PublishedSuspendFence::for_capabilitiesare covered; uncovered: theshield_appliesconsumer and the two arms' call sites, see belowcaller_completes_claimed_restoreDIRECTfalse → truenode_addressingKUBERNETES→DirectEndpointnode_addressingDIRECT→SubstrateNodeIdregisters_nodes_via_admin_apiKUBERNETESfalse → trueroutes::admin::node_maintenance::tests::node_registry_delete_follows_the_running_substrate(new)registers_nodes_via_admin_apiDIRECTtrue → falseresolves_cold_create_shapeKUBERNETEStrue → falsecontrollers::sandbox::queries::create_postgres_tests::postgres_kubernetes_create_fills_unset_resources_from_the_template_shape(existing)resolves_cold_create_shapeDIRECTfalse → truesupports_node_pool_placementKUBERNETEStrue → falseroutes::admin::sandboxes::tests::admin_pinned_create_follows_the_running_substrate(new)supports_node_pool_placementDIRECTfalse → trueThe first
registers_nodes_via_admin_api/KUBERNETESrun could not start its Postgres container (Postgres did not become ready within 30 seconds) because the Docker disk was full at that moment (see Validation). That was ENVIRONMENTAL: after the prune, the rerun failed the new test as expected.Uncovered consumers of
caller_completes_claimed_restore. The new test callsPublishedSuspendFence::for_capabilitiesdirectly, and no test callsshield_applies. Revertingshield_applies, or either arm's (Wake/ImplicitWake, Restore) fence selection, to a literal would still pass the suite. Observing them means cancelling a wake after dispatch on each substrate and checking whether the redrive ran. No existing fixture does that, and this PR adds none.node_addressingstays uncovered. The read happens inside the fork executor's artifact-copy step. Observing it means driving a fork operation to that step, where a source row with an incomplete active-node target either reuses the target or falls back toselect_ready_worker_node_api_target. No existing fixture reaches that point on both substrates without node RPCs. Theactive_copy_node_target_is_completeunit tests pin each variant's rule.New tests
Each runs against a real
DirectSubstrateand, under#[cfg(feature = "kubernetes")], the store-backed Kubernetes test substrate (kubernetes::tests::substrate_with_store), wrapped inSubstrate. Each asserts an observable outcome, not a capability value. They use only the repository's Postgres test fixture, and the Kubernetes substrate's client is the existing inert test client. The two admin tests run in a child test process (enter_test_process) that setsSPACE_K8S_CONTROL_PLANE=false, soAppState::from_poolbuilds Direct whatever the host environment says and never reaches a real cluster. The admin-create test also setsSPACE_ALLOW_SANDBOX_PLACEMENT=truethere.wake_completion_fence_follows_the_running_substrate: picks the fence throughPublishedSuspendFence::for_capabilities(substrate.capabilities())and runscomplete_wake_lifecycleon asuspendedrow. Kubernetes leaves the rowsuspended; Direct advances it tostarted.node_registry_delete_follows_the_running_substrate: calls thedelete_nodehandler for a nonexistent node as the managing-org admin. Direct reaches the registry lookup (404Node not found); Kubernetes gets the unchanged 501 and message.admin_pinned_create_follows_the_running_substrate: with placement enabled, calls the admincreate_sandboxhandler with anode_poolpin. Direct gets 400 (sandbox node-pool placement requires the Kubernetes substrate) and nothing is queued; Kubernetes gets 202, and the queuedpublic_operationspayload carries the pin.Existing tests are moved, never rewritten to pass. They change only where a signature changed, and their assertions are untouched:
SubstrateCapabilities::KUBERNETES/DIRECT(bound to localk8s/space) where it passed"k8s"/"space".NodeAddressing::….SubstrateAdapterdoubles increate_postgres_tests.rsimplementcapabilities().The api characterization tests (#273, #290, #326) are untouched and pass.
Decisions made unattended
SubstrateCapabilities::DIRECT/KUBERNETES, rather than on each substrate type. Both answers sit side by side, and the cold-create test can name the Kubernetes answer without thekubernetesfeature.KUBERNETESispuband compiled unconditionally; it is plain data.capabilities()is required and has no default. With a default, a substrate that forgot to override it would silently answer like the other one.caller_completes_claimed_restore(re-derived from the fence and the shield, see above), andresolves_cold_create_shape(the gate covers both the pool shape and the template build shape).ColdCreateShapeInputtakes the wholeSubstrateCapabilities, not just one bool, so its test readsresolve(k8s, …)rather thanresolve(true, …).require_direct_substrateis renamedrequire_admin_node_registry, because it now asks a capability question. The status (501) and the message are unchanged.PublishedSuspendFence::DirectRestoreBaselinekeeps its name: existing tests name it./melabel is left as is, for the reasons above.architecture-baseline.tomlis not edited.cargo archadvises loweringfork_operation_executor.rs's cap, because this change shrinks that file. The finding is advisory, and the shared baseline is a conflict point for other PRs.Overlaps with open PRs
controllers/sandbox/queries.rsandcreate_postgres_tests.rs. Its new test buildsColdCreateShapeInput { managed_by: "k8s", … }. It also changesresolve_cold_create_resourcesto return a third value,Option<CreateShapeSource>, which flows into completion events and metrics. Whichever lands second must keep both: the capability-based selector (capabilities: SubstrateCapabilities,!capabilities.resolves_cold_create_shape) and Record where a sandbox create's shape came from #237's three-value return with its telemetry propagation.controllers/substrate/AGENTS.md). Its "For new work" bullet says "ask a capability such assupports_node_pool_placement". After this PR, that is aSubstrateCapabilitiesfield. Whichever lands second should make the bullet say: add a field toSubstrateCapabilities, set it in bothDIRECTandKUBERNETES, and read it viacapabilities().controllers/sandbox/queries.rsand/orcreate_postgres_tests.rs, but in other hunks. The two test doubles gain a method right aftermanaged_by(); api: decide admission pool claims for the create's owner; collect refused claims promptly #168 editsNodeRpcStubbedKubernetes's impl elsewhere.controllers/substrate/mod.rsin other hunks. This PR edits the trait head (wheresupports_node_pool_placementwas) and the start ofimpl SubstrateAdapter for Substrate. api: decide admission pool claims for the create's owner; collect refused claims promptly #168 adds a trait method and its forwarding arm elsewhere.kubernetes/adapter/substrate_adapter.rsin other hunks.main, which includes it (and internal-api: characterize node publication's lifecycle writers (tests only) #324, Keep scratch-build staging outside the shared cache #343, Run Kind's sandboxes in their own namespace, confined by NetworkPolicies #355, Run Kind's control plane highly available behind load-balanced entry #356, sbx-mgr: build every test service from explicit test options #322, sbx-mgr: test create after template lookup, unpersisted cleanup and endpoint capture on the seams (tests only) #327). No interaction.Validation
cargo fmt --all -- --check;ci/scripts/run-rust-checks.sh fmt(env-contract and error-policy checks included)cargo clippy --workspace --all-targets --all-features --locked -- -D warningscargo nextest run --locked -p pplx-space-api --all-features --retries 0,SPACE_CI_REQUIRE_TEST_DEPS=1, three consecutive runsbenchmark_single_node_occupancy)SPACE_K8S_CONTROL_PLANE=truein the parent environmentregisters_nodes_via_admin_apiandsupports_node_pool_placement, each flipped for each substrate)cargo arch; source guards;scripts/check-rust-error-policy.shcargo archexit 0, advisory findings only. Guards pass, exceptthin_service_mains.pyandcheck_no_inline_rust_test_modules.py, which fail only on files outside this diffscripts/tests/smoke-kind-e2e.shspace-apiand bothspace-cluster-controllerpods ran binaries hash-identical to the fresh artifact image.scripts/tests/smoke-kind-typed.sh all(SPACE_SMOKE_KIND_COLD_RECEIPTSset as CI does)native_contract.command_replay_credentialsis IGNORED by design). The failure,local.telemetry_reaches_local_backends, happened because Tempo hitno space left on deviceafter the devbox Docker disk filled. It passed on its one rerun on a second fresh cluster (baseline e2e passed again, same binary hashes).docker builder prune -af(12.92 GB and 8.64 GB freed) anddocker image prune -f(0 B) were used."Final" is this head. "Previous" is the preceding head on the same base. The only differences between the two are comment wording and the two admin tests' child-process environment, so production binaries are unchanged and Kind was not rerun.
Full Kind validation
Run after review on a fresh Kind cluster at
6f1dc66(the tip of #386, which includes #368 at251e4c7). Deployed api, internal-api and cluster-controller binaries matched the build's hashes before and after the suites.smoke-kind-e2e.sh(start and end)smoke-kind-typed.sh all(unsharded, CI's env)local.node_group_boundary_schedules,local.rate_limiters_enforce_and_fail_open) lost a request during the scenario's own api rollout; both passed on rerun and onmain.smoke-kind-sandbox-fork.pysmoke-kind-leader-election-brownout.shsmoke-spaced-rest.sh,run-local-smokes.sh allsmoke-space-m1-conformance.shmain(a malformed sandbox id returns 404, not 400); pre-existing, fixed separately🤖 Generated with Claude Code