ADR-065: Contract surface — admission rules and payload pattern
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). The killer test applies to runtime substrate; test substrate (fakes, fixtures, assertion helpers consumed by tests of source code) is categorically outside the kernel’s discipline and lives undertests/<context>/<area>/mirroring its source counterpart (e.g. shared LLM test helpers attests/core/llm/). Sub-namespaces within an admitted functional area for runtime purposes 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.
The type-shape constraint above (frozen Pydantic / Protocol / enum / Literal / Final-constant, no infra SDK imports) binds the pure kernel zone — everything under core/ that is not beneath an infrastructure/ subdirectory. A second zone, core/<area>/infrastructure/ (ADR-099 D4), holds the concrete implementations of the seven functional-area contracts (the classes the pure-zone Protocols describe); the infra zone may import infrastructure SDKs but no bounded context and no domain types (the core-infra-zone-no-context validator rule, per the ADR-097 registry), and the pure zone may not import the infra zone. The infra zone admits only concrete impls of the seven functional-area contracts — not agent-runtime / LangGraph substrate, which stays dissolved into apps/workers (ADR-097 D5).
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 (the validator’s app-context-surface rule) makes apps/* the legitimate consumer.
D4. Inter-context notification flow — events + ACL + projection
This D4 governs the notification-flow mechanism (typed payload, ACL, projection), described below. Inter-context mechanism selection follows the simplest-fit ladder by flow shape (ADR-070): 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 D3 OHS Protocol on escalation when conditions warrant).
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 agent-to-agent delegation tool — the pattern the retired Ops Agent’s ask_world_agent would have used, per ADR-101; with a single agent there is no alpha consumer, but the mechanism stands for a future multi-agent topology), 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 the validator’s app-context-surface rule) 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 (the validator’s tests-contracts-exempt rule) 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 (the validator’s slug-identified rule set per ADR-097 + 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.
- The kernel as a positive contract layer admitting rich Pydantic models with methods. Framing
coreas a positive inter-context contract layer (rich models, shared enums, shared protocols). Rejected and inverted:spectral.coreis the no-clean-single-context-home residue (the killer-test home), not a positive contract layer; domain methods belong with the domain (the D1 type-shape bound); inter-context contracts live in<context>.contracts.{events,protocols}(D2, D3). - A generic opaque-envelope (
metadata: dict[str, Any]) as a kernel-wide convention. Generalizing a single attribution-metadata use case into a kernel-wide envelope the kernel does not interpret. Rejected: the opacity principle is kept but realized at the consumer ACL (D4) — typed payloads live in<producer>.contracts.events.*with the producer’s domain-meaningful names, and opacity is enforced where the consumer parses, not by a generic producer wire shape.
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 rules are identified by stable slug —
clean-arch-layers(clean-architecture layer order within each context,domain → application → infrastructure),inter-context-import(imports between contexts forbidden, D7),contracts-events-admission(<context>.contracts.events.*modules contain only Pydantic payload models + the module docstring),contracts-protocols-admission(<context>.contracts.protocols.*modules contain onlytyping.Protocoldefinitions + the module docstring),consumer-acl-no-producer-payload(consumer ACL parsing must not import producer payload models outsidetests/contracts/),tests-contracts-exempt(tests/contracts/exempt from the inter-context import rule), andapp-context-surface(framework-to-context imports allowed;apps/*may import<context>.contracts.*) — and carried canonically in the rule registry per ADR-097. 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.
- Typed inter-context payloads live in the producer’s contract surface. The restructure relocates typed payloads to
<context>.contracts.events.*and the lone genuine inter-context OHS Protocol (WorldAgentRunner) toworlds.contracts.protocols.world_agent;spectral.corekeeps only the no-owner-context residue. - Trade-off accepted: consumer-implements-from-Codex-docs requires the Codex doc generator to ship before any inter-context epic refines new events under the new doctrine.
- 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 validated that this is ergonomic and that the contract test pattern catches drift cleanly; the cost is bounded.
- The contract-requirement-test enforcement tool (
tools/quality/check_core_contract_tests.py) retires alongside the validator’s new ruleset.