Templates and letterheads are one thing — unification spec
Status: phases 0–3 SHIPPED on branch worktree-template-unification (not deployed).
Phases 4–5 (dropping the legacy routes and the /letterheads aliases) remain.
Thesis. letterhead_templates and document_templates are the same record with
different histories. Both are a named, versioned, content-addressed artifact an author
starts a document from or wraps a document in. The split is an accident of the order the
features shipped, and the office module has already collapsed it in practice: a docx
letterhead carries the whole body, not just the kop.
One table. One library. One picker. Capability is derived from what the row holds,
not declared by which table it lives in.
Related: 2026-08-18-letters-are-documents.md does the same collapse one level up
(letter → document facet). This spec is independent of it and can land before, after, or
alongside; §8 notes the one place they touch.
0. The evidence they are already the same thing
Not an argument from taste. Four places in the tree already say it.
a. The letterhead seeds the whole letter. handlers_correspondence.go:151:
"With
authoring=docx… the server seeds the editable docx: from the chosen
letterhead's docx template (zero-copy — content-addressed hash reuse)"
and params.SeedDocxHash = lh.DocxHash at :196. That is byte-for-byte the mechanic
document_templates was created for. handlers_correspondence.go:468 is blunter still:
"The letterhead was fixed at creation (it IS the docx the letter was seeded from)."
b. With the office module on, the HTML fragments are dead. LetterheadTab.tsx:201
and :277 both create with { headerHtml: '', footerHtml: '' } and jump straight to the
OnlyOffice editor. When officeOn, a letterhead is only a docx file. The kop lives in
the Word header section — which is what a header section is for.
c. The schemas were copied from each other. 00171_template_fields.sql:
"rev mirrors the letterhead DocxRev pattern: it bumps on every content change and
keys the OnlyOffice editing session"
and server.go:1881: "Editing the template ITSELF in OnlyOffice (letterhead pattern:
the admin …)". Two stores, one design, maintained twice.
d. Letterheads have already been unified once. 00056_unify_letterheads.sql collapsed
the correspondence module's letter_templates into core letterhead_templates so
"an admin creates a letterhead ONCE and both letters and documents use it." Same
argument, one table short.
The countervailing document is 00168_document_templates.sql:5-9, which deliberately
lists the other three "template" tables and refuses to touch them. It was right at the
time — document_templates was new and unproven, and the docx letterhead path
(00128) had not yet turned a letterhead into a file. That premise expired.
1. What actually differs
Strip the naming and only these survive:
letterhead_templates (00055/00128) |
document_templates (00168/00171) |
|
|---|---|---|
| Bytes | docx_hash + docx_rev |
content_hash + rev + version history |
| HTML chrome | header_html, footer_html |
— |
| Applied | at seed (docx letter) and at render (HTML finalize) | at seed only |
| Metadata | name |
name, description, category, archived_at |
| Fields | — | document_template_fields + values + uses |
| Filing defaults | — | folder / classification / doc_type / title_template |
| Mime | docx only | any (.dotx/.xltx/.potx/.ott, xlsx, …) |
| Licence | CORE, no module gate | office module for fields/preview/editor |
| Permission | letterhead.manage; read open to all |
template.read / template.admin |
document_templates is a strict superset except for two things: the HTML chrome pair,
and the render-time application. Everything else is document_templates having more.
That makes the direction obvious: document_templates survives and absorbs the chrome.
2. The unified model
D1 — one table, document_templates, plus three HTML columns
ALTER TABLE document_templates
ADD COLUMN header_html text NOT NULL DEFAULT '',
ADD COLUMN footer_html text NOT NULL DEFAULT '',
ADD COLUMN body_html text NOT NULL DEFAULT '';
That is the entire schema addition. No kind, no role, no enum.
A row therefore holds up to four things, any of which may be empty: bytes
(content_hash), a body (body_html), and chrome (header_html, footer_html).
body_html is D5 — it is what makes the whole feature work with no Office licence.
D2 — capability is DERIVED, never declared
A row's capabilities are read off its contents, exactly as has_docx is derived today
(letterhead/domain/template.go:21):
can_seed := content_hash <> '' OR body_html <> '' -- offer in "new document/letter from…"
can_wrap := header_html <> '' OR footer_html <> '' -- offer in the finalize kop picker
Rationale: an enum forces a false choice. A kop docx genuinely is both — you seed a
letter from it, and (if its author also filled the HTML pair) you can wrap an HTML-authored
document in it. Deriving also means there is no third state where the flag and the bytes
disagree, which is the failure mode has_docx was introduced to avoid.
Pickers filter on the derived flag. Neither picker ever shows a row it cannot use, so
"the letterhead has no Word template yet" (422 correspondence.letterhead_no_docx) stops
being reachable from the UI — keep the backstop.
D5 — body_html: a template you author in the rich-text editor
Today a document template is a file, or nothing. There is no way to author one in the
app; you upload a .docx. That is the last real dependency on somebody having Word, and
it is the gap that makes "does this work without Office?" a fair question.
body_html closes it. A template row may carry a TipTap-authored body instead of (or as
well as) bytes, and /use seeds a text/html document directly — the same shape the
"Write document" flow produces, and the same shape make-editable produces from a docx.
Why this matters beyond convenience:
- No Office licence needed end to end. Author the template in
RichTextField, author
the kop inRichTextField, write in TipTap, finalize through Gotenberg. Nothing in that
chain touches OnlyOffice. - It skips the lossy round trip. The docx route today is
upload → seed →POST /documents/{id}/make-editable→ TipTap. That conversion
"intentionally drops layout (fonts, colors, columns, headers/footers)"
(handlers_make_editable.go:22). An HTML-authored template never enters that pipeline. - The kop is unaffected by the loss. Chrome is re-applied at finalize by
injectLetterhead, which is exactly why dropping headers/footers on the way in is
safe. Layout is chrome; chrome belongs to the render, not the source.
This is also where the legacy templates table (00014) goes — see §3. Its capability
("HTML bodies with {{placeholders}}") was right and is what we want; what it lacked was
a UI and a reason to exist beside three other template tables. It gets both here.
D3 — category carries the human grouping
Migrated letterheads land as category = 'Kop Surat'. Free text, already exists, already
drives the picker grouping. The library screen groups by it; nothing needs a new taxonomy.
D2b — what happens without the Office module
Premise correction: the Office module gates the OnlyOffice editor, not the converter.
officeEditEnabled is moduleEnabled("office") && OnlyofficeJWTSecret != ""
(handlers_office.go:41). Everything below is CORE and works with the module off:
s.office.ToPDF(...)— Gotenberg/LibreOffice docx → PDF.s.office.HTMLToPDF(...)— the finalize path.POST /letterheads/{id}/docx— uploading a docx kop is gated onletterhead.manage
only, no module check (handlers_letterhead.go:91,server.go:1900).
And the fallback the module-off case needs already exists, in this exact shape
(handlers_office_subjects.go:565-584):
if s.officeEditEnabled(ctx) {
if p, cerr := s.onlyofficeConvert(...); cerr == nil { pdf = p } else { warn(...) }
}
if len(pdf) == 0 { // ← module off, or OnlyOffice failed
pdf, gerr = s.office.ToPDF(ctx, "letterhead.docx", docxBytes, "")
}
So a docx letterhead renders and seeds fine with no Office licence. The admin just
cannot author it in the browser — they upload it. There is nothing to fall back from.
The one case that genuinely breaks is narrower: an HTML-authored document
(TipTap → Finalize-to-PDF) picking a letterhead that has a docx but no header_html.
injectLetterhead (handlers_finalize.go:191) takes two strings, gets two empty ones,
and returns the body unchanged — a PDF with no kop, no error, no warning.
🔴 This is a live bug today, independent of this spec.
NewLetterModal.tsx:77 filters the picker with letterheads.filter((l) => l.hasDocx).
TextEditorView.tsx:391 does not filter at all — it maps every letterhead into the
finalize dropdown. Every letterhead created while officeOn has empty HTML
(LetterheadTab.tsx:201, :277), so today an author can pick a real kop in the document
editor and silently get a PDF without one.
Resolution: filter, do not fall back. can_wrap (D2) is exactly the missing filter.
A runtime "fall back to the HTML pair when Office is off" would fall back to an empty
pair in precisely the deployments that have a docx kop — a silent no-op dressed as a
safety net. Making the capability visible is strictly better than making the failure quiet.
Rejected alternatives, recorded so they are not re-proposed:
- Derive
header_htmlfrom the docx at render time. The zip machinery exists
(platform/docx/replace.go:51already walksword/header*.xml/word/footer*.xml),
but WordprocessingML → HTML with logos, tables and precise spacing is a fidelity
problem, not a parsing one; the repo's docx→HTML path is mammoth, which ignores
headers and footers entirely. Runtime conversion also puts a new failure mode on the
finalize hot path. - Composite the kop's rendered PDF under the HTML output. Best fidelity, and the
preview PDF is already rendered and cached (letterheadPreviewCache). But it is a new
pipeline on the official-copy path, and margin regressions on exactly this path have
reached prod before. Not now — revisit if customers ask for one kop authored once.
Optional follow-on (not in phase 1): a one-time "extract HTML chrome from this docx"
admin action. Same conversion as above, but run once, on demand, with the result
written into header_html/footer_html and shown to the admin in the existing
RichTextField editor before saving. Stored, reviewable, and no runtime dependency —
which is the whole difference between this and a flaky auto-fallback. Size it as ~1 day
and only build it if one-kop-authored-once is a real customer ask.
D6 — the Office-off matrix
What a deployment with no Office licence can do, after this spec. Verified route by
route against server.go; only seven routes in the whole API carry
requireModule("office").
| Capability | Office off | Gate |
|---|---|---|
| Author a letterhead kop/footer | ✅ TipTap (RichTextField) |
letterhead.manage |
Upload a .docx kop |
✅ | letterhead.manage, no module check |
| Preview a docx kop as PDF | ✅ Gotenberg fallback | authenticated |
| Author a template body | ✅ TipTap — D5, new | template.admin |
Upload a .docx/.dotx template |
✅ | template.admin, no module check |
| Start a document from a template | ✅ | template.read + document.create |
| Turn a seeded docx into an editable doc | ✅ mammoth → TipTap | AccessReadWrite + DLP |
| Write a document | ✅ TipTap | — |
| Finalize to PDF with a kop | ✅ injectLetterhead + Gotenberg |
— |
| HTML letters with a kop | ✅ | correspondence.write |
| Edit a docx in the browser | ❌ | office |
Declare/fill {{FIELD}} prompts |
❌ | office (server.go:1878-1879) |
authoring=docx letters |
❌ 403 office.disabled |
office |
So the answer to "does it work via the rich text editor?" is yes, completely — once
D5 lands. Without D5 there is one hole: you can use a template without Office but you
cannot author one.
The two remaining ❌ rows are the correct commercial line: Office sells editing Word in
the browser and prompted template variables. It must not sell the kop, and after §4 it
does not.
⚠️ {{FIELD}} prompts being Office-only is worth a second look at some point — the
variables engine itself is CORE (/variables, server.go:1904) and only the
per-template prompted layer is gated. Out of scope here; noted so it is not discovered
as a surprise.
D4 — what does NOT change
- Blob lifetime stays reference-counted; deleting a template never deletes bytes a
seeded document still references (00168's rule, now also covering letterheads). - Tokens still live in the stored source and substitute at render
(00171D1, the letter doctrine). Chrome resolves the same variable map the body does
(handlers_finalize.go:114). injectLetterhead(handlers_finalize.go:191) is unchanged — it takes two strings.
Only where those strings come from changes.- Body first, then chrome, on the official-copy path (
handlers_official_copy.go:142) —
the working copy must not be able to reach into the kop's markup. Keep that ordering
comment verbatim; it is a security note, not a style note.
3. Migration 00205_unify_templates_and_letterheads.sql
Same shape as 00056, which is the house precedent for this exact move.
-- Letterheads become ordinary document templates. IDs are preserved so
-- letters.letterhead_id keeps resolving with no FK rewrite.
ALTER TABLE document_templates
ADD COLUMN header_html text NOT NULL DEFAULT '',
ADD COLUMN footer_html text NOT NULL DEFAULT '';
INSERT INTO document_templates
(id, name, description, category, filename, content_hash, mime, size_bytes,
rev, header_html, footer_html, created_by, created_at, updated_at)
SELECT lh.id,
-- Pre-filter BOTH unique constraints, exactly as 00056 does: a plain
-- ON CONFLICT can only target one, and BOTH id and name can collide.
CASE WHEN EXISTS (SELECT 1 FROM document_templates d WHERE d.name = lh.name)
THEN lh.name || ' (kop)' ELSE lh.name END,
'', 'Kop Surat',
lh.name || '.docx',
COALESCE(lh.docx_hash, ''),
CASE WHEN COALESCE(lh.docx_hash,'') = '' THEN ''
ELSE 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' END,
0,
GREATEST(lh.docx_rev, 1),
lh.header_html, lh.footer_html,
lh.created_by, lh.created_at, lh.updated_at
FROM letterhead_templates lh
WHERE NOT EXISTS (SELECT 1 FROM document_templates d WHERE d.id = lh.id);
Then a version row per migrated letterhead that has bytes (mirroring 00171's backfill),
then re-point the FK:
ALTER TABLE letters DROP CONSTRAINT letters_letterhead_id_fkey;
ALTER TABLE letters ADD CONSTRAINT letters_letterhead_id_fkey
FOREIGN KEY (letterhead_id) REFERENCES document_templates(id) ON DELETE SET NULL;
DROP TABLE letterhead_templates;
Why the FK survives untouched. letterhead_templates.id and document_templates.id
are both uuid PRIMARY KEY and the INSERT reuses the letterhead's own id. From that
moment letterhead_id names a document_templates row. No id remapping, no data rewrite,
and ON DELETE SET NULL keeps its original meaning (the seed was zero-copied into
draft_docx_hash, so deleting the template never breaks the letter — 00128:10).
size_bytes is written 0 and backfilled lazily on first read; the blob store knows the
real size and no caller has ever needed it for a letterhead.
Down migration recreates the empty letterhead_templates shell and restores the FK,
one-way on data — the 00056 precedent. Say so in the comment.
Also absorbed: the legacy templates table (00014)
templates (HTML bodies with {{placeholders}}, routes at server.go:1853-1857) has
no frontend consumer and no MCP consumer — MCP's list_templates reads docTemplates
(handlers_mcp.go:800).
Earlier drafts of this spec deleted it. D5 changes that: its capability is exactly
body_html, which the Office-off story needs. So it is absorbed, not discarded — the
rows move, then the table and its routes go:
INSERT INTO document_templates
(id, name, description, category, filename, content_hash, mime, size_bytes,
rev, body_html, created_by, created_at, updated_at)
SELECT t.id, t.name, t.description, '', '', '', 'text/html', 0, 1,
t.body_html, t.created_by, t.created_at, t.updated_at
FROM templates t
WHERE NOT EXISTS (
SELECT 1 FROM document_templates d WHERE d.id = t.id OR d.name = t.name);
Then drop templates, the 5 routes, and go/internal/template/. The {{placeholder}}
tokens survive verbatim — they are resolved by the same shared variables engine either
way (00140), so a migrated body renders exactly as it did.
⚠️ Verify against a prod dump first — an integration could be calling
POST /templates/{id}/render. If one is, keep the route as an alias over the unified row
for a release rather than dropping it. The absorb step is safe regardless; only the
route removal is in question.
4. Permissions — the trap that must not be sprung
Letterheads are CORE and readable by everyone today (server.go:1887-1892, and
00055's comment: "letterheads are presentation chrome, not sensitive data").
document_templates needs template.read, and its fields/preview/editor routes need the
office module.
A naive merge paywalls the kop on every deployment without the office module and
breaks finalize for every user without template.read. This is the single highest-risk
part of the change.
Resolution: gate on capability, not on table.
| Route | Gate |
|---|---|
GET /document-templates?usable_as=chrome |
none (authenticated) — today's letterhead list behaviour |
GET /document-templates/{id} where the row has no content_hash |
none |
GET /document-templates (full library) |
template.read |
GET /document-templates/{id} where the row has bytes |
template.read |
| write to a row with chrome but no bytes | letterhead.manage or template.admin |
| write to a row with bytes | template.admin |
| fields / preview / office-config | unchanged — office module |
Keep letterhead.manage in the permission catalogue
(web/src/features/admin/permissionCatalog.ts:114), relabelled "Manage letterheads and
kop templates". Do not fold it into template.admin: a role that today can edit the
org kop and nothing else must not silently gain the whole template library.
Migration for roles: none. Every existing grant keeps its exact current power. Roles with
template.admin gain letterhead management, which is the intended direction.
⚠️ Re-run the module-audit harness (-tags moduleaudit) after this. It exists precisely
to catch "a core feature drifted behind a paid gate", and this change is the shape of bug
it was built for.
5. API surface
New canonical surface is /document-templates/* plus ?usable_as=base|chrome.
Keep /letterheads/* as thin aliases for one release. They are consumed by
web/src/api/letterhead.ts, the mobile bundle (mobile/src/lib/queries.ts,
mobile/src/app/letters/[id]/number.tsx), and possibly customer integrations. A deployed
mobile build outlives a web deploy, so removing them in the same release strands installed
apps.
GET /letterheads→GET /document-templates?usable_as=chrome, response reshaped to
the old{letterheads: [{id, name, header_html, footer_html, has_docx}]}envelope.GET|POST /letterheads/{id}/docx→ thecontentroutes.GET /letterheads/{id}/preview→ keep; there is no equivalent on the template side and
the compose picker needs it.- Mark all of them
deprecated: truein the OpenAPI sopackages/api-clientregenerates
with the annotation.
letterhead_id stays the request field name on finalize
(handlers_finalize.go:35), official copy (handlers_official_copy.go:66) and the three
correspondence handlers. It now carries a document_templates id. Do not rename it in
this change — it is in the mobile bundle and in customer-facing request bodies, and the
rename is cosmetic.
6. Frontend
TemplatesPage.tsxabsorbsLetterheadTab.tsx. The library gains a "Kop Surat"
category group.LetterheadTabis deleted fromAdminPage.tsx; admins reach kops
through/templateslike everything else.api/letterhead.tsbecomes a thin re-export overapi/doctemplates.tsso the
compose modals (NewLetterModal,AssignNumberModal,LetterOfficeEditorView) and
the finalize picker change import path only. Delete it once those move.- The finalize/official-copy kop picker filters
usable_as=chrome— this is the fix
for the silent-empty-kop bug in D2b, andTextEditorView.tsx:391is the line. Show
docx-only rows disabled with a hint ("Word kop — used when starting a letter")
rather than hiding them: an admin who cannot find the kop they just made will file a
bug. The letter compose picker filtersusable_as=base— that is today'shasDocx
filter (NewLetterModal.tsx:77) under its real name. RichTextFieldheader/footer editing moves onto the template edit modal, shown
only when the row has nocontent_hashor when the admin expands "HTML fallback
chrome". With office on, this stays out of the way — it is already dead in that
configuration.- i18n:
admin.letterhead.*keys move undertemplates.*inen.tsandid.ts. Keep
the Indonesian term Kop Surat for the category — it is what users call it.
Mobile: no change. It only reads letterheads through queries.ts, which keeps working via
the aliases in §5.
7. Risks
- Core feature paywalled. §4. Mitigated by capability-gating + the module-audit
harness. Highest severity — this is how prod 502s and silent feature loss have
happened before. - Name collisions on migration. A letterhead and a template sharing a name violate
document_templates_name_uniq. Handled by the(kop)suffix in the INSERT; log the
renamed rows so an admin can fix them. - Letterheads with no docx and no HTML. Empty shells created by
LetterheadTab:201
before the admin opened the editor. They migrate to rows with neither capability and
appear in no picker. Correct, but list them in the migration output — an admin may
think they lost a kop. - A deployed mobile build calling
/letterheads. §5 aliases. Do not skip them. ON DELETE SET NULLsemantics. Deleting a template now nullsletters.letterhead_id
for letters seeded from it. That was already true and is documented as safe
(00128:10), but the blast radius is bigger once one library holds everything —
considerarchived_atas the default action in the UI and make delete the deliberate
one.document_templatesalready has archiving; letterheads never did.
8. Phasing
| # | Phase | Ships | Rough size |
|---|---|---|---|
| 0 | Filter TextEditorView.tsx:391 on hasDocx — the silent-empty-kop fix (D2b) |
a real bug fix | 30 min |
| 1 | Schema + migration 00205, FK re-point, absorb 00014, alias routes, capability gates |
nothing user-visible; /letterheads still works |
1 day |
| 2 | FE merge — TemplatesPage absorbs the tab, pickers filter on capability | one library, one picker | 1 day |
| 3 | D5 — author a template body in TipTap; /use seeds an HTML document |
templating with no Office licence | 1–1.5 days |
| 4 | Drop legacy /templates routes + go/internal/template/ | dead surface gone | 2 hours |
| 5 | Drop /letterheads/* aliases after the next mobile release | — | 1 hour |
Phase 0 rides in this branch (user's call, 2026-08-18) rather than shipping standalone.
Phase 1 is independently shippable and reversible. Phase 3 is the one that closes the
Office-off story; phases 4 and 5 are cleanup and can slip.
Total ≈ 4 days to a complete, Office-free templating and letterhead story.
Where this touches letters-are-documents. That spec makes letters.id == documents.id.
Neither plan changes the other's FKs, and letterhead_id is re-pointed here at its own id
either way. The one ordering note: if letters-are-documents lands first, phase 1's
ALTER TABLE letters runs against a letters table that has become a facet — still a real
table with the same column, so the statement is unchanged. No hard ordering dependency.
Open questions for the user
- Does any customer integration call the legacy
POST /templates/{id}/render? If yes,
phase 4 keeps the route as an alias instead of dropping it. The repo has no consumer;
only a prod query can answer it. Does not block the absorb step in §3. - Docx-only kops in the document finalize picker: disabled-with-hint, or hidden?
Spec assumes disabled-with-hint (§6.3). - Build the one-time docx → HTML chrome extraction (D2b follow-on)? Only worth it if
"author the kop once, in Word, and have HTML documents use it too" is a real ask.
Otherwise an admin who wants both authors both, which is today's behaviour. - Should deleting a letterhead become archive-by-default?
document_templateshas
archived_at; letterheads never did, and after the merge a delete nulls
letters.letterhead_idacross a bigger library (§7.5).
Answered 2026-08-18 (user):
- RBAC gating as specced in §4 — approved.
- Phase 0 (silent-no-kop picker fix) rides in this branch, not standalone.
- "Does it work via the rich text editor with no Office module?" — yes, once D5
lands; see the D6 matrix. D5 was added to the spec in response.
What shipped, and where it differs from this spec
Three commits on worktree-template-unification:
19e6722f |
phase 0 + 1 — migration 00205, FK re-point, capability gating, the silent-no-kop picker fix |
d7bd7932 |
phase 2 — TemplatesPage absorbs the letterhead screen |
ab783aca |
phase 3 — body_html authoring end to end |
95b0dbeb |
phase 4 — legacy 00014 API deleted, checked against every deployment (0 rows everywhere) |
c45931e1 |
integration pass — a written template fills, previews, downloads and opens like one |
The integration pass exists because the authoring path landed complete while the CONSUMING
side still asked "what extension?" of a template that has none: field_capable was
filename-derived (hiding the fill form), preview only knew the docx pipeline, download hit
the blob store with an empty hash, and the seeded document missed the text editor. It also
fixed a live pre-dating bug the flow tripped on every dev mount: CheckOut's
check-then-insert races itself and the 23505 escaped as a 500 because InsertLock never
honored its own "surfaces as ErrConflict" comment.
Migration number: 00216, and why it moved twice
Claimed 00205 originally; main took that for esign saved signatures, so it became 00215;
worktree-letters-are-documents-p0 then claimed 00215 for a prod fix (published letters
were never locked), and the user gave letters the earlier slot on urgency. This branch is
00216 and merges after letters.
Not cosmetic. goose records a VERSION NUMBER, not a filename: if two branches ship the same
number, whichever deploys second is recorded as already-applied and NEVER RUNS, with no
error anywhere. For this migration a silent skip means the feature ships with its schema
missing — the code would look for document_templates columns that were never added.
Checked before claiming it: main's highest is 00214 (3638a976, v1.3.0), and prod
(t_dms), VM2 (t_demo) and the x056 demo are all at 214 with zero rows for 215 or 216 — so
nothing double-applies. Re-confirm main's highest is 00215 after letters lands, before the
final re-merge.
🔴 filed_at: template-seeded documents are invisible, and it is NOT this branch's bug
Measured on the merged tree, not inferred:
| created how | filed_at |
in folder browsing |
|---|---|---|
from a template (/use) |
NULL | ✗ |
| ordinary upload | NULL | ✗ |
Root browsing returned 0 rows for both. InsertDocument (dms/adapters/pg.go:424)
never writes filed_at; filedPredicate gates five listing/search queries; and the only
writer, FileLetterDocument, is guarded to letter-born rows by an EXISTS, so it cannot file
an ordinary document.
The identical behaviour on both rows is the point: seeding goes through the same
CreateDocument → InsertDocument as an upload, so this is main's pre-existing bug, not a
regression here — and the fix letters ships in InsertDocument will cover the template path
for free. Verify after the re-merge rather than assume:
POST /document-templates/{id}/use → SELECT filed_at FROM documents WHERE id = <new id>
Non-null, and the row appears in GET /documents. If letters instead fixes only the letter
path, this branch needs its own filing call in seedDocumentFromTemplate — about an hour.
Remaining before "done done": phase 5 only — dropping the /letterheads/* aliases,
blocked on the next mobile release by design. Everything else on this spec is shipped on
the branch. Open question #4 (archive-by-default for kops) is still open: a kop-only
curator holds letterhead.manage, which cannot reach the template PATCH's archived
field — wiring that would mean either an archive verb on the letterhead facade or accepting
that kops delete-with-confirm while templates withdraw. Decide when it hurts.
Four things ended up different from the plan above. Each is a decision, not a slip:
-
The legacy
templatesroutes are a FACADE, not deleted. §3 planned to absorb the rows
and drop the five routes. The rows moved, butgo/internal/template/adaptersnow reads
and writes thebody_html <> ''slice of the unified table, so the routes keep answering
exactly as they did. That removes the open question from phase 1's critical path: the prod
check now only gates removing them (phase 4), not the merge. -
One create route, not two.
POST /document-templates/bodyreads better, but chi
resolves/document-templates/bodyinto the{id}node next door and answers 405.
POST /document-templatesnow branches on Content-Type: multipart carries a file, JSON
carries an authored body. One intention, one route. -
can_manage_letterheadsis a new/meflag. Not in the spec, and needed the moment
the two screens merged:letterhead.manageis a grant a role can hold WITHOUT
template.admin, so nesting the kop affordances insidecanCuratewould have shown a
kop-only curator an empty toolbar. -
An authored body gets no version row.
document_template_versionsis keyed on a
content_hash, and a body has no blob to address — a row with an empty hash would be a
history you cannot restore from.revstill bumps. If HTML bodies later need
contract-grade history, the honest fix is their own revision store, not a faked entry in
this one.
One live bug the work found, unrelated to the merge
content_hash was NOT NULL with no default. Every INSERT in the migration names it
explicitly, so the migration smoke passed — but any fileless writer (an HTML kop, an authored
body) would have hit 23502 at runtime. Caught by internal/template/adapters once its
tests ran against a real database. Fixed at the column, with an assertion that would have
caught it.
Verification the branch carries
scripts/template-unification-smoke.sh— 203 migrations against a scratch Postgres with a
fixture shaped like live data (collisions on both unique constraints, docx-only kop,
HTML-only kop, a letter holding a reference). 16 assertions.TestCapabilityIsDerivedFromContent— the CanSeed/CanWrap truth table, including the
docx-kop trap.TestScanHTMLFindsDeclaredTokens— token discovery in an authored body, including the
fragmented-markup case and the attribute-injection case.- The module-audit harness passes with the real router booted per licence set, which is the
check that would catch this merge paywalling the kop. - Both UI phases were verified by driving the real app (server + vite + headless Chromium),
not by reading the diff.
00217 — for_letters: the user's checkbox, and the end of "letterhead"
Shipped 2026-08-21 (b1389681), from the user's own diagnosis: "kop surat is basically
just like templates." Four changes, one migration:
for_letters booleanDECLARED per template — the checkbox ("Can start a letter").
Capability stays derived; permitted use is now declared, which is the distinction the
category magic conflated. The/letterheadsfacade scopes on the flag; category is a
shelf heading again. Backfill: everyKop Suratrow. Prod's four templates become
letter-startable with one tick each.- The Word family seeds letters —
docx.NormalizeToDocumentrewrites the one
content-type declaration that separates .dotx/.dotm from .docx/.docm, applied at kop
upload, letter upload and letter seed. A .docx passes through untouched (zero-copy
preserved). - Office ON ⇒ Word letters only (user decision). The Quick-text switch is gone from
the composer; deployments without Office keep text as their only path and gain
templates there: afor_lettersbody template seeds the HTML letter server-side,
empty-body-only so typed text always wins. - The HTML numbering merge now substitutes the BODY — parity with docx. The old
exemption assumed hand-typed prose; a seeded body carries {{NOMOR}} on purpose.
Key-scoped, so unknown tokens round-trip and token-less letters render unchanged.
Verified live at migration 217, including extracting the official PDF's text: "Nomor:
0001/SK/…", Indonesian long date, no leaked tokens. Smoke 214 migrations, 18/18.