Dossier · Internal docs
Internal · Abstract vs Domain

Layers

Internal architecture note for the dev team. Covers how Dossier is split into an abstract case-management layer and a domain layer, and how SchemaUiConfig bridges them.

Thesis

Dossier is two layers separated by a thin config bridge:

  1. Abstract case-management layer — domain-blind. Serves cases, contacts, tasks, notes, events, billing_entries, history, attachments, files (filings), entries. Ships a generic list/detail/calendar/CRM UI. No law words in the schema, no bankruptcy branches in the code paths.

  2. Domain layer — all the law lives here as JSON artifacts (no code): schemas, forms (with fields, bindings, validations), data_sources, materialized from the domains/<vertical>/ folder tree.

  3. The bridgeSchemaUiConfig, a block on each schema that tells the generic UI how to shape itself per vertical: labels, statuses, party roles, reference fields, date fields, event types, document checklist, sections.

Swap the schema, the same generic UI renders "Case / Debtor / Trustee / Chapter 7" for bankruptcy, or "Application / Petitioner / Adjudicator / I-130" for immigration. To add a new vertical you drop a new domains/<vertical>/ folder and a schema with a ui block. No changes in packages/server or packages/client.

One honest caveat up front: per project rule, the client app is law-first — components still reference words like "Case", "Filing", "Attorney" as default copy, and a few screens (e.g. pages/files.tsx status tabs) still assume bankruptcy statuses until the schema-driven replacement lands. But the bridge is real, wired through useUiConfig, and grew again in commit 038f70e (sections drive data-tab rendering). The direction is consistent.


The two layers

┌──────────────────────────────────────────────────────────────────────────┐
│                   ABSTRACT CASE-MANAGEMENT LAYER                         │
│                                                                          │
│  DB (packages/database + packages/core schemas)                          │
│    cases · files · entries · contacts · case_contacts · tasks · notes    │
│    events · history · attachments · billing_entries · tenants · users    │
│                                                                          │
│  Server (packages/server/src/routes/)                                    │
│    cases.ts · contacts.ts · events.ts · billing.ts · filings.ts          │
│    data-sources.ts · schemas.ts · documents.ts · marketplace.ts          │
│    intake.ts · portal.ts · health.ts · auth/* · cases/* · root/*         │
│                                                                          │
│  Client (packages/client/src/)                                           │
│    sidebar · top bar · dashboard · cases list · case shell · calendar    │
│    contacts directory · billing · settings · help · forms library        │
│                                                                          │
└──────────────────────────────────┬───────────────────────────────────────┘
                                   │
                                   │  reads schema.ui on every case load
                                   │  (DynamicUiConfigProvider + useUiConfig)
                                   ▼
┌──────────────────────────────────────────────────────────────────────────┐
│                    BRIDGE — SchemaUiConfig                               │
│                                                                          │
│  packages/core/src/api/types.ts                                          │
│    fileLabel · statuses · dateFields · referenceFields                   │
│    partyRoles · eventTypes · tagKeys · documentChecklist                 │
│    dataCompletionSegments · sections                                     │
│                                                                          │
└──────────────────────────────────┬───────────────────────────────────────┘
                                   │
                                   ▼
┌──────────────────────────────────────────────────────────────────────────┐
│                         DOMAIN LAYER (JSON only)                         │
│                                                                          │
│  domains/bankruptcy/                                                     │
│    schemas/…               → ingested into `schemas` table               │
│    federal/forms/<form>/   → ingested into `forms` + `form_versions`     │
│    federal/groups/<grp>/   → composite forms (children + bindings)       │
│    local/<state>/<dist>/   → jurisdiction-specific leaf forms            │
│    data-sources/           → import recipes (credit report, intakes)     │
│                                                                          │
│  domains/schemas/law/bankruptcy/                                         │
│    bankruptcy.json         → root schema                                 │
│    bankruptcy.ui.json      → SchemaUiConfig for the root schema          │
│    debtor.json, creditor.json, property.json, … (imported)               │
└──────────────────────────────────────────────────────────────────────────┘

What lives in the abstract layer

DB tables

Case-management tables (no domain knowledge in column names):

  • cases — client matter. Columns: schema_id, name, status, references JSONB, dates JSONB.
  • case_forms — working set of forms attached to a case. Columns: case_id, form_id, state (scratch | drafting | queued | filed).
  • filings — envelopes submitted to court. Columns: case_id, name, status, filed_at, snapshot_at, court_reference.
  • filing_forms — child forms inside a filing envelope. Columns: filing_id, form_id, form_version.
  • filing_attachments — uploaded attachments inside a filing envelope. Columns: filing_id, attachment_id, required.
  • entries — batch data changes. Columns: case_id, source, data_source_id, values JSONB, confirmed.
  • contacts, case_contacts — people/orgs directory, linked to cases with a role string. The role text comes from SchemaUiConfig.partyRoles; the table doesn't know what a "Trustee" is.
  • tasks, notes, events, activity, attachments, billing_entries — plain CRM/accounting shapes.
  • tenants, users — platform.

Schema-bearing tables also in the abstract layer, but their content is the domain layer:

  • schemas — vocabularies (entries JSONB, ui JSONB, tags TEXT[]).
  • forms, form_children — PDF form definitions and composition (no separate form_versions table; filing_forms.form_version is an int captured at envelope creation for future-proofing).
  • data_sources — import recipes.

Server routes

Routes live under packages/server/src/routes/:

File Responsibility
cases.ts GET/POST/PUT/DELETE /api/cases and /:id
cases/entries.ts entries (data changes) per case
cases/filings.ts filings (envelopes) list/create/update, stamps filed_at + snapshot_at on 'filed'
cases/contacts.ts link/unlink contacts to a case
cases/tasks.ts, cases/notes.ts, cases/events.ts, cases/billing.ts, cases/activity.ts, cases/attachments.ts, cases/validations.ts, cases/readiness.ts, cases/forms.ts the sub-resources
cases/intake-invites.ts tokenized invitations against a case
contacts.ts tenant-wide directory
events.ts tenant-wide calendar feed
billing.ts firm-wide billing summary
files.ts PDF export + preview
data-sources.ts, schemas.ts, documents.ts, filings.ts engine-side CRUD + filing PDF export/preview
marketplace.ts published platform forms
intake.ts, portal.ts client-portal intake runtime
auth/index.ts sign-in, sign-up, sign-out, refresh, me
root/tenants.ts, root/schemas.ts, root/documents.ts, root/data-sources.ts admin UI APIs

Search confirms none of the files under packages/server/src/routes/ (outside root/schemas.ts and root/documents.ts, which do schema/form import) contain the strings "bankruptcy", "debtor", "trustee", or "chapter". Those two files use the words only in comments and import-path strings, not in request handling. The runtime routes are domain-blind.

Client UI

Schema-agnostic screens under packages/client/src/pages/:

  • dashboard.tsx — stats, quick actions, recent cases.
  • files.tsx — cases list (file name is historical; the page lists rows from /api/cases — under the new naming this page maps to "Cases").
  • contacts.tsx, calendar.tsx, billing.tsx, settings.tsx, help.tsx, forms.tsx, login.tsx.
  • case-tabs/ — the case detail shell: overview.tsx, data.tsx, data-sections-view.tsx, filings-tab.tsx, plus documents/tasks/notes/billing/calendar/history/efiling/portal children.

Layout and chrome (components/layout/, components/ui/) have no domain branches.

Law-word defaults that still appear in the React tree: "Case", "Filings", "Attorney", "Trustee" copy in some screens, and hardcoded status tabs in pages/files.tsx (there's an explicit TODO at line 15 to drive them from useUiConfig().statuses). These are acceptable defaults, not hard dependencies — SchemaUiConfig overrides them per schema.


What lives in the domain layer

None of this is TypeScript. All JSON. None of it is in git (domains/ is IP).

domains/bankruptcy/

  • federal/forms/ — 69 leaf forms, one directory per form (b101/, b106ab/, b122a2/, …). Each directory holds <id>_<date>.fields.json, .bindings.json, .validations.json, .meta.json, .knowledge.json, plus the PDF.
  • federal/groups/ — 19 composite forms: chapter-7-ind, chapter-11-ind, chapter-13-ind, chapter-7-nin, chapter-11-nin, petition-ind, petition-nin, petition-involuntary, schedule-ind, schedule-nin, means-test-ch7, means-test-ch11, means-test-ch13, small-biz-ch11, attorney-compensation, reaffirmation, claims, discharge, notices.
  • local/<state>/<district>/forms/ — jurisdiction-specific leaf forms (ga/mdga, ga/ndga, ga/sdga, il/cdil, il/ndil, il/sdil, tx/edtx, tx/ndtx, tx/sdtx, tx/wdtx).
  • data-sources/ — import recipes: credit-report-mapping.md, clio-api-mapping.md, and 4 portal intake configs (intake-atlas.json, intake-debtstoppers.json, intake-greenfield.json, intake-individual-self.json).

domains/schemas/law/bankruptcy/

The shared vocabulary, decomposed by concept and assembled via imports:

  • bankruptcy.json — root schema (individual + nonindividual surface).
  • bankruptcy.ui.jsonSchemaUiConfig for that root, attached at import time (see packages/server/src/routes/root/schemas.ts ~line 490).
  • case.json, debtor.json, codebtor.json, creditor.json, property.json, income.json, expenses.json, exemption.json, contract.json, cure.json, dependent.json, disclosure.json, intention.json, means_test.json, military_service.json, notice.json, operating_report.json, petitioner.json, plan.json, poa.json, preparer.json, proof_of_claim.json, reaffirmation.json, sofa.json, trustee.json, compensation.json — imported by the root.

All law-specific vocabulary — debtor.firstName, schedule_d.secured_creditors[], means_test.disposable_income — is here, not in code.


The bridge — SchemaUiConfig

From packages/core/src/api/types.ts:

export type UiOptionCategory = 'neutral' | 'active' | 'success' | 'warning' | 'terminal'

export interface UiOption       { key: string; label: string; category?: UiOptionCategory }
export interface UiSchemaField  { key: string; label: string; schemaKey?: string }
export interface UiPartyRole    { key: string; label: string; schemaSlice?: string; mirrorFields?: string[] }
export interface UiChecklistItem{ key: string; label: string; required?: boolean }

export type SchemaSectionType = 'identity' | 'party' | 'financial' | 'verdict' | 'list' | 'default'
export interface SchemaSection {
  key: string
  label?: string
  sectionType?: SchemaSectionType
  scheduleRef?: string        // "Schedule I", "SOFA", "Form 122A"
  position?: number
  partyRole?: string
}

export interface SchemaUiConfig {
  fileLabel?: string               // "Case" | "Claim" | "Application"
  fileLabelPlural?: string
  statuses?: UiOption[]            // workflow states
  dateFields?: UiSchemaField[]     // dates shown in the case header
  referenceFields?: UiSchemaField[]// "Case #", "Docket #"
  partyRoles?: UiPartyRole[]       // "Debtor", "Trustee", "Attorney"
  eventTypes?: UiOption[]          // calendar categories
  tagKeys?: UiSchemaField[]
  documentChecklist?: UiChecklistItem[]
  dataCompletionSegments?: UiChecklistItem[]
  sections?: SchemaSection[]       // drives data-tab grouping
}

Schema.ui?: SchemaUiConfig is optional. On schema import the bankruptcy bankruptcy.ui.json file attaches alongside bankruptcy.json.

Client-side, packages/client/src/hooks/use-ui-config.tsx exposes:

  • DynamicUiConfigProvider — reads the route param :id, loads the case, loads the case's schema, and threads schema.ui into context. Outside a case route it falls back to BANKRUPTCY_UI_CONFIG (defined in hooks/use-mock-data.ts) — a bridge concession, not a permanent one, until a tenant-default schema id is wired.
  • useUiConfig() — returns a ResolvedUiConfig with defaults applied (e.g. fileLabel defaults to "File", standard statuses fill in when a schema omits them).

Any label that sounds like law — "Debtor", "Trustee", "Schedule I", "341 Meeting" — lives in the schema UI config, not in React components.


Walkthrough: one case's render

When the client navigates to /cases/:id:

  1. DynamicUiConfigProvider picks up :id from the route.
  2. useCase(id) fetches the case row. The row has schema_id.
  3. useSchema(kase.schemaId) loads the full schema including ui.
  4. UiConfigProvider wraps the subtree with the resolved UI config.
  5. OverviewTab (pages/case-tabs/overview.tsx):
    • const ui = useUiConfig()
    • Status chip label and color: ui.statuses.find(s => s.key === kase.status) → mapped to a CSS var via CATEGORY_CLASS.
    • Reference fields (Case #, District) read from kase.references, labels from ui.referenceFields.
    • Date rail from ui.dateFields.
  6. The Contacts tab / contact chips label role strings using ui.partyRoles.
  7. The Data tab (pages/case-tabs/data.tsx) passes uiSections: schema?.ui?.sections into the derive step, and data-sections-view.tsx renders groups by section (identity / party / financial / verdict / list / default). This is what commit 038f70e landed.
  8. The calendar screen reads event categories from ui.eventTypes for chip coloring.
  9. Filings list, tasks, notes, billing, history — all go through the same abstract APIs. No bankruptcy-specific code paths run on the server.

The only bankruptcy-flavored strings on the page came from schema.ui and from the schema's own key paths (e.g. petition.caseNumber). Change the schema and you change the page.


Adding a new vertical

  1. Create domains/<vertical>/ with schemas/, federal/ (or the analogous jurisdiction tree), data-sources/.
  2. Write a root schema + SchemaUiConfig:
    • fileLabel (e.g. "Application"), fileLabelPlural.
    • statuses for the workflow (Intake → Submitted → Approved → …).
    • partyRoles (Petitioner, Beneficiary, Adjudicator, …).
    • referenceFields (Receipt #, A-Number).
    • dateFields, eventTypes, documentChecklist, sections.
  3. Extract + map PDFs with the domain_tools/ pipeline (AcroForm extract → bindings → validations).
  4. Configure data sources (intake configs, API mappings).
  5. Import via /api/root/schemas and /api/root/documents.

No code changes in packages/server or packages/client. The 45-route API continues to serve cases, contacts, tasks, events, billing; the client continues to render the same shell; the only thing that changed is the JSON.

Contrast with conventional vertical SaaS, where a new vertical demands new tables, new routes, and new screens. Here the abstract layer is fixed and the vertical is data.


References

  • packages/core/src/api/types.tsSchemaUiConfig, SchemaSection, Case, File, Entry, Contact, Task, etc.
  • packages/client/src/hooks/use-ui-config.tsx — the context, DynamicUiConfigProvider, defaults.
  • packages/client/src/hooks/use-mock-data.tsBANKRUPTCY_UI_CONFIG fallback.
  • packages/client/src/pages/case-tabs/overview.tsx — reference consumer of useUiConfig().
  • packages/client/src/pages/case-tabs/data.tsx + data-sections-view.tsx — consume schema.ui.sections.
  • packages/server/src/routes/ — ~45 domain-blind HTTP handlers.
  • packages/server/src/routes/root/schemas.ts — schema + UI import, including .ui.json pairing.
  • domains/schemas/law/bankruptcy/bankruptcy.ui.json — the canonical bankruptcy SchemaUiConfig.
  • docs/engine.md — engine story, domain-agnostic infrastructure, cross-domain concept table.
  • docs/domain.md — domain model technical reference.
Source: docs/layers.md