8.4 KiB
Phase 2 — CoreContext ownership, ambient context, registry collapse, store traits
Status: Stage A + ambient-context + registry-collapse DONE; per-domain store-trait migration remains. Goal: make state reachable through a context instead of only through process globals, without a big-bang DI rewrite.
2.a Handler context access — ambient scope, not a signature sweep (Stage B)
Implemented (deviation from the original plan, with rationale below): the
literal signature change to ControllerHandler was rejected after measuring its
true cost. ControllerHandler fn pointers appear at 846 handler definitions
across 109 files, plus ~381 direct call sites — a change of that type must
land atomically to compile, needs a default-context OnceLock with fallbacks
for the many callers that never build a runtime (CLI one-shots, tests, MCP
dispatch), and yields zero functional value until each domain later reads
ctx (Stage C). That is disproportionate churn and a high risk of a
non-compiling tree.
Instead, the goal — every handler can reach the CoreContext for the
current dispatch — is delivered with a tokio::task_local! ambient context:
CoreContext::initregisters the first-built context as the processDEFAULT_CONTEXT(OnceLock<Arc<CoreContext>>).- The dispatch chokepoint
all::try_invoke_registered_rpcwraps each handler future inCoreContext::scope(ctx, fut), wherectxisCoreContext::current()— the active scope if any (so nested dispatches stay in the same tenant context), else the default. - Handlers reach it via
CoreContext::current() -> Option<Arc<CoreContext>>. Controller handlers stay barefnpointers — zero per-handler churn. - A domain migrates off a process global by reading its store handle from
CoreContext::current()instead. Once its state lives on the context, two contexts dispatched under distinctCoreContext::scopes read isolated state — exactly the Phase 3 exit criterion, verified by the unit tests insrc/core/runtime/context.rs(scope_sets_current_context,nested_scope_overrides_then_restores).
One implementation note: the extra future layer at the chokepoint pushed the
Send auto-trait solver past the default depth on the deepest axum→tinyagents
routes; the scoped future is re-boxed into a ControllerFuture and the crate
sets #![recursion_limit = "256"] (both in src/lib.rs / src/core/all.rs).
This is strictly a better realization of Stage B's intent; the explicit-param approach is not planned.
2.b Registry collapse
Replace the three hand-maintained parallel lists in src/core/all.rs
(handlers :105-344, schemas :375-509, namespace descriptions :530-690)
with one per-domain struct:
pub struct DomainRegistration {
pub namespace: &'static str,
pub description: &'static str,
pub controllers: Vec<RegisteredController>, // schema + handler already paired
pub cli: Option<CliHandler>, // absorbs CLI_ADAPTERS (all.rs:88)
}
fn all_domains() -> Vec<DomainRegistration> { vec![about_app::domain(), /* … one line per domain … */] }
Implemented (partial — the drift-elimination half):
all_controller_schemas()now derives the schema list from the registered controllers (registry().iter().map(|c| c.schema.clone())), so the parallelbuild_declared_controller_schemas()list is deleted andvalidate_registry's declared-vs-registered cross-check is removed — the two lists can no longer drift.validate_registrykeeps the duplicate-method / empty-namespace / duplicate-required-input checks on the registered set. Obsolete drift unit tests were removed.- Explicitly rejected:
inventory/linkme link-section auto-registration.
Deferred (cosmetic, no correctness value): folding the per-domain
all_X_controller_schemas() fns and the namespace_description match into a
single DomainRegistration struct. The per-domain schema fns still exist but
are no longer aggregated centrally; collapsing them further is churn across 109
domains for no behavior change, tracked as a follow-up.
2.c Per-domain migration + store traits
Domains migrate _ctx → real context usage opportunistically; required
(because multi-context isolation in phase 3 needs them) for: memory
(tinycortex seam handle), people, attachments, config. Everything else
migrates when touched.
Storage abstraction lands here, at the handle boundary:
impl CoreContext {
pub fn people(&self) -> Arc<dyn PeopleStore>;
pub fn attachments(&self) -> Arc<dyn AttachmentStore>;
// … per-domain accessors added as domains migrate
}
pub enum StorageBackend { WorkspaceFs } // CoreBuilder::storage(..); only impl for now
- Traits are carved per domain as it migrates (drift-ledger column "store trait extracted?"), never as one up-front storage rewrite.
WorkspaceFswraps the existing SQLite/JSON-on-disk stores byte-for-byte; the global facades (*::global()) delegate to the default context's handles so both paths hit the same instance.- Host-owned stores only (people, attachments, cost/x402 ledgers, config
state, run ledgers).
tinyagents(StoreRegistry, checkpointers) andtinycortexalready own their internal store interfaces — the context holds handles to those seams, it does not re-abstract them. - Adding a remote backend (e.g. Postgres) must become "new
StorageBackendimpl", but shipping one is out of scope.
Risks & mitigations
| Risk | Mitigation |
|---|---|
| Sweep-size diff unreviewable | three PR series: signature-only → registry collapse → per-domain |
| Global facade and context handle diverge | facade delegates to default context (single instance); parity asserts in debug builds |
| Store trait too narrow, churns later | extract traits from observed handler usage per domain, not speculatively |
| Coverage gate on mechanical diffs | signature/registry PRs carry existing tests; per-domain PRs add store-trait unit tests |
2.d DomainSet — the runtime composition axis (#4796)
DomainSet (in src/core/runtime/builder.rs, sibling of ServiceSet) is the
runtime axis that selects which domain families exist, complementing
ServiceSet (which selects background services). One flag per DomainGroup
(src/core/all.rs); presets full() (default — byte-identical to pre-#4796),
harness() (agent + memory + threads + config + security only), none().
Mechanism (Shape B — filter seam, not the full per-domain
DomainRegistration struct collapse, which remains phase-2-deferred):
- Every controller is tagged with its
DomainGrouponce, at the single registration site (build_registered_controllers/build_internal_only_controllers), via aGroupedController { group, controller }wrapper — the ~109 domain modules keep returning bareRegisteredControllerlists and never learn about groups. - The live surface filters by the ambient
CoreContext::domains():all_registered_controllers/all_controller_schemas(so/schemaomits gated namespaces),try_invoke_registered_rpc(gated method ⇒None, i.e. unknown-method), the agent tool surface (tools::ops::all_tools_with_runtimepost-filter bytool_group), workspace-bound store init (init_stores), and domain event subscribers (register_domain_subscribers). - No active context (pre-boot unit tests) ⇒ no filtering (treated as full).
- Per-gate Cargo
[features](children #4797–#4804) narrow the compile-time surface further;DomainSetis the runtime axis they compose with.
Verification
pnpm test:rust+json_rpc_e2egreen after each sub-series.- Drift ledger lists every domain with columns: signature migrated / context usage / store trait / notes.
all::try_invoke_registered_rpcinstalls an ambientCoreContext::scopearound registered handler futures, and tests verifyCoreContext::current()propagation through registered RPC invocation.DomainSet:full_registration_is_byte_identical,harness_excludes_gated_namespaces,dispatch_returns_none_for_gated_method,group_mapping_smoke(src/core/all_tests.rs) +domain_set_presets_have_expected_flags(builder.rs).