Story 2063: The graph of record's substrate โ€” one door, fail-closed, self-healing

WHY this exists. The Context Engine's L3 validation layer stores what disagrees with what, what supports what, and what happened in order โ€” as a graph on Neptune (RFD-context-engine ยง5.3). But Neptune has no row-level security. Postgres protects one firm's data from another's with RLS the database enforces on every query; Neptune has nothing of the kind. So if a query builder anywhere in the codebase writes a raw traversal and forgets the firm predicate, that query can walk from Firm A's assertion straight into Firm B's contradiction โ€” and a critic agent could cite another law firm's privileged matter into a real brief. This story builds the substrate that makes that impossible by construction: one tenancy-scoped door, a closed schema, and a reconciler that heals the graph back to relational truth when a write is lost mid-flight.

๐Ÿ›ฐ๏ธ Satellite: four files, one package

Everything lives in src/shared/graph/, which the producers and read-tools of CE-9โ€ฆCE-12 will build on:

FileWhat it is
registry.pyThe closed, additive-only schema: node labels (Assertion, Event, Document), the reused edge types, the property allow-lists, the endpoint-shape rules, and the business key that gives each node its MERGE identity. Adding to it is a reviewed change, like the audit registry.
client.pyThe low-level openCypher seam. NeptuneConnection is the real, SigV4-signed HTTPS client; InMemoryGraphConnection is the local/test seam. Both satisfy the GraphConnection protocol.
tenancy.pyScopedGraph โ€” the only door. Bound to one (firm_id, matter_id), it is the single place in the repo allowed to build openCypher, and it injects the tenancy predicates on every node and edge.
reconciler.pyGraphReconciler โ€” re-projects any relational row whose graph node/edge went absent or stale. Relational-truth โ†’ graph, never the reverse.

โœˆ๏ธ Airplane: the analogy โ€” a bank vault with one teller window

Neptune is a vault holding every firm's records in one room. There is no lock on the individual boxes (no RLS). The safety comes entirely from the rule that you may only reach the vault through one teller window (ScopedGraph), and the teller is handed your firm badge when the window opens โ€” not when you make a request. You cannot ask the teller for "Firm B's box" because the teller only ever looks in the box on your badge. A raw traversal is someone climbing over the counter into the vault directly; the CI grep gate is the guard that fires the moment anyone tries. And because a clerk sometimes files the paper copy (Postgres) but drops the vault copy on the way, a nightly reconciler walks the paper records and re-files any vault box that's missing or out of date.

๐Ÿ”ฌ Microscopic: the three acceptance criteria, by the code that satisfies them

1. A cross-firm graph query returns zero โ€” enforced by the bound scope

ScopedGraph takes firm_id and matter_id at construction and never as a method argument. A caller cannot widen its own scope. Every statement it builds carries firm_id: $firm_id, matter_id: $matter_id in the node pattern, and those values ride as bound parameters โ€” never f-string interpolated (the DR-4 / V5 rule; also injection-safe):

MERGE (n:Assertion {assertion_id: $key, firm_id: $firm_id, matter_id: $matter_id})
SET n += $props
SET n.graph_schema_version = $schema_version

So a node written under Firm A keys on (firm-A, matter-1, โ€ฆ); the same get_node issued from a Firm B ScopedGraph keys on (firm-B, โ€ฆ) and finds nothing. The test test_cross_firm_query_returns_zero proves it: Firm A writes an assertion, Firm B โ€” same matter_id, same node key โ€” reads back None and an empty match.

2. The reconciler heals an induced divergence

A ProjectionSource supplies the nodes/edges that must exist (in CE-9 this reads context.assertions; here it's a test seam). reconcile() checks each expected node against the graph and re-upserts anything absent or stale (a property that no longer matches relational truth), then heals edges after nodes so a freshly re-projected node is present for its edges. The test induces the exact ยง5.3 failure โ€” "Postgres row present but node absent" โ€” by writing only node a1 to the graph while truth expects a1, a2, and a CONTRADICTS edge; after one reconcile, a2 and the edge are present, and a second reconcile heals nothing (idempotent). Because upserts are MERGE keyed by the registry business key and deterministic edge identity, re-projection never duplicates.

3. The grep gate in CI

.github/scripts/graph-tenancy-gate.sh (wired by graph-tenancy-gate.yml) is the structural enforcement of "one door." It bans, in product code outside src/shared/graph/: raw openCypher literals (MERGE (, MATCH (, the relationship arrows) and construction of a raw connection. It is case-sensitive so Python's lowercase match x: pattern statement never trips it โ€” a subtlety the test test_gate_ignores_python_match_statement pins. This mirrors the raw-tracer ban (pramaan-docs ADR-124) and the domain-events outbox-only gate.

What this story deliberately does not do

CE-8 is the substrate. The concrete ProjectionSource that reads context.assertions arrives with CE-9 (which owns that table). The live Neptune round-trip is IN-2's proof (pramaan-spec #2056), not this merge gate โ€” locally and in CI the client falls back to the in-memory seam. And the reconciler's persisted jobs-table lease + schedule land with the first real producer: there is nothing to reconcile until assertions are projected, so persisting an empty lease now would be speculative infra. The reconcile mechanism is delivered and tested here.

One-sentence recall. The graph has no RLS, so tenancy is one firm-scoped door you cannot climb around (grep-gated), the schema is a closed registry, and a reconciler heals the graph back to relational truth โ€” the substrate every Context-Engine graph producer builds on.