The per-firm
mapping ships now as FirmTemplate (slug-keyed until migration). The definition's
home, the standalone Form table this plan proposes versus one canonical typed
Template, was deliberately left open with a provisional lean to the
canonical Template; see the forms-on-content
alternative. Custom firm forms and multi-scope sharing are deferred. This plan remains the
standalone option for that comparison.
- A form has two halves: a shared definition and a per-matter filled instance. Documents already split this way (Template and MatterDocument).
- The definition becomes a standalone
Formtable in content-service, with versions inFormVersion. Firm visibility and tags stay on the already-shippedFirmTemplate(LEX-677). No Template row, no emptyContent. - The instance becomes a
MatterFilledFormin matter-service, mirroringMatterDocument. The agent'sfilledforms://surface does not change. - Two additive, reversible, flagged migrations get us there: one for definitions, one for instances.
01 · The problem
The form that got left behind
A matter artifact comes in six types. Five are prose; one isn't, and that one has never moved to the modern model.
Form.FILLED_FORM artifact holds the answers as a JSON blob, rewritten on each save.filledforms://.A form is a grid of structured fields, not prose. When the artifacts → documents migration moved the five prose types, the form was carved out. The old artifact tables can't retire until it has somewhere else to live. This plan is that somewhere.
02 · The key idea
A form is two things, and documents already split them
Template, the shared definition, in the library.→
MatterDocument, one filled instance per matter.Form, the shared definition, in the library.→
MatterFilledForm, one filled instance per matter.03 · The design
In four decisions
The definition is a new Form table beside Template, not a row in it
Forms are not added to the Template table. They get their own table with their own columns, the ones the Forms tab actually shows:
Why not a FORM type in Template: two misfits.
Template requires FKs to ProseMirror content a form doesn't have. And a template
is one row per firm, while the form catalogue is one global definition with a per-firm view
on top. The per-firm slice is specified in the
forms library backend plan (LEX-677).
This also reverses the earlier draft's empty-Content workaround: a form has no
prose, so it has no content record, and the Template invariants stay untouched.
The instance is a MatterFilledForm, mirroring MatterDocument
The lawyer's answers live in matter-service as a MatterFilledForm: structured fields, a full snapshot per save, clean history, sitting beside MatterDocument and behaving the same way.
Why: it reuses the document model's finalisation, file-linking and list machinery. Matter-scoped access comes for free.
Firm visibility is the per-firm FirmTemplate row (shipped, LEX-677)
One row per (form, firm) says which firms see which forms and holds the firm-editable jurisdictions and categories. Lawhive curates and assigns; firms browse and organise. Firms don't upload forms and can't edit definitions.
Why not the template scope string: the catalogue is one global definition seen by many firms, a many-to-many; scope strings would duplicate the definition per firm.
The agent surface (filledforms://) doesn't change
The read/edit contract Lawrence already uses, per-field value/checked/skip, citations, confidence, compare-and-set, stays identical. Only what it writes underneath moves from the artifact blob to MatterFilledForm.
Why: low blast radius on the agent side; the fill loop and its prompts are untouched.
04 · The homes
Tested against the alternatives
Three candidate homes for the definition: content-service (this plan), the Lawrence Engine store (where definitions live today), or a new forms-service. Tested against what the designs demand:
FirmTemplate, one row per (form, firm)publishedVersionId, existsWhy not a dedicated forms-service: a fourth store whose first jobs are rebuilding scope, tags, permissions, upload, and a library UI that all exist in content-service.
Why not keep the catalogue in Lawrence Engine: a good pipeline, but no
tenancy. The ingestion plan's own v2 proposes an access table "modelled on content-service's
TemplateEditor". That is the tell: the access model already exists, one table
over from the precedents.
Lawrence Engine owns how a definition gets made (extraction, OCR, the correction loop). content-service owns what exists (the catalogue, visibility, the published version, the blank PDF). The one ingestion change: the finalise step writes a content-service FormVersion instead of the engine's Form.annotations_raw.
05 · Before and after
The architecture, side by side
Same three services in both panels; watch what moves. The catalogue moves to content-service. The instance gets a real model beside documents. Lawrence Engine shrinks to the one thing only it can do.
The whole change in one diff: the definition moves up-left, the instance gets a model, Lawrence Engine becomes a processor.
The data model: where every column lands
Matching colours mark the same data arriving in its new
home. The Template table is never touched.
Nothing is orphaned: every source column has a coloured destination. The blob's per-field review state (value, status, citations) maps 1:1 into fields.
The full target schema, as a reference:
// content-service, the definition Form id, slug @unique, name, code, issuer, publishedFormVersionId, deletedAt FirmTemplate formSlug (→ formId with this migration), scope, jurisdictions[], categories[] // shipped in LEX-677; which firms see it + their tags FormVersion id, formId → Form, status, fieldAnnotations (json), templatePdfFileId → content-service File // presigned upload + malware scan // matter-service, the instance (mirrors MatterDocument) MatterFilledForm id, matterId, formSlug, formJurisdiction, name, status, currentFinalisationId, latestVersionId MatterFilledFormVersion id, matterFilledFormId, fields (json), createdById, createdAt MatterFilledFormFinalisation id, versionId, finalisedByIdentityId // links a MatterFile
And what lives in the two JSON columns. One field of FL-100, definition beside its filled-in instance:
// FormVersion.fieldAnnotations · the definition: frozen structure, shared by every matter { "field_id": "21", "field_name": "Petitioner", "field_annotation": "Full legal name of the person filing the petition", "field_type": "Case Information", "page": 1, "field_coordinates": { "x1": 72.0, "y1": 214.5, "x2": 396.0, "y2": 232.0 }, "max_chars": 60, "field_pdf_ref": "FL-100[0].Page1[0].PetitionerName[0]", "field_pdf_widget_type": "text", "field_pdf_on_state": null // checkboxes carry their PDF on-state here, e.g. "1" } // MatterFilledFormVersion.fields · the instance: the same field, filled in on one matter { "fieldId": "21", "fieldName": "Petitioner", "fieldValue": "Jane A. Mitchell", "fieldStatus": "Complete", // or Incomplete · Skipped · Unsupported "changedBy": "Lawrence", "changedAt": "2026-06-12T14:03:22Z", "fieldExplanation": "Named as petitioner in the intake note", "citations": [{ "ref": 1, "source": { "type": "note", "id": "matnt_4f" }, "passage": { "text": "Petitioner: Jane A. Mitchell" } }], "confidence": { "score": 0.98, "rationale": "named verbatim in intake" }, "reviewedBy": null // set when the lawyer verifies the value }
The definition never changes per matter; the instance stores only answers and their provenance, one full snapshot per save.
The library's status column is a derivation of two
fields the schema already has: FormVersion.status and
Form.publishedFormVersionId. Matters in flight keep the version they pinned.
06 · Getting there
Two migrations, not one
Form row (+ FormVersion) in content-service. Catalogue-scale, around 250 forms. Run first, so the library is populated.FILLED_FORM artifact converts into a MatterFilledForm, carrying its own field snapshot. Per-matter scale, many. Self-contained, so it doesn't depend on A.Two datasets in two services: they run independently and ship in parallel, meeting only at the form picker (new work picks a Form and creates a MatterFilledForm).
07 · Implementation
Shipping it in four PRs
Thin vertical slices: each PR flips one resolver, behind a flag, reversibly.
The per-firm catalogue slice, specified in the forms library backend plan (LEX-677): FirmTemplate + shared categories in content-service, joined live to Lawrence Engine. Zero changes to any shared table. Built in content-service because it is this migration's end-state (Form.publishedFormVersionId → FormVersion cannot FK across databases).
Add FormVersion.fieldAnnotations; migrate the annotations into content-service; flip the definition resolver and the ingestion pipeline's finalise-write (extraction and the correction UI now consolidate into a FormVersion); drop the sourceFormId pointer. Lawrence Engine keeps PDF → fields extraction as a processor.
Add MatterFilledForm / MatterFilledFormVersion; migrate existing FILLED_FORM artifacts; repoint filledforms:// writes from the blob to the new tables. Self-contained, so it doesn't block on PR 2.
Finalisation and server-side stamping; retire the old artifact and artifact-generation catalogue tables once both migrations are validated.
08 · Moving the existing data
Backfilling what's already there
Both migrations reuse the proven artifacts → documents backfill machinery: chunked, idempotent, dry-run first, born hidden, validated, one-command rollback.
MatterFilledFormVersion snapshot). Plus 170 discarded, carried over soft-deleted for rollback symmetry.Counts from the warehouse mirror, June 2026. The published migration-scale doc sized the five prose types but excluded forms; these are the figures it didn't have.
The backfill job
- Idempotent and resumable. A
migratedTo…marker on the source is the single commit point. Re-runs skip what's done. A failed item leaves its source untouched. - Dry-run, canary, then full. Runs at
BATCHpriority, yielding to live traffic. - Full snapshot per version. Each
MatterArtifactVersionbecomes one completeMatterFilledFormVersion. The field JSON maps straight across; a form was never prose. - Finalised forms keep their bytes. The rendered PDF is re-pointed, never re-rendered. One foreign key flips.
Born hidden, validated before anyone sees it
PENDING_VALIDATION, invisible to the lawyer-facing list.VALIDATED and become visible.The backfill never destroys source data, it only sets the marker and writes new rows. Rollback restores the finalised-file foreign key, deletes the new rows, and clears the marker, returning the system byte-identical to before. Because lawyers only ever see VALIDATED rows, a rollback before promotion is invisible to them.
09 · What could go wrong
The blast radius
Every consumer of the Template table across four services was mapped. With forms in their own table: nothing breaks.
- Precedent flows are untouched. Forms never enter the
Templatetable. The editor, versioning and every template query keep working without a filter change. - No schema surgery. New tables only: no null-guards, no phantom rows, no consumer sweep.
- The one real job: the list endpoint and assignment plumbing, already shipped in LEX-677.
A form's definition becomes its own Form table beside our precedents; its filled-in instance lives beside matter documents; the per-firm FirmTemplate says which firms see it, and the Template table never changes.
10 · Open questions
What's still undecided
- Alignment with the flat-form ingestion workstream. Its correction UI writes to Lawrence Engine's
Form.annotations_raw, and its v2 plans an access table this design makes unnecessary. Agree the finalise-write flip before either puts down deeper roots. - Jurisdiction vocabulary.
jurisdictions[]is multi-value free text. Who curates the vocabulary, and does the picker ever need a hierarchy? - Is
MatterFilledFormFinalisationworth it for v1? It mirrorsMatterDocumentFinalisation(records the finalise, links theMatterFile, enables re-finalise). Could be deferred alongside server-side PDF stamping. - Server-side PDF stamping, deferred. Until then finalising still needs a browser, so an agent can't finalise a form it filled.
Template.statusvs a literalactivebool. Recommend folding intostatus(single source of truth); pending a final nod vs a duplicate column for migration parity.