eliziff brings Canadian and US legal sources closer to home

This fork shifts legal research toward local, indexed source material and spends real effort keeping retrieved authorities intact.

searchinfrastructure

eliziff adds local imports for Canadian open legal data, including Ontario legislative debates, alongside CourtListener's US case-law collection. CourtListener is a public legal research archive. The aim is clear: search the material already held locally before reaching for a live source.

  • Canadian legal-data imports, including searchable Ontario Hansard material in the research chat.
  • Local US case-law imports from CourtListener, without requiring an access key.
  • A cache for downloaded authorities, reducing repeat retrievals from public sources.
  • Better support for court sites using a common publishing platform, including links that render correctly on mobile.

The less visible work may matter most: fixes protect headings and paragraph order in difficult decisions, backed by repeatable checks that catch parser regressions. Litigation teams and legal-product builders who need dependable source text, especially across Canadian and US research, should take a look.

So what Local-first research can mean fewer brittle dependencies and more confidence that the authority on screen matches the authority retrieved.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

23 commits from eliziff/Beaver, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
6b9b95d0 Integrate Codex and A2AJ bridges Eli Ziff 2026-07-25 ↗ GitHub
80901b45 feat: add indexed A2AJ pinpoint lookup Eli Ziff 2026-07-26 ↗ GitHub
1da1a1bd feat: add provider-neutral legal source lookup Eli Ziff 2026-07-26 ↗ GitHub
d8234eb5 refactor: delete dead CourtListener logger, TNA builder, unreachable branches Eli Ziff 2026-07-27 ↗ GitHub
commit body
logRawOpinionPayload had zero call sites and was the only consumer of
the fs/path imports; buildTnaPinpointUrl had no production caller
(superseded by provider-native anchors); opinionText's html/plain_text
branches were unreachable because compactOpinion already sets text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QUsMXzBsEJ27DjJVPSfW5o
3de14d98 fix: generalize the SCC iframe workaround to all Decisia deployments Eli Ziff 2026-07-27 ↗ GitHub
commit body
Live probes across 14 Decisia/Norma hosts (FCA, FC, TCC, ONCA, NSCA,
CIRB, SST, CART, CMAC, CT, FPSLREB, CITT, CHRT, SCC) show the identical
behaviour Beaver only handled for decisions.scc-csc.ca: the default URL
is an iframe shell with no document text, so anchors and text fragments
silently no-op; ?iframe=true serves the inline document with name=parN
anchors. One host+path predicate replaces the hostname special case,
the 22-entry LEXUM_PARAGRAPH_DATASETS table (wrong in both directions),
isLexumParagraphSource, and the scrollableLexum option. The flag now
applies for every locator kind and every caller, not just A2AJ
paragraph lookups. site_preference=mobile is dropped: unnecessary and
it pinned a persistent layout cookie on the court's whole site.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QUsMXzBsEJ27DjJVPSfW5o
09825524 fix: restore site_preference=mobile on Decisia links Eli Ziff 2026-07-27 ↗ GitHub
commit body
Removing it broke the feature: in the desktop rendering a text-fragment
jump locks the viewport on the matched text and the page cannot scroll.
mobile is load-bearing for scrollable pinpoints, and the preference
cookie is irrelevant because users only reach these URLs through
Beaver's deep links. Comment now records this so no future cleanup
repeats the mistake. The 14-host Decisia generalization stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QUsMXzBsEJ27DjJVPSfW5o
c1142333 feat: SourceDoc core and A2AJ compiler (P1.1a stages 1-2) Eli Ziff 2026-07-27 ↗ GitHub
commit body
One immutable content-hashed artifact per fetched source: text, lazy
token spans, ordered blocks with native/heuristic origin, and a
locator index. Providers are compilers, consumers are queries.

Every fixture shape was captured live from api.a2aj.ca and two of the
brief's claims died on contact: the 'inline heading marker' SCC shape
is really a table-of-contents block (which halves the paragraph spine
- recorded in ranges.missing), and Oakes 1986 is dot-numbered, not
unnumbered (R. v. Duarte 1990 is the true abstain case). Newly
indexed: federal bold-emphasis sections (Criminal Code 0 -> 11,165
blocks live), alphanumeric regulation sections (CRC 870 0 -> 7,911),
Ontario out-of-order cross-reference traps. Two section-map nesting
bugs fixed and pinned: Number('01')===Number('1') dropped s.231(6.1),
and (C)/(D) parsed as Roman numerals. Byte-identical parity with the
legacy engine everywhere legacy produced anything.

Compile: 2.26 MB Criminal Code in 29-39 ms; tokenization deferred to
a memoized getter (144 ms, first quote query only).

Browser gate (scripts/deeplink-gate.mjs) run on the generated URLs:
Decisia anchors and text fragments land (including anchor-less 2001
SCC 1, where the fragment alone jumps - anchor availability is
per-document); A2AJ's raw-XML and JSON-API source URLs are proven
non-pinpointable (fragment never jumps) and recorded as negative
controls for the stage-3 host/anchor table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QUsMXzBsEJ27DjJVPSfW5o
0dbe0bfe refactor: compact courtlistener fallback chains and bulk lookup Eli Ziff 2026-07-27 ↗ GitHub
commit body
firstString() collapses the asString ?? asString chains (order
preserved per chain - API and bulk paths intentionally differ),
attachOpinionStructure() dedupes the WeakMap + structure tail shared
by API and R2 opinion mapping, failRow() replaces four identical
error-row pushes, and the sanitizer allowlists collapse to split
strings with identical contents. -165 lines; wire shapes untouched.

Verified: tsc clean; courtlistenerLocalBulk, docxCitationLinking,
legalSourceStructure suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QUsMXzBsEJ27DjJVPSfW5o
0214b47c feat: a2aj ontario hansard local import and search Eli Ziff 2026-07-27 ↗ GitHub
commit body
Imports huggingface.co/datasets/a2aj/hansard (Ontario, 1.35M interventions)
into the local SQLite legal-data plane via scripts/import_a2aj_hansard.py;
a2ajHansard.ts exposes FTS search and fetch over interventions. Tests run
the real import script over captured fixture rows, no network.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yXxuitxX5h4wm1v88hnga
a78e30c2 feat: courtlistener local bulk import for keyless US caselaw Eli Ziff 2026-07-28 ↗ GitHub
commit body
Add fetch_courtlistener_bulk.py: downloads CourtListener's official,
no-auth bulk CSVs (citations 127 MB, opinion-clusters 2.46 GB in full)
and a deterministic HTTP-Range prefix of the 54.6 GB opinions file,
cut at the last complete CSV record with a quote/escape-aware scanner.
The existing import_courtlistener_bulk.py loads the slice unchanged.
Measured: 2.68 GB downloaded, 30m26s import, 5.87 GB SQLite with
18.1M citations, 10.07M clusters, 17,343 full-text opinions; citation
lookup, case-name search, and opinion retrieval all serve locally with
no COURTLISTENER_API_TOKEN.

Test drives the slicer over a truncated multi-stream bz2 fixture via
file:// and the real importer, proving slicer, importer, and reader
agree on schema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yXxuitxX5h4wm1v88hnga
f8b118b2 Wire the orphaned Hansard plane as research chat tools Eli Ziff 2026-07-28 ↗ GitHub
commit body
hansard_search / hansard_fetch expose lib/a2ajHansard.ts (store,
importer, and tests existed; the read path was imported by nothing).
Registered in the research-gated group so sealed benchmark runs
never see it; typed hansard_not_installed reply when no database
has been imported. Tool-layer tests drive the real import script
over the captured fixture rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018bhP89cYSdEEjHNPnxacns
ed15c59d Downloaded-authority content cache over CourtListener and public sources Eli Ziff 2026-07-28 ↗ GitHub
commit body
Generalizes the parseCache pattern (evaluation-context plan §12) to
a keyed, TTL-bounded, atomically written cache and threads it
through the two authority fetch seams that had only per-session
in-memory caching: CourtListener API GETs (24h ceiling, token never
in the key) and public legal source text/JSON fetches (7d/24h).
A2AJ already had its own persistent response cache. Stands aside
under NODE_ENV=test so fetch-stubbing suites keep their call
assertions; failures fall through to real fetches.

Chunks/embeddings/retrieval-result/preview caches from the plan's
menu stay unbuilt: no live consumer exists for them yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018bhP89cYSdEEjHNPnxacns
fb644886 docs: CLAUDE.md - local CourtListener bulk is on disk, local before network Eli Ziff 2026-07-30 ↗ GitHub
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012AgUm5EcRKbT3duVFMbrQD
9990823b retrieval: product passage lane over the A2AJ bulk store Eli Ziff 2026-07-30 ↗ GitHub
commit body
searchLocalA2AJPassages puts passageRetrieval on the product search
path additively - searchLocalA2AJ and every existing export are
unchanged. Two lanes, deterministic first: citation_lookup exact
matches on citation-shaped substrings of the query prepend those
documents' best passages ahead of the bm25 ranking; identity beats
ranking. Results carry document metadata joined from the source db and
verbatim char offsets.

The 5.5 GB-scale sidecar is never built inline (a build would hang a
user request): a missing index is a typed MissingPassageIndexError
naming scripts/build-passage-index.ts, the one place it is built.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012AgUm5EcRKbT3duVFMbrQD
53d3bed8 perf(a2ajPassageSearch): narrow + memoize the per-hit metadata row Eli Ziff 2026-07-31 ↗ GitHub
commit body
The product passage lane pulled `SELECT * FROM document WHERE id = ?`
once per pooled hit. That row carries unofficial_text_en and _fr, both
unofficial_sections maps and both cases_cited lists - hundreds of KB -
and the loop keeps a citation, a name, a date, a URL and a dataset, all
short strings. The passage text itself comes from the hit, which
searchPassages already sliced, so the bodies on that row were never
read at all. Worse, the dedupe key is docId:start, so a document that
supplies many hits (perDocCap 24 under rerank) refetched its whole
payload once per hit.

Same defect as 17884220, one module over: it was fixed for the text
lookup in passageRetrieval and left standing here.

Narrow column list + a per-call memo. Same rows, same values, same
ordering; the memo dies with the call.

Measured on 300 LegalBench mini queries (size 8, rerankHits 48) against
a 69-document / 106 KB-mean bulk db: 157.93 -> 54.92 ms/query, 2.9x.
Unchanged-output proof: sha256 of every returned A2AJPassageResult[] is
byte-identical over all 300 queries (digest of digests
e4f136fc8a327666447bafea8f873ecdda3aa72c9bf1a6e26d5410d56dadff31). The
production A2AJ corpus populates both languages plus the section maps,
which this bed leaves empty, so the real saving is larger.

28 tests pass across a2ajPassageSearch, a2ajPassageWiring,
passageRetrieval.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9ToHYJVDxfeJcJwdzrP2H
bd8bb49d Add A2AJ name lookup metadata Eli Ziff 2026-07-31 ↗ GitHub
05917012 Scope the segmentation recovery out of the A2AJ lane by construction Eli Ziff 2026-07-31 ↗ GitHub
commit body
Inert-by-measurement is not scoped. Two independent differentials say the
recoveries do not move legislation - 23,531 statutes / 2,924,267 nodes
byte-identical, and a 6,129-document sweep whose 114 skeleton changes are
ALL LegalBench-RAG (cuad 79, maud 35), zero in statutes, cases, amending
acts or DOCX - but a measurement is evidence about today's corpus, not a
guarantee about tomorrow's.

`sourceDocA2AJ` never calls `compileAgreementSkeleton`, so the bulk
legislation compiler was always out of reach. The live path that is not is
`passageRetrieval.ts` in clause mode, where `ensurePassageIndex` feeds A2AJ
`unofficial_text_en/fr` for doc_type laws straight in. That call now passes
`recoverExtraction: false`.

A2AJ ships the publisher's line breaks, so there is no extraction damage to
recover there and nothing to compete: legislation and case law keep the
structure the hardened statute and paragraph work gives them, because the
new code does not run at all. The option defaults to true for the Library
lane, which compiles PDF- and DOCX-derived agreements and is what the
recoveries exist for. Contract numbers are unchanged: mini 16 refused /
4,414 resolved / 9.1% missing.

Also recorded from the same sweep, the soundness invariant of the space-run
recovery: zero of 6,129 documents changed skeleton output without containing
an internal run of 2+ spaces, and 5,516 of them contain none.

Rejected on the way, with its cost, so it is not retried: gating instead on
"a statute spine wins" is blunt and charges contracts whose page-number runs
fake a spine (mini 16 -> 21 refused, holdout 9 -> 13); conditioning that on
the spine reading also addressing 50% of the document's own references gets
the cost down to 5 and 85 resolved but is a heuristic, not a scope, and
Eli asked for a scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9ToHYJVDxfeJcJwdzrP2H
cf857fa4 fix(a2aj): recover joined CITT heading paragraphs Eli Ziff 2026-08-02 ↗ GitHub
b2d72ae8 fix(a2aj): enforce contiguous case paragraph spines Eli Ziff 2026-08-02 ↗ GitHub
2d11ea2f fix(courtlistener): harden fallback paragraph spines Eli Ziff 2026-08-02 ↗ GitHub
01d8c62b feat(a2aj): deterministic opinion-roster harness with precise text delimiters Eli Ziff 2026-08-03 ↗ GitHub
commit body
- seeded capture/verify harness (harness.mjs) with layered text/claims caches
- worker surfaces opinion delimiters at semantic (par N-M), offset, and
  combined page resolutions from SourceDoc geometry
- legalOpinionBoundaries partition now emits spans and judges
- broaden A2AJ heading-join grammar (roman numerals, abbreviations)
- docx conformance, legal-grounding, and LAB audit worktree updates
0d6b4501 refactor(sourceDocA2AJ): inline joined-heading token regexes Eli Ziff 2026-08-04 ↗ GitHub
Drop named ROMAN_NUMERAL_RE/UPPER_WORD_RE/UPPER_ABBREV_RE constants in
favor of the two inlined patterns they collapse to; behavior preserved.

Co-Authored-By: Claude <noreply@anthropic.com>
446db2bd Revert "refactor(sourceDocA2AJ): inline joined-heading token regexes" Eli Ziff 2026-08-04 ↗ GitHub
This reverts commit 0d6b4501d0ddb68629254d2de5656eccdb59f0cc.

Capture this thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-834.md from inside the repo you want the changes in.

⬇ Download capture-thread-834.md