previews · architecture · forms library backend

Lawrence engineering · Tech plan · 6 July 2026

Forms join the Library

The Library gets a Forms tab next to precedents. It lists the court forms Lawhive has assigned to the firm, with the codes and issuers lawyers browse by. This plan is the backend only: two new tables, two Lawrence Engine columns, one list endpoint. Lawhive curates the catalogue. Firms organise it: categories and jurisdictions are theirs to edit.

status: draft LEX-677 content-service figma

TL;DR · One new table: a per-firm FirmTemplate in content-service. It holds the firm's editable jurisdictions and categories. Global facts (name, code, issuer) stay in Lawrence Engine and are joined in by slug at render, behind a ~60s cache. Nothing is seeded and nothing needs syncing. The global Form table waits for Migration A.
Decision update · 9 Jul · Agreed with Lukas, Adolfo and Petros: the table ships as FirmTemplate (renamed from FirmForm) so any template type can be firm-associated later. It keys by formSlug until the wider migration, then re-points to templateId and the slug column is dropped. Home stays content-service. Firm-editable tags stay on this table, pending product clarity on whether tags become Lawhive-finalised at publish.

The gap

The fillable definitions already exist in Lawrence Engine (artifact‑generation‑service), keyed by slug. Three things are missing: which firm sees which form, the browsing metadata (code, issuer), and the firm's own tags: categories and jurisdictions are firm-editable (PM call, 7 Jul). The new tables supply the firm columns. Lawrence Engine supplies the global columns, joined live behind a short cache. Nothing needs keeping in sync.

What ships

Jurisdiction is multi-value like categories; both live on the firm's row, editable in place, seeded with defaults. England and Wales are one legal jurisdiction, shown as the single value England & Wales.

legal-osLibrary · Forms tab
platform-apicontent.form.lawyer.listFirmTemplates · ctx.firmId
content-serviceform.listFirmTemplates · scope guard
Postgrescontent db · FirmTemplate + categories
Lawrence Enginecatalogue joined by slug at render (cached ~60s) · definitions resolved at fill time
Figma design: the Precedents page's Forms tab listing forms with Name, Code, Issuer, Categories, Jurisdiction and Last updated columns, multi-value jurisdiction badges, and a search box
The latest design (Figma): the Forms tab beside Documents, with exactly this column set. Search-only toolbar: no upload.

Why not the Template table

Sharing the precedents' Template model looks tempting, but the fit breaks:

PropertyTemplate (precedents)Forms need
ContentcontentId and currentVersionId are required FKs to ProseMirror documentsNo document content; a slug reference to a Lawrence Engine definitionmisfit
OwnershipFirm or user created; scope string firm/<id>, one row per firmA global curated definition plus a per-firm customisable row on top; Template alone can't express the global halfmisfit
CategoriesFirm-scoped, user-managed vocabularyThe same, by PM decision, so forms share the firm's vocabulary via their own joinshared
ConventionsPrefixed ids, soft delete, scope-access middlewareSamemirror

The future unified precedents+forms view is presentational and composes from two queries. Merging two clean tables later is cheap; un-mixing a shared one is not.

Which database

lawrence-api was weighed and rejected. It has surface pull: the tab already calls its forms router, and its Skill/SkillAccess catalogue is the shape precedent. But two foreign keys decide against it. FormCategoryAssignment references the firm's TemplateCategory rows, which live in content-service. And Migration A ends with Form.publishedFormVersionId → FormVersion, also in content-service. Foreign keys don't cross databases. Build the slice where the migration ends.

content-service
documents, files, the precedent library · Postgres
files
File
collaborative prose stack
ContentContentUpdateContentSnapshotContentVersionContentShell
precedent library
TemplateTemplateCategory · shared with formsTemplateCategoryAssignmentTemplateEditor
this plan adds
FirmTemplate (per-firm)FormCategoryAssignmentForm (global) + FormVersion · arrive together with Migration A
lawrence-api
AI gateway · Postgres
chat + ai state
AIThreadAIMessageAIFeedbackAICustomInstructions
skills catalogue · the shape precedent
SkillSkillOwnerTeamSkillAccess
forms surface · no forms tables
forms routerLawrence Engine client
platform-api
BFF gateway · no database
content.template.lawyer.*content.form.lawyer.* (new)
lawrence-engine (AGS)
form definitions today · Postgres + S3
form+ code · issuer (this phase)formfillrecordS3 forms/empty (blank PDFs)
matter-service
per-matter instances · Postgres
MatterArtifact (FILLED_FORM)LegalForm (legacy)
rejected
Store in lawrence-api
  • Beside the forms router and Lawrence Engine client; the tab already calls lawrenceApi.forms.*
  • SkillAccess precedent one table over
  • No new platform-api module
Form.publishedFormVersionId ↬ FormVersion · FK cannot cross databases → second table move when Migration A lands
chosen
Store in content-service
  • The migration's end-state home: Migration A adds FormVersion + blank-PDF File beside Form, in place
  • "Next to precedents", as the ticket asks
  • Interim cost is small: one thin platform-api wrapper, seed over HTTP
Form.publishedFormVersionId → FormVersion · real FK, same database

Amber chips: the shape precedent (a Lawhive-curated, firm-assignable catalogue). Dashed chip: arrives with Migration A, not this plan.

Schema and API

Two tables in content-service, one Prisma migration, plus two columns in Lawrence Engine:

// The firm's form: one row per (Lawrence Engine form, firm), mirroring Template's anatomy.
model FirmTemplate {
  id                  String    @id @default(dbgenerated("concat('frmtpl_', nanoid())"))
  formSlug            String    // references the Lawrence Engine catalogue by slug (validated at assign)
  scope               String    // "firm/<id>", exactly like Template.scope
  jurisdictions       String[]  @default([])  // firm-editable, e.g. + "Fulton County"; Lawrence Engine defaults copied at assign
  createdByIdentityId String?   // who assigned it
  createdAt           DateTime  @default(now())
  updatedAt           DateTime  @updatedAt
  deletedAt           DateTime? // soft delete = unassign
  categories FormCategoryAssignment[]
  @@unique([formSlug, scope])
  @@index([scope])
}

// Shared vocabulary: the same firm-scoped categories the precedents use.
model FormCategoryAssignment {
  firmTemplateId String  // → FirmTemplate
  categoryId String  // → TemplateCategory (rename to LibraryCategory: follow-up)
  createdAt  DateTime @default(now())
  @@id([firmTemplateId, categoryId])
  @@index([categoryId])
}

// Lawrence Engine (Alembic migration): global facts stay at the source.
form + code    // court form code, e.g. "N5", "TR1"
     + issuer  // e.g. "HMCTS", "HM Land Registry"
// supersede flows carry both onto the replacement row (create_form inherits)

No seed, no sync, and the slug question

The catalogue is read live, so there is nothing to seed and nothing to reconcile. New Lawrence Engine forms are assignable immediately. Retired ones drop out of the join. Lawrence Engine's clone-based versioning can't strand a stored pointer.

FirmTemplate.formSlug treats slugs as globally unique. Lawrence Engine only enforces per-jurisdiction uniqueness, but prod data already satisfies the stronger claim (21 active forms, 21 distinct slugs), and platform-v3 assumes it today. Follow-up: a global slug WHERE active unique index in Lawrence Engine.

Future trajectory: each layer absorbs its own kind of change

Phase Form gains Arrives beside it
v1 (this plan)does not exist yet; Lawrence Engine holds the global facts (+ code, issuer)FirmTemplate + shared categories, joined live to Lawrence Engine
Migration Acreated once, from one source: slug, name, code, issuer, defaults + publishedFormVersionIdFormVersion (fieldAnnotations, templatePdfFileIdFile, status); list join flips local; FirmTemplate.formSlug optionally re-points to formId
Firm uploadownerScope String? (null = Lawhive; the Skill.firmId pattern); slug goes nullableupload → File (scan) → extraction → FormVersion; an auto-created FirmTemplate for the owner. Browse path unchanged.

Global facts → Form; definition content → FormVersion; firm flavour → FirmTemplate.

How it's used, end to end

Browse
Forms tab → platform-api content.form.lawyer.listFirmTemplates (ctx.firmId) → content-service → FirmTemplate + categories ⋈ Lawrence Engine catalogue (cached)
Organise
lawyer edits tags → form.updateFirmTemplateJurisdictions · add/removeFirmTemplateCategory → their FirmTemplate row only · global Form untouched
Preview blank
row → lawrence-api getBlankFormPreviewUrl(id from the live join) → Lawrence Engine S3, always the active version · after Migration A: content-service File
Fill on matter
Lawrence filledforms:// → lawrence-api → Lawrence Engine definition by slug · after Migration A: FormVersion
Curate
ops → form.internal.assignFormToFirm(formSlug) (validates vs Lawrence Engine, copies defaults) · code/issuer set in Lawrence Engine

Grey tails show where a chain moves once Migration A lands; nothing in this plan blocks or depends on it.

Build, non-goals, risks

PR 1Schema and ops

  • Migration: FirmTemplate, FormCategoryAssignment
  • Internal assign / unassign (slug-validated, defaults copied)
  • Lawrence Engine PR (ai-platform): code + issuer columns, exposed in /forms/, copied by the clone flow
  • Pre-task: deactivate the stale dev n5e row

PR 2Read path

  • content-service form.listFirmTemplates + platform-api wrapper
  • Organise mutations: jurisdictions + category add/remove
  • The Forms tab swaps its query source to platform-api
  • List-shape tests
  • Frontend consumes it in its own ticket
no definition editing no personal assignments no seed or sync machinery no unified table

Explicitly out of scope (these belong to the filled-forms migration plan, not LEX-677): Migration A (global Form + FormVersion + File: definitions move out of Lawrence Engine), Migration B (FILLED_FORM matterArtifacts → MatterFilledForm), and owner/firm-uploaded forms (needs Migration A's definition machinery plus an ownerScope column).