P1-A2 — Citation Ledger entry table + assembly (design)
Date: 2026-06-24
Milestone: Fiduciary-grade agentic legal work — Phase 1 (WS-A)
Branch: feat/fiduciary-p1a2-citation-ledger
Pins: ADR 0018 D1 (thin referencing table), D5 (no-raw-payload / tripwire), D6 (reserved treatment slot), D7 (granularity). Builds on P1-A1 (message_caselaw_citations, merged in #218).
Security review: required (citation/audit-adjacent surface). No new egress.
Problem
P1-A1 made external caselaw quotes character-verifiable. The chat-finalize path now persists three independent per-turn artifacts: message_citations (KB-document quote verification), message_caselaw_citations (caselaw quote verification — P1-A1), and message_tool_sources (retrieval provenance — "sources consulted"). They are disconnected — there is no single, matter-and-turn-scoped record that unifies "every source the turn brought into context and the verification status of each." That unified artifact is the Citation Ledger (ADR 0018 D1), and it is what makes one-click trace (P1-A3) and the fiduciary-grade gate (P1-B1) possible. P1-A2 builds the ledger table and the assembly that populates it; it does not build the read API, the gate, or the UI.
Decisions (maintainer-approved 2026-06-24)
- Three nullable FKs +
source_kind— the entry references exactly one ofmessage_citations/message_caselaw_citations/message_tool_sources(a CHECK enforces exactly-one-non-null). This reconciles ADR 0018 D1, whose hypotheticalcitable_source_idis replaced by the concretemessage_caselaw_citation_id(P1-A1 reused research-opinion storage + a caselaw-citation table instead of creating acitable_source). A short reconciliation note is appended to ADR 0018 D1. - One entry per underlying row, no dedup — a consulted caselaw cluster (a
message_tool_sourcesprovenance row) and a verbatim quote from its opinion (amessage_caselaw_citationsrow) are distinct ledger facts; both get entries (ADR D7: per-(turn, source)). - Defer DE-350 from P1-A2 — assembly is source-kind-agnostic over
message_tool_sources, so generic-MCP entries flow into the ledger automatically once DE-350 lands. DE-350 is sequenced as the immediate next slice after P1-A2 (maintainer directive 2026-06-24). - Metadata-only entry — the ledger holds no
source_text; it references content by id. So it is added to the P3 no-raw-payload tripwire (ADR D5) — the opposite ofmessage_caselaw_citations, which holds quoted text and stays out.
Design
Component 1 — citation_ledger_entry table (migration 0058)
| Column | Type / notes |
|---|---|
id |
uuid pk, gen_random_uuid() |
project_id |
uuid, nullable (a chat may be matter-less) |
chat_id |
uuid, FK → chats.id ON DELETE CASCADE, not null |
message_id |
uuid, FK → messages.id ON DELETE CASCADE, not null (the assistant turn) |
source_kind |
text, not null — 'kb_document' | 'caselaw' | 'mcp' (extensible) |
message_citation_id |
uuid, FK → message_citations.id ON DELETE CASCADE, nullable |
message_caselaw_citation_id |
uuid, FK → message_caselaw_citations.id ON DELETE CASCADE, nullable |
message_tool_source_id |
uuid, FK → message_tool_sources.id ON DELETE CASCADE, nullable |
verification_status |
text, not null — the citation's verification_method ('exact_match'/'tolerant_match'/…), or 'provenance' for a consulted-but-not-quoted tool source |
confidence |
double precision, nullable — mirrored from the citation; null for provenance entries |
provider |
text, nullable — e.g. 'courtlistener'; null for local KB documents |
retrieved_at |
timestamptz, nullable — the tool-source row's created_at; null for KB documents |
treatment_id |
uuid, nullable, no FK — reserved for WS-G derived treatment (ADR D6); always null in P1-A2 |
created_at |
timestamptz, not null, now() |
CHECK constraints:
- exactly one source FK non-null —
(message_citation_id IS NOT NULL)::int + (message_caselaw_citation_id IS NOT NULL)::int + (message_tool_source_id IS NOT NULL)::int = 1. confidence IS NULL OR (confidence >= 0 AND confidence <= 1).
Indexes: chat_id, message_id, project_id.
ADR 0018 D1 also sketched a tier column; dropped in v1 — message_tool_sources does not carry a tier, so it cannot be populated honestly. A follow-on if a tier source materializes.
Component 2 — Assembly
assemble_ledger_entries(db, *, project_id, chat_id, message_id) -> int in a new module api/app/citation/ledger.py:
- Query
message_citations,message_caselaw_citations, andmessage_tool_sourcesformessage_id. - Build one
CitationLedgerEntryper row:message_citations→source_kind='kb_document',message_citation_idset,verification_status = row.verification_method,confidence = float(row.verification_confidence),provider=None,retrieved_at=None.message_caselaw_citations→source_kind='caselaw',message_caselaw_citation_idset,verification_status = row.verification_method,confidence = row.verification_confidence,provider='courtlistener',retrieved_at = row.created_at.message_tool_sources→source_kind = row.source_kind(already'caselaw';'mcp'once DE-350 lands),message_tool_source_idset,verification_status='provenance',confidence=None,provider = row.provider,retrieved_at = row.created_at.
add_all+flush. Returns the entry count.
message_citations.verification_confidence is a Decimal; coerce to float for the ledger's confidence. Only verified message_citations / message_caselaw_citations rows exist (the persist steps drop unverified ones), so every citation-backed entry has a real method + confidence.
Component 3 — Hook
A sibling call at chat finalize, after the citation/tool-source persist steps (so all rows exist), at every finalize site that persists citations — the streaming tool-loop site, the resume site, and the single-shot/non-tool site. (P1-A1 wired only the two tool-loop sites because only those produce caselaw sources; the ledger applies to KB-document chats too, so it must also cover the single-shot path where only message_citations rows exist.) The plan enumerates the exact sites in api/app/api/chats.py. Each call is guarded in try/except that logs and never re-raises (a ledger failure must not break the turn). The assembly reads project_id from the chat already in finalize scope. Because it simply maps whatever rows exist for the message, no per-site branching on source kind is needed.
Component 4 — P3 tripwire (ADR 0018 D5)
Add CitationLedgerEntry to api/tests/test_transparency_invariants.py's no-raw-payload model scan. The entry carries only ids, labels, a numeric confidence, provider, and timestamps — no content — so the scan must pass with it included, proving the ledger is a metadata index, not a content store.
Error handling (conservative posture)
- Assembly is guarded at every finalize site — a failure logs and degrades to "no ledger this turn," never aborting the assistant turn/stream.
- A row with an unexpected shape is skipped, not fatal.
- No new egress, no gateway/LLM call — pure DB reads + writes.
Testing
- Integration (real Postgres): a turn with (a) a
message_citationsrow, (b) amessage_caselaw_citationsrow, and (c) amessage_tool_sourcesrow →assemble_ledger_entrieswrites three entries with the correctsource_kind, the correct single FK set, and the mirroredverification_status/confidence/provider/retrieved_at. Assert the exactly-one-FK CHECK rejects a 0-FK and a 2-FK insert. Assert a KB-only turn (onlymessage_citations) yields onekb_documententry. - Transparency invariant:
test_transparency_invariants.pystays green withCitationLedgerEntryadded to the scanned set. - No provider gating (pure DB). Run via host venv + throwaway pgvector.
- Gates:
ruff format+ruff check+mypy app+ fullpytest(coverage no-decrease).
Acceptance criteria
citation_ledger_entryexists (migration0058) with the exactly-one-FK CHECK and the three nullable source FKs.- At chat finalize, every persisted
message_citations/message_caselaw_citations/message_tool_sourcesrow for the turn yields exactly one correctly-typed ledger entry. CitationLedgerEntryis in the P3 tripwire scan and the test passes (proves metadata-only).- A ledger failure never breaks the turn.
ruff+mypyclean; integration + invariant tests green.
Out of scope / sequencing
- DE-350 (generic-MCP provenance) — the immediate next slice after P1-A2 (maintainer directive). Once it emits
source_kind='mcp'rows onmessage_tool_sources, those flow into the ledger with no ledger change. - Ledger read API + one-click trace → P1-A3. Fiduciary-grade gate → P1-B1. UI → P1-C1. Treatment population (
treatment_id) → WS-G. - ADR 0018 D1 gets a one-line reconciliation note (
citable_source_id→message_caselaw_citation_id); the decision itself is unchanged.