ADR-065: Contract surface — admission rules and payload pattern
Status: Accepted (2026-04-29) — D1 superseded by ADR-068; D4 default-mechanism framing partially superseded by ADR-070; other Ds in force Supersedes: ADR-013, ADR-016, ADR-024
Context
Three observations forced this decision.
The kernel had drifted. A placement audit on spectral.core revealed that every type currently in the kernel had a clean single-context home — i.e., every type failed the test “if spectral.core did not exist, where would this go?” That outcome means the kernel’s scope, contents, and the inter-context interface posture had not been designed with intent. ADR-024’s PR-statement governance, which was supposed to prevent precisely this drift, did not catch the failure; the test produced the appearance of discipline without the substance.
Three load-bearing prior ADRs disagreed on the same picture. ADR-013 defined packages/core as types/protocols/logic “defined by the interface between” the contexts and admitted rich Pydantic models with validators. ADR-016 generalized an opaque-envelope pattern from a single use case (attribution metadata) into a kernel-wide convention. ADR-024 layered a PR-statement governance test on top of ADR-013’s substantive test. The three together created a kernel that absorbed any inter-context-shaped item under one of three admission framings, with no structural enforcement that could fail closed when the framings disagreed.
The doctrine survived end-to-end pressure-testing against the real code. A coherent end-state design — kernel scope reframed, ADR-024’s review-shaped governance replaced with structural enforcement, typed inter-context payloads and callee-owned Protocols moved out of the kernel into producer contexts’ own contract surfaces — verified against the live codebase before being committed here. spectral.core is retained (the name and the context-topology floor are intentional) but its scope is the residue after that relocation, not a positive contract layer.
Decision
D1. spectral.core admission discipline
spectral.core admits an item iff the item passes both:
- Conceptual admission (killer test + functional-area placement). “If
spectral.coredid not exist, where would this go?” must have no clean single-context answer. If it does, the item belongs to that context. If it does not, the item is placed under thecore/<subdir>/whose functional area it belongs to (auth,events,db,retention,llm,embeddings,tools). Sub-namespaces under each admitted functional area (e.g.,core/llm/testing/for area-scoped test helpers) are organizational and inherit the area’s admission; the killer test gates top-level subdir admission, not every nested level. - Type-shape constraint. The item is expressed as a frozen Pydantic model,
Protocol, enum subclass,TypedDict,Literal,Final-typed constant, exception class, or stdlib type. No mutable state. No context-internal aggregates. Method admission on kernel value types is governed by ADR-068 (clarifies the original “no methods beyond field validators” clause; methods are admitted iff pure functions ofself/clsplus value-typed args).
The conceptual test defines what belongs; the type-shape constraint defines how it’s expressed. Both must pass. Per-subdirectory rules tighten the type-shape constraint to the cadence and content shape appropriate for each functional area: auth admits identity/scope value types and the boundary-error taxonomy; events admits the envelope shape, dispatcher Protocols, and substrate-only primitives (typed payloads relocate to <context>.contracts.events.* per D2); db admits the session-var constants and pool-lifecycle Protocols; retention admits the policy enum and the RetentionPolicy value type; llm admits the cross-provider request/response shape and cost-tracking value types; embeddings admits the canonical-profile value type and the retrievable-table convention markers; tools admits the four-class error taxonomy, ToolCallMetadata, and ToolApprovalRequest. The functional-area subdirs are the seven listed above; new subdirs require a substrate-kernel ADR.
Every Python module under core/<subdir>/, including __init__.py, carries a one-line module docstring stating its functional-area-membership rationale. Docstring presence is enforced by ruff D100 + D104; the functional-area-membership wording is convention.
D2. Producer-owned typed event payloads in <context>.contracts.events.*
Each context publishes the typed payloads it emits onto the event substrate under its own <context>.contracts.events.* sub-package. The producer’s Pydantic model is the single source of truth for the wire shape. The model is never imported across contexts by another context’s code; it is consumed only by:
- the producer context itself when constructing emit envelopes;
- bilateral contract tests under
tests/contracts/, which are exempt from the inter-context import rule; - the Codex documentation generator, which walks
<context>.contracts.events.*and emits one Codex page per event from the Pydantic class + module docstring.
Consumers parse incoming envelope payloads into their own local <EventName>Event model declared in the consuming flow. Local models declare only the fields the consumer actually needs; producer-rich + consumer-narrow asymmetric typing is the expected pattern.
D3. Callee-owned OHS Protocols in <context>.contracts.protocols.*
Each context publishes the synchronous-call Protocols it offers under its own <context>.contracts.protocols.* sub-package. These are callee-owned Open Host Service contracts: the context that implements the capability defines the type. Consumed by apps/* framework deliverables only — never by another context’s application code. The framework-to-context import seam (validator rule 7) makes apps/* the legitimate consumer.
D4. Inter-context notification flow — events + ACL + projection
Default-mechanism framing partially superseded by ADR-070. Inter-context mechanism selection follows a simplest-fit ladder by flow shape: notification flow uses this D4; call flow follows ADR-070’s ladder (Tier 1 = use case handler in
<callee>.application.*is the call-flow default; Tier 2 = the OHS Protocol pattern in D3 is the escalation when conditions warrant). D4 governs the notification-flow mechanism below.
When the flow shape is notification:
- Producer constructs a
<EventName>Payload(D2) and publishes it onto the substrate viaEventEnvelope(spectral.core.events). - Consumer parses the envelope payload through its own
<EventName>Eventlocal model (an anti-corruption layer per consuming context). - Consumer projects the parsed event into local state (its own aggregates / projections); subsequent reads come from local state.
The consumer never imports the producer’s typed payload class outside tests/contracts/. The bilateral contract test pattern (D6) covers drift detection.
D5. Bridge tools live at apps/*, not in context code
When a future capability needs one context to invoke another (e.g. an Ops Agent → World Agent delegation tool), the bridge implementation lives in a framework deliverable — apps/workers/tools/ for raw LLM tool callables or a new apps/mcp-<bridge>/ for an MCP server. The bridge imports <callee>.contracts.protocols.* (framework-to-context, allowed under validator rule 7) and is composed into the caller agent’s tool list at workers startup. The caller context sees opaque tool callables; it never imports anything from another context.
D6. Bilateral contract tests in tests/contracts/
Inter-context contracts are pinned by tests under tests/contracts/, which are exempt from the inter-context import rule (validator rule 6) so they may import both producer and consumer models. Each inter-context event is covered by:
- a round-trip test verifying the consumer’s local model parses the producer’s
model_dump(mode="json")output; - a schema-drift snapshot test on the producer’s
model_json_schema()output, captured as asyrupysnapshot.
The contract-test tool is syrupy (covered by a separate ADR landing alongside this one).
D7. Imports between contexts forbidden by default; per-exception ADR mint is the sole gate
The architecture validator forbids any import from <context-a> to <context-b>. The single permitted exception is a load-bearing case authorised by an ADR that explicitly names it; the import site carries a # noqa: AUTHORIZED-IMPORT ADR-XXX marker and the validator verifies that docs/decisions/XXX-*.md exists. The ADR mint is the gate, not a PR-statement convention. Routine inter-context information exchange goes through the notification flow (D4) or a framework-layer bridge tool (D5) — neither of which is an import between contexts.
D8. Layout stays at ADR-031 single-library + app leaves
No flip to physical packages-per-context nesting. ADR-031 is unchanged. The inter-context contract surface is namespace-only (<context>.contracts.{events,protocols}), not a separate distribution.
D9. ADR-024’s PR-statement governance is repealed
Structural enforcement (validator rules 1–7 + ruff D100/D104 admission docstring presence) carries the discipline that ADR-024 attempted to carry through PR-statement convention. Routine core additions are gated by per-PR “no clean single-context home” judgment against the docstring rationale. Substrate-kernel additions (core/events/) remain ADR-level. A trigger-driven killer-test re-audit per ADR-069 is the structural backstop against the cumulative drift mode that ADR-024 failed to catch.
Alternatives considered
- Named-exception slot for agnostic Protocols in the kernel. Keep a
core/interfaces/slot for inter-context Protocols with bilateral implementations, withSystemCardReaderas the seed entry. Rejected: SystemCardReader collapses entirely via event-driven projection, so the named-exception slot was protecting against a case that doesn’t exist when the notification flow covers the polymorphism need. - Strict-purist — no inter-context Protocols at all. Same end-state as the chosen doctrine for events; differed on whether
WorldAgentRunnercould exist as a callee-owned Protocol. Resolved by the framework-layer-bridge framing (D5): bridge tools live inapps/*, never in context code, so the import-between-contexts question doesn’t arise for routine cases. The doctrine accepts callee-owned OHS Protocols (D3) as the synchronous-call surface forapps/*consumption while keeping imports between contexts forbidden by default (D7). - JSON Schema artifacts as portable inter-context contracts. The producer’s Pydantic model is the single source of truth; Codex docs auto-generate; bilateral contract tests use
syrupysnapshots onmodel_json_schema()output. A separate<context>.contracts.schemas/directory of committed JSON files would duplicate the source of truth and decay. If a future need for portable schemas materializes (non-Python consumer; external doc tooling), generate on-demand viamodel.model_json_schema(). - Physical packages-per-context nesting (
packages/<context>/). Considered as a flip from ADR-031’s single-library layout; no flip-trigger fired during the orchestration. Layout stays as-is; the inter-context contract surface is namespace-only. - Continuing ADR-024’s PR-statement governance. Failed under examination (the killer test exposed cumulative drift across every type in the kernel). A periodic killer-test re-audit (separate ADR) is the structural backstop; per-PR judgment + structural enforcement carries the rest.
Consequences
spectral.coreis the no-owner-context residue, not a contract layer for either context’s domain. Domain-shaped artifacts leave the kernel completely; typed event payloads relocate to<producer>.contracts.events.*; the lone genuine inter-context OHS Protocol (WorldAgentRunner) relocates toworlds.contracts.protocols.*.- The validator ruleset grows from the current import-direction enforcement to seven rules plus ruff D100/D104 for docstring presence. The seven rules are: (1) clean-architecture layer order within each context (
domain → application → infrastructure); (2) imports between contexts forbidden (D7); (3)<context>.contracts.events.*modules contain only Pydantic payload models + the module docstring; (4)<context>.contracts.protocols.*modules contain onlytyping.Protocoldefinitions + the module docstring; (5) consumer ACL parsing must not import producer payload models outsidetests/contracts/; (6)tests/contracts/exempt from the inter-context import rule; (7) framework-to-context imports allowed (apps/*may import<context>.contracts.*). Phase 4 ref-impl confirmed every rule is implementable in Python AST + ruff configuration without exotic tooling. - The Codex doc generator is new infrastructure that walks
<context>.contracts.events.*and<context>.contracts.protocols.*and emits one Codex page per artifact. It is required before the re-refinement queue (M10) executes. - Bilateral contract tests carry the inter-context schema discipline that previously lived in shared rich types. Producer-rich + consumer-narrow asymmetric typing is intentional; the contract test catches drift the type system cannot.
- Migration scope: three ADRs deleted (this one supersedes them); ~30 retained ADRs and ~14 Codex pages get cross-reference sweeps; AGENTS.md gets two section rewrites; source code restructure relocates typed payloads from
spectral.core.events.signals.<context>.*to<context>.contracts.events.*and relocatesWorldAgentRunnerfromspectral.core.tools.protocolstoworlds.contracts.protocols.world_agent. The full migration sequence (milestones M1–M10) is preserved in git history at the commit set landing the rebuild. - Per-decision supersession of prior ADRs. This ADR’s D2/D3/D5 framings supersede specific D#s in: ADR-014 (shared rich types → producer-typed payloads), ADR-015 (SystemCard Protocol surface →
<context>.contracts.protocols.*), ADR-017 (event placement →<producer>.contracts.events.*), ADR-020 (tournament-scoring shared types → producer-owned), ADR-044 (typed payload location), ADR-055 (event placement), ADR-056 (event placement), ADR-057 (event placement + producer ownership), ADR-060 (WorldAgentRunnerplacement →worlds.contracts.protocols.world_agent), ADR-063 (call-flow Protocol placement), ADR-064 (event placement). The original ADR files remain per Spectral’s whole-ADR-supersession-only doctrine; superseded D#s remain in those files as historical record. - Trade-off accepted: consumer-implements-from-Codex-docs requires the doc generator to ship before any inter-context epic refines new events under the new doctrine. The dependency is named in the migration plan (M4 lands the generator before M10’s re-refinement queue starts).
- Trade-off accepted: producer-rich + consumer-narrow asymmetric typing creates two Pydantic models per inter-context event instead of one. The reference-implementation pressure test (Phase 4) validated that this is ergonomic and that the contract test pattern catches drift cleanly; the cost is bounded.
Addendum: ADR-013 — packages/core boundary definition
ADR-013 (retired by this ADR) defined packages/core as a positive contract layer that admitted “rich Pydantic models with meaningful methods, shared enumerations, and shared protocol definitions” between contexts.
- Preserved: The intent that the kernel hold inter-context contractual material rather than context-specific types — reframed structurally here rather than carried as a per-PR judgment test.
- Rejected: “Rich Pydantic models with meaningful methods” by this ADR’s D1 type-shape constraint. Domain methods belong with the domain; the kernel is type-shape-bounded.
- Inverted: The framing of
packages/coreas a positive contract layer.spectral.coreis the no-clean-single-context-home residue (the killer-test home), not a positive inter-context contract layer. Inter-context contracts live in<context>.contracts.{events,protocols}(D2, D3).
Addendum: ADR-016 — Attribution metadata as opaque envelope in packages/core
ADR-016 (retired by this ADR) generalized an opaque-envelope pattern from a single use case (attribution metadata) into a kernel-wide convention: inter-context payloads flowed through a generic outer envelope with metadata: dict[str, Any] that packages/core did not interpret.
- Preserved: The opacity principle — one context’s payload should not encode another context’s domain concepts. Generalized here by D4: consumers parse with their own local model and never read producer-internal field semantics. The consumer ACL is the modern realization of opacity.
- Rejected: The generic-envelope shape itself. Under D2, typed payloads live in
<producer>.contracts.events.*as the single source of truth with the producer’s domain-meaningful field names; opacity is enforced at the consumer ACL, not at the producer’s wire shape.
Addendum: ADR-024 — packages/core governance
ADR-024 (retired by this ADR) layered a PR-time governance discipline on top of ADR-013: each PR touching packages/core carried a one-line “this exists because the contract requires it, specifically: [reason]” statement, with founder veto authority.
- Preserved: The insight that structural validators cannot enforce intent. That observation is correct and motivates this ADR’s structural-plus-narrow-judgment approach.
- Rejected: PR-statement convention as the discipline mechanism. Every type in
spectral.corehad passed PR-time review under ADR-024 yet failed the killer test under retrospective audit — PR-statement governance produces the appearance of discipline without the substance when the substance requires multi-quarter cumulative-drift detection. - Replaced: D9 substitutes structural enforcement (validator rules 1–7 + ruff D100/D104 admission docstring presence), per-PR “no clean single-context home” judgment against the docstring rationale (replacing the free-form statement), ADR-level treatment for substrate-kernel additions only, and a trigger-driven killer-test re-audit per ADR-069 as the cumulative-drift backstop.
The contract-requirement-test enforcement tool (tools/quality/check_core_contract_tests.py) retires alongside the validator’s new ruleset.