Everything lives in src/shared/graph/, which the producers and
read-tools of CE-9โฆCE-12 will build on:
| File | What it is |
|---|---|
registry.py | The 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.py | The low-level openCypher seam. NeptuneConnection is the real, SigV4-signed HTTPS client; InMemoryGraphConnection is the local/test seam. Both satisfy the GraphConnection protocol. |
tenancy.py | ScopedGraph โ 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.py | GraphReconciler โ re-projects any relational row whose graph node/edge went absent or stale. Relational-truth โ graph, never the reverse. |
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.
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.
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.
.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.
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.