Skip to content

api: ask the substrate for capabilities instead of matching on it - #368

Merged
ac-pplx merged 1 commit into
mainfrom
ac/substrate-capabilities-api
Oct 14, 2026
Merged

ac-pplx merged 1 commit into
mainfrom
ac/substrate-capabilities-api

Conversation

@ac-pplx

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

Copy link
Copy Markdown
Contributor

What merging does

Shared api code outside the substrate module stops asking "which substrate is this?" where it means "does this substrate support X?". SubstrateAdapter gets one required method, capabilities(), which returns a small Copy struct, 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:

pub enum NodeAddressing { DirectEndpoint, SubstrateNodeId }

pub struct SubstrateCapabilities {
    pub caller_completes_claimed_restore: bool,
    pub node_addressing: NodeAddressing,
    pub registers_nodes_via_admin_api: bool,
    pub resolves_cold_create_shape: bool,
    pub supports_node_pool_placement: bool,
}
field meaning DIRECT KUBERNETES
caller_completes_claimed_restore Every restore-shaped wake (from suspended or restoring) claims restoring before its node call and leaves restoring → started to the caller's completion CAS. The caller can then fence a suspended row at completion as a newer suspend, and must shield the CAS against request cancellation. false true
node_addressing Which NodeApiTarget field addresses a node; must match what the substrate's resolve_node_api_endpoint requires DirectEndpoint SubstrateNodeId
registers_nodes_via_admin_api Nodes live in the admin node registry (create, update, delete, template pull and delete) true false
resolves_cold_create_shape The control plane resolves a cold Firecracker create's shape: a fallthrough create takes a configured or policy pool shape, and otherwise vCPU and memory the request left unset come from the template's build shape false true
supports_node_pool_placement Create requests may carry a node-pool placement pin false true

Why caller_completes_claimed_restore is false for Direct. Direct rows do become restoring, so the reason is not that they never do. Direct doesn't guarantee that every restore-shaped wake claims restoring first and leaves the completion to the caller:

  • Its explicit restore, and a wake that falls back to it, go through restore_execution_with_candidates. That path claims restoring under its own lease (claim_direct_restore_lease) and writes started itself (complete_direct_restore_lease) before returning.
  • A placed worker-local wake (wake_sandbox) doesn't touch lifecycle. It leaves the pre-wake lifecycle for the caller's completion CAS to advance: suspended, or restoring kept by an earlier lease restore that released its lease, timed out or failed to persist its receipt.

So on Direct, a suspended row at completion can be the wake's own baseline, which the completion must advance, not fence. The shield's doc and two lifecycle.rs comments said Direct "never flips the row out of suspended" or "doesn't touch lifecycle at all"; they now describe both Direct paths. A cancelled Direct wake leaves suspended, started, or a restoring row that a later restore reclaims once any lease expires.

  • DirectSubstrate::capabilities() returns SubstrateCapabilities::DIRECT and KubernetesSubstrate::capabilities() returns SubstrateCapabilities::KUBERNETES.
  • impl SubstrateAdapter for Substrate has a forwarding arm for capabilities(), which the new tests exercise (they call it on Substrate).
  • The method has no default, so a substrate can't silently inherit an answer.
  • Every field has a consumer in this PR.
  • supports_node_pool_placement() used to be a trait method (default false, overridden to true by Kubernetes, forwarded by Substrate). Its one caller, the admin create route, converted cleanly, so the method folds into the struct. SandboxController::supports_node_pool_placement() becomes SandboxController::substrate_capabilities().

Site table

All paths are under crates/libs/api/src/.

site before after Direct Kubernetes why it is the same
controllers/sandbox/lifecycle.rs PublishedSuspendFence (Wake/ImplicitWake and Restore arms) match substrate { Direct => DirectRestoreBaseline, Kubernetes => Enforced } for_capabilities(caps): if caps.caller_completes_claimed_restore { Enforced } else { DirectRestoreBaseline } false → DirectRestoreBaseline true → Enforced same variant for each substrate
controllers/sandbox/wake_completion.rs shield_applies (same two arms) restore_shaped && match substrate { Direct => false, Kubernetes => true } restore_shaped && caps.caller_completes_claimed_restore false true same bool for each substrate
fork_operation_executor.rs copy-target completeness copy_node_endpoint_requirement(&substrate): Direct => DirectEndpoint, Kubernetes => SubstrateNodeId (private CopyNodeEndpointRequirement) substrate.capabilities().node_addressing (NodeAddressing, same two variants) DirectEndpoint SubstrateNodeId same variant; the private enum and helper are removed
routes/admin/node_maintenance.rs create_node, update_node, delete_node, pull_node_template, delete_node_template require_direct_substrate: managed_by() != "space" → 501 require_admin_node_registry: !caps.registers_nodes_via_admin_api → the same 501 and message "space" → allowed / true "k8s" → 501 / false managed_by() is "space" exactly for Direct
controllers/sandbox/queries.rs resolve_cold_create_resources ColdCreateShapeInput { managed_by }, managed_by != "k8s" → requested shape ColdCreateShapeInput { capabilities }, !caps.resolves_cold_create_shape → requested shape "space" → early return / false "k8s" → pool or template shape / true managed_by() is "k8s" exactly for Kubernetes
routes/admin/sandboxes.rs create_sandbox sandbox_controller.supports_node_pool_placement() sandbox_controller.substrate_capabilities().supports_node_pool_placement false (trait default) true (override) same values as the removed method

Left as they are, deliberately:

  • routes/admin/node_maintenance.rs update_node's node.managed_by == NodeManagedBy::Space tests the row, not the running substrate.

  • routes/session_auth.rs ConsoleSubstrate::from_managed_by (the console /me label) needs a typed substrate kind, and none is reachable here without one of three changes, each rejected:

    • parsing managed_by()'s string, which adds a fallible step that the current total mapping doesn't have;
    • changing managed_by()'s return type, which this PR must not do;
    • copying the kind into the capability struct, which would create a second source for the same label.

    It becomes a one-line From once the sibling SubstrateKind work lands and managed_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.

field flip failing test coverage
caller_completes_claimed_restore KUBERNETES true → false controllers::sandbox::lifecycle::wake_completion_postgres_tests::wake_completion_fence_follows_the_running_substrate (new) the field's value and PublishedSuspendFence::for_capabilities are covered; uncovered: the shield_applies consumer and the two arms' call sites, see below
caller_completes_claimed_restore DIRECT false → true same (new) covered
node_addressing KUBERNETES → DirectEndpoint none uncovered, see below
node_addressing DIRECT → SubstrateNodeId none uncovered
registers_nodes_via_admin_api KUBERNETES false → true routes::admin::node_maintenance::tests::node_registry_delete_follows_the_running_substrate (new) covered
registers_nodes_via_admin_api DIRECT true → false same (new) covered
resolves_cold_create_shape KUBERNETES true → false controllers::sandbox::queries::create_postgres_tests::postgres_kubernetes_create_fills_unset_resources_from_the_template_shape (existing) covered
resolves_cold_create_shape DIRECT false → true same (existing) covered
supports_node_pool_placement KUBERNETES true → false routes::admin::sandboxes::tests::admin_pinned_create_follows_the_running_substrate (new) covered
supports_node_pool_placement DIRECT false → true same (new) covered

The first registers_nodes_via_admin_api/KUBERNETES run 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 calls PublishedSuspendFence::for_capabilities directly, and no test calls shield_applies. Reverting shield_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_addressing stays 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 to select_ready_worker_node_api_target. No existing fixture reaches that point on both substrates without node RPCs. The active_copy_node_target_is_complete unit tests pin each variant's rule.

New tests

Each runs against a real DirectSubstrate and, under #[cfg(feature = "kubernetes")], the store-backed Kubernetes test substrate (kubernetes::tests::substrate_with_store), wrapped in Substrate. 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 sets SPACE_K8S_CONTROL_PLANE=false, so AppState::from_pool builds Direct whatever the host environment says and never reaches a real cluster. The admin-create test also sets SPACE_ALLOW_SANDBOX_PLACEMENT=true there.

  • wake_completion_fence_follows_the_running_substrate: picks the fence through PublishedSuspendFence::for_capabilities(substrate.capabilities()) and runs complete_wake_lifecycle on a suspended row. Kubernetes leaves the row suspended; Direct advances it to started.
  • node_registry_delete_follows_the_running_substrate: calls the delete_node handler for a nonexistent node as the managing-org admin. Direct reaches the registry lookup (404 Node not found); Kubernetes gets the unchanged 501 and message.
  • admin_pinned_create_follows_the_running_substrate: with placement enabled, calls the admin create_sandbox handler with a node_pool pin. Direct gets 400 (sandbox node-pool placement requires the Kubernetes substrate) and nothing is queued; Kubernetes gets 202, and the queued public_operations payload carries the pin.

Existing tests are moved, never rewritten to pass. They change only where a signature changed, and their assertions are untouched:

  • The cold-create shape test passes SubstrateCapabilities::KUBERNETES / DIRECT (bound to local k8s / space) where it passed "k8s" / "space".
  • The fork copy-target tests name NodeAddressing::….
  • The two hand-written SubstrateAdapter doubles in create_postgres_tests.rs implement capabilities().

The api characterization tests (#273, #290, #326) are untouched and pass.

Decisions made unattended

  1. Constants live on the struct, as 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 the kubernetes feature. KUBERNETES is pub and compiled unconditionally; it is plain data.
  2. capabilities() is required and has no default. With a default, a substrate that forgot to override it would silently answer like the other one.
  3. Field names describe what the consumers rely on: caller_completes_claimed_restore (re-derived from the fence and the shield, see above), and resolves_cold_create_shape (the gate covers both the pool shape and the template build shape).
  4. ColdCreateShapeInput takes the whole SubstrateCapabilities, not just one bool, so its test reads resolve(k8s, …) rather than resolve(true, …).
  5. require_direct_substrate is renamed require_admin_node_registry, because it now asks a capability question. The status (501) and the message are unchanged.
  6. PublishedSuspendFence::DirectRestoreBaseline keeps its name: existing tests name it.
  7. The console /me label is left as is, for the reasons above.
  8. architecture-baseline.toml is not edited. cargo arch advises lowering fork_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

Validation

check head result
cargo fmt --all -- --check; ci/scripts/run-rust-checks.sh fmt (env-contract and error-policy checks included) final pass
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings final pass
pre-commit (gitleaks), on the commit and over the commit range final pass
cargo nextest run --locked -p pplx-space-api --all-features --retries 0, SPACE_CI_REQUIRE_TEST_DEPS=1, three consecutive runs final 1734/1734 passed each time; 0 dependency skips (the one skipped test is the opt-in benchmark_single_node_occupancy)
The three new tests with SPACE_K8S_CONTROL_PLANE=true in the parent environment final pass (the child process overrides it)
Mutation proofs of the two admin tests (registers_nodes_via_admin_api and supports_node_pool_placement, each flipped for each substrate) final each flip fails its test; baseline passes
Mutation runs for the other fields (table above) previous as tabled
cargo arch; source guards; scripts/check-rust-error-policy.sh previous cargo arch exit 0, advisory findings only. Guards pass, except thin_service_mains.py and check_no_inline_rust_test_modules.py, which fail only on files outside this diff
Kind: fresh cluster on the current harness (HA control plane, sandbox namespace), scripts/tests/smoke-kind-e2e.sh previous pass
Kind provenance previous The build sources were byte-identical to the checkout. Both space-api and both space-cluster-controller pods ran binaries hash-identical to the fresh artifact image.
scripts/tests/smoke-kind-typed.sh all (SPACE_SMOKE_KIND_COLD_RECEIPTS set as CI does) previous ECW 77 passed and 1 ENVIRONMENTAL failure; native 23/23 (native_contract.command_replay_credentials is IGNORED by design). The failure, local.telemetry_reaches_local_backends, happened because Tempo hit no space left on device after the devbox Docker disk filled. It passed on its one rerun on a second fresh cluster (baseline e2e passed again, same binary hashes).
Docker disk previous Only docker builder prune -af (12.92 GB and 8.64 GB freed) and docker 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 at 251e4c7). Deployed api, internal-api and cluster-controller binaries matched the build's hashes before and after the suites.

Suite Result
smoke-kind-e2e.sh (start and end) pass, pass
smoke-kind-typed.sh all (unsharded, CI's env) ECW surface 76/78, native 23/23. The two non-passes (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 on main.
smoke-kind-sandbox-fork.py pass
smoke-kind-leader-election-brownout.sh pass, all three scenarios, including the apiserver outage
smoke-spaced-rest.sh, run-local-smokes.sh all pass
smoke-space-m1-conformance.sh fails identically on main (a malformed sandbox id returns 404, not 400); pre-existing, fixed separately

🤖 Generated with Claude Code

@ac-pplx
ac-pplx force-pushed the ac/substrate-capabilities-api branch 2 times, most recently from b4ea64d to e5862f7 Compare October 13, 2026 07:39
@ac-pplx
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.

@zibi-pplx zibi-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.

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

  1. node_addressing duplicates what resolve_node_api_endpoint requires.
    • The field (substrate/mod.rs:2166) restates what each substrate's resolve_node_api_endpoint needs. direct.rs:4175 reads direct_endpoint; kubernetes/adapter/node_eligibility.rs:224 requires substrate_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.
  2. caller_completes_claimed_restore names one protocol, but on Direct the two consumers are false for different reasons.
    • The worker-local wake keeps suspended as its baseline. The lease restore has already written started.
    • A related window, which predates this PR and is worth an issue:
      • In the Restore arm on Direct, complete_direct_restore_lease writes started.
      • complete_wake_lifecycle then runs with DirectRestoreBaseline ($2 = FALSE, lifecycle.rs:393-412).
      • So a suspend that fully finishes in between is overwritten back to started.
    • 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.
  3. The new wake test mostly repeats an existing one.
    • wake_completion_fence_follows_the_running_substrate largely re-covers wake_completion_cas_outcome_for_every_prior_lifecycle (same file, :48).
    • It calls for_capabilities itself, so the Wake and Restore call sites stay uncovered.
    • Per the space-code-quality test policy, running [DIRECT, KUBERNETES] through for_capabilities inside 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's CreateRequest has no shape fields. "the control plane passes the requested shape through unchanged" is accurate.
  • wake_completion_postgres_tests.rs:71-72 still says "a direct restore keeps suspended as 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 node managed_by to NodeManagedBy::Space.
  • The body says "five api-crate branches", but the site table lists six sites, covered by five fields.

@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
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.
Merged via the queue into main with commit 723bbcc Oct 14, 2026
14 checks passed
@ac-pplx
ac-pplx deleted the ac/substrate-capabilities-api branch October 14, 2026 14:29
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.
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.
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.
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