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:
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.Domain layer — all the law lives here as JSON artifacts (no code):
schemas,forms(withfields,bindings,validations),data_sources, materialized from thedomains/<vertical>/folder tree.The bridge —
SchemaUiConfig, 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,referencesJSONB,datesJSONB.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,valuesJSONB,confirmed.contacts,case_contacts— people/orgs directory, linked to cases with arolestring. The role text comes fromSchemaUiConfig.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 (entriesJSONB,uiJSONB,tagsTEXT[]).forms,form_children— PDF form definitions and composition (no separateform_versionstable;filing_forms.form_versionis 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.json—SchemaUiConfigfor that root, attached at import time (seepackages/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 threadsschema.uiinto context. Outside a case route it falls back toBANKRUPTCY_UI_CONFIG(defined inhooks/use-mock-data.ts) — a bridge concession, not a permanent one, until a tenant-default schema id is wired.useUiConfig()— returns aResolvedUiConfigwith defaults applied (e.g.fileLabeldefaults 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:
DynamicUiConfigProviderpicks up:idfrom the route.useCase(id)fetches the case row. The row hasschema_id.useSchema(kase.schemaId)loads the full schema includingui.UiConfigProviderwraps the subtree with the resolved UI config.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 viaCATEGORY_CLASS. - Reference fields (Case #, District) read from
kase.references, labels fromui.referenceFields. - Date rail from
ui.dateFields.
- The Contacts tab / contact chips label role strings using
ui.partyRoles. - The Data tab (
pages/case-tabs/data.tsx) passesuiSections: schema?.ui?.sectionsinto the derive step, anddata-sections-view.tsxrenders groups by section (identity / party / financial / verdict / list / default). This is what commit038f70elanded. - The calendar screen reads event categories from
ui.eventTypesfor chip coloring. - 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
- Create
domains/<vertical>/withschemas/,federal/(or the analogous jurisdiction tree),data-sources/. - Write a root schema +
SchemaUiConfig:fileLabel(e.g. "Application"),fileLabelPlural.statusesfor the workflow (Intake → Submitted → Approved → …).partyRoles(Petitioner, Beneficiary, Adjudicator, …).referenceFields(Receipt #, A-Number).dateFields,eventTypes,documentChecklist,sections.
- Extract + map PDFs with the
domain_tools/pipeline (AcroForm extract → bindings → validations). - Configure data sources (intake configs, API mappings).
- Import via
/api/root/schemasand/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.ts—SchemaUiConfig,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.ts—BANKRUPTCY_UI_CONFIGfallback.packages/client/src/pages/case-tabs/overview.tsx— reference consumer ofuseUiConfig().packages/client/src/pages/case-tabs/data.tsx+data-sections-view.tsx— consumeschema.ui.sections.packages/server/src/routes/— ~45 domain-blind HTTP handlers.packages/server/src/routes/root/schemas.ts— schema + UI import, including.ui.jsonpairing.domains/schemas/law/bankruptcy/bankruptcy.ui.json— the canonical bankruptcySchemaUiConfig.docs/engine.md— engine story, domain-agnostic infrastructure, cross-domain concept table.docs/domain.md— domain model technical reference.
docs/layers.md