Story 2052: The Context Engine's contracts β€” one seam, span-carrying, versioned, pure

WHY this exists. The Context Engine has many producers (Blake enriches cards, a normalizer writes assertions, a judge writes graph edges, a builder assembles bundles) and many consumers (Mira's four-role panel, Vega, the app's cite click-through). If each invents its own JSON shape, two catastrophes follow. First, provenance leaks: a producer emits a naked string β€” "the respondent admitted the debt" β€” with no pointer to the page it came from, and a downstream brain cites that unsupported sentence into a real brief filed in a real court. Second, the ORM leaks into the brains: if the contract imports sqlalchemy, every agent that reads a citation drags a database driver it can never use. CE-1 is the one shared seam that forbids both by construction β€” before any store, migration, or executor is built.

πŸ›°οΈ Satellite: the atom the whole engine points at

Everything in the engine β€” every card field, every assertion, every graph node and edge, every bundle item, every cited answer β€” points at ONE shape: source_span.v1. A span is a byte-exact address into documents.document_pages (never the synthesised document.md): a document, a page, a paragraph, a character range, and the verbatim quote those bytes contain. Build on that atom four layers of typed DTOs β€” cards (L1), evidence hits (L2), assertions and graph edges (L3), bundles (L4) β€” plus the seven tool result envelopes and the honesty types (EngineState, context_provenance). All of it lives in src/shared/context_contracts/, a package with no sqlalchemy, no boto3, no settings: brains import it as the single source of contract truth; the executors that fill it in live functions-side and arrive in sibling stories (CE-2…CE-24).

✈️ Airplane: the analogy β€” an exhibit tag on every fact

Think of a courtroom. Nothing is "in the record" until it carries an exhibit tag pointing at the exact page and line it came from; an assertion with no tag is inadmissible β€” the judge will not read it. SourceSpan is that exhibit tag. CitedValue[T] is a fact that cannot be filed without one: its spans field is min_length=1, so a cited value with an empty span list fails to construct β€” the "no naked strings" rule is enforced by the type system, not a downstream reviewer who might forget. The single carve-out is NonCitable (the card summary): display garnish, explicitly typed as tag-free, and by that same typing barred from ever entering a bundle item or a graph node as evidence.

            documents.document_pages  (the real record, byte-exact)
                        β–²
                        β”‚  span_id = "sp1:{doc}:{page}:{block}:{start-end}"
                        β”‚  + verbatim quote  (V-CITE byte match)
                   SourceSpan  ──────────── the exhibit tag
                   β–²    β–²    β–²    β–²    β–²
        CitedValue β”‚    β”‚    β”‚    β”‚    β”‚  spans: min_length=1  (LAW)
       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”  β”Œβ”΄β”€β”€β”€β”€β”΄β”  β”Œβ”΄β”€β”€β”€β”€β”€β”€β”΄β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
       β”‚DocumentCardβ”‚  β”‚Asser-β”‚  β”‚Chrono-  β”‚  β”‚EvidenceHitβ”‚
       β”‚  (L1)      β”‚  β”‚tion  β”‚  β”‚logyEventβ”‚  β”‚  (L2)     β”‚
       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚AssertionEdge (L3) β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                        assembled into β–Ό
                  ContextBundle (L4) ── sections carry EngineState + truncated

πŸ”¬ Microscopic: how each invariant is welded in

The whole package is thirteen small files; there is no logic to run, only shape to freeze. Each freeze is one line of Pydantic:

# _base.py β€” the frozen wire model every DTO extends
model_config = ConfigDict(extra="forbid", populate_by_name=True)
#   extra="forbid"  β†’ an unknown field is contract drift, raises on validate

# source_span.py β€” the version pin + the provenance law
schema_version: Literal["source_span.v1"] = "source_span.v1"
#   a wrong version string ("source_span.v2") is rejected by the Literal
class CitedValue[T](ContractModel):
    spans: list[SourceSpan] = Field(min_length=1)   # empty list β†’ ValidationError

# bundle.py β€” one section, mixed item types, still typed
BundleSectionItem = DocumentCard | ChronologyEvent | EvidenceHit | Assertion | ContradictionItem
#   pydantic's smart union re-hydrates each item to its OWN type through JSON β€”
#   distinct schema_version literals / required fields make discrimination safe

"Additive-only versioning" is not a comment, it is the shape: every carrier pins a schema_version Literal; new fields land as optional-with-default within a vN; a meaning change forks vN+1 beside the old one. And purity is proven, not asserted: a fresh interpreter imports the package and fails the test if sqlalchemy or boto3 ever appears in sys.modules β€” transitive leaks caught, not just direct imports.

What changed

FileWhat it owns
context_contracts/_base.pyThe pure ContractModel base: extra="forbid", snake_case, no store imports.
context_contracts/source_span.pysource_span.v1 β€” the provenance atom β€” plus CitedValue[T] (spans min_length=1) and the NonCitable alias (RFD Β§4, Β§5.1).
context_contracts/engine_state.pyEngineState (stubbed/partial/live) and context_provenance.v1 β€” the honesty mechanism (RFD Β§7).
context_contracts/enums.pyClosed vocabularies: date precision, assertion kind, confirmation status, edge type, support status, retrieval leg, profile/budget/role literals, section kind.
context_contracts/document_card.pydocument_card.v1 + sub-models (dates, parties, amounts, key_assertions, enrichment stamp) β€” L1 (RFD Β§5.1).
context_contracts/assertion.pyassertion.v1 and the single assertion_edge.v1 view covering all five closed edge types β€” L3 (RFD Β§5.3).
context_contracts/chronology.pychronology_event.v1 β€” first-class timeline, span-required (RFD Β§5.3 redline item 2).
context_contracts/views.pyEvidenceHit and ContradictionItem β€” shared by bundle sections and tool results (broke a bundle↔tools import cycle).
context_contracts/bundle.pycontext_request.v1, context_bundle.v1, BundleSection, BuildWatermark β€” L4 assembly (RFD Β§5.4).
context_contracts/tools.pyThe seven tool result envelopes on a shared ToolResponse (EngineState on every envelope).
context_contracts/__init__.py, AGENTS.mdFlat re-export surface + package doctrine (purity, additive versioning, provenance-is-law).

Acceptance mapping

CriterionWhere it is proven
round-trip teststest_contracts_roundtrip.py::test_roundtrips_through_json β€” every DTO (one representative each) survives model_dump_json β†’ model_validate_json equal; test_bundle_section_union_rehydrates_each_item_to_its_own_type proves the mixed-type section union.
version-discipline teststest_schema_version_default_is_the_pinned_literal + test_wrong_schema_version_is_rejected (every versioned carrier) + test_unknown_field_is_rejected (extra="forbid") + test_cited_value_requires_at_least_one_span / test_assertion_requires_at_least_one_span (provenance-is-law).
no sqlalchemy import (CI-checked)test_purity.py β€” a fresh-interpreter import asserts no store module leaks into sys.modules, and an AST scan bans the direct import lines. Runs in CI's pytest.

Deferred (sibling stories, not this Story)

CE-1 is contracts only. The stores and RLS (CE-2), the chunker + embed worker (CE-3), the retrieval + rerank service (CE-5), the Neptune graph substrate and tenancy helper (CE-8), the normalizer/judge/chronology writers (CE-9…CE-11), the bundle builder (CE-13), the broker pre-fetch (CE-14), and the cite endpoint (CE-17) all consume these shapes and are their own stories. A deliberate omission here: the span_id compose helper and byte-exact resolution belong with the store (CE-2's V-CITE reuse), not the pure DTO package.

Verification

uv run ruff check src/shared/context_contracts tests/unit/context_contracts
uv run ruff format --check src/shared/context_contracts tests/unit/context_contracts
uv run mypy src/shared/context_contracts          # local-only per AGENTS.md
uv run pytest tests/unit/context_contracts -q     # 55 passed
make openapi                                       # no drift β€” DTOs wired to no route yet

Recall in one sentence: CE-1 freezes the Context Engine's one shared seam β€” every fact carries a byte-exact exhibit tag it cannot be constructed without, every carrier pins its version, and the package stays pure so a brain can read a citation without importing a database.