Story 2069: Turning scattered dates into a timeline you can walk

WHY this exists. The v1 product is chronology over the record (RFD §1.1). A matter's story is "what happened, in order" — the deed executed, the notice served, the suit filed — and a lawyer must be able to walk it step by step, with the proof for each step one click away. CE-7 stored each document's DocumentCard with its span-carrying dates, and CE-9 typed some of those into assertions. But a card date sitting in one document and a hearing date in another are still just unordered strings: "5 May 2019" here, "1 January 2020" there, with nothing saying which came first or that they belong to one timeline. This story turns those dates — across every document in the matter — into first-class (:Event) nodes on the Neptune graph of record, ordered by deterministic [:PRECEDES] edges. Now get_chronology can return an ordered, span-carrying timeline the Proof-mode surface renders, instead of a bag of dates a human has to sort by hand. The redline (item 2) is emphatic: chronology is a first-class edge type, not a card-date aggregation.

🛰️ Satellite: matter's dates in, an ordered Event graph out

The whole story is src/shared/context/chronology_extractor.py, wired into the registration consumer right after CE-9 normalizes a card. It reads the matter's relational truth (card dates + event-kind assertions), builds (:Event) nodes chained by [:PRECEDES], and upserts them idempotently through the CE-8 tenancy helper — in the one order the RFD fixes (relational is the source, the graph is the projection).

StepWhat it doesRFD
ExtractEvery card date becomes an event (its kind is the description); every event_assertion row with a parseable date becomes one too.§5.3 chronology extraction
GateAn ExtractedEvent cannot be built without an event_date AND ≥1 span — undated / unspanned candidates are dropped at extraction, never projected.§5.3 "an undated or unspanned event does not exist"
OrderSort by (event_date, event_id) and chain consecutive events with [:PRECEDES {basis: date_order}] — the deterministic ordering, materialized as edges.§5.3 deterministic PRECEDES
Project (graph follows)Idempotent openCypher MERGEs through the CE-8 tenancy helper — one (:Event) per event, keyed by a stable event_id, plus the PRECEDES chain.§5.3 write path (2)
No new table. Events are derived, not stored in a relational events table — there is none (RFD §5.3: no dual store to drift). Their source of truth is the context.document_cards card dates and the context.assertions event_assertion rows, both RLS-guarded. So this story ships no migration: the substrate (the Event label and PRECEDES edge) already exists in the CE-8 closed graph registry.

✈️ Airplane: the two acceptance gates, and why each is structural

GateHow it is guaranteed (not just tested)
Ordered, span-carrying timeline on the fixture matter Every (:Event) node carries the span ids of the card date / assertion it came from (the reader hydrates them to byte-exact SourceSpans). Ordering is not a stored opinion: events are sorted by (event_date, event_id) and chained — n events yield n−1 PRECEDES edges, the same order get_chronology re-derives. The integration test seeds two documents in one matter and asserts the four events come back in strict date order, each spanned.
Undated / unspanned events excluded by construction Card dates are dated + spanned by their contract (CardDate.date required, spans min_length=1), so they cannot be malformed. The exclusion bites on the assertion source: assertion_event_from_row returns None — no node ever exists — when value_json has no parseable event_date or the row carries no span. It is a property of the ExtractedEvent type, not a filter someone can forget to apply.

🔬 Microscopic: the deterministic PRECEDES chain

"Deterministic" means the same inputs always produce the same graph — so a re-projection MERGEs in place and never duplicates, and the reconciler can rebuild an identical timeline from relational truth. Two ingredients make it so:

  card dates + event assertions (across the whole matter)
        │  extract (drop undated/unspanned)
        ▼
   [ 2019-05-05  execution_date ]   ev1:…a
   [ 2019-12-01  notice served  ]   ev1:…b     sort by (date, id)
   [ 2020-01-01  hearing_date   ]   ev1:…c   ────────────────────►
   [ 2020-06-01  deadline       ]   ev1:…d
        │  chain consecutive
        ▼
   (a)─[:PRECEDES {date_order}]→(b)─[:PRECEDES]→(c)─[:PRECEDES]→(d)
        │  upsert via ScopedGraph (CE-8 tenancy helper)
        ▼
   get_chronology → ordered, span-carrying ChronologyEvent list
  

Where it runs, and where it goes next

_project_chronology runs in the registration consumer after a card is ingested and normalized, in its own isolated read transaction — a fault is logged, never raised (the document is already registered; the CE-8 reconciler over load_chronology_projection_source re-drives any missed matter). It rebuilds the whole matter's chronology because a new date shifts the timeline order; that per-document rebuild is bounded per invocation (two matter-scoped reads + event-count-bounded upserts) and is superseded by the §9 debounced rebuild consumer (CE-13) that reacts to the coalesced matter_assertions.updated — the same in-consumer projection pattern CE-9 uses today.

One-sentence recall. CE-11 turns a matter's scattered card dates and event assertions into first-class (:Event) nodes ordered by deterministic [:PRECEDES] edges — an undated or unspanned event never exists — so get_chronology can return a timeline a lawyer can walk with the proof for every step.