Story 2057: Where the Context Engine's facts live — and the gate that keeps them honest

WHY this exists. CE-1 (Story-2052) froze the shapes of the Context Engine — cards, assertions, bundles, all pointing at a byte-exact source_span. But a shape is not a home. Without storage, three catastrophes wait. First, cross-tenant leakage: two firms' matters share one Postgres, so a card or assertion table with no row-level security is a door welded open — firm B reads firm A's privileged analysis. Second, trusted-by-birth facts: an assertion written straight to canonical is a machine's guess promoted to record with no confirmation — exactly how a hallucinated "admission" ends up in a brief. Third, and the one that ends the company: a span that lies — a stored quote that is not actually the bytes on the cited page, cited into a real filing in a real court. CE-2 builds the home (four tenant-isolated tables + one job ledger) and the byte-exact gate (span_resolution) that makes the third catastrophe structurally impossible.

🛰️ Satellite: a new schema, four tables, one resolver

A fresh context Postgres schema holds the engine's durable state: document_cards (L1 understanding, RFD §5.1), assertions + the global assertion_attributes vocabulary (L3 validation's relational projection, §5.3), and bundles (L4 assembled-bundle cache, §5.4). Alongside them, ops.context_bundle_jobs is the event-driven rebuild queue (§9). Every firm-owned table clones the exact RLS posture of documents.document_pages — firm isolation + staff-with-grant read, FORCEd. The byte-exact span resolver (src/shared/context/span_resolution.py) reuses the LIVE V-CITE primitive so there is one byte-verification semantic in the codebase, not two that can drift apart.

✈️ Airplane: the analogy — a bonded evidence room

Picture the courthouse evidence room. Every exhibit sits in a firm's locked cage (RLS: firm_id = app.current_firm_id_or_null()) — you cannot even see another firm's shelf. Nothing is filed as admitted until a clerk signs it in: an assertion is born provisional and can only become canonical if it records who confirmed it and when (a DB CHECK enforces it — you cannot forge admission by setting a flag). And the intake clerk physically compares the exhibit's tag against the document on the shelf before it is accepted: the span resolver slices the cited byte range out of the real page and demands it equal the claimed quote, byte for byte, no case-folding — "a court reads the bytes." A mismatch is not filed; it is bounced back for review.

          app request  ──set RLS──▶  app.current_firm_id
                                         │
   ┌─────────────────────────────────────┴───────────────────────────┐
   │  context (firm-isolated, FORCE RLS)          ops (staff relay)    │
   │  document_cards ── card JSONB + pages_hash    context_bundle_jobs │
   │  assertions ───── trust cols + spans + fact_key   (1 pending/     │
   │      │ attribute FK                                 matter =       │
   │      ▼                                              coalescing)    │
   │  assertion_attributes (GLOBAL closed vocab, no RLS, app SELECT)   │
   │  bundles ──────── version_hash + watermarks + stale               │
   └───────────────────────────────────────────────────────────────────┘
                 every span ▼ verified before it is trusted
      span_resolution.resolve_span()  →  slice page bytes == quote bytes?
                 (reuses vcite.quote_is_byte_present — ONE semantic)

🔬 Microscopic: the three welds that matter

1. Tenancy is fail-closed, cloned not reinvented. Each firm-owned table gets the identical two policies as document_pages, applied by one helper so they cannot drift:

CREATE POLICY firm_isolation_policy ON context.assertions FOR ALL
    USING (firm_id = app.current_firm_id_or_null())
    WITH CHECK (firm_id = app.current_firm_id_or_null());
-- + staff_with_grant_policy (SELECT, only with a live ops.firm_support_grants row)
-- Derived/re-enrichable rows are upserted whole, never deleted:
REVOKE DELETE ON context.assertions FROM pramaan_app;

2. Facts are born untrusted, and a span is mandatory. Two CHECKs on context.assertions encode the L3 trust law and provenance-is-law directly in the schema:

-- born provisional; canonical only WITH a confirmation timestamp
CHECK (confirmation_status = 'provisional' OR confirmed_at IS NOT NULL)
-- an unspanned assertion does not exist (RFD §4)
CHECK (jsonb_typeof(spans) = 'array' AND jsonb_array_length(spans) >= 1)
-- fact_key is a real sha256, so identical facts collide (SUPPORTS candidate)
CHECK (length(fact_key) = 64)

3. Byte-exactness, reusing V-CITE. The resolver has two modes on one coordinate system, and fails closed in every drift case:

# char offsets present → EXACT byte range: slice and compare bytes
sliced = anchored_text[span.char_start : span.char_end]
verified = sliced.encode("utf-8") == span.quote.encode("utf-8")
# offsets absent → byte-PRESENCE via the live V-CITE primitive (one semantic)
verified = quote_is_byte_present(span.quote, anchored_text)

The anchored text is the block when block_index is set, the whole segment when it is None — "one coordinate system, never mixed" (§4). A wrong page, a wrong document, an out-of-range block, drifted offsets, a case-folded quote, or an empty quote each return a typed SpanResolution(verified=False, reason=…) with a low-cardinality reason code — never the raw quote or page text (ADR-124).

What changed

FileWhat it owns
alembic/versions/…0149_context_engine_cards_bundles_assertions.pyThe whole migration: the context schema; document_cards, assertion_attributes (seeded), assertions, bundles; ops.context_bundle_jobs; RLS, grants, CHECKs, firm-leading + candidate-blocking indexes; full down-migration.
src/shared/context/span_resolution.pyThe byte-exact SourceSpan → document_pages resolver, reusing vcite.quote_is_byte_present; resolve_span/resolve_spans/unresolved + typed reason codes.
src/domains/context_bundle_job/entity.pyORM mirror of ops.context_bundle_jobs so alembic check sees the autogenerate-owned ops table. (The context.* tables are hand-written, excluded from autogenerate like agents/extraction.)
alembic/env.pyImports ContextBundleJob into the metadata.
tests/unit/test_context_span_resolution.py13 pure byte-exactness cases: exact/multibyte/block-anchored verify; drift/offset/page/doc/block/empty/case-fold fail closed.
tests/unit/test_context_engine_migration.pyCI-without-DB static guards pinning schema creation, RLS, REVOKE DELETE, the global registry, trust/span/fact_key gates, bundle axes uniqueness, and the coalescing index.
tests/integration/test_context_engine_rls.pyReal-Postgres system-boundary proof (runtime pramaan_app role).

Acceptance mapping

CriterionWhere it is proven
alembic up/down/up + alembic checkmake test-db-alembic-roundtrip on a fresh volume: upgrade head → downgrade base → upgrade head → alembic check = "No new upgrade operations detected."
cross-firm zero-rows RLS testtest_context_engine_rls.py::test_context_engine_rls_and_coalescing — firm A writes a card/assertion/bundle/job; firm B reads 0 across every table (and its own tables are an empty 0 on first run, not an error). Bonus: the coalescing partial-unique index rejects a second undispatched job per matter.
span helper byte-exactness testtest_context_span_resolution.py — 13 cases; the exact-offset match verifies, the drifted quote at the same offsets fails closed, multibyte (₹) slices byte-exact, and presence-mode reuses V-CITE.

Design decisions worth naming

Deferred (sibling stories, not this Story)

CE-2 is storage + the resolver. The card ingest boundary that calls the resolver fail-closed (CE-7), context.chunks + pgvector (CE-3), the retrieval service (CE-5), the Neptune substrate and graph projection (CE-8…CE-11), the bundle builder that writes context.bundles and drains context_bundle_jobs (CE-13), and the cite endpoint's resolve-time re-verification (CE-17) are their own stories. No ORM entities for the context.* tables are added here — the query layer arrives with its consumer.

Verification

make test-db                                        # fresh Postgres 16 on :55432
make test-db-alembic-roundtrip                     # up/down/up + alembic check — clean
ruff check src/ tests/                             # CI scope — clean
mypy src/shared/context/span_resolution.py src/domains/context_bundle_job/entity.py  # clean
pytest tests/unit/test_context_span_resolution.py \
       tests/unit/test_context_engine_migration.py \
       tests/integration/test_context_engine_rls.py -q   # 28 passed
make openapi                                        # no drift — no route/DTO change

Recall in one sentence: CE-2 gives the Context Engine's facts a tenant-isolated home where assertions are born untrusted and a span is mandatory, and a byte-exact resolver — reusing the one V-CITE semantic — that refuses to let a stored quote lie about the page it cites.