A-S3 part 1 (merge-fallback letterhead, in-repo): the fallback skeleton
(docx.BuildFallbackSkeleton) gains a minimal Word page-header letterhead
(word/header1.xml) carrying only {{firm.name}}, filled from branding by the
variable bag. A generated fallback-path document now repeats a correct,
firm-agnostic firm identity on every page (the firm name moved out of the body
into the header; no hard-coded firm name anywhere). Wired the header part:
Content_Types override + document.xml.rels relationship (rId2) + sectPr
headerReference; document gains xmlns:r. Deliberately minimal — the fallback is
a starter, not full firm chrome.
A-S3 part 2 (firm-skeleton .docx, mWorkRepo — separate commit): _firm-skeleton.docx
footer1's "Firm name" SDT content control hard-coded "Hogan Lovells" → now
{{firm.name}}, filled by the Composer's final renderer pass / merge.go (both run
SubmissionRenderer over header/footer parts). Surgical <w:t> edit + repack (all
38 parts preserved, verified renders + fills cleanly, integrity OK). Pushed to
HL/mWorkRepo as mAi (commit 5a3a1722).
Path taken: option (a) for the firm NAME (cleanly placeholderised). NOT option
(a) for footer2's HL legal-entity boilerplate (registered no. OC323639, LLP
structure, 40+ office cities) — token-swapping the name into it would assert
false legal facts for a non-HLC firm; left intact and flagged (needs per-firm
legal-footer config, not templating). No corrupt .docx shipped.
Completes the Rubrum + letterhead auto-fill Option A train (A-S1 signature_block
+ generate-fallback fix; A-S2 parametric caption.*; A-S3 firm-agnostic letterhead).
Tests: fallback header asserts {{firm.name}} present + renders firm-agnostically
(de+en); patched firm-skeleton verified to render + fill (transient check).
go vet ./... + bun build clean; touched packages green.
Completes the nomen train (S1–S5). Adds the FIRM tier of the name-composition
precedence chain — per-document → user → FIRM → system (PRD §3.1/§3.2) —
mirroring firm_dashboard_default exactly.
Storage + service:
- Migration 162: paliad.firm_name_compositions singleton (id=1, CHECK id=1,
RLS read-all + service-role writes) — same shape as firm_dashboard_default
(mig 117), holding a validated { artifact_id: Composition } jsonb map.
- FirmNameCompositionService (Get/Set/Clear) + getFirmNameCompositions /
setFirmNameCompositions / clearFirmNameCompositions singleton helpers in
name_composition_spec.go.
Resolution:
- resolveComposition is now variadic over ordered specs (user, firm); first
valid wins, else system default. Existing single-spec callers unchanged.
- Render path threads the firm tier: renderSubmissionDraftTitle /
RenderSubmissionFilenameFor gain a firm param; newDraftName +
submissionDownloadFilename load it (nil-safe). A firm default thus changes
the effective name for every user without a personal override.
Admin surface (mirrors firm_dashboard_default):
- GET/PUT/DELETE /api/admin/name-compositions{/artifact_id} (adminGate) read
back / set / clear the firm default per artifact.
- /settings Namensschemata cards gain an admin-only "Firmenstandard" block
(set from the current template field / clear) revealed via is_admin, plus a
"Firmenstandard" badge for non-admin users whose effective name comes from
the firm tier. SettingsNameArtifact now resolves user→firm→system and
exposes firm_is_set/firm_template.
Tests: pure precedence (user>firm>system) + firm-tier view + live firm
round-trip/Validate-rejection (via db.ApplyMigrations). go vet, go test ./...,
bun build all clean; gated live tests green against TEST_DATABASE_URL.
NOTE (merge ordering): golang-migrate is forward-only. Migration 162 must not
reach a DB before bohr's 161 (Rubrum Composer seed) exists, or 161 will be
skipped (current>161 → never applied). Merge 161 before/with 162.
Browser Playwright of the admin firm controls deferred to post-deploy
mai-tester — shared Supabase login wall blocks pre-merge browser login (same
ceiling as t-paliad-354).
Promotes the case caption (Rubrum) to ONE parametric set of resolver keys
(caption.*) consumed identically by every render path, so the wording no
longer diverges per path and reflects the forum.
New caption.* keys (addCaptionVars, submission_vars.go), each in bare +
_de + _en forms (bare resolves to the draft language):
caption.heading · caption.claimant_designation · caption.defendant_designation
caption.versus · caption.subject
Parametrised from data already in the bag — NO new schema:
- designations reuse the proceeding-type role-label overrides (mig 137:
upc.apl.unified→Berufungskläger, upc.rev.cfi→Antragsteller (Nichtigkeit),
epa.opp.*→Einsprechende(r)/Patentinhaber(in)); else instance-derived
appeal/cassation (project.instance_level); else civil default Klägerin/
Beklagte // Claimant/Defendant.
- heading + subject from jurisdiction + the dotted code's nature segment
(inf→"In dem Rechtsstreit"/"Patentverletzung", null→"In der
Patentnichtigkeitssache", UPC→"In der Sache"/"In the matter",
opp→"Im Einspruchsverfahren").
Also exposes project.proceeding.jurisdiction.
All three render paths now reference the SAME keys:
1. docx.BuildFallbackSkeleton (merge fallback) — heading/designations/versus/
wegen-subject are {{caption.*}} placeholders.
2. demo per-code template de.inf.lg.erwidg.docx (mWorkRepo) — regenerated via
scripts/gen-demo-submission-template; caption wired to caption.*; closing
drops the duplicate {{firm.name}} line (now carried by signature_block).
Pushed to HL/mWorkRepo as mAi (commit 3682299).
3. Composer caption seeds — mig 161 rewrites the caption section seed_md of
all 4 bases (hlc-letterhead, neutral, lg-duesseldorf, upc-formal) to the
parametric form (position-independent jsonb_agg patch; reversible down).
our_side is intentionally NOT a caption driver — the caption designates both
parties by procedural role regardless of which side we act for.
Tests: resolveCaption forum matrix (DE-LG/BPatG/UPC/role-label/instance-appeal/
EPA-opp), bare-resolves-to-lang, fallback skeleton renders caption.* keys.
mig 161 passes TestMigrations_DryRun. go vet ./... + bun build clean; all
touched packages green (the live approval/migration_136 failures are
pre-existing shared-DB env issues, unrelated).
LEXY-REVIEW FLAGS in the report — DE caption conventions are practitioner
convention, not in the youpc corpus; specific wordings flagged for sign-off.
Adds the /settings "Namensschemata" tab so users can customise the two wired
name artifacts (submission_draft_title, submission_docx_filename) via a
single-line {token} template, with a clickable palette, live preview, and
reset-to-default — PRD §7.
Engine (pure, pkg/nomen):
- Composition.Template() serialises a composition to "{var}" shorthand;
ParseTemplate() is its inverse — tokens + literal separators (trailing,
owned by the left segment) + paren Wrap. Missing-rules are NOT in the
shorthand (PRD §7); the parser leaves every segment KindOmit. Leading /
trailing literals are rejected (the trailing-separator model can't carry
them) so a save never silently drops characters. Table + round-trip tests.
Paliad glue (internal/services/name_template.go):
- ParseNameTemplate overlays each segment's missing-rule from the artifact's
system default and validates against the catalog.
- PreviewNameComposition renders against the fixed PRD sample (Bayer AG / UPC
/ Sandoz / UPC_CFI_123/2026 / today) and an empties resolver so the
missing-rule behaviour is visible. The frontend never parses templates —
the nomen engine stays the single source of truth.
- SettingsNameArtifacts / SettingsNameArtifact build the per-artifact cards
(current template, system default, override flag, ordered palette, previews).
API (internal/handlers/name_compositions.go):
- GET /api/me/name-compositions — cards
- POST /api/me/name-compositions/preview — live preview + validation
- PUT /api/me/name-compositions/{artifact_id} — store override
- DELETE /api/me/name-compositions/{artifact_id} — reset to system default
Storage reuses the Slice-3 service surface (UserNameCompositions /
SetUserNameCompositions) via read-modify-write; no new column, no migration.
Frontend: new tab + JS-built cards (palette insert-at-cursor, 250ms-debounced
preview, save/reset, DE/EN labels), CSS, and i18n keys (de + en).
Gates: go vet, go test ./..., bun build all clean. Browser verification of the
settings UX is deferred to post-deploy mai-tester — the shared Supabase login
wall blocks pre-merge browser login (same ceiling as t-paliad-354).
Two letterhead/Rubrum auto-fill fixes (Option A, no schema change):
1. firm.signature_block: was hardcoded "" ("reserved for Phase 2"), so every
template referencing {{firm.signature_block}} rendered blank. Now filled
from branding.Name — the firm identity line of a submission's signature
block (the signature section seeds with signature_block + user.display_name).
Firm-agnostic: a FIRM_NAME redeploy signs with the right firm.
2. Generate-fallback junk (kepler audit §1 Path 3): resolveSubmissionTemplate is
the merge-path resolver (every caller feeds merge.go), but its lower tiers
fetched _firm-skeleton.docx / _skeleton.docx — which were repurposed into
anchors-only Composer bases (t-paliad-313 Slice B). Their bodies hold only
{{#section:KEY}} markers, which placeholderRegex ignores, so merge.go emitted
them verbatim as literal "{{#section:letterhead}}…" junk for every code
without a per-code template (i.e. everything except de.inf.lg.erwidg).
Fix:
- docx.BuildFallbackSkeleton(lang): in-process, lang-aware, merge-safe basic
Schriftsatz with a data-driven basic Rubrum (real {{key}} placeholders the
var bag fills). Always available, no Gitea round-trip.
- docx.HasMergePlaceholders guards tiers 3/4/5: a fetched skeleton is used
only if it carries real placeholders, else we fall through to the embedded
fallback. Today's anchors-only/placeholder-free files are skipped; a future
merge-safe firm-skeleton (with letterhead) is preferred again automatically.
- merge.go strips stray {{#section:…}}/{{/section:…}} markers defensively so
no anchors-only carrier can ever leak Composer junk into a merged document.
Verified: confirmed live that deployed _firm-skeleton.docx + _skeleton.docx are
anchors-only (fetch+unzip); unit tests cover BuildFallbackSkeleton rendering a
real Rubrum (de+en), HasMergePlaceholders classification, marker stripping, and
the signature_block fill. go build / vet ./... / test ./... + bun build clean.
Out of scope (flagged for next slices): demo template's closing prints
{{firm.name}} then {{firm.signature_block}} (=firm.name) → A-S2 dedups the demo
wording. Restoring firm letterhead chrome to the merge fallback → A-S3.
PRD §3. A user can now override the system-default name composition for an
artifact; rendering prefers a valid user override over the system default.
No UI yet (Slice 4) — overrides are settable via the service API + tests.
- Migration 160: users.name_compositions jsonb NOT NULL DEFAULT '{}' — a
{ artifact_id: Composition } map.
- NameCompositionSpec (internal/services/name_composition_spec.go): Validate
(write: known artifact + segments reference the artifact's catalog vars)
and SanitizeForRead (read: drop unknown artifacts, drop segments with
unknown vars, clamp version) — mirrors DashboardLayoutSpec. Plus
nomen.Composition.SanitizeForRead and lowercase JSON tags on the nomen
types so the stored shape is stable.
- Resolution: resolveComposition(artifact, overrides) returns the user
override when present + valid, else the system default. The firm slot
(PRD §3.1) is reserved for Slice 5 — system is the fallback below user
here. Wired into BOTH artifacts: the draft-title via the create path
(newDraftName → autoNameFor{Project,NonProject}) and the .docx filename
via RenderSubmissionFilenameFor in the three download handlers.
AutoSubmissionTitle / RenderSubmissionFilename stay as the nil-override
(system-default) references the #155/354 matrices pin.
- Per-document keyword override generalised: writes now land at
composer_meta.name_overrides.keyword (the general {var:value} shape);
reads honour both that and the legacy composer_meta.filename_keyword
(back-compat). The read moved to services.SubmissionFilenameKeyword
(handlers delegate) so it is live-testable.
BACK-COMPAT FINDING: the one shipped filename_keyword row needs no data
migration — the read resolves legacy composer_meta.filename_keyword as
name_overrides.keyword. Verified by a live round-trip (seed legacy jsonb →
Get → SubmissionFilenameKeyword == the legacy value).
Verified (TEST_DATABASE_URL): (a) name_compositions Set/Get round-trip +
write-time Validate rejection of an out-of-catalog variable; (b) a user
override beats the system default for the title (through Create:
'Klageerwiderung <date>' vs default '<date> Klageerwiderung') and the
filename (through RenderSubmissionFilenameFor), with the nil-override
default unchanged; (c) legacy filename_keyword back-compat read. Plus unit
tests for nomen.SanitizeForRead, NameCompositionSpec Validate/Sanitize/
resolve, and the keyword back-compat read. go vet + go test ./... (15
pkgs) + bun build clean. No Playwright (no UI this slice).
Read-only audit. Maps the 3 doc-generation fill paths (merge.go fills
header/footer placeholders; compose.go passes headers byte-for-byte;
skeleton-as-merge-fallback latent bug), three gap tables (template /
var-bag / data-model), forum-dependence grounded on UPC RoP r.13, and a
tracer-bullet-first wiring proposal. States the A/B fork for m: wire what
current data supports (basic caption, no schema) vs. forum-correct Rubrum
(structured party address/Rechtsform/Sitz + court details + capture UI).
PRD §6. Project-less drafts no longer fall back to the bare 'Entwurf N'
counter — they render the submission_draft_title artifact through the
nomen engine, leading with the date like project drafts do (m's ask).
newDraftName's non-project branch now resolves a keyword (document type)
from the draft's submission_code and renders '<date> <keyword>', e.g.
'2026-06-01 Klageerwiderung'. When the code has no published filing rule
the keyword degrades to the localized 'Entwurf'/'Draft' word, yielding
'<date> Entwurf'. Collisions get the usual ' (N)' suffix via
uniqueDraftName.
FLAG resolved: project-less drafts DO carry a required submission_code
(the global-create handler rejects an empty code), and submission_code ->
rule name is a function across the published filing rules (the code
encodes the proceeding, e.g. de.inf.lg.erwidg -> Klageerwiderung), so the
keyword resolves project-independently via a LIMIT 1 catalog lookup. Both
branches (rule-name / Entwurf fallback) flow through the same composition.
The keyword is a new, normally-omitted segment on the title composition;
project drafts leave it empty so they render identically to #155 — the
regression guard. The identity trio and the keyword are mutually exclusive
by construction (project => trio, no project => keyword).
Verification: PRIMARY gate is a service-level live test (TEST_DATABASE_URL)
asserting Create(projectID=nil) yields '<date> <keyword>', the ' (2)'
collision suffix, EN document type, and the '<date> Entwurf'/'Draft'
fallback — all green against the real DB. Existing project-title matrix
(TestAutoSubmissionTitle) unchanged. go vet + go test ./... + bun build
clean. Browser (Playwright) verification not meaningful pre-merge: S2 is
not yet deployed (only S1/cd3f784 is) and the naming is server-side in
Create behind the shared-auth login wall.
Slice 1 of the filename-generator train (PRD 2026-06-01 §8). Pure refactor
behind byte-equality — no user-visible change.
pkg/nomen: the reusable engine. A Composition (ordered Segments, each with
a trailing separator, optional wrap, and an omit/placeholder/literal
missing-rule) renders against a VarResolver and a RenderTarget. Targets
split into SanitiseValue (per-variable) + Finalise (whole-string + suffix)
so a human title and a sanitised filename are two targets of one
composition. VarCatalog + Validate guard stored compositions.
internal/services/namegen.go: paliad-side wiring — the two seed system-
default compositions that reproduce AutoSubmissionTitle (#155) and
submissionFileName (354) as DATA, their variable catalogs, the resolvers
(built from the existing submission_autoname helpers), and the artifact
registry binding artifact -> catalog -> target -> default.
Repointed call-sites: AutoSubmissionTitle and handlers.submissionFileName
are now thin wrappers rendering through the registry; the assembly logic
lives in the engine. Removed the hardcoded title/filename assembly and the
handler's Az.-folgt const (now the case_number segment's placeholder).
FLAG resolved (separators): the PRD sketched LEADING separators; that can't
reproduce #155's client-absent case (date must join forum with a space
while forum->opponent stays ' ./. '). Switched to TRAILING separators
(owned by the left segment) — the minimal faithful fix. PRD §2.1 annotated.
FLAG resolved (back-compat): the shipped composer_meta.filename_keyword
override still flows through the engine — live round-trip test green.
Acceptance: all existing #155/354 test matrices pass UNCHANGED (the
byte-equality gate); new pkg/nomen unit tests cover trailing-sep, the three
missing-rules, targets, and Validate; namegen_test validates the seeds
against their catalogs. go vet + go test ./... + bun build all clean.
Engine (pkg/nomen) renders a name from (composition, var-bag, render-
target). Structured segments + string shorthand; omit/placeholder/literal
missing-rules; title vs filename targets. Both shipped schemes (#155
draft title, 354 export filename) fold in as data-driven seed defaults
with byte-equality as the acceptance gate. Non-project drafts get a
date-first <date> <keyword> name (m's immediate ask).
Settings built ON the dashboard-layout-spec precedent: system -> firm ->
user -> per-document precedence, validated jsonb spec. Project-level
deferred to v1.1 (storage path reserved on projects.metadata).
5-slice train: engine+faithful-refactor -> non-project fix -> user
override -> settings UX -> firm default. All 8 grilling questions
answered (matched recommendations), captured in the decisions section.
Generated documents now download as "YYYY-MM-DD keyword (case number).docx"
(date first/sortable, case number bracketed) instead of the old
"rule-case-date.docx" shape.
- submissionFileName: date-led frame; keyword = user override > lang-aware
rule name > "submission"; case number always bracketed, placeholder
"Az. folgt" (named const) when the project has no Aktenzeichen.
- SanitiseSubmissionFileName hardened to fold the full Windows-reserved
set (colon star question angle pipe) on top of slash/backslash, while
preserving spaces + parentheses so the assembled frame stays
human-facing yet filesystem-safe.
- User-replaceable keyword stored in the draft's composer_meta jsonb
(filename_keyword, no migration). Editor gains a "Stichwort (Dateiname)"
input that placeholders the auto rule name and persists via the draft
PATCH path. One-click /generate has no draft row -> keeps auto keyword.
Tests: submissionFileName (full / no-AZ / override / EN / slash case-no /
blank override / empty rule), submissionFilenameKeyword, extended
sanitiser cases.
t-paliad-354
New project-bound submission drafts now default to a sortable, legal-
convention title instead of the bare "Entwurf N" counter:
<YYYY-MM-DD> <ClientName> ./. <ForumShort> ./. <OpponentName>
- Date leads (ISO, Europe/Berlin) so drafts list chronologically; " ./. "
is the German legal "gegen" separator.
- Client = root 'client' ancestor of the project tree.
- Forum = proceeding-type jurisdiction (UPC/EPA/DPMA); German proceedings
resolve to the deciding court (LG/OLG/BGH/BPatG) from the code tail.
- Opponent = primary opposing party, picked by our_side posture
(active → defendant bucket, reactive → claimant bucket).
- Any segment that resolves empty is omitted with its leading separator;
a project-less draft keeps the legacy "Entwurf N" scheme entirely.
- Create-time only: existing drafts are never renamed, and a lawyer's
later manual rename via Update is untouched. Same-slot collisions
de-duplicate with a " (N)" suffix.
Customization scope (per-user / firm / template, issue #155 Q4) is v1.1 —
the template is hardcoded in submission_autoname.go for now; the override
string is documented as the single extension point on AutoSubmissionTitle.
Example output:
full: 2026-05-31 Bayer AG ./. UPC ./. Novartis Pharma
no opponent: 2026-05-31 Bayer AG ./. BPatG
no forum: 2026-05-31 Bayer AG ./. Novartis Pharma
date only: 2026-05-31
AutoSubmissionTitle + segment resolvers are pure and table-tested
(submission_autoname_test.go); the Create flow is covered end-to-end
against real Postgres in submission_draft_autoname_live_test.go (gated
on TEST_DATABASE_URL).
planck flagged via mai report feedback (id 12301) after the B5+B6
verification round caught them:
- §5.4 'INSERT into paliad.project_parties' → real table is paliad.parties
- §5.4 'status=open' → real CHECK constraint allows pending/completed/cancelled/waived
- §7.4 listed verfahrensablauf-detail-mode.ts as dead code, but builder
imports filterByDetailMode from it; struck through with KEEP note.
Code shipped (B5+B6) used the correct values throughout; this aligns
the historical PRD with reality so a future reader doesn't repeat the
verification time planck spent.
Litigation Builder slice B6 (m/paliad#153 PRD §7.1 + §7.4 + §10) — last
slice of the train; the Builder is now complete.
Mobile basic-read (<640px, PRD §10):
- builder.ts wireMobileGuard — a capture-phase document click listener that,
only when matchMedia("(max-width:640px)") matches, intercepts taps on
mutating affordances (rename/share/promote/new-scenario/add-proceeding +
every in-triplet button/input/select), preventDefault+stopPropagation,
and surfaces a "Auf größerem Bildschirm öffnen" toast. Desktop code paths
are untouched (guard early-returns off-mobile). Column-triplets already
collapse to a single column via the reused .fr-columns-view @640px rule;
reading (open/switch scenarios, search, mode tabs) stays fully functional.
- global.css — .builder-mobile-toast + full-bleed modal on phones.
Dead U0-U4 catalog cleanup (PRD §7.4) — deleted, no remaining references
(grep "from.*fristenrechner-|from.*verfahrensablauf" shows only the kept
verfahrensablauf-core + verfahrensablauf-detail-mode the builder reuses):
- client/fristenrechner-mode-a.ts, fristenrechner-result.ts(+test),
fristenrechner-wizard.ts(+test)
- client/verfahrensablauf.ts, client/views/event-card-choices.ts,
client/views/verfahrensablauf-state.ts(+test)
- components/VerfahrensablaufBody.tsx
(/tools/fristenrechner + /tools/verfahrensablauf stay as 301 redirects to
/tools/procedures — handlers already redirectToProcedures.)
i18n finalised (DE+EN): removed 4 duplicate deadlines.party.* keys per
block (behaviour-preserving — the later, winning copy is kept) and added
the missing DE "cal.today". Codegen clean, no dupes, full DE/EN parity.
go build/vet clean; bun build + 227 frontend tests pass. Playwright-
verified: at 375px the triplet collapses to one column + the scenario
list reads, while "+ Verfahren hinzufügen" and "Teilen" are blocked
(toast shown, no action); at 1280px the same actions work normally.
The final slice: land the format-neutral document model with REAL consumers
and unify the Markdown parser — no duplication, byte-identical output.
Neutral model (pkg/docforge/model.go): Document / Block / InlineSpan.
BlockKind values are the stylemap keys. A hyperlink is a span with Link set
+ Children (the label's spans), preserving link boundaries so adjacent
same-URL links stay distinct — byte-exact with the pre-model walker.
Markdown importer (pkg/docforge/markdown): Import(md) → Document. The SINGLE
Markdown parser for docforge — block split, marker detection, inline
bold/italic/link tokenisation, {{placeholder}} pass-through (the b78a984
fix). Relocated out of the docx walker.
docx renderer (pkg/docforge/docx/markdown.go): now RENDERS a Document →
OOXML (RenderDocumentToOOXML); RenderMarkdownToOOXML[WithStyles] = render(
markdown.Import(md)). The shipped submission walker routes through the model,
so there is one parser, not two. The comprehensive byte-exact render tests
(RenderMarkdownToOOXML_*) all PASS unchanged = output identical.
Exporter interface (pkg/docforge/exporter.go, PRD §4 B4): Exporter{Format,
MIMEType, RenderBody(Document)} with the .docx impl (pkg/docforge/docx/
exporter.go). The seam a future PDF/HTML exporter slots into.
Tests: parser tests relocated to the markdown pkg (parseSpans/detectBlockMarker)
+ new importer Document tests + exporter conformance test.
Verification: go build/vet clean; gofmt clean; full NO-DB test suite GREEN
(authoritative — proves no regression); docforge byte-exact render oracle
PASS; composer live test renders through the rewired walker (PASS); bun build
+ bun test 274/274. The shared-DB live run fails ~85 tests across unrelated
services from a harness pq-42P08 $1-type seeding quirk + a stale
deadline_rules test — systemic/environmental (the no-DB run is clean), not
this change.
docforge train complete: 8 slices, the engine extracted + cleaned + a working
author→generate→export loop on uploaded templates, plus the neutral model +
importer + exporter seam for future formats/consumers.
m/paliad#157
A submission draft can now render from an uploaded docforge template
instead of a legacy Gitea base. DB-VERIFIED against TEST_DATABASE_URL (the
head greenlit option C) before commit — not just compiled.
Schema: migration 159 adds submission_drafts.template_version_id (nullable,
FK template_versions ON DELETE SET NULL) — the snapshot pin (PRD A3). A
later template edit creates a new version; the pinned draft keeps rendering
its version.
Draft service: TemplateVersionID on the model + draftColumns + the JOIN
list + DraftPatch (two-level pointer like base_id) + Update SET. Column-sync
verified live (Create_seeds_section_rows + the new pin test both pass).
Export/preview (handlers): a template-version path checked FIRST — load the
carrier via TemplateStore.GetVersion, render via the existing Export/
RenderPreview (the carrier already carries {{slots}}; no Composer/sections
needed). Falls through to base_id / v1 if the pin is missing. Both preview
sites + the view assembly branch on it.
Store: TemplateMeta.VersionID exposes the current version's row id (slice-4
gap — a consumer needs it to pin); populated in List/Get/GetVersion + the
authoring JSON. New GET /api/templates (authenticated, firm-filtered) is the
picker list any lawyer reads; admin authoring endpoints stay gated.
Frontend: the submission editor's base picker now offers uploaded templates
as a 'tpl:<version_id>' optgroup; selecting one PATCHes template_version_id
(clearing base_id) and vice versa — mutually exclusive render paths.
Live test (submission_draft_template_live_test.go, gated): pin round-trips
Update→Get, the uploaded carrier renders ({{firm.name}}→HLC via Export), and
clearing nulls it — all PASS against real Postgres.
Verification: go build/vet/gofmt clean; bun build + bun test 274/274; slice-7
+ slice-4 store + draft/composer live tests PASS against TEST_DATABASE_URL.
Pre-existing env failures (approval/projection seed $1-type quirk,
migration136 stale deadline_rules table) are unrelated — confirmed my branch
touches none of that code.
m/paliad#157
The WYSIWYG authoring surface at /admin/templates (admin-gated page route):
- templates-authoring.tsx — page shell (upload form, template list,
workspace: palette / run-addressable preview / placed slots).
- client/templates-authoring.ts — hydrates it: lists templates, uploads a
.docx (multipart), renders the run-span preview, builds the variable
palette from the Go catalogue (GET /api/docforge/variables), and wires
the select-then-pick gesture: select text within one .docforge-run, click
a palette variable → POST the slot → re-render with the response. Reuses
the docforge-editor lib (escapeHtml, catalogue client). Cross-run
selections rejected with a hint (v1: single-run text slots).
- build.ts emits dist/templates-authoring.html + bundles the client.
- handleTemplatesAuthoringPage serves the shell; GET /admin/templates
registered under adminGate.
- 12 i18n keys (DE+EN) for the page; i18n-keys.ts regenerated (3079).
Verification: go build/vet/test green (13 pkgs); bun run build.ts clean
(i18n scan passes); bun test 274/274; gofmt-clean. The docx surgery + store
+ catalogue are unit/live-tested. VERIFICATION CEILING: the integrated live
flow (upload→render→select→inject→save in a browser) needs the app running
with DATABASE_URL + Supabase auth + Playwright — verified post-merge, not in
this env.
m/paliad#157
Admin-gated authoring API over docforge.TemplateStore + the docx authoring
engine (handlers/templates.go, routes under adminGate):
GET /api/admin/templates — catalog list
POST /api/admin/templates — multipart upload → ImportForAuthoring
(validate + detect slots) → Create v1
GET /api/admin/templates/{id} — authoring view (run-addressable
preview + slots)
POST /api/admin/templates/{id}/slots — InjectSlot at the selection →
AddVersion (re-detect slots from the
new carrier so template_slots mirror
the carrier's actual {{tokens}})
docforge.ErrTemplateNotFound → 404; injection failures (bad selection/key)
→ 400 with the engine's message for the UI to surface. Upload capped at
10 MB. Slot placement creates a version per placement (immutable snapshot);
batching a session into one version on explicit save is a documented
refinement.
Verification: go build/vet clean, handlers test green, gofmt-clean. The docx
surgery + store are unit/live-tested; the integrated HTTP flow is verified
post-merge (needs DATABASE_URL + Supabase auth).
m/paliad#157
The verifiable backend heart of the authoring surface, before the HTTP +
frontend layers.
pkg/docforge/docx/authoring.go:
- ImportForAuthoring(carrier) → AuthoringView{PreviewHTML, Slots}: parses
an uploaded .docx into a run-addressable preview (one
<span class="docforge-run" data-run="N"> per <w:t>, document order)
plus the {{placeholder}} slots already present.
- InjectSlot(carrier, runIndex, selectedText, slotKey) → new carrier:
replaces the selection inside run N with a {{slot_key}} token. Keys on
the selected TEXT (not a byte/UTF-16 offset) so umlauts can't desync the
client selection from the server slice; preview + injection walk runs in
the identical paragraph→<w:t> order so data-run indices line up.
- v1 scope: text slots in body paragraphs; out-of-run / cross-run / not-
found selections return an error the UI turns into a hint.
6 unit tests cover run-addressable preview, slot detection, injection +
round-trip re-import, umlaut/run-targeting, and the error paths (selection
absent, out-of-range run, invalid slot key) — all passing.
Wired PgTemplateStore through the stack (main.go → handlers.Services →
dbServices) so the upcoming authoring endpoints can reach it.
Verification: go build/vet clean, full module test green (13 pkgs), new
files gofmt-clean. The HTTP endpoints + frontend authoring page land next;
their live flow needs the post-merge e2e/manual loop (DB+Supabase).
m/paliad#157
Establish the shared frontend editor package and make the Go resolvers the
single source of truth for variable labels.
Go — catalogue SSOT:
- VariableResolver gains Keys() []VariableKey; ResolverSet gains
Catalogue(). The 7 submission resolvers implement Keys() with the
bilingual labels ported from the TS VARIABLE_LABELS table (incl. the
legacy rule.* aliases). Keys() is entity-independent, so
SubmissionVariableCatalogue() builds a metadata-only ResolverSet.
- GET /api/docforge/variables serves the catalogue (auth-gated, static).
- Tests: docforge ResolverSet (BuildBag merge + Catalogue order) and the
submission catalogue integrity (no dupes, labels present, spot-checks).
Frontend — frontend/src/lib/docforge-editor/ (new shared package):
- dom.ts: escapeHtml + cssEscape (pure), with bun tests. Dedupes the two
identical escapeHtml/escapeHTML copies + the cssEscape copy that lived
in the submission editor.
- catalogue.ts: fetchVariableCatalogue() + labelMap() — the client for
the Go catalogue.
- submission-draft.ts now imports escapeHtml/cssEscape from the lib and
fetches the catalogue on boot into state.varLabels (labelFor reads it,
falling back to the raw key if the fetch fails — graceful degrade). The
hardcoded VARIABLE_LABELS table is removed; VARIABLE_GROUPS stays
(presentation: which keys to show + how to section them, legitimately
frontend).
Scope note: the DOM-coupled editor plumbing (wireDraftVars/focus
preservation/autosave debounce) is extracted in slice 6 alongside its first
reuse — the authoring page — rather than speculatively now (extract with the
consumer; same principle as slices 2-3). Slice 5 lands the pure utilities +
the catalogue, which the slice-6 authoring palette consumes.
Verification: go build/vet/test green (Go files gofmt-clean; handlers.go
pre-existing drift, added region clean); bun run build.ts clean;
bun test 274/274 (incl. 5 new docforge-editor tests).
m/paliad#157
Persistence foundation for authoring (slice 6) + generation-on-templates
(slice 7). docforge owns no tables — it defines the contract; paliad
implements it (litigationplanner pattern).
Migration 158_docforge_templates (additive, generic — NOT submission_*-named
so a second docforge consumer reuses it):
- templates — catalog row; current_version_id pins the live
version (FK added post-create to break the
templates<->versions cycle; ON DELETE SET NULL).
- template_versions — immutable snapshots; carrier .docx in a bytea
column (the TemplateStore bytea backend) + stylemap
jsonb. Versioning = snapshot-at-create (PRD A3).
- template_slots — variable slots per version; anchor = sentinel token
locating the slot in the carrier OOXML (PRD §5
lean), slot_key = the bound variable.
RLS mirrors submission_bases: firm-shared SELECT for authenticated,
mutations admin-only + gated in Go (no mutation policy = denied).
docforge root: TemplateStore interface + neutral types (TemplateMeta,
Template, TemplateSlot, *Input, TemplateFilter) + ErrTemplateNotFound.
CarrierBytes is format-opaque []byte so the root never imports the docx
adapter; the exporter wraps (CarrierBytes, Stylemap) into a docx.Carrier.
paliad: PgTemplateStore (sqlx, follows the submission_base_service pattern):
List / Get (current version) / GetVersion (pinned snapshot) / Create
(version 1 + pin) / AddVersion (next version + re-pin), all transactional.
Gated live round-trip test (TEST_DATABASE_URL) covers carrier+stylemap+slot
round-trip and the version bump. No handler wires this yet (PRD: no UI in
slice 4).
Verification: go build ./... clean, go vet clean, gofmt clean, full module
test green, migration NoDuplicateSlot structural test green.
m/paliad#157