Dossier · Internal docs
Internal · Domain Model

Dossier — Domain Model

The Schema is the spine — DataSources write to it (inbound), Bindings read from it (outbound). Cases own the data, Filings are snapshots submitted to court.

DataSource → [mapping] → Schema key → [Binding] → Form field
                              ↓
                         Entry on Case → Filing (snapshot)

Domain Concepts

Engine Layer (document assembly)

# Name What it is
1 Schema Named collection of data point definitions. Has ui config for shaping the client app per law type.
2 Form Recursive. Leaf (PDF + fields), composite (children), or both.
3 Field A fillable spot on a PDF — text box, checkbox, dropdown.
4 Binding Routes schema keys → form fields or $child targets.
5 Validation Expression-based correctness rule.
6 DataSource Reusable recipe for getting external data into the schema.

Runtime Layer (case management)

# Name What it is
7 Case A client matter. Owns data (entries), contacts, threads, events, billing, activity, attachments. Has references (Case #, Docket #) and key dates. Always has at least one Filing (auto-created on case creation).
8 Filing A workspace within a case. The active draft (status='draft', most recent) is where new form work happens; other filings are filed envelopes. Carries FilingForms + FilingAttachments. Status: draft → filed → accepted/rejected.
9 FilingForm A form attached to a filing. Carries draftOverrides (live per-filing field pins for drafts) and overrides (frozen snapshot captured at file-time).
10 CaseFieldOverride Per-(case, form, field) PDF-field override value. Case-wide live source for drafts — every filing on the case sees it unless a draftOverride pin shadows it.
11 Entry A batch of data point changes on a case. One event = one entry with N values. Append-only, source-tagged, optionally tied to an attachment.
12 Contact Person/org reusable across cases. Linked with a role (Debtor, Attorney, Trustee).
13 Thread Unified note / task / question on a case. kind ∈ {'note', 'task', 'question'}. Tasks carry assignee + deadline; questions track answers.
14 Event Calendar event — hearing, deadline, meeting. Case-level or firm-wide.
15 ActivityEntry Auto-generated audit event (data change, import, status change, filing). Stored in the activity table.
16 Attachment Uploaded supporting document (ID scan, pay stub, certificate).
17 BillingEntry Fee or payment on a case.
18 IntakeInvite Token-gated client portal invite scoped to one case.

File Structure

domain_tools/                       — form processing scripts, prompts, and pipeline docs (in git)

domains/                            — NOT in git (IP)
├── bankruptcy/
│   ├── schemas/
│   │   ├── individual.json         — ~1,100 keys
│   │   └── nonindividual.json      — ~640 keys
│   ├── data-sources/
│   │   └── credit-report.json      — MISMO XML → schema key mapping
│   └── federal/
│       ├── forms/                  — 70 leaf forms (PDF + JSON per directory)
│       └── groups/                 — 16 composite forms (chapter packages, schedules, etc.)

Groups are composite forms stored separately from leaf forms because they have no PDF. A group like chapter-7-ind.json references leaf forms and other groups as children. In the database and type system, a group is just a Form with children and no filePath. There are 19 composite forms (chapter packages, petition variants, schedule groups, means-test packages, etc.).


Schema

The single source of truth for what data points exist in a domain. Every key has a type, label, hint, and group. Forms bind to schema keys. DataSources map to schema keys.

interface Schema {
  id: string
  name: string              // "bankruptcy.individual"
  description: string | null
  entries: SchemaEntry[]
}

interface SchemaEntry {
  key: string               // "debtor1.first_name", "creditors.secured[].name"
  type: 'text' | 'money' | 'date' | 'boolean' | 'enum' | 'number'
  label: string
  hint?: string
  group?: string            // UI grouping: "Debtor 1 — Identity"
  options?: string[]        // for enum type
}

One schema per domain variant. Bankruptcy has two:

Schema Keys Overlap
bankruptcy.individual ~1,100 12 shared (case., attorney.)
bankruptcy.nonindividual ~640 Same 12

The overlap is only administrative keys. All substantive data (debtors, income, property, creditors, entity, officers) is completely separate.


Form

A form can be a leaf (has PDF with fields), a composite (has children), or both. Recursive — composites contain composites.

interface Form {
  id: string
  tenantId: string | null
  schemaId: string | null       // which vocabulary
  number: string | null         // "101" for leaf forms
  name: string
  description: string | null
  version: number
  status: string
  filePath: string | null       // PDF path (leaf only)
  effectiveDate: string | null
  pages: number                 // 0 for composites
  fields: FormField[]           // empty for composites
  children: ChildEntry[]        // empty for leaves
  bindings: Binding[]
  validations: Validation[]
}

Leaf form example

Has a PDF, fields extracted from it, bindings mapping schema keys to PDF field names.

{
  "id": "F0001010-BA01-4000-8000-000000000000",
  "name": "Form 101 — Voluntary Petition",
  "schemaId": "S0000001-BA01-4000-8000-000000000001",
  "filePath": "b101/b101_2024-06-22.pdf",
  "pages": 9,
  "fields": [{ "key": "Debtor1.First name", "type": "text", "page": 1 }],
  "children": [],
  "bindings": [
    { "source": "debtor1.first_name", "target": "$.Debtor1.First name" }
  ]
}

Composite form example (group)

Has children, no PDF. Bindings route schema keys to children.

{
  "id": "BB000106-BA01-4000-8000-5C4ED0000001",
  "name": "The Schedules (Individual)",
  "schemaId": "S0000001-BA01-4000-8000-000000000001",
  "filePath": null,
  "pages": 0,
  "fields": [],
  "children": [
    { "id": "F0010600-...", "key": "b106sum", "position": 1 },
    { "id": "F0001190-...", "key": "b119", "position": 3, "condition": "case.has_petition_preparer == true" }
  ],
  "bindings": [
    { "source": "debtor1.first_name", "target": ["$b106sum.Debtor 1", "$b106ab.Debtor 1"] }
  ]
}

Leaf vs composite bindings

Source Target Self-reference
Leaf schema key or $.field self-ref $.<fieldKey> (preferred) or bare PDF field name $.field
Composite schema key or $child.field $child.field addresses not applicable

Field

A fillable spot on a PDF.

interface FormField {
  key: string                 // AcroForm field name
  type: 'text' | 'checkbox' | 'dropdown' | 'radio' | 'signature' | 'optionList'
  page: number
  rect: { x: number, y: number, w: number, h: number } | null
  options: string[] | null
  label: string | null
  hint: string | null
  needsReview: boolean        // true if field name is generic/auto-generated
  source: 'acro' | 'llm' | 'manual'

  // PDF-native metadata (when present on the source PDF)
  flags?: { required?: boolean, readOnly?: boolean }
  maxLen?: number | null

  // Authored runtime rules — layered on top of flags, never exclusive
  behavior?: {
    visibleIf?:  string       // engine expression; absent = always visible
    requiredIf?: string       // OR'd with flags.required at runtime
    readOnlyIf?: string       // OR'd with flags.readOnly at runtime
    computed?:  boolean       // value derived (formula/copy); UI shows calculator affordance
  }
}

Behavior expressions

visibleIf / requiredIf / readOnlyIf accept five left-hand-side forms, all evaluated by the same engine:

Form Resolves against Use for
case.* / debtor.* / schema paths Case-merged values + case.* namespace Cross-form / case-wide state (chapter, debtor type, marital status)
$.<fieldKey> (dotted) The form's own field values (binding-derived + per-field overrides) Self-reference when the field key is [A-Za-z0-9_]+
$[<fieldKey>] (bracketed) Same as $.<fieldKey> Self-reference when the field key has spaces / dots / hyphens — e.g. $[Check Box8], $[Debtor1.First name]. Equivalent to dotted form at runtime; choose by what the key permits.
$<childKey>.<fieldKey> (cross-form, dotted) A sibling form via composite parent Cross-form refs (rare at field level)
$<childKey>[<fieldKey>] (cross-form, bracketed) Same as above Cross-form refs when the field key has spaces / non-identifier chars

Bracketed-form rules: brackets are unquoted ($[Check Box8], not $["Check Box8"]); scan to first ]; empty $[] and unterminated $[… are parse errors.

Examples:

{ "visibleIf": "case.is_joint == true" }                // Debtor-2-only field
{ "visibleIf": "case.petition.chapter == 7" }           // Chapter-7-only field
{ "visibleIf": "$.Check_Box4 == 'yes'" }                // Sub-question (clean key)
{ "visibleIf": "$[Check Box8] == 'yes'" }               // Sub-question (PDF-native key with space)
{ "requiredIf": "$.amount > 2000" }                     // Threshold-driven required

The runtime aggregator (aggregateBindingsForCase) builds a per-form context that resolves the form's bindings + applies overrides under $.<fieldKey> keys before evaluating each field's behavior, so $.field self-references resolve to the field's currently-effective value. See packages/core/src/engine/behavior-eval.ts (buildFormBehaviorCtx).

generate-bindings.mjs runs a static buildBehaviorDeps pass over each form: it inverts the dependency graph into a trigger map (gating field → dependents) and detects cycles (logic deadlocks like A.visibleIf=$.B + B.visibleIf=$.A). The trigger map is shipped to the field editor so the client knows when a change requires a server round-trip.


Binding

One flat shape: { source, target, when?, note? }. The source returns a scalar or an array; array sources fan out to one write per element with i (0-based index) and e (current element) bound in scope. target is a template string (or string[] for multi-target fan-out) containing {expression} holes evaluated against the iteration scope. There's no kind discriminator and no expansion step — the resolver dispatches on whether the source evaluates to an array.

interface Binding {
  source: string                 // schema key or expression; may project an array
  target: string | string[]      // target template(s) with optional {expression} holes
  when?:  string                 // boolean gate; for array sources, evaluated per element
  note?:  string                 // developer documentation
}

Iteration

Array projections (creditors[].name, creditors[? classification == 'secured']) fan out per element. Two loop variables are bound for the duration of that element's writes:

  • i — 0-based index.
  • e — the current array element (scalar when the source projected a single field, otherwise the whole element).

Templates pass arbitrary expressions through {...} holes, so slot labels are first-class — {i + 1} for 1-based labels, {['a','b','c'][i]} for alphabetic slots, {IF(i == 0, '', CONCAT('_', i + 1))} for blank-first underscore suffixes.

// Inclusive 1-based labels: Name_1, Name_2, …
{ "source": "plan.secured.maintenance[].creditor_name",
  "target": "$.Name_{i + 1}" }

// Blank-first underscore suffix: Name, Name_2, Name_3, …
{ "source": "plan.secured.maintenance[].creditor_name",
  "target": "$.Name{IF(i == 0, '', CONCAT('_', i + 1))}" }

// Alphabetic single-char slots
{ "source": "sofa.prior_addresses[].address.street",
  "target": "$.Street address 1{['b','c','d'][i]} Debtor 1" }

// Multi-target fan-out
{ "source": "plan.secured.maintenance[].creditor_name",
  "target": ["$.s3a_r{i + 1}_creditor", "$.s3b_r{i + 1}_creditor"] }

// Per-element gate via `e`
{ "source": "creditors[]",
  "target": "$.Active_{i + 1}",
  "when":   "e.amount > 0" }

Cross-form array sync

Source and target can reach into other forms via $formKey.field (cross-form ref) — the resolver wires the dependency edges automatically.

{ "source": "$schedules.creditors.secured[].name",
  "target": "$plan.maintain_cure_creditor_{i + 1}" }

Validation

interface Validation {
  expression: string          // "case.district != ''"
  severity: 'error' | 'warning'
  description: string         // "District is required"
}

DataSource

Reusable recipe for getting external data into the schema. Platform-defined or tenant-created. Stored as JSON config files in domains/<domain>/data-sources/.

interface DataSource {
  id: string
  tenantId: string | null     // null = platform-provided
  name: string                // "Experian Credit Report"
  type: string                // "credit-report", "case-mgmt", "csv", "document"
  schemaId: string            // scoped to a schema
  config: DataSourceConfig    // parse rules + inbound mappings
}

interface DataSourceConfig<TInputs = unknown> {
  inputs: TInputs             // source-specific parse config (format, credentials, wizard sections, etc.)
  mappings?: Binding[]        // inbound: raw field → schema key (reuses the same Binding primitive as form bindings)
}

DataSource mappings are inbound (external field → schema key). Bindings are outbound (schema key → form field). Both use the same key vocabulary — one key picker, one autocomplete, same [] array syntax.

Data flow

External data (XML, API, CSV, PDF)
    ↓
DataSource (parse + map rules)
    ↓
Schema keys (the shared vocabulary)
    ↓
Entry on Case (batch of key=value with source + timestamp + confirmed)
    ↓
Binding engine (resolves confirmed entries → form fields)
    ↓
Filled PDF

Case

A client matter. Owns all data and sub-resources.

interface Case {
  id: string
  tenantId: string
  schemaId: string            // which vocabulary drives this case
  formId: string              // the initial form package
  name: string                // "Garcia, Maria"
  status: string              // Intake, Prep, In Review, Ready, Filed, Active, Discharged, Closed
  references: Record<string, string>  // {"Case #": "26-12345", "Docket #": "...", "District": "NDIL"}
  dates: Record<string, string>       // {"Filed On": "2026-03-25", "341 Meeting": "2026-04-14"}
}

Filing

A workspace within a case. Every case has at least one filing; the active draft (status='draft', most recent) is where new form work happens. When status transitions to filed, filed_at and snapshot_at are stamped to NOW(); snapshotOverridesForFiling runs the cross-form resolver and freezes the full resolved field map per form into filing_forms.snapshot, also clearing draft_overrides. Filed envelopes resolve exclusively from that frozen snapshot.

interface Filing {
  id: string
  caseId: string              // belongs to a case
  name: string                // "Ch.7 Petition Package"
  status: 'draft' | 'filed' | 'accepted' | 'rejected'
  courtReference: string | null   // ECF #
  filedAt: string | null
  snapshotAt: string | null   // timestamp cutoff for replay
  forms: FilingForm[]         // child form_versions, captured at envelope creation
  attachments: FilingAttachmentSummary[]
}

interface FilingForm {
  id: string
  formId: string
  formNumber: string
  formName: string
  formVersion: number
  position: number
  draftOverrides?: Record<string, string>   // live per-filing field pins (drafts only)
  overrides?: Record<string, string>        // frozen snapshot (filed envelopes only)
}

filings (workspace) ↔ filing_forms (which forms it contains) ↔ filing_attachments (uploaded exhibits). Amendments and conversions create new filings. A filed filing's overrides snapshot is immutable.

Field overrides — three tiers

Effective field value for the active draft is resolved by the client (resolveFiling runs in the browser via @dossier/core) in this precedence order, low → high:

  1. Schema-derived — value computed from the case's confirmed entries via bindings.
  2. case_field_overrides — per-(case, form, field) row. Case-wide live source for drafts; every filing sees it unless a draft pin shadows it.
  3. filing_forms.draft_overrides — per-(filing, form, field) JSONB pin. Wins over the case-wide override for one filing only.
  4. Buffer — in-memory pending edits in the Forms-tab editor; flushed by the draft-commit dispatcher into the right tier (case-data → entries, case-fieldscase_field_overrides, filing-overridefiling_forms.draft_overrides).

REF_LOOKUP reference tables ride to the client through GET /api/cases/:id/reference-bundle, so the live cascade is fully client-side.

When a form is filled or exported for a draft filing, the resolver picks the highest tier that has a value. For filed filings, only the frozen filing_forms.snapshot is read — case_field_overrides and draft_overrides never bleed in. The snapshot is the full {fieldKey: value} resolved map captured at file-time, not just the override layer; bindings are not re-evaluated for filed envelopes.

When a divergence between an override and its bound schema-key value appears, the Forms tab surfaces it as override drift, with two reconcile actions:

  • Reset to schema — clear the override; the field reverts to the binding-derived value.
  • Apply to schema — write the override value as a normal schema entry on the field's bound key, then clear the override (it now matches the derived value). Available only when the field's binding is a single direct schema-key reference; expression-bound fields have no canonical writable target.

Filed filings reject override writes — their snapshot is frozen at snapshot_at.


Entry

A batch of data point changes on a case. One event (save, import, sync) = one entry with N values.

interface Entry {
  id: string
  caseId: string              // belongs to a case (not a filing)
  source: string              // "manual", "credit-report", "csv"
  dataSourceId: string | null
  attachmentId?: string       // set when entry originates from a document upload
  timestamp: string
  confirmed: boolean          // false = pending review
  values: Record<string, unknown>
  raw?: Record<string, unknown>  // full source payload before mapping
}

How entries work

  • Lawyer types 5 fields, clicks save → 1 entry with 5 values, source = "manual", auto-confirmed
  • Credit report is pulled → 1 entry with 80 values, source = "credit-report", pending confirmation
  • Lawyer corrects a creditor name → 1 entry with 1 value, overrides the credit report entry for that key

Current state = merge all entries by timestamp (latest wins per key). Only confirmed entries are used for PDF generation.

Case activity = auto-logged events (data changes, imports, status changes, filings) in the activity table. Separate from entries — entries are data, activity is audit.


Child Entry

interface ChildEntry {
  id: string              // UUID of the child form
  key: string             // alias used in expressions: "b106ab", "petition"
  position: number        // ordering
  condition?: string      // boolean expression controlling inclusion
}

No type field — a child is always a form (which may itself be leaf or composite).


Expression Syntax

All source, condition, and target values are parsed by the expression engine.

Reference resolution

Pattern Meaning Example
$child.field Child form's field (dotted) $b106ab.undefined_155
$child[field] Child form's field (bracketed; supports spaces / dots / hyphens in field key) $b240b[Check Box14]
$.field Current form's own field (dotted) $.line55
$[field] Current form's own field (bracketed) $[Check Box8], $[Debtor1.First name]
group.key Schema key (dotted) debtor1.first_name
collection[].field Array item field creditors.secured[].name
bare word Literal or function true, 42, CONCAT
'...' String literal 'installments'

Disambiguation

  • Starts with $ → form field reference (dotted or bracketed)
  • Has a . but no $ → schema key
  • No . and no $ → literal value or function name

Bracketed form rules

  • Brackets are unquoted: $[Check Box8], not $["Check Box8"]. One less escape level when stored in JSON expression strings.
  • Scan stops at the first ]. Field keys containing literal ] aren't representable; verify before authoring on a new corpus (none exist in the federal bankruptcy corpus).
  • Empty $[] and unterminated $[… are parse errors.
  • Equivalent to dotted form at evaluation time — both forms resolve to the same values["$.<key>"] lookup. Choose by what the key's character set permits.

Operators

+, -, *, /, ==, !=, >, <, >=, <=, &&, ||

The unary ! does not parse — use NOT(x) for logical negation. + doubles as string concatenation when at least one operand is a string.

Functions (34)

Category Functions
String CONCAT, JOIN(sep, ...args) (skips blanks), FORMAT_NAME(prefix [, opts]), FORMAT_ADDRESS(prefix [, opts]), UPPER, LOWER, TRIM, LEFT, RIGHT, LEN, SUBSTITUTE, SPLIT(text, delimiter)
Math SUM, MAX, MIN, COUNT, ROUND, ABS
Logical IF, AND, OR, NOT, IN, ISBLANK(value), ANY_FILLED(a, b, …) / ALL_BLANK(a, b, …), COALESCE(a, b, c, …)
Date TODAY, YEAR, MONTH, DAY
Array INDEX(array, n) — nth element (0-based); LOOKUP(array, keyField, keyValue, returnField) — find by key
Reference REF_LOOKUP(table_name, ...keys) — read from the file-based reference-data layer at packages/core/src/reference-data/*.json (Census median income, IRS standards, federal/state exemptions, trustee fees). Returns null on miss — never throws.

Array iteration (parser-level)

Square brackets after a path navigate into arrays. Three forms, all built into the parser:

Syntax Meaning Example
arr[N] Element at index N (0-based); returns null for out-of-bounds creditors[0].name
arr[] Projection — maps a field across every element, returns an array creditors[].lien_value
arr[? predicate] JMESPath-style filter — predicate resolves identifiers against the current element first, falling back to outer values creditors[? classification == 'secured']

Aggregator functions (SUM, COUNT, MAX, MIN) flatten one level of array input automatically:

SUM(creditors[? classification == 'secured'].lien_value)

The bracketed self-ref form ($[Field With Spaces]) shares the same tokenizer rule as numeric subscripts, but it's a self-reference, not array iteration — context decides which.

Resolution: single-pass vs topological cross-form

resolve(bindings, schemaValues) evaluates each binding once against the schema-values map. Self-refs ($.field) and cross-form refs ($formKey.field) return null because no binding's output is in values when the next binding starts evaluating.

resolveFiling(forms, schemaValues, { onCycleError?, onParseError? }) is the cross-form path. It builds a static dependency graph over the filing's bindings — every $.field and $formKey.field reference inside a source, when, or target template becomes an edge from the reader binding to its producer — topo-sorts the result, and evaluates each binding exactly once in order. Each form's resolved fields are exposed as $formKey.field to other forms and as $.field self-refs to the form currently being resolved. Cycles surface synchronously at graph-build time through onCycleError({ cycles }) — each cycle is a list of binding ids of the form <formKey>::<index>. Cycle members are skipped from the walk (their targets are not written); non-cycle bindings still run. onParseError({ expression, error }) fires once per unique unparseable expression. The server wires both to the structured logger.

The old fixed-point loop, maxPasses cap, and onNonConvergence callback are gone. Parity-checked against the full bankruptcy corpus (byte-identical to the legacy resolver) and ~6.6× faster on a 40-form synthetic filing.

Use resolveFiling() for any binding that reads sibling/self fields — including conditions like $.has_codebtors_yes == true that gate downstream bindings on a parent flag. The server's field/preview endpoint uses resolveFiling().

Schema functions

For computations too tall to author as one expression — the means test, exemption resolution, the Chapter 13 plan waterfall, party / address formatting, schedule totals — Dossier ships schema functions: pure (data, ctx) => { values, trace } TS functions registered in packages/core/src/functions/index.ts. Each function declares produces[] (keys it writes) and dependsOn[] (keys it reads); evaluateSchemaFunctions topo-sorts them, runs each once, and merges only declared produces keys back into the values map.

Schema entries reference their function by name via the x-computed annotation. Boot-time validateSchemaFunctions cross-checks: every x-computed value must point at a registered function and every produces[] key must be annotated. The draft-commit + intake dispatchers reject writes against any x-computed key (schema/computed-readonly). The first wave covers meansTestCalculator, exemptionResolver, ch13PlanCalculator, partyFormatter, scheduleTotalsResolver. See docs/engine.md for the full table and trace-disclosure UI.

Boolean-flag conditions (authoring rule)

When a parent binding emits a boolean flag (e.g. COUNT(creditors) > 0 for $.has_creditors_yes), child conditions must compare with == true / != true, not == 'yes' / == 'no'. The string comparisons silently never fire because true ≠ 'yes' and the engine doesn't loose-coerce. Locked in by packages/core/src/engine/__tests__/resolver.test.ts "boolean-flag conditions".

The same applies to checkbox field self-refs: $.check5 == true is the canonical form. The engine's normalizeOverrides() coerces PDF checkbox export values ('Yes', 'On', 'Off', etc.) to real booleans for any field with type: 'checkbox', so == true always matches. Bare $.check5 works too — JS truthiness on a real boolean does the right thing.


Scoping Rules

Context $child.field / $child[field] $.field / $[field] schema key
Composite binding source yes no yes
Composite binding target yes no no
Child condition yes (siblings visible) no yes
Leaf binding / validation no yes yes

Ancestry rule: bindings can reference any descendant. Never up, never sideways — only down. Cross-form bindings live on the common ancestor.


IDs

JSON files on disk have no id field. The seed script assigns randomUUID() at load time. Forms and schemas are looked up by schema_key (the namespace enum, e.g. 'bankruptcy', 'immigration', 'injury') and form number/key. Groups reference children by key (the form's directory name or group filename), not by UUID. The seed builds a key → id lookup and wires up the references when inserting into the database.

Filenames: -ind / -nin always last suffix (e.g., petition-ch7-ind.json).


Database Tables

Engine

Table Purpose
schemas Data vocabularies (entries JSONB, ui JSONB, tags)
forms All forms — leaf and composite (fields, bindings, validations as JSONB)
form_children Parent → child composition (key, position, condition)
data_sources Import recipe configs (type, schema_id, config JSONB)

Runtime

Table Purpose
cases Client matters (schema_id, status, references JSONB, dates JSONB)
filings Workspaces per case (case_id, status, filed_at, snapshot_at, court_reference). Every case has ≥1; the active draft is status='draft' ORDER BY created_at DESC LIMIT 1
filing_forms Forms inside a filing (filing_id, form_id, form_version, position). draft_overrides JSONB = live per-(filing, form, field) pins for drafts. snapshot JSONB = frozen full resolved-field map populated at file-time and cleared off draft_overrides
case_field_overrides Per-(case, form, field) PDF-field override values. Case-wide live source for drafts; frozen into filing_forms.snapshot (alongside the live binding-resolved values) at file-time
filing_attachments Attachments inside a filing envelope (filing_id, attachment_id, required)
entries Batch data changes on a case (case_id, source, data_source_id, values JSONB, confirmed)

Case Management

Table Purpose
contacts People/orgs reusable across cases
case_contacts Case ↔ contact link with role
threads Unified notes / tasks / questions (case_id, kind, text, assignee_id, deadline, done, answer, …)
events Calendar events (tenant_id, case_id, title, date, type)
activity Auto audit trail (case_id, type, text, detail)
attachments Uploaded files (case_id, name, file_path, category)
billing_entries Fees + payments (case_id, amount, type, method, date)

Intake & LLM

Table Purpose
intake_invites Token-gated client portal invites scoped to one case (token, schema_key, expires_at)
llm_calls Record of LLM calls (model, prompt hash, tokens, cost, latency)

Platform

Table Purpose
tenants Firms (name, address, phone, website)
users Accounts (tenant_id, email, role)
Source: docs/domain.md