Repository navigation
api, cluster-controller: make the Substrate variants private to the substrate module - #607
Merged
Merged
Conversation
ac-pplx
force-pushed
the
ac/substrate-private-variants
branch
from
October 17, 2026 02:27
2540563 to
b3ab233
Compare
…ubstrate module
`Substrate` becomes a struct over a private `Inner` enum, so only
`controllers::substrate` and its submodules can construct or match the
Direct and Kubernetes variants. A new `match substrate {
Substrate::Direct(..) => .. }` in shared code, tests included, now fails
to compile (E0599); the inner enum and its field are private (E0603,
E0616). Shared code asks `capabilities()` for static facts, calls
`SubstrateAdapter` or inherent methods for behavior, and uses
`as_kubernetes()`, binding its payload, for Kubernetes-only state.
`SubstrateAdapter::managed_by()` still names the running substrate for
SQL row scope and wire labels.
Earlier phase 3 changes had already converted every production variant
match outside the module, so the compiler found only construction sites.
They now use `Substrate::from_direct` and `Substrate::from_kubernetes`:
`from_env_with_lifecycle` and the test fixtures in the api crate and the
cluster controller. The forwarding impls inside the module match
`&self.inner`.
The cluster controller's leader-election choice was the remaining
identity check: it matched `as_kubernetes()` as `Some(_)` and built its
lease client from the environment. It now reads the new
`SubstrateCapabilities::contends_for_leader_lease` (Kubernetes true,
Direct false) through `LeaderElectionConfig::contends`, which also
applies the kill-switch. The decision is unchanged, and a table test
covers both substrates with election on and off.
Two archcheck `pattern_ratchet` instances fence identity checks.
`kubernetes_access` counts every `as_kubernetes(` call outside the
substrate module against a baseline (today one, in `reconcilers.rs`, which
binds the payload), so a new caller in any form needs a reviewed baseline
entry. `capability_identity` rejects naming `SubstrateCapabilities::DIRECT`
or `::KUBERNETES` in production code outside the two substrates'
`capabilities()`, since comparing `capabilities()` with a const is another
identity check.
No production behavior, wire format, schema, metric or configuration
changes.
The substrate `AGENTS.md` now says the variants are private, how shared
code builds a `Substrate`, how to add a capability, that `as_kubernetes()`
must bind and use its payload, what the two ratchets enforce, and that
`managed_by()` is never parsed to decide behavior: the transient-lifecycle
sweeper's `failed_active_execution_scope` is the only parse, and new
fixed-scope SQL returns early by reading a capability or reusing that
helper. The api crate's `AGENTS.md` points at `capabilities()` and
`as_kubernetes()` instead of `Substrate::Direct`.
ac-pplx
force-pushed
the
ac/substrate-private-variants
branch
from
October 17, 2026 03:30
b3ab233 to
3112b47
Compare
ac-pplx
marked this pull request as ready for review
October 17, 2026 03:46
johnlouie-pplx
approved these changes
Oct 17, 2026
This was referenced Oct 17, 2026
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.
Phase 3's last step:
Substrate's Direct and Kubernetes variants are now private tocrates/libs/api/src/controllers/substrate/, so a new match on them in shared code (tests included) fails to compile. The cluster controller's leader-election choice, the one remaining "is this Kubernetes?" check, now reads a capability. Behavior-preserving: no change to production behavior, wire formats, schema, metrics or configuration.What changed
Substrateis nowpub struct Substrate { inner: Inner }over a privateenum Inner { Direct(..), #[cfg(feature = "kubernetes")] Kubernetes(..) }(substrate/mod.rs:1701,:1706). Onlycontrollers::substrateand its submodules can nameInneror readinner.Substrate::from_direct(DirectSubstrate)andSubstrate::from_kubernetes(KubernetesSubstrate)(mod.rs:1714,:1722) replaceSubstrate::Direct(Box::new(..))/Substrate::Kubernetes(Box::new(..)). Building aSubstrateis not a branch, so the constructors staypub; the cluster controller's tests are in another crate.impl SubstrateAdapter for Substratenow match&self.inner(Inner::Direct(..)/Inner::Kubernetes(..)) instead ofself. No arm's body changed.capabilities()for static facts,SubstrateAdapter/inherent methods for behavior,as_kubernetes()for Kubernetes-only state. api: ask the substrate for capabilities instead of matching on it #368 and api, cluster-controller: reach Kubernetes-only state through one accessor #386 converted every production variant match outside the module (api: reject node drain and cordon on the Direct substrate #505 later addedsupports_node_drainthe same way), so with the variants private the compiler flagged only construction sites (below).contends_for_leader_lease(mod.rs:1074;DIRECTfalse,KUBERNETEStrue). The cluster controller'slib.rs:150wasmatch substrate.as_kubernetes() { Some(_) if le_config.enabled => … }: it discarded the payload and built its lease client from the environment, so it only asked which substrate was running. It now readsif le_config.contends(substrate.capabilities()).LeaderElectionConfig::contends(leader_election/mod.rs:439) isself.enabled && capabilities.contends_for_leader_lease, the same decision as before, and both arm bodies are unchanged. A table test,contention_follows_the_substrate_and_the_kill_switch, covers both substrates with election on and off.pattern_ratchetinstances (config only,ARCHITECTURE.toml, next todirect_tables; modes asrootfs_copy's:no_crossing,listed_no_growth,stale_entriesrequired,no_new_entries/no_raised_capsadvisory).kubernetes_accesscounts every\bas_kubernetes\s*\(call outsidecrates/libs/api/src/controllers/substrate/**againstarchitecture-baseline.toml. Today that is one call,reconcilers.rs = 1, which binds and uses the payload. A new caller in any form (wrapped chain,if let Some(_),matches!,.is_some_and(,.map(..).is_some()) needs a visible, reviewed baseline entry.capability_identityrejects\bSubstrateCapabilities\s*::\s*(?:DIRECT|KUBERNETES)\bin production code outsidedirect.rsandkubernetes/adapter/substrate_adapter.rs.SubstrateCapabilitiesderivesPartialEqand its consts are public, socapabilities() == SubstrateCapabilities::KUBERNETESwould be another identity check. Tests are outside archcheck's inventory and still compare with the consts.SubstrateAdapter::managed_by()still names the running substrate publicly, for SQL row scope and wire labels. TheSubstratedoc comment says so rather than claiming shared code never learns which substrate is running.AGENTS.mdnow says the variants are private (Innerinmod.rs), how shared code builds aSubstrate, how to add a capability (field with a doc comment, set in bothDIRECTandKUBERNETES, read throughcapabilities()), thatas_kubernetes()must bind and use its payload (Some(_),.is_some()and.is_none()are identity checks that need a capability instead), and that the runningmanaged_by()is never parsed to decide behavior, apart from the sanctioned SQL-scope early return (failed_active_execution_scope,transient_lifecycle_sweeper/store.rs). Its test guidance names the constructors, and the "existing substrate matches are debt" note is gone because none remain. The api crate'sAGENTS.mdpoints atcapabilities()/as_kubernetes()instead ofSubstrate::Direct.docs/architecture.md's substrate overview names the adapters, not the variants, so it is unchanged.Site table (this PR)
Line numbers before are at this PR's base, 17cd654. The replacement is on the same logical line.
api/src/controllers/substrate/mod.rs:1685pub enum Substrate { Direct(Box<..>), Kubernetes(Box<..>) }pub struct Substrate { inner: Inner }+ privateenum Innersubstrate/mod.rs:1737,:1745(from_env_with_lifecycle)Self::Kubernetes(Box::new(s)),Self::Direct(Box::new(DirectSubstrate::new(..)))Self::from_kubernetes(s),Self::from_direct(..)substrate/mod.rs:1697–1906(8 inherent methods:as_kubernetes,live_node_names,try_resolve_node_api_endpoint,reserve_/persist_rollback_route_identity,readiness,eligible_start_node_count,set_node_schedulability)match self { Self::Direct(..) .. }match &self.inner { Inner::Direct(..) .. }substrate/mod.rs:2290–2945(impl SubstrateAdapter for Substrate, 42 methods)match self { Self::Direct(s) => s.op(..), Self::Kubernetes(s) => s.op(..) }&self.innercluster/controller/server/src/lib.rs:150match substrate.as_kubernetes() { Some(_) if le_config.enabled => …, _ => … }if le_config.contends(substrate.capabilities()) { … } else { … }contends_for_leader_leasecapabilityapi/src/test_support.rs:62Substrate::Direct(Box::new(DirectSubstrate::new(..)))Substrate::from_direct(..)api/src/routes/public/sandboxes/admission_test_fixture.rs:166Substrate::Kubernetes(Box::new(substrate))Substrate::from_kubernetes(substrate)api/src/controllers/sandbox/lifecycle/tests.rs:216,290,543,751,842,924Substrate::Direct(Box::new(..))Substrate::from_direct(..)api/src/controllers/sandbox/lifecycle/wake_completion_postgres_tests.rs:63,68,211Substrate::Direct(Box::new(..)),Substrate::Kubernetes(Box::new(..))×2from_direct,from_kubernetesapi/src/controllers/sandbox/{ingress/tests.rs:288, queries/create_postgres_tests.rs:499, spaced/tests.rs:244, updates/postgres_tests.rs:75, updates/transport_tests.rs:262}Substrate::Direct(Box::new(..))Substrate::from_direct(..)api/src/controllers/template/tests.rs:191,399,678Substrate::Direct(Box::new(..))Substrate::from_direct(..)api/src/public_sandbox_operations/tests.rs:69,154,765Direct,Kubernetes,Directfrom_direct,from_kubernetes,from_directapi/src/routes/admin/{node_maintenance/tests.rs:168, nodes/drain_handler_tests.rs:70, nodes/tests.rs:2079, sandboxes/tests.rs:578}Substrate::Kubernetes(Box::new(..))Substrate::from_kubernetes(..)api/src/routes/e2b/tests.rs:344,388,4023Substrate::Kubernetes(Box::new(..))Substrate::from_kubernetes(..)substrate/tests.rs:671,675,714,734(714/734 are #597'snode_addressingtable),substrate/runtime_rpc/tests.rs:14,substrate/direct/tests.rs:2754,2956Substrate::Direct/Kubernetes(Box::new(..))substrate/kubernetes/tests.rs(18 sites: 3047, 3093, 5610, 5820, 5941, 5970, 6013, 6070, 6135, 6199, 6773, 9415, 9479, 9557, 9616, 9624, 9680, 9687),kubernetes/tests/substrate_scope.rs:37,44,kubernetes/adapter_characterization_tests/hydration_and_suspend.rs:348Substrate::Direct/Kubernetes(Box::new(..))cluster/controller/server/src/warm_pool/tests.rs:159,211,887,981,1340Substrate::Direct(Box::new(DirectSubstrate::new(..)))Substrate::from_direct(..)cluster/controller/server/src/public_operation_dispatcher/tests.rs:697,810Substrate::Direct(Box::new(..))Substrate::from_direct(..)cluster/controller/server/src/transient_lifecycle_sweeper/lifecycle_writer_tests.rs:55Substrate::Direct(Box::new(..))Substrate::from_direct(..)65 construction sites in total: 57 in the api crate, 8 in the cluster controller. None of them matched a variant. Which tests use Direct as a fixture is phase 4's job; this PR changes only how they build it.
The phase 0 inventory's 33 checks
git show origin/ac/direct-substrate-inventory:reports/direct-substrate-inventory/lists 33 category-drows outside the module: 14 decision sites (7Substrate::{Direct,Kubernetes}matches plus 7 string/typed comparisons). None of the 7 variant matches remains, and none of the 7 comparisons matches theSubstrateenum.sandbox/lifecycle.rs:366–368Substrate::Direct(_) => DirectRestoreBaseline,Kubernetes(_) => Enforcedcapabilities.caller_completes_claimed_restore(lifecycle.rs:367)sandbox/wake_completion.rs:95–97shield_appliesmatched the variantcapabilities.caller_completes_claimed_restore(wake_completion.rs:101)sandbox/wake_completion.rs:256–258Direct(_) => Ok(true),Kubernetes(kube) => hydrateSubstrateAdapter::ensure_routable_execution(wake_completion.rs:259)fork_operation_executor.rs:61–63CopyNodeEndpointRequirementby variantcapabilities().node_addressing(fork_operation_executor.rs:794)cluster/controller/server/src/lib.rs:145Substrate::Kubernetes(kube) if le_config.enabledas_kubernetes()withSome(_), still an identity check; this PR makes it thecontends_for_leader_leasecapability (lib.rs:150)cluster/controller/server/src/reconcilers.rs:391,394Direct(_) => skip,Kubernetes(kube) => spawnsubstrate.as_kubernetes()(reconcilers.rs:393)transient_lifecycle_sweeper/store.rs:952–954Direct(_) => Routable,Kubernetes(kube) => hydrateSubstrateAdapter::ensure_routable_execution(store.rs:992)sandbox/queries.rs:606managed_by != "k8s"capabilities.resolves_cold_create_shape(queries.rs:626)routes/admin/node_maintenance.rs:431–432+ 5 callersrequire_direct_substrate:managed_by() != "space"require_admin_node_registry:capabilities().registers_nodes_via_admin_api(node_maintenance.rs:198)sandbox/spaced.rs:75managed_by == "k8s"SubstrateKindparse of the row (spaced.rs:76)SubstrateKindsibling task's scoperoutes/session_auth.rs:31–33"k8s" => K8sSubstrateKindparse (session_auth.rs:31). It maps the running substrate (ConsoleSubstrate::from_managed_by(state.sandbox_controller.managed_by()),session_auth.rs:143) to the console's wire label: a deliberate wire-mapping exception, not a row-level checkroutes/admin/node_maintenance.rs:1210node.managed_by == NodeManagedBy::Spacenode_maintenance.rs:554)cluster/trigger-queue/src/lib.rs:265target.managed_by == NodeManagedBy::Kuberneteslib.rs:266)sandbox/store/src/lib.rs:485self.managed_by == "k8s"SubstrateKindparse (lib.rs:521)Not in the inventory:
transient_lifecycle_sweeper/store.rs:1199,1231,1262callfailed_active_execution_scope(self.substrate.managed_by())(store.rs:153, added by #373 after the inventory). It reads the running substrate's label to choose a fixed SQL scope, or to return early: the sanctioned SQL-scope exception named in the substrateAGENTS.md.Of the remaining rows,
spaced.rs,node_maintenance.rs:554, trigger-queue and the sandbox store check a row'smanaged_by.session_auth.rsand the sweeper's scope helper read the running substrate'smanaged_by(), one for a wire label and one for SQL scope. None names aSubstratevariant, so making the variants private cannot reach them. SQL literals, sbx-mgr andSubstrateKindrow methods are other tasks' scope and untouched here.Integration with open PRs
Whichever lands second fixes these:
crates/libs/api/src/test_support/kubernetes.rs, which buildsSubstrate::Kubernetes(Box::new(substrate)), and conflicts textually with this PR inpublic_sandbox_operations/tests.rs. If api: run the shared lifecycle tests on Kubernetes too #603 lands first, that line becomesSubstrate::from_kubernetes(substrate), and the helper whose doc calls it "the one place tests … name aSubstratevariant" is inlined or deleted.AGENTS.mdbullet this PR rewrites ("Don't matchSubstrate::Direct… MatchingSubstrate::Kubernetes(k)… is fine") and appends aSubstrateKindbullet. The merged file keeps this PR's bullets and Ask SubstrateKind row-level questions instead of matching the substrate #602's new bullet, and drops the stale text. Ask SubstrateKind row-level questions instead of matching the substrate #602's wording becomes "mapping aSubstrateKindvariant to a value … may match it", since a bare "variant" is ambiguous next to "Substrate's variants are private".Compile-fail proof
A scratch
crates/libs/api/src/substrate_match_probe.rs(declared fromlib.rs, outside the module; not committed), checked withcargo check -p pplx-space-api --all-features:Reaching for the inner enum instead (
use crate::controllers::substrate::{Inner, Substrate}; matches!(&substrate.inner, Inner::Direct(_))) fails witherror[E0603]: enumInneris privateanderror[E0616]: fieldinnerof structsubstrate::Substrateis private. The repository does not usetrybuild, so there is no compile-fail test.Review round 1: finding → change
lib.rs:150usedas_kubernetes()asSome(_), a pure identity check;as_kubernetes().is_none()was an escape hatchSubstrateCapabilities::contends_for_leader_lease(doc comment;DIRECTfalse,KUBERNETEStrue), read throughLeaderElectionConfig::contends; same decision; table test; mutation-checkedAGENTS.mdrule foras_kubernetes()/managed_by()as_kubernetes()must bind and use its payload;Some(_)/.is_some()/.is_none()need a capability; never parse the runningmanaged_by()to decide behavior, exceptfailed_active_execution_scopeSubstratedoc and this body now saymanaged_by()still names it for SQL row scope and wire labelskubernetes_accessandcapability_identity)pattern_ratchet.kubernetes_identityinARCHITECTURE.toml, its own table next todirect_tables, no baseline,required; a probe line makescargo archexit 1session_auth.rsmarked as a running-substrate wire-label mapping; sweeper'sfailed_active_execution_scope(#373) added as the sanctioned SQL-scope exception; summary sentence rewrittenSubstrateenum" commentsreconcilers.rs:380→ "Substrate",transient_lifecycle_sweeper/store.rs:1→ "the substrate"Review round 2: finding → change
kubernetes_identitymisses wrapped chains,.is_some_and(,matches!,if let Some(_),.map(..).is_some(); a trailing comment could fail an unrelated PRkubernetes_access, a call-count ratchet with a baseline (reconcilers.rs = 1), pluscapability_identityfor the const comparisonAGENTS.mdcontradicted itself on early returnsfailed_active_execution_scopeis the onlymanaged_by()parse; new fixed-scope SQL returns early by reading a capability or reusing that helper, never a new parse; the ratchet sentence says exactly what each instance countslib.rs:150lib.rsValidation
Round 2 (config, docs, a comment) at head
3112b47, base17cd654. No Kind, per the review.cargo fmt --all -- --check, workspace clippy-D warnings, pre-commit (gitleaks)cargo archon the treekubernetes_accessmatches equal the baseline (reconcilers.rs1),capability_identitymatches only the allowed filescargo arch --base-baseline <main's> --base-architecture <main's>reconcilers.rsentry shows as advisoryno_new_entries, as the archcheck README expects for a new instances\n.as_kubernetes()\n.is_some_and(..)in cluster-controllerlib.rskubernetes_access.no_crossing (required), exit 1if let Some(_) = s.as_kubernetes()inlib.rskubernetes_access.no_crossing (required), exit 1s.capabilities() == SubstrateCapabilities::KUBERNETESinlib.rscapability_identity.no_crossing (required), exit 1matches!(s.as_kubernetes(), Some(_)), in the baselinedreconcilers.rskubernetes_access.listed_no_growth (required): "grew from 1 to 2", exit 1cargo nextest run -p pplx-space-archcheckSPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run -p pplx-space-cluster-controller-server --all-features --run-ignored all --retries 0Round 1 at head
b3ab233(same code, with the line-pattern rule):cargo fmt --all -- --checkcargo clippy --workspace --locked --all-targets --all-features -- -D warningscargo check -p pplx-space-api --no-default-featurescargo build -p pplx-space-api-svcSPACE_CI_REQUIRE_TEST_DEPS=1 cargo nextest run --locked -p pplx-space-api -p pplx-space-cluster-controller-server --all-targets --all-features --run-ignored all --ignore-default-filter --retries 0×3DIRECT.contends_for_leader_lease = truecontention_follows_the_substrate_and_the_kill_switchfails ("direct substrate, election enabled: true")KUBERNETES.contends_for_leader_lease = falsecargo arch+cargo nextest run -p pplx-space-archcheckkubernetes_identitymatches nothing; archcheck tests 55/55. A scratchs.as_kubernetes().is_none()inreconcilers.rsgavepattern_ratchet.kubernetes_identity.no_crossing (required)and exit 1Inner/innerkind get clustersempty →bootstrap.sh),SPACE_KIND_TELEMETRY_MODE=otel; API and cluster-controller loggit_sha=b3ab233b9087;SPACE_K8S_CONTROL_PLANE=true; both controller replicas log "contending for leadership" and one "elected leader"smoke-kind-e2e.sh(SKIP_BOOTSTRAP=1)local.leader_election_brownout_keeps_one_leader(the typed port of the brownout smoke from #593; the shell script no longer exists), run by namesmoke-kind-e2e.shagain17cd654pulled in no substrate changes (#590 template pins, #217 cached template lookups)Earlier rounds, on the head before this review (same variant change, without the capability):
otelallecw 78/78, native 24/24; fork passotellocal.ecw_template_build_from_oci_boots,local.private_template_build_isolation: "publisher node UID is awaiting inventory synchronization") also failed Main CI's Kind shards ata0f5dc2and come from #458's template-publication path, which main has since reverted (#606); the infra error (object-store port-forward) passed on rerunNotes and decisions
Name
Inner, notSubstrateInner. archcheck'sfile_sizerule capssubstrate/mod.rs. WithSubstrateInner::the longer arms re-wrapped and the file grew by 59 code lines; withInner::it grows by 17, which is just the new struct and constructors (the new capability field adds 8 more). TheInnertype is private, and theSubstratedoc comment explains it.cargo archreports no new findings for the touched files. The stale cap onmod.rs(2771) already existed on main and is left for the baseline owner.No
#[cfg(test)]escape hatch. Tests outside the module use the same constructors and have no access toInner.Call-site gap (pre-existing, no fix). Changing
lib.rs:150back toif le_config.enabledpasses every unit test: the table test coversLeaderElectionConfig::contends, not its call site. No CI job runs the cluster controller on Direct, and election defaults to enabled.reconcilers.rs:393keepsas_kubernetes(). ItsSome(kube)arm binds and uses the payload (client, caches, pod store), which is the sanctioned use.One pre-existing warning.
cargo check -p pplx-space-api --no-default-features --testswarns that the re-exportpooled_prepare_error_is_reclaimable(controllers/sandbox/mod.rs:1427) is unused, because onlykubernetes-gated tests use it. This PR doesn't touch that file, and CI does not build that configuration. The plain--no-default-featureslib build is warning-free.Kind harness notes. (0) Round C: the first brownout attempt ran without
SKIP_BOOTSTRAP=1, so its "bootstrap kind stack" phase started Tilt. Tilt replaced the Postgres pod (data kept on its PVC) and re-created thespace-db-migrateJob, which hit the race below. I stopped that attempt and Tilt; the control-plane deployments were still the bootstrap'sb3ab233pods. The brownout and the second e2e then ran withSKIP_BOOTSTRAP=1, as CI's typed step does. (1) In rounds A and the 3ac9e5b round, starting Tilt over abootstrap.sh-deployed stack re-created Postgres and thespace-db-migrateJob together; the Job exhausted its backoff on "connection refused" and every dependent resource waited. Atilt trigger space-db-migrateonce Postgres was up let the bring-up finish. This is a harness race from my bootstrap-then-Tilt sequence; CI's flow starts Tilt from the e2e bring-up. (2) Round B ran against thebootstrap.sh-deployed stack withSKIP_BOOTSTRAP=1and no Tilt, because the Docker host had 11 GB free after the bring-up (under the 15 GB floor) and cleanup is not permitted while a cluster runs. Tilt's rebuilds would have added more. (3)local.retention_claim_finish_forwardreported an INFRA_ERROR when the object-store port-forward lost its pod after an object-store pod replacement, then passed once the forward recovered.AI assistance: this change was prepared with Claude Code.
🤖 Generated with Claude Code