Lawrence engineering · Tech plan · June 2026

Filled forms, a home of their own

Five of our six artifact types are moving to a clean document model. The sixth, the filled-in form, can't follow, because it's structured data, not prose. This is the plan to give it a home that mirrors how documents already work.

content-service + matter-serviceadditive · reversible · flag-gatedstatus: draft for review

Status after the 9 Jul migration chat

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.

The plan, in one box

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.

definition
Lawrence Engine, artifact-generation. Blank PDF, AI vision, field annotations stored as a Form.
instance
matter-service. A FILLED_FORM artifact holds the answers as a JSON blob, rewritten on each save.
surface
frontend + agent. A form editor renders the PDF and overlays values; Lawrence fills via filledforms://.
Why it can't just move with the others

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

documents (already split)
Template, the shared definition, in the library.
MatterDocument, one filled instance per matter.
forms (this plan)
Form, the shared definition, in the library.
MatterFilledForm, one filled instance per matter.

03 · The design

In four decisions

Decision 1

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:

slug
name · code · issuer
jurisdictions[] · per firm
categories[] · per firm
n5e-claim…
"Claim for possession" · N5 · HMCTS
England & Wales
L&T
tr1-transfer…
"Transfer of title" · TR1 · HM Land Registry
England & Wales
Conveyancing

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.

Decision 2

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.

Decision 3

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.

Decision 4

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:

the mockups demand
content-service
Lawrence Engine today
Per-firm visibility
FirmTemplate, one row per (form, firm)
no tenancy at all
One library, Documents | Forms tabs
two tables, two queries, one page
federate two backends
Upload → Processing → Published
the precedent-upload pipeline, incl. malware scan
admin-only presigned upload
Draft vs Published definition
publishedVersionId, exists
active flag only
"Changes apply to future matters"
instance pins a FormVersion
would need inventing

Why 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.

The seam

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.

Today
content-service
Template · DOCUMENT only
Content · File · scope, scan, versions
forms don't exist here; the library never sees them
matter-service
MatterDocument · the clean model
MatterArtifact · FILLED_FORM
version = one JSON blob · rewritten each save
Lawrence Engine · artifact-generation
form · annotations · slug · jurisdiction · active
blank PDFs · its own S3 bucket
extraction pipeline · PDF → fields
fill pipeline · being retired
the catalogue lives here: global, no tenancy, invisible to the precedent library
admin uploads PDF Lawrence Engine extracts agent fills via filledforms:// the blob
Proposed
content-service
Template · DOCUMENT only, untouched
Form · FormVersion · FirmTemplate · catalogue, annotations, publishNEW
File · now also the blank PDF
the catalogue joins the precedents: scope, tags, publish, upload
matter-service
MatterDocument
MatterFilledForm · MatterFilledFormVersion · MatterFilledFormFinalisationNEW
the instance sits beside documents, snapshot per save
Lawrence Engine · artifact-generation
extraction only · a processor: PDF in, fields out
keeps its pipeline working state; the catalogue is no longer its job
Lawhive ops upload PDF File Lawrence Engine extracts FormVersion library picker MatterFilledForm · filledforms:// unchanged

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.

form · lawrence-engine
slug · jurisdiction · display_name
annotations (json, per-field)
filename → Lawrence Engine's S3 bucket
active · processing_status
Migration A~252 definitions
Form · content-service
slug · name · code · issuer
publishedFormVersionId · deletedAt
FirmTemplate · content-service · shipped, LEX-677
formId · firmId · assignedBy
FormVersion · content-service
fieldAnnotations (json)
templatePdfFileId → File (scanned upload)
MatterArtifact · matter-service · FILLED_FORM
formSlug · formJurisdiction
artifactStatus
fileId → the finalised MatterFile
MatterArtifactVersion
htmlContent · filled_fields (the blob)
createdById · createdAt
Migration B476 forms · 2,398 versions
MatterFilledForm · matter-service
formSlug · formJurisdiction
status
MatterFilledFormVersion
fields (json) · one full snapshot per save
createdById · createdAt (preserved)
MatterFilledFormFinalisation
the same MatterFile, re-pointed (FK flip, no re-render)

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.

Processing
extraction running on the uploaded PDF
Draft
version READY, not yet published
Published
publishedFormVersionId set; new matters use it
Archived
Form archived (soft delete); hidden from pickers

06 · Getting there

Two migrations, not one

Migration A · definitions
Every blank form leaves Lawrence Engine and becomes a Form row (+ FormVersion) in content-service. Catalogue-scale, around 250 forms. Run first, so the library is populated.
Migration B · instances
Every existing 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.

PR 1
Catalogue entry de-risk

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).

PR 2
Definitions move home Migration A

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.

PR 3
Instances move home Migration B

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.

PR 4
Cleanup last

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.

Migration A · definitions
~252 form definitions in the artifact-generation store, one version each. Catalogue-scale: a few minutes of work.
Migration B · instances
476 filled forms across 97 matters, 2,398 version rows (each becomes a 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

Born hidden, validated before anyone sees it

1 · backfill
migrated rows land PENDING_VALIDATION, invisible to the lawyer-facing list.
2 · validate
field-by-field equivalence, version-count reconciliation, finalisation integrity (same file, no new bytes).
3 · promote
only rows passing every check flip to VALIDATED and become visible.
Rollback is one command

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.

In one breath

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