Submission templates: select court address + team members for letterhead (variable bag extension) #139
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Goal (m, 2026-05-26 17:49)
In the submission-draft editor, lawyers should be able to:
competent_court_id, but overridable for letters going to a different court / co-defendant's court / etc.). The chosen court's name + address fields flow into the template via{{court.*}}variables.{{lawyer.1.*}},{{lawyer.2.*}}etc. Same shape as the t-paliad-277 multi-party pattern.Existing data
internal/handlers/courts.goalready carries full address per court (e.g. UPC-CD München: "Cincinnatistraße 64, 81549 München, Deutschland"). All UPC + DE Berufung + NL + Paris + Milan courts have it. No data gap on court side.paliad.userscarriesdisplay_name,email,office(8-office enum),profession(partner/of_counsel/associate/senior_pa/pa/paralegal),job_title(free text),additional_offices[]. No DB gap for the names.internal/offices/offices.gocarries only{key, label_de, label_en}per office. Missing: street address, phone, fax per office. Same shape ascourts.go— needs the 8 office addresses hardcoded.direct_phoneorfaxcolumn onpaliad.users. Optional gap for individualised contact lines.Scope (one issue, one branch — m's pick)
Data
internal/offices/offices.goto addAddress+Phoneper office (hardcoded directory). Head needs the 8 office addresses from m before dispatch — see open question §A below.direct_phone text NULL,fax text NULLtopaliad.users(additive migration, noset_configneeded since user-table mutations don't fire the deadline_rules audit trigger — verify via Supabase MCP audit). User edits via the existing/profilepage (add the two fields). NOT a blocker — letterhead can render an empty phone line until users fill it.Submission-draft editor UI
project.competent_court_id. Reads the courts catalog (already an HTTP endpoint at/api/courts). Selected court_id stored onpaliad.submission_drafts(new nullable columnselected_court_id).selected_lawyers uuid[]onpaliad.submission_drafts.Variable bag (
internal/services/submission_vars.go){{court.name}},{{court.name_en}},{{court.address}},{{court.city}},{{court.country}}populated from the selected court (or empty when none selected).{{lawyers.N.name}},{{lawyers.N.title}}(=job_title||profession_label),{{lawyers.N.email}},{{lawyers.N.office}},{{lawyers.N.office_address}},{{lawyers.N.office_phone}},{{lawyers.N.direct_phone}}for N ∈ [1, len(selected_lawyers)]. Mirror the existing multi-party emit-grouping pattern inemitPartyGroup.{{lawyers.names}}(comma-joined) and{{lawyers.offices}}for free-text letterhead variants — same way{{parties.claimants}}works.submission_vars_*_test.go.Word template hookup
de.inf.lg.erwidg.docx's letterhead block — that's m authoring the template in Word + push to mWorkRepo. Document the variable list in the README so template authors know what's available.Open questions for head before dispatch
hoganlovells.comsite footer. If m doesn't have time today, head can stubAddress: ""for now and lawyers fill it via a follow-up.Hard rules (lessons from today's mig 134 hotfix-3)
mcp__supabase__execute_sqlBEFORE drafting the migration. Specifically: confirmdirect_phone/faxdon't already exist onpaliad.users; confirmselected_court_id/selected_lawyersdon't already exist onpaliad.submission_drafts; confirm no audit trigger on either table that requiresset_config. Today's mig 134 had 3 hotfixes from skipped audits.updated_at = now()writes unless the column exists (it does onpaliad.usersandpaliad.submission_drafts— verify both before any UPDATE).Acceptance
/api/submission_drafts/{id}/renderproduces a .docx with{{court.*}}and{{lawyers.N.*}}substituted.go test ./...+bun run buildclean.Slice A (backend + contract) is on
mai/diesel/issue-139-letterhead-vars, CI run 677 green. Slice B (the editor surface) is a separate slice, approved by head. Not closing — m closes.Commits
af87252— migration 212e6cd7b3— column type fixfe47b12— Slice AThe issue's "Existing data" section is two months stale
Checked before building, because most of the proposed work is already done:
court.*is built and shipping.court.name/address/city/email/chamber, acourtResolver, andsubmission_vars_court_test.go, sourced fromproject.court_id. Item 1 is therefore not "add court variables" — it is only the per-submission override.firm.office.*already resolves the letterhead sender for the acting user (t-368 S5). Item 2 is only the generalisation from one person to N.internal/officesis no longer{key, label_de, label_en}. It is a DB-backed service overpaliad.officescarryingaddress_lines_de/en,phone,fax. Following the issue's recommendation to hardcode the 8 office addresses would today create a second copy of a fact the DB masters and exports to patentstyle. Open question §A is dead.Verified read-only against prod:
submission_draftshad neither column;paliad.usershas nodirect_phone/fax;paliad.officeshas phone/fax/address lines.What Slice A adds
Migration 212 adds
selected_court_id(FK topaliad.courts,ON DELETE SET NULL, mirroringprojects.court_id) andselected_lawyers uuid[]. Both default to today's behaviour, so no existing draft moves — an unset court means the project's court, an empty lawyer list means the acting user alone.selected_parties(mig 131) was the precedent for the array end to end — rawpq.StringArrayplus decoded[]uuid.UUID, the*[]uuid.UUIDthree-state patch contract, the handler field. Mirrored rather than reinvented.Variable shape follows t-277, not a second convention:
{{lawyers}}joined, zero-based{{lawyer.0.name}},{{lawyer.name}}for the first — exactlyparties.claimants/parties.claimant.0.name/parties.claimant.name. m wrote{{lawyer.1.*}}; head is putting the 0-based choice to him while nothing consumes the keys and a rename is still a find/replace.Two smaller calls worth stating:
lawyer.N.titleisjob_titleand nothing else.professionis the approval-ladder tier (senior_pa), not a form of address. It must not reach a filing. The issue proposedjob_title || profession_label; no profession-label table exists and inventing one would put a permissions enum on a court document.firm.office.*(extracted tolookupOffice), so the sender block and the office record cannot disagree about the same office.direct_phone/faxon users are not in this slice, per head. A per-user direct line is a genuinely absent fact rather than a duplicate, but columns nobody fills buy nothing.A live defect this uncovered, reported in its own right
filterPartiesBySelectionhas silently dropped unresolvable ids since mig 131. Delete a party from a project while a draft still lists it and the caption loses a named claimant with nothing said anywhere. The document exports looking finished.Mig 131 chose the prune deliberately — its comment says so, to avoid FK cascades across two tables — so the rendering is unchanged. Only the silence is.
Buildnow returnsUnresolvedSelectionsnaming the court, parties and lawyers it could not resolve.This is the same class as the two inert generator rules in #158: a state that produces no error, no marker, and no visible difference from the correct one.
resolveLawyerstakes a narrowuserLookuprather than the wholeUserService, so the deleted-colleague branch is testable without a database. It is the point of the file; it must not be the one path nothing exercises.Both detections were mutation-checked. Reverting each to its silent form fails exactly its own test and nothing else — 8 tests, no DB required.
One migration bug caught before it shipped
paliad.courts.idistext, notuuid— it is a slug, and so isprojects.court_id. The first draft of migration 212 declaredselected_court_id uuid REFERENCES paliad.courts(id), which would have failed at deploy on the FK type. CI run 674 failed on that commit and 675 passed with only the column type changed. (I read the type from the live schema; I did not read 674's log — the Gitea API returns no job detail for it.)Still open
.dotmletterhead block must actually USElawyer.*— patentstyle's artifact, needs a template edit by m plus a regeneration. Head owns it.Slice B is on
mai/diesel/issue-139-slice-b@cf3e4d7, CI run 682 green. Not closing — m closes.Read this part first: the feature is not working end to end
The
.dotmletterhead block does not consumelawyer.*yet. The pickers work, the variables resolve correctly, and they reach a template that ignores them. Editing that block is patentstyle's artifact and needs m — a template edit plus a regeneration. Until that lands, choosing three colleagues changes what the bag contains and not what the document prints.Same for the still-open
lawyer.0vslawyer.1naming: with m, and still a find/replace while nothing consumes the keys.The warning is the point of this slice
Slice A made
Buildreport every selection that resolved to nothing — a retired court, a party removed from the project, a deleted colleague. Nothing rendered it. A report that reaches no user is worse than no report, because the next person reads the case as covered while a lawyer still exports a document with a named person silently missing from it.So:
unresolved— the ids and a rendered sentence, both on purpose. The ids let the editor offer a repair; the sentence is what it shows when it cannot name what is missing, because a deleted user has no name left to print.role="alert", before the document is generated — not in the sidebar where it competes with the variable list.unresolvedJSONis its own function so that link is testable without a database. It was the untested link, and it was the one that did not exist.hasUnresolvedkeys on the three id lists and never on the message string — keying on the string would make an empty-but-present message read as all clear. There is a test for exactly that.Order is meaningful, and the picker keeps it
The backend prints
lawyer.0,lawyer.1, … in stored order, so the selection is a list from checkbox to PATCH, never a Set. A Set-based picker returns whatever order the DOM gives and would silently reshuffle a letterhead.Order is selection order, shown as a position number, and changeable with up/down buttons. Without those, reordering means unchecking everyone and re-picking in sequence — data entry rather than a choice.
Smaller calls
job_title, and the only other title on that row isprofession, the approval-ladder tier. Slice A ruled that out of the letterhead; previewing it here would show a title the document will never print.Verification
nilfromunresolvedJSONfails exactly its two Go tests; keyinghasUnresolvedon the message fails exactly its one TS test. Neither touched anything else.bun run buildclean, gofmt clean.Re-measured before building — two of three asks are already shipped
Branch
mai/knuth/issue-139-letterhead-vars, commit08c58df. Writeup:docs/findings-issue-139-remeasured-2026-07-30.md. Nothing built.{{court.*}}{{lawyer.N.*}}Items 1 and 2 are Slice A (
af87252,e6cd7b3,fe47b12) and Slice B(
cf3e4d7), both merged; mig 212 is applied in prod. The "Existing data"section above is dead — all four bullets are wrong now, and open questions
§A and §B are both closed. Anyone picking this up should not work from it.
Item 3 is not where this issue says it is
The issue, both slice comments and the dispatch all place the remainder in the
Word template — "patentstyle's artifact, needs a template edit by m plus a
regeneration". Measured, that is not the missing step:
HLC-Patents-Style.dotmcarries zero{{…}}placeholders. I scanned everyXML part. It is the macro-only letterhead.
gen-skeleton-submission-templateemits none either in its default mode.anchorsOnly = trueis the default and writes ten{{#section:KEY}}pairs andnothing else; the placeholder-carrying body needs
-anchors=falseand servespre-Composer drafts.
The letterhead text a draft gets comes from
paliad.submission_bases.section_spec, thehlc-letterheadrow:So the recipient half is already wired — the caption seed carries the court
and its address, and Slice A made which court that is overridable per submission.
The sender half is not:
{{user.display_name}}is whoever has the draftopen.
lawyer.*appears in no seed. Picking three colleagues changes thevariable bag and nothing the document prints.
That is the whole remaining gap: one field of DB-mastered content, not a Word
template and not a vendored artifact.
The question that has to be answered first
lawyer.0vslawyer.1is still open with m, and it is about to stop beingfree. The head's reason for deferring it was "nothing consumes the keys and a
rename is still a find/replace". That holds only while no seed names the key: a
seed is copied into
submission_sectionsat draft creation, so afterwards thestring lives in per-draft rows a lawyer may have edited, and the rename becomes a
data migration over user-edited text.
Measured now: 0 of 33 drafts have
selected_lawyersset, 0 haveselected_court_id. Nobody has used either picker yet. The rename is stillfree today, and it is free precisely because the seed does not reference the keys.
The split falls on exactly that line:
{{lawyers}}(chosen names, comma-joined) plus
{{lawyer.office_address}}/{{lawyer.office_phone}}for the first-named. No index reaches stored content.lines, which needs
{{lawyer.0.name}}or{{lawyer.1.name}}in the seed.A firm letterhead is normally the second shape, which is why this is worth
settling rather than shipping the first and redoing it.
Two things I checked rather than assumed, both favourable
resolveLawyersreturns theacting user when
selected_lawyersis empty(
submission_vars_letterhead.go:171), so a seed naming{{lawyer.name}}isbehaviour-preserving for all 33 existing drafts by construction. No separate
fallback work needed.
edited, none empty. A backfill discards nothing today. It stops being free the
moment somebody edits one, which argues for doing it in the same migration as
the seed change rather than later.
Together those make the remaining work one migration, not a slice.
One separate thing found on the way
gen-skeleton-submission-template's doc comment promises a file "exercisingevery placeholder SubmissionVarsService resolves". Its legacy body covers 38 of
78 catalogue keys — missing all of
court.*,lawyer.*,caption.*,deadline.*,procedural_event.*,firm.office.*. Part is deliberate(
deadline.*, t-paliad-287, and the comment says so) and part is drift; thepackage has no test file, so the claim is unchecked either way. Low stakes,
since the default path emits no placeholders at all — but the comment should
either stop promising completeness or be held to it.
Not closing — m closes.
Item 3: the letterhead now names the selected colleagues
Branch
mai/knuth/issue-139-letterhead-vars. Not closing — m closes.27a4b42{{lawyers.block}}, migration 216c086366c3263f9m's decision: per-colleague lines, each selected lawyer with name, title
and office. Head's decision: 1-based,
{{lawyer.1.name}}is the first.Resolver, tests and seed are in one commit on purpose — a seed naming
lawyer.1against a resolver emittinglawyer.0renders an empty senderblock, which does not error. The document simply comes out without its
letterhead.
The seed could not name indexed keys, and this is why
PlaceholderMapis a flatmap[string]string. The substitution engine hasno repetition construct, so a static seed can only name a fixed number of
indexed slots — and every fixed choice is wrong at some count:
{{lawyer.1}}…{{lawyer.3}}prints[Briefkopf: Name]twice on aone-colleague draft, which is every draft today.
{{lawyer.1}}alone silently drops colleagues two and three — the exactomission this issue exists to fix.
Measured rather than reasoned. With one colleague in the bag, all three slots
render as markers today:
(SLOT1 is a marker too, because the resolver was still 0-based — the seed/
resolver mismatch the head predicted, demonstrated.)
So the seed names
{{lawyers.block}}: one key whose value carries therepetition, rendering each colleague as their own group of lines. It is the
only count-independent shape the engine supports, and it produces exactly the
layout m asked for. The indexed keys stay, 1-based, for a template author who
wants finer control.
A colleague with no
job_titleloses that line, not their block — anabsent title is not a gap in a letterhead. A deleted colleague keeps their
position and renders the marker, matching the joined
{{lawyers}}value.Migration 216 — the WHERE clause is the refusal
It repoints the
hlc-letterheadseed and backfills the sections alreadymaterialised from the old one. A row whose text differs from the old seed
was edited by a lawyer and is not touched.
The head asked for refusal rather than overwrite; I read that as a guard,
not an abort, and want the reading on the record. Matching on the old value
makes overwriting an edit impossible rather than merely unlikely, and it
cannot take a deploy down — aborting the migration would mean one lawyer
editing their own letterhead crash-loops the deploy for everyone. Rows left
alone are counted and
RAISE NOTICEd, so a drifted row shows up in the deploylog rather than only in the difference between two documents. Say the word if
you want the harder form.
The signature section is deliberately unchanged: it names who signs, which
is a different question from who is on the letterhead, and m asked for the
letterhead. The test pins that it stays untouched.
Verified, not assumed
TestMigration216BackfillRefusesEditedRowsseeds a pristine and an editedletterhead section and applies the real migration file to them. The reference
seed carries no users, drafts or sections, so it builds the
auth.users → paliad.users → draft → sectionchain inside a transaction itrolls back.
Mutation-checked: dropping the value guard from the backfill fails exactly
the edited-row assertion and nothing else. It also pins what the jsonb rebuild
must not disturb — the signature seed and the 10-entry defaults array.
Both facts the head asked me to assert rather than assume now have tests:
resolveLawyersreturns the acting user on an empty selection (so all 33existing drafts keep today's behaviour), and the backfill leaves an edited row
alone.
Gate:
ci-test-gate.shgreen, all 7 migration gates executed,check-gofmtclean, 441 frontend tests. Migration 216 is free — prod's applied set is at
214 (my #150 migration landed), 215 is cronus', 216 is unheld elsewhere.
Two things that are not this issue
A regression I shipped yesterday, fixed here (
c3263f9).openTestPoolreturns
nilwhenTEST_DATABASE_URLis unset — it does not skip, andevery other caller checks the nil itself. Two tests I added with #150 assumed
it skipped, so
go test ./internal/services/panicked on a nil pool foranyone without a harness running. The gate never caught it because the gate
always exports the DSN. Separate commit, pushed first.
Still open, and it is what makes this feature visible. Nothing in the
.dotmconsumes any of this — it carries zero merge placeholders. Theletterhead a draft renders comes from the section seed, which this commit
fixes, so new drafts and the 24 backfilled ones will print the selected
colleagues. What still needs m is the firm's actual Word letterhead block if
the sender is to appear in the firm's own layout rather than as section prose.