Story 2067: CE-12 graph read tools

WHY this exists (the catastrophe it prevents). A litigator's critic asks the panel one question: "does anything in the record contradict this?" If the answer is a blank because the graph is empty and a blank because the tool was never built look identical, the critic reads "nothing contradicts you" and lets a losing claim go into a brief. The whole Context Engine is built to never confuse no data with no code. These three read tools are where that promise is kept on the graph: they serve real relationships from the Neptune graph of record, and they say live + empty when the matter is genuinely quiet โ€” never the ambiguous silence of a stub.

๐Ÿ›ฐ๏ธ Satellite โ€” where this sits

The Context Engine has four layers; L3 (Validation) is the graph of record โ€” assertions, contradictions, chronology, evidence โ€” living on Neptune (RFD ยง5.3). CE-8 built the substrate: the openCypher client, the closed label/edge registry, and the one tenancy-scoped helper (ScopedGraph) that is the only door to the graph. CE-9/10/11 (still landing) write nodes and edges. This story, CE-12, reads them: three of the ONE tool catalog's seven executors (RFD ยง8), backing the brain-facing DTOs that CE-1 already froze.

โœˆ๏ธ Airplane โ€” the three tools

ExecutorConsumer sentence (RFD ยง8)Reads
find_contradictions(graph, target, k) critic: "does anything contradict this?" CONTRADICTS edges touching target + the grounding spans of both assertions
get_chronology(graph, spans, date_from?, date_to?, k) proposer: "what happened, in order, between the notice and the filing?" (:Event) nodes ordered by the PRECEDES (date-order) basis
get_assertion_evidence(graph, spans, assertion_id) critic: the Why chain โ€” assertion → edges → spans, with a support roll-up the (:Assertion) node, every edge touching it, and its spans

๐Ÿ”ฌ Microscopic โ€” the four design invariants, each titled by its reason

1. Tenancy & matter authz are structural, not a check you can forget

Every executor takes a ScopedGraph โ€” bound to one (firm_id, matter_id) at construction, and it never accepts a firm/matter argument on a method. Neptune has no RLS, so this helper is the tenancy story: the firm/matter predicates come from the bound scope, never from caller input, so a caller cannot widen its own reach. The tool signatures therefore carry no matter_id to disagree with the scope โ€” the caller resolves can_read_matter before it ever builds the scope (ยง8's three authz layers: app-route dep, broker re-resolution, tenancy helper). The cross_matter_scope_sees_nothing test proves it: seed under matter A, read through a matter-B scope on the same connection, get nothing.

2. Bounded query counts โ€” latency is round-trips, not slow SQL (DR-1)

Each Lambda invocation talks to the graph over the network, so what must stay bounded is the number of round-trips, not the rows per trip. Every executor fetches whole matter-scoped sets โ€” one trip per fixed edge type, never one per row (DR-2) โ€” and joins them in memory:

find_contradictions   : 1 edge fetch  (+ 1 grouped node fetch when spans wanted)
get_chronology        : 1 node fetch
get_assertion_evidence: 1 node get + 1 fetch per edge TYPE (4) = 5

The query_count_is_bounded_regardless_of_row_count test pins this: 2 events vs 20 events both cost 1 graph call; 1 edge vs 15 edges both cost 5. Traversal depth is โ‰ค 1 hop, well inside the ยง5.3 โ‰ค 3 bound; k caps the result set.

3. Honesty โ€” live + empty is a real answer, stubbed is not

The backing is built (CE-8), so these executors never return stubbed. A quiet matter is live + empty. The only downgrade is partial, and only for an honest reason: a known event/assertion whose spans cannot be hydrated is dropped, not emitted spanless โ€” "an unspanned assertion does not exist" (ยง5.3). A fabricated span could be cited into a real brief, so the tool would rather admit a degraded leg than invent evidence.

4. The span-hydration seam โ€” the graph stores addresses, not quotes

Think of the graph node as an index card that lists call numbers (span_ids), not the book pages themselves. The byte-exact SourceSpan bodies live in document_pages and are resolved by CE-2's helper. So the executors that must emit spanned DTOs take a SpanResolver the caller injects (not imports) โ€” this story ships the traversal and the seam; CE-2 slots in the real byte-exact resolver. That is also why CE-12 depends only on CE-8: it reads the graph, and hands span bodies off to a collaborator.

One seam fidelity fix in CE-8

Neptune's properties(n) returns the business key (it is part of the MERGE pattern), but the in-memory test seam was dropping it โ€” so match_nodes returned events/assertions with no id. CE-12 is the first match_nodes consumer, so it surfaced here. Fixed in the seam (client.py now stores the key field, fed by the helper) so the local and real clients agree.

Verification

uv run ruff check          # clean
uv run mypy src/shared/context/graph_read_tools.py   # clean
bash .github/scripts/graph-tenancy-gate.sh .          # passed (no raw cypher outside the package)
uv run pytest tests/unit/context tests/unit/graph tests/unit/context_contracts   # 92 passed

No route/DTO changes (contracts were frozen by CE-1), so no OpenAPI snapshot regeneration and no migration.

Recall in one sentence

CE-12 reads the graph of record through the one tenancy-scoped door in a constant number of round-trips, and answers live + empty for a quiet matter so "nothing contradicts you" can never be a silent stub.