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.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.
Why not the Template table
Sharing the precedents' Template model looks tempting, but the fit breaks:
| Property | Template (precedents) | Forms need | |
|---|---|---|---|
| Content | contentId and currentVersionId are required FKs to ProseMirror documents | No document content; a slug reference to a Lawrence Engine definition | misfit |
| Ownership | Firm or user created; scope string firm/<id>, one row per firm | A global curated definition plus a per-firm customisable row on top; Template alone can't express the global half | misfit |
| Categories | Firm-scoped, user-managed vocabulary | The same, by PM decision, so forms share the firm's vocabulary via their own join | shared |
| Conventions | Prefixed ids, soft delete, scope-access middleware | Same | mirror |
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
lawrence-api
platform-api
lawrence-engine (AGS)
matter-service
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
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
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)
- Read:
form.listFirmTemplates({ scope })returns the firm's rows + categories, joined by slug to Lawrence Engine's active catalogue (~60s cache, stale on failure). Each row carries Lawrence Engine's current form id, so the blank-PDF preview never goes stale. platform-api exposes it ascontent.form.lawyer.listFirmTemplates(); the firm comes fromctx.firmId, never the client. - Organise:
updateFirmTemplateJurisdictionsandadd/removeFirmTemplateCategory, scope-guarded like template edits. Categories come from the firm's existing vocabulary, so one category manager serves both tabs. - Ops-only:
assignFormToFirm({ formSlug, firmId })validates the slug against Lawrence Engine and seeds mapped jurisdiction defaults (gb-england-wales → ["England & Wales"]). Unassign soft-deletes. Re-assign revives the row with the firm's edits intact. - No status column: visibility for a firm is its live
FirmTemplaterow.
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 A | created once, from one source: slug, name, code, issuer, defaults + publishedFormVersionId | FormVersion (fieldAnnotations, templatePdfFileId → File, status); list join flips local; FirmTemplate.formSlug optionally re-points to formId |
| Firm upload | ownerScope String? (null = Lawhive; the Skill.firmId pattern); slug goes nullable | upload → 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
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+issuercolumns, exposed in/forms/, copied by the clone flow - Pre-task: deactivate the stale dev
n5erow
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
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).
- Row-click behaviour (preview vs start a fill) is a separate ticket.
- Vocabulary naming: renaming
TemplateCategoryto a neutralLibraryCategoryis a mechanical follow-up. - Lawrence Engine in the browse path: on Lawrence Engine failure the list serves stale or fails visibly. Accepted interim; today's tab has the identical dependency, and Migration A removes it.
- Clone-copy requirement: Lawrence Engine's supersede flows must carry
code/issueronto the new row, or a re-annotation silently nulls them. Fixed at the create choke point in the Lawrence Engine PR, with a regression test. - Filtering on jurisdictions and categories comes later; Postgres array operators cover it without schema change.