ADR-063: Inter-context access pattern — no SQL grants; calls via DI; notifications via events
Status: Accepted (2026-04-25)
Context
Spectral’s three-context topology (spectral.core / spectral.worlds / spectral.platform, per ADR-001 as refined by ADR-031) forbids direct imports between contexts inside library code. Inter-context interaction is a recurring need anyway: the Ops Agent asks the World Agent a question; Operations consumes Worlds outcome rows; T3 memory has a body that lives in platform storage and is fetched by worlds. Through the 0.3.0 Tech Arch Review, three different mechanisms had been proposed for these flows:
- SQL role grants between contexts (TA-5 D5 default; TA-7 D3
worlds_outcomes_reader; TA-8 D3worlds_t3_reader; TA-19 D2 inherited the default). The pattern: producer context owns the table; consumer context’s runtime user has aSELECTgrant; reads happen via direct SQL. - Events (TA-5 substrate; consumer materializes a local replica from notifications).
- In-process DI (consumer holds a protocol from
spectral.core; the impl is supplied at the framework-layer composition seam, e.g., the workers entrypoint).
TA-27 was opened to dispose the default — should inter-context access be SQL-default with documented exceptions, or events-default with SQL-as-named-exception? TA-15 (agent tool invocation + inter-context composition; SPEC-318) reframed the question before TA-27 ran. Two observations forced the reframing:
- The axis is flow shape, not sync vs async. Notification flow (one-way push; producer doesn’t await a result) and call flow (caller dispatches a request and needs the result back) are structurally different problems with different mechanism fits. Both are implemented with
async defPython functions in workers; the transport choice is orthogonal to function-definition semantics. - Scaffolding exceptions during architectural planning indicates the default isn’t right. Founder-lens challenge during TA-15 conversation: a defensible architecture cannot need a named-exception list out of the gate; if a candidate default produces exceptions during greenfield design, the framing is wrong.
SQL grants between contexts pierce the context seal at the database layer, make the granted table’s schema an implicit inter-context contract without a typed surface, require the producer context to remain available for consumers to read, and demand inter-context coordination on schema migrations without a typed contract for it. Neither flow shape needs them: notifications fit events cleanly; calls fit DI through the framework-layer composition seam cleanly. The default that survives the reframing is no SQL grants between contexts at any layer, with both flow shapes routed to mechanisms whose contracts already exist.
Decision
Inter-context access splits along flow shape. Neither shape uses SQL grants between contexts. There are no exceptions.
1. Notification flow → events via the TA-5 substrate
When a producer context publishes a fact and consumers react without the producer awaiting a result, the mechanism is the event substrate from ADR-044 (TA-5 + TA-19 D2/D5). The substrate envelope shape lives in spectral.core.events; producer-owned typed payloads live in <producer>.contracts.events.* per ADR-065 D2. Consumer contexts subscribe and parse the envelope payload via their own local <EventName>Event model per ADR-065 D4 (consumer ACL pattern); consumer-side state (replicas, derived caches) is the consumer’s own concern, materialized into the consumer context’s schema. Replay, additive-only payload evolution, DLQ, and observability are inherited from the substrate.
2. Call flow → callee-owned OHS Protocol; impl in callee context; bridge tool in apps/*
“Consumer holds protocol from
spectral.core” framing superseded by ADR-065 D3 + D5. Callee-owned OHS Protocols live in<callee>.contracts.protocols.*(not inspectral.core); bridge tools live inapps/*framework deliverables (not in caller-context code). The “no SQL grants between contexts” core decision is unaffected; only the placement of the Protocol surface and the bridge composition seam are reframed.Call-flow default-mechanism refined by ADR-070. ADR-070 establishes a simplest-fit ladder: Tier 1 (use case handler in
<callee>.application.*consumed by the framework deliverable per validator rule 7) is the call-flow default; Tier 2 (the callee-owned OHS Protocol pattern below) is the escalation when conditions warrant (multiple framework consumers, LLM-tool wrapping per ADR-060, cross-process / cross-language boundary, or genuine polymorphism). The pattern below describes Tier 2; ADR-070 carries the selection rule + eval criteria.
When a caller dispatches a request and needs the result back, the mechanism is in-process DI through the framework-layer composition seam (per ADR-031 — apps/workers for agent-resident code; apps/api for synchronous request-scoped paths). The pattern per ADR-065 D3 + D5:
- Callee-owned OHS Protocol in
<callee>.contracts.protocols.*. The Protocol is owned by the context that implements the capability; consumed byapps/*framework deliverables only. - Impl in callee context’s application layer (e.g.,
spectral.worlds.application). The impl never imports from the caller context. - Bridge tool in
apps/*(e.g.,apps/workers/tools/). The bridge imports<callee>.contracts.protocols.*(framework-to-context, allowed under validator rule 7) and is composed into the caller agent’s tool list via DI at workers startup. - Caller-context code never imports another context. The seam is structural; bridge tools live at the framework layer, not in caller-context code.
WorldAgentRunner (in spectral.worlds.contracts.protocols.world_agent) is the reference example. The same pattern applies to any future inter-context call-shaped capability. Notification-shaped reads (eventual consistency on facts already recorded elsewhere) follow event-driven local replicas per ADR-064 D3 (broadened 2026-04-30 to cover both rule-candidate outcomes and T3 memory).
3. No SQL grants between contexts at any layer
The “direct read with role-scoped grants” default in TA-5 D5 is superseded for inter-context use. TA-7 D3’s worlds_outcomes_reader grant is removed; outcome reads happen via the worlds.rule_candidate_outcome_recorded event + Operations-side local replica per ADR-064 D3. TA-8 D3’s worlds_t3_reader grant is removed; T3 body reads happen via the platform.t3_memory.written event + worlds-side local projection per ADR-064 D3 (broadened 2026-04-30). TA-19 D2’s inheritance from TA-5 D5 follows. There is no exception list — both flow shapes have clean mechanisms; SQL grants don’t ship; Reader Protocols don’t ship for notification-shaped reads.
The core schema remains shared substrate (outbox, retention registry, embedding profile, deployments, workers, users mirror); access to core from any context is not “inter-context” in the architectural sense and is unaffected by this ADR. RLS within a single context’s schema is also unaffected.
4. Architecture-validator enforcement
tools/quality/validate_architecture.py gains an AST rule that flags any direct SQL access (psycopg connection, ORM session, or raw SELECT/INSERT/UPDATE/DELETE text) targeting another context’s schema from outside the framework-layer composition entrypoints. The rule is invariant — there is no allowlist. Violations block CI.
Alternatives considered
SQL-default with documented exception list (the operative pattern through TA-7 / TA-8 / TA-19). Rejected after the founder-lens challenge: the existence of two exceptions out of three motivating cases (TA-7 D3, TA-8 D3) before disposition completion meant the default was wrong, not that the exceptions were justified. The reframing eliminated both exceptions structurally.
Events-default with SQL-as-named-exception. Rejected on the same ground. A “named exception” list institutionalizes the position that the default doesn’t fit some cases — but DI through the framework-layer composition seam fits the call-flow cases that motivated the SQL exceptions, without piercing the context seal.
Inter-context tool calls via events (request-reply pattern). Considered for ask_world_agent and similar call-flow tools. Rejected: request-reply over events introduces correlation IDs, timeouts, suspend-resume orchestration, and lost-response handling for what is structurally an in-process function call. DI at the framework-layer seam uses primitives that already exist (closed-over factories per ADR-007 / ADR-031), preserves normal stack traces, and incurs no substrate-handler overhead.
HTTP through apps/api for in-workers inter-context calls. Rejected for agent-resident call flows after TA-15 D-runtime ratified that all three agents run in workers. Workers is the framework-layer composition seam for workers-resident code; an HTTP roundtrip to apps/api and back would be a needless network hop. apps/api remains the seam for synchronous request-scoped paths.
SECURITY DEFINER stored procedures (the original TA-5 D5 alternative to direct grants). Rejected as part of the broader supersession — both stored-proc and direct-grant approaches share the disqualifying property that they pierce the context seal at the DB layer.
Consequences
- TA-5 D5 is partially superseded. ADR-044 (TA-5 event substrate) carries the supersession status line; the notification-shape portion of TA-5 holds, the SQL grant default between contexts does not. ADR-044 cross-references this ADR.
- TA-7 D3 grant removed. SPEC-310 close-out chooses between richer event payload or DI-injected
RuleCandidateOutcomeReader. ADR-055 (TA-7) carries the choice. - TA-8 D3 grant removed. SPEC-311 close-out follows ADR-064 D3 (broadened) — event-driven worlds-local projection from
platform.t3_memory.written; noT3MemoryReaderProtocol minted. ADR-056 (TA-8) carries the choice. - TA-19 D2 inheritance superseded. ADR-048 (TA-19) reflects that the workers tier composes inter-context dependencies via DI; no shared DB role assumes an inter-context grant.
- TA-27 (SPEC-331) disposition collapses to ratification. This ADR is the canonical statement; the close-pass for SPEC-331 is the validator extension and the Codex page (
system-design/foundations/contract-surfaces.mdx). spectral.worlds.contracts.protocols.world_agent.WorldAgentRunneris the worked reference example for new inter-context call-flow protocols (placement reframed by ADR-065 D3). Future inter-context tool dependencies follow the same shape (callee-owned Protocol in<callee>.contracts.protocols.*; impl in callee context’s application layer; bridge tool inapps/*per ADR-065 D5).- Workers-entrypoint composition becomes load-bearing. All inter-context call-flow wiring lives at the workers entrypoint (or
apps/apifor request-scope paths). The composition module is more substrate to maintain than a monolithic app, but the context seal is enforced structurally — context code never imports another context, and the validator rule is invariant. - Schema migration coordination is removed. Producer contexts evolve their schemas freely; consumers are coupled to typed protocols (call flow) or typed event payloads (notification flow), not to each other’s tables.
- Architecture-validator gains an invariant AST rule. Direct SQL access between contexts from outside framework-layer composition entrypoints fails CI. No allowlist; no escape hatch.
References
- ADR-001 — three-context topology
- ADR-031 — single-library + app-as-framework-layer-leaves; framework-layer composition pattern
- ADR-065 —
spectral.coreadmission discipline (the protocols introduced here are core surface) - ADR-044 — event substrate; carries the TA-5 D5 supersession
- ADR-048 — deployment topology; workers tier as composition seam
- ADR-055 — TA-7 grant removal
- ADR-056 — TA-8 grant removal
- ADR-060 — TA-15 (introduces
WorldAgentRunnerreference protocol) - TA-15 disposition — SPEC-318 comment
66b07620 - TA-27 disposition — SPEC-331 comment
e87e0977 tools/quality/validate_architecture.py— invariant inter-context SQL access rule (added with this ADR)- Codex
system-design/foundations/contract-surfaces.mdx— declarative pattern documentation