Skip to content
GitHub
Decisions

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:

  1. Conceptual admission (killer test + functional-area placement). “If spectral.core did 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 the core/<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 under tests/<context>/<area>/ mirroring its source counterpart (e.g. shared LLM test helpers at tests/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.
  2. 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 of self/cls plus 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 via EventEnvelope (spectral.core.events).
  • Consumer parses the envelope payload through its own <EventName>Event local 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 a syrupy snapshot.

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, with SystemCardReader as 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 WorldAgentRunner could exist as a callee-owned Protocol. Resolved by the framework-layer-bridge framing (D5): bridge tools live in apps/*, 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 for apps/* 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 syrupy snapshots on model_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 via model.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 core as a positive inter-context contract layer (rich models, shared enums, shared protocols). Rejected and inverted: spectral.core is 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.core is 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 to worlds.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 only typing.Protocol definitions + the module docstring), consumer-acl-no-producer-payload (consumer ACL parsing must not import producer payload models outside tests/contracts/), tests-contracts-exempt (tests/contracts/ exempt from the inter-context import rule), and app-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) to worlds.contracts.protocols.world_agent; spectral.core keeps 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.