Data shape
Everything observable lives in one SQLite file: /var/lib/fraud-data/corpus.db on node-eighteen. There is no Postgres, no Timescale, no parquet.
corpus.db tables
cases doc_chunk_meta documents_fts sources
documents doc_chunks documents_fts_* tickers
documents_fts doc_chunks_chunks entities triples
case_facts (new) doc_chunks_info mentions urls
workflow_runs (new) doc_chunks_metadatachunks00 meta schema_version
matters doc_chunks_rowids doc_page_summaries
defendant_dispositions (new) doc_chunks_vector_chunks00
documents — the main table
CREATE TABLE documents (
id INTEGER PRIMARY KEY,
source_id INTEGER NOT NULL REFERENCES sources(id),
url TEXT UNIQUE NOT NULL,
kind TEXT NOT NULL, -- press_release, litigation_release, complaint, judgment, pdf, ...
title TEXT,
published_at TEXT,
case_number TEXT,
caption TEXT,
release_number TEXT,
fetched_at TEXT NOT NULL DEFAULT (datetime('now')),
http_status INTEGER,
sha256 TEXT,
content_type TEXT,
byte_size INTEGER,
body_path TEXT,
pdf_path TEXT,
parent_document_id INTEGER REFERENCES documents(id),
body TEXT,
-- OCR-side columns
ocr_text TEXT,
ocr_method TEXT,
ocr_confidence REAL,
-- Summarization-side columns
summary_llm1_gemma TEXT,
summary_llm2_qwen TEXT,
summary_combined TEXT,
summary_one_sentence TEXT,
summary_paragraph TEXT,
summary_llm3_qwen14b TEXT,
llm_artifacts TEXT,
processed_at TEXT
);
Columns added by enrichment workflows (via migrations, see below):
| Column | Migration | Workflow that writes it |
|---|---|---|
title_formatted |
2026-05-18-title-format.sql |
format-title, title-llm |
title_format_source |
2026-05-18-title-format.sql |
one of regex / composite / fallback / llm |
reference_filing_json |
2026-05-18-title-format.sql |
link-references (JSON array of linked SEC / court filings) |
scheme |
2026-05-18-scheme.sql |
scheme-classify |
scheme_confidence |
2026-05-18-scheme.sql |
scheme-classify (0.0–1.0) |
scheme_classified_at |
2026-05-18-scheme.sql |
scheme-classify |
scheme_slug |
2026-05-18-scheme.sql |
scheme-classify |
queued_at |
2026-05-18-federation-queue.sql |
fraud-publisher |
queued_workflow |
2026-05-18-federation-queue.sql |
fraud-publisher |
envelope_id |
2026-05-18-federation-queue.sql |
fraud-publisher |
GAP: the case-facts.ts workflow comment says it writes columns via /apply/case-facts, not by ALTERing documents — facts land in the dedicated case_facts table below.
GAP: migrations 2026-05-18-title-format.sql and 2026-05-18-scheme.sql have an allowlist mismatch in the broker (_overnight-questions.md Q7) — the filename doesn't match the name= value, so POST /admin/migrate?name=title-format returns "file not found". Those columns landed via a different path (direct sqlite ALTER) on prod.
case_facts — sibling table for the case-facts workflow
-- broker/src/migrations/case_facts.sql
CREATE TABLE IF NOT EXISTS case_facts (
document_id INTEGER PRIMARY KEY REFERENCES documents(id),
defendants_json TEXT, -- JSON array of strings
first_act_date TEXT, -- YYYY-MM-DD
plea_date TEXT,
sentence_date TEXT,
monetary_amount INTEGER, -- USD, integer cents (sic: comment says cents but workflow writes whole dollars — verify)
monetary_currency TEXT DEFAULT 'USD',
notes TEXT,
extracted_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_cf_first_act ON case_facts(first_act_date);
CREATE INDEX idx_cf_amount ON case_facts(monetary_amount);
TODO: confirm monetary_amount units. SQL comment says "integer cents", case-facts.ts extracts "largest dollar amount (integer USD)".
workflow_runs — the new gating table (2026-05-19)
-- broker/src/migrations/2026-05-19-workflow-runs.sql
CREATE TABLE IF NOT EXISTS workflow_runs (
workflow TEXT NOT NULL,
document_id INTEGER NOT NULL,
status TEXT NOT NULL CHECK(status IN ('pending','running','done','failed','skipped')),
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
last_attempt_at TEXT,
result_summary TEXT,
PRIMARY KEY (workflow, document_id)
);
CREATE INDEX idx_workflow_runs_status ON workflow_runs(workflow, status, last_attempt_at);
defendant_dispositions — per-defendant outcomes (2026-05-27)
One row per (document_id, defendant_name). Populated by the disposition-extract,
sentence-extract, and sanctions-extract workflows (see enrichment.md),
each writing a disjoint set of columns via POST /apply/defendant-disposition
(UPSERT + COALESCE). Created on broker startup by migrate-015.
-- broker/src/migrations/2026-05-27-defendant-dispositions.sql (mirrored in migrate.ts migrate-015)
CREATE TABLE IF NOT EXISTS defendant_dispositions (
document_id INTEGER NOT NULL, -- REFERENCES documents(id)
defendant_name TEXT NOT NULL, -- raw, as printed
entity_id INTEGER, -- best-effort resolve via nameSignature(canon(name)) → entities.norm
role TEXT, -- defendant | relief_defendant | nominal | unknown
defendant_class TEXT, -- individual | corporate | entity
disposition_type TEXT, -- guilty_plea|jury_verdict|bench_verdict|dpa|npa|
-- default_judgment|consent_judgment|dismissed|cooperation|unknown
charge_date TEXT,
disposition_date TEXT,
charge_count INTEGER,
top_charge TEXT, -- e.g. "securities fraud", "wire fraud"
prison_months INTEGER,
probation_months INTEGER,
supervised_release_months INTEGER,
home_confinement_months INTEGER,
cooperation INTEGER, -- 0/1
acceptance_of_responsibility INTEGER, -- 0/1
sanctions_json TEXT, -- JSON array of sanction enums
monetary_json TEXT, -- {disgorgement,civil_penalty,restitution,forfeiture,criminal_fine}
confidence REAL,
source_method TEXT, -- regex | llm
extracted_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (document_id, defendant_name)
);
CREATE INDEX idx_defdisp_entity ON defendant_dispositions(entity_id) WHERE entity_id IS NOT NULL;
NOTE: roll up by entity_id, not defendant_name — the same actor appears
under casing variants ("Paul Sexton" vs "PAUL SEXTON") across docs, both resolving
to one entity_id. Two derived stats-miner materialisers consume this table:
actor-recidivism (entity in ≥2 matters) and parallel-proceedings
(entity in both an SEC and a DOJ doc on one scheme). These are computed views,
not corpus tables.
Other tables (read-only summary)
| Table | What it has |
|---|---|
sources |
id, source name (e.g. doj-sdny, sec-pr, sec-lr), base URL. |
cases |
Case-number → metadata. |
entities |
Person / org canonicalised across documents. |
mentions |
entity_id → document_id join with span. |
triples |
LLM-extracted (subject, predicate, object) triples — drives /stats D2/D3 charts. |
tickers |
Stock-symbol mentions per document. |
urls |
Outbound URL references in each document. |
documents_fts + _fts_* |
SQLite FTS5 index of documents.body. |
doc_chunks + doc_chunks_vector_chunks00 |
ModernBERT embeddings of chunked docs (vec extension). |
doc_page_summaries |
Per-page LLM summaries (PDFs). |
meta, schema_version |
Schema versioning. |
Workflow-runs state machine
fraud-publisher fraud-enricher-v2
publishes consumes
documents.queued_at = NULL ──────▶ queued_at = NOW ──────▶ workflow_runs row
status='running'
│
┌──────────┴─────────────┐
▼ ▼
process()→{patch} process()→{skip}
│ │
▼ ▼
broker /update OR status='skipped'
/apply/<endpoint> attempts++
│
▼
status='done'
queued_at cleared by enricher
Terminal statuses (skipped from re-publish): done, failed, skipped. Active gating is by workflow_runs left-join NULL — see fraud-publisher/index.ts:fetchCandidateIds() which wraps the workflow's selectSql() in:
SELECT inner_q.id FROM (<workflow.selectSql>) inner_q
WHERE NOT EXISTS (
SELECT 1 FROM workflow_runs wr
WHERE wr.workflow = ?
AND wr.document_id = inner_q.id
AND wr.status IN ('done','failed','skipped')
)
The queue envelope
interface PublishEnvelope {
doc_id: number;
workflow: string; // one of the 10 known workflows
attempt?: number;
idempotency_key?: string;
envelope_id?: string;
attribution: {
project: string; // "fraud-heuristics"
service: string; // "fraud-publisher" | "fraud-enricher-v2"
workflow_stage?: string;
client_id?: string;
};
}
Source: shared/federation-queue-client.ts:PublishEnvelope.
GAP: master spec at *********/gpu-federation-monitor/docs/specs/federation-queue-architecture.md referenced from comments — for cross-project changes update both.
Ingest-side filings.db
This repo does not own EDGAR filings. The fingerprint NDJSONs at node-eighteen:/mnt/oink/docker/edgar-fraud-scan/data/flagged/ come from a sibling project (edgar-fraud-scan). For us they are read-only inputs cross-referenced when starting an investigation. See heuristics.md for the per-rule schemas.
Migration list (as of 2026-05-19)
broker/src/migrations/
├── 2026-05-18-federation-queue.sql # queued_at, queued_workflow, envelope_id
├── 2026-05-18-scheme.sql # scheme, scheme_confidence, scheme_classified_at, scheme_slug
├── 2026-05-18-title-format.sql # title_formatted, title_format_source, reference_filing_json
├── 2026-05-19-workflow-runs.sql # workflow_runs gating table
├── 2026-05-27-defendant-dispositions.sql # defendant_dispositions table
└── case_facts.sql # case_facts table
Apply each via POST http://fraud-db-broker:3100/admin/migrate?name=<name> from inside the swarm overlay. Idempotent.
NOTE: the date-prefixed .sql files back the /admin/migrate?name= route, but the
auto-run-on-startup migrations live in code (broker/src/migrate.ts, migrate-005…migrate-015).
defendant_dispositions is created by migrate-015 on every broker boot, so no manual
/admin/migrate call is needed for it. New tables should add a migrate-NNN block there too,
not just a .sql file.