Templates as a module — design + decisions + what shipped
Status: BUILT 2026-08-05 (v1, migration 00171, module templates). §7 records what
shipped and how it maps to the decisions. Originally written as the decision document
against 118293b; decisions D1–D11 below were approved by Efran on 2026-08-05 — D1
resolved as substitute-at-render + frozen merged official copies (the two chosen
options are one architecture: the letter doctrine).
This turns the document-template library (mig 00168) into a first-class,
variable-powered, separately-licensable module.
1. What already exists (verified, not assumed)
| Piece | Where | What it does today |
|---|---|---|
| Document templates | 00168, internal/doctemplate |
Catalogue + blob of reusable FILE bases (.dotx/.xltx/.potx/.ott). Zero-copy seed into a new document via content hash. No variable awareness at all. |
| Variables | 00140, internal/variables |
Org-wide static {{KEY}} rows + code-registered built-ins (NOMOR, TANGGAL, TODAY, TITLE, OWNER, …). Resolved at 4 named points with per-key allow-lists. |
| docx replacer | platform/docx.ReplaceFields |
Single-pass {{KEY}} substitution that reunites text split across runs — the hard part of docx merge, already solved and battle-tested. |
| HTML replacer | platform/mergefield.ReplaceHTML |
The HTML twin, same single-pass guarantee. |
| OnlyOffice subjects | handlers_office_subjects.go |
officeSubDocument / officeSubLetter / officeSubLetterhead — a pluggable subject enum with per-subject serve + callback. Letterhead proves a non-document subject can be edited. |
| Live-preview cache | letterPreviewCache |
Renders a draft docx to PDF keyed on draft hash + digest(resolved map). This is the real-time preview machinery, already written. |
| Module gating | licensing.go, config.KnownModules |
requireModule(name), signed offline licence, Cloud intersects tenant entitlements. Fails closed. |
Three other things are also called "template": templates (00014, legacy HTML bodies),
letterhead_templates (00055), letter_templates (00026/56). And custom_vars on the
document API already means something else entirely — the {custom:NAME} tokens in
document-ID formats. Naming needs a decision (§4, D9).
Bottom line: roughly 80% of the engine exists. What is missing is the scope tier for
variables and the fill-in ceremony.
2. The one thing I'd change about the brief
"When they save, it shall replace variables with set values like our current variable system."
The current variable system does not do that, and that is why it works. Verified:
handlers_finalize.go:107— resolves the map and substitutes into the rendered PDF.
The editable HTML source keeps its{{TOKENS}}.handlers_office_subjects.go:99— the letter draft preview merges on the fly; the stored
draft is never rewritten.{{NOMOR}}deliberately survives literally until numbering.- Numbering freezes a separate merged artifact (
final_docx_hash) and leaves the draft alone.
So the house doctrine is already: tokens live in the source forever; substitution happens at
render/egress; the official artifact is a frozen merged copy.
If saving instead bakes values into the stored bytes:
- the variable-only editor works exactly once — after the first save there are no tokens
left to edit, which kills the headline feature of the brief; - a typo in a variable is unfixable without re-seeding from the template;
- there is no provenance linking the document back to what it was filled with;
- live preview has to become a special case instead of just "render with a different map".
Recommendation: keep tokens in the source, substitute at render. You get the
variable-only editor, real-time preview, and re-editability for free, and it matches the
letter pipeline exactly. This is decision D1 and it determines the whole data model.
The cost of this choice, stated honestly: every path that streams raw document bytes has
to route through the merge, or a recipient sees {{CLIENT_NAME}} instead of a client name.
That is a chokepoint audit, same shape as egressProtectPDF — download, share, official copy,
export, workflow attachments, MCP text, AI summarize. It is a day of work and it is the
riskiest part of this feature. Baking on save avoids that audit entirely, which is the only
real argument for it.
3. Proposed shape
3.1 Variables get a third tier
Today there are two tiers. Add the one 00140 explicitly reserved
(kind CHECK (kind IN ('static')) — "RESERVED for v2 (per-subject prompted values)"):
| Tier | Defined by | Value comes from |
|---|---|---|
| Built-in | code | the subject at render time |
| Org static | variables rows |
one value for the whole org |
| Template field (new) | a template's declaration | the user, per document, at use time |
Two new tables:
document_template_fields -- what a template ASKS for
template_id, key, label, help, type, options, required, default_value, sort_order
document_field_values -- what one document ANSWERED
document_id, key, value, updated_by, updated_at
Resolution order at a document point becomes: org static → built-ins → document field
values → caller overrides → point allow-list. Template fields sit above org statics, so a
template may deliberately shadow an org value; they sit below caller overrides so ceremonies
still win.
3.2 Auto-discovery, not homework
On template upload, scan the docx text for {{TOKENS}}, subtract built-ins and org statics,
and present the remainder as "you use these — describe them". Needs one small new function
(docx.ScanFields) reusing the existing scanTextNodes. The author then sets label / type /
required / default per field. Manual declaration stays available.
3.3 Two editors, as asked
- Full editor — the existing pair: OnlyOffice for docx, TipTap for HTML. Unchanged.
- Fill-in editor — a generated form, one control per declared field, with a live preview
pane beside it. This is the mode a normal user gets.
Preview cost: HTML templates render instantly client-side. Docx needs a Gotenberg round-trip
(~1s), so debounce 600ms + reuse letterPreviewCache keyed on template hash + digest(map).
That cache already exists and already does exactly this for letters.
3.4 Editing the template itself
Add officeSubTemplate to the OnlyOffice subject enum — the letterhead path is the precedent,
about 60 lines. Template edits should be versioned (document_template_versions,
blob-addressed, cheap) because ReplaceContent currently swaps bytes with no history, and a
contract template is a legally significant artifact.
4. Decisions you need to make
Each has my recommended default. Approving the defaults is a valid answer to all of them.
| # | Decision | My recommendation |
|---|---|---|
| D1 | Bake values on save, or substitute at render? | Substitute at render. See §2. This is the load-bearing one. |
| D2 | Is the whole template feature paid, or only the new surface? | Only the new surface. Add module templates; keep today's "upload a .dotx, start a document" in core. Making an existing shipped feature paid is a regression for anyone already on template.read. |
| D3 | Field types in v1 | text, textarea, number, date, select, boolean — plus required and default. It is one type + options jsonb column and a form generator. Anything less makes the fill-in form feel like a text box farm. |
| D4 | Conditionals ({{#if}}) and repeating rows (invoice line items)? |
Out of scope for v1. Repeating rows means rewriting docx table XML — that is its own project. But decide now, because it changes the field schema if you ever want it. |
| D5 | Does a template carry filing defaults? (folder, classification, doc type, retention, title pattern like Kontrak {{CLIENT_NAME}}) |
Yes. Cheap, high value, and it mirrors what the request feature just shipped ("the ask carries the filing decision"). |
| D6 | Template versioning + an approval workflow before a template goes live? | Versioning yes, approval no in v1. The workflow engine can be pointed at templates later if contract templates need sign-off. |
| D7 | Who may re-open the fill-in editor on an existing document? | Whoever can edit the document (Contributor+). No new permission — but the perm catalogue must stay in sync (npm run perm-guard). |
| D8 | Can a template be used for letters too, or documents only? | Documents only in v1. Letters already have their own template stack; merging the two is a bigger consolidation. |
| D9 | Naming — there will be five things called "template" | User-facing "Templates" = this module. Legacy HTML templates (00014) gets renamed or hidden. Letterhead and letter formats keep their own names. |
| D10 | Do template blobs count against the tenant storage quota in Cloud? | Yes — they are blobs like any other. (Quotas fail open; see the tenant-quota notes.) |
| D11 | Mobile parity for the fill-in ceremony? | Web first. Mobile can still use a template with its defaults; the fill-in form lands in a later release. |
5. Rough effort
| Slice | Estimate |
|---|---|
Migration, variable tier, docx.ScanFields, auto-discovery |
~1 day |
| Template CRUD extension, OnlyOffice template editing, versioning | ~1–2 days |
| Fill-in ceremony, generated form, live preview | ~2 days |
| Render-time substitution chokepoint + egress audit (the risky one) | ~1 day |
| Module gating, licence regeneration, perm-guard, en/id i18n, e2e | ~1 day |
~1 week of focused work.
6. Landmines to respect
- Licence regeneration. Adding to
KnownModulesis cheap in code and expensive in ops:
every licence (prod, demo, Cloud) must be reissued. Themcpmodule's prod licence still
has not been regenerated — do not repeat that. - Migration numbering. Latest is
00170. Runls go/migrations | tailimmediately before
writing the file — co-agents race for numbers. - Egress paths. If D1 is "substitute at render", enumerate every raw-bytes path before
shipping, not after. An unmerged token reaching a recipient is the failure mode. - e2e needs a non-admin. Every demo user is
admin, so permission-gated behaviour is
unverifiable there without creating one.
7. What shipped (v1, 2026-08-05)
Schema — 00171_template_fields.sql
| Table | Holds |
|---|---|
document_template_fields |
What a template ASKS: per-template {{KEY}} declarations (label, help, type, options, required, default, order) |
document_field_values |
What one document ANSWERED. Row exists only while set; empty value on save deletes the row — absence is the unset state, the token round-trips again |
document_template_uses |
Seed provenance: which template made which document (the fill-in editor finds its form through this) |
document_template_versions |
Content history: every upload/office save leaves a row (rev, hash, who, when) |
Plus on document_templates: rev (bumps per content change, keys the OnlyOffice
session) and the D5 filing defaults (default_folder_id, default_classification,
default_doc_type, title_template).
Engine
docx.ScanFields— the discovery direction of the existing replacer: same
run-reunification, same tolerances, key syntax^[A-Z][A-Z0-9_]{1,40}$. Runs on every
upload/replace/office-save; new keys become declarations automatically (built-ins
excluded — code wins). Word family only (.docx/.docm/.dotx/.dotm).- Resolve order (variables engine, document points): org statics → built-ins →
document field values (new tier) → caller overrides → point allow-list → disabled
built-ins. A value row carrying a built-in key is ignored; a template field deliberately
shadows an org static of the same key. - Preview merges (
loadPreviewArtifact): a document with saved values previews merged
through the same OnlyOffice/gotenberg engines as the official copy. Cache identity is
srcHash + "|" + fingerprint(resolvedMap)— critical because zero-copy seeding means two
documents from one template SHARE a content hash, and the bare-hash key would leak one
document's answers into the other's preview. Documents without values keep the untouched
byte-exact fast path. Official copy + finalize already merged — field values ride in
with zero changes there (the frozen-merged-copy half of D1).
Surfaces
- Template library: Fields… declaration editor (typed controls, per-key
required/default/options), Edit in OnlyOffice (newdoctemplateoffice subject,
letterhead pattern:template.adminre-checked at save time, save validates OOXML,
bumps rev, re-scans fields; native.dotxround-trips under its own fileType). - Use ceremony: generated fill-in form + live preview pane (600 ms debounce,
server-cached bycontent hash + fingerprint— theletterPreviewCachepattern).
Blank title +title_template⇒ the title renders from the answers. - Document detail: Fill fields button (write access) → same form over the stored
values; saving remounts the hero preview, which picks up the merged render. - API:
GET/PUT /document-templates/{id}/fields,POST …/preview,GET …/versions,
GET …/office-config,GET/PUT /documents/{docID}/fields, andvalueson…/use.
Gating (D2 as approved)
Module templates added to KnownModules. Gated: declaring fields, template preview,
answering values (at use and after). Ungated: the base library, seeding, reading
declarations (so the use dialog can show what a template would ask, controls disabled —
the standard cosmetic/nav split). The demo licence was regenerated with all nine modules
(dev key, verified); prod/Cloud licences still need reissuing before the module works
there.
Deliberately NOT in v1
- Repeating rows / conditionals (D4) — docx table XML surgery, its own project.
- Letters (D8) — they have their own template stack.
- Live unsaved preview on an existing document — the protected-document preview gate
(secure-preview-only classes) makes a raw-PDF side channel too easy to open; saved
values + preview refresh covers the need. - Mobile fill-in (D11).
Verification
go build clean; new unit tests green (docx/scan_test.go — whole/fragmented/duplicate
tokens, non-tokens, garbage archive, scan↔replace agreement; doctemplate/domain/field_test.go
— field validation, title rendering, capability matrix); existing httpapi + config
suites green; tsc + vite build clean; perm-guard ok (53 enforced ↔ 54 catalogued,
both directions — no new permission keys, by design D7).
Live verification — x056 demo, 2026-08-06 (1a0f9ca)
Deployed via update.sh --ref <sha> --yes (env deploy/mekari.env). Migration 00171
applied, all four tables present, /readyz all-green, licence reports nine modules
including templates. End-to-end smoke test, then artifacts purged:
| Checked | Result |
|---|---|
| Token auto-discovery on upload | CLIENT_NAME, JENIS and the run-fragmented {{CON}}{{TRACT_}}{{VALUE}} all found; TODAY correctly excluded as a built-in |
| Declaring a built-in key as a field | 400, refused |
| Merged preview render (gotenberg) | 200 in ~3s; extracted text read Perjanjian dengan PT Maju Bersama, 250000000 tertanggal 6 Agustus 2026. Jenis: Jasa. — no literal tokens left |
| Shared-blob isolation | Two documents seeded from one template held the same content_hash and each rendered only its own answers (A: "PT Maju Bersama…Jasa"; B: "CV Sinar Jaya…Barang"). The cache-key fix holds. |
| Re-answering an existing document | Values updated on re-render; clearing a value brought {{JENIS}} back literally — the D1 payoff, proven |
| Answering an undeclared key | 400 doctemplate.field_not_declared |
Raw-PDF preview returns 409 on this deployment (watermarking is licensed ⇒ secure
image preview is the only PDF surface) — expected, not a fault; verification went through
/secure-preview + /preview-session/{sid}/page/{n} (pages are 0-indexed).
Still unverified: the OnlyOffice template editing session round-trip (needs a browser
against the doc-server) and the fill-in UI itself; only the APIs behind them were driven.
🔴 Found and fixed during that pass — purge left the answers behind
Purging the smoke-test documents left 5 document_field_values rows alive.
PurgeDocument deletes metadata_values because that is user content about the record;
field values are the same shape and stronger — the literal text that printed in the
document body — so a purged contract kept its CLIENT_NAME and CONTRACT_VALUE. There
is no FK (the read-model convention), so nothing cascaded them. Fixed in 1a0f9ca
(values + the seed-provenance row), verified through the real code path: 2 rows before
purge, 0 after.