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:
- Schema-derived — value computed from the case's confirmed entries via bindings.
case_field_overrides— per-(case, form, field) row. Case-wide live source for drafts; every filing sees it unless a draft pin shadows it.filing_forms.draft_overrides— per-(filing, form, field) JSONB pin. Wins over the case-wide override for one filing only.- Buffer — in-memory pending edits in the Forms-tab editor; flushed by the draft-commit dispatcher into the right tier (
case-data→ entries,case-fields→case_field_overrides,filing-override→filing_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) |
docs/domain.md