think
16px
820px

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

  1. Full editor — the existing pair: OnlyOffice for docx, TipTap for HTML. Unchanged.
  2. 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 KnownModules is cheap in code and expensive in ops:
    every licence (prod, demo, Cloud) must be reissued. The mcp module's prod licence still
    has not been regenerated — do not repeat that.
  • Migration numbering. Latest is 00170. Run ls go/migrations | tail immediately 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 (new doctemplate office subject,
    letterhead pattern: template.admin re-checked at save time, save validates OOXML,
    bumps rev, re-scans fields; native .dotx round-trips under its own fileType).
  • Use ceremony: generated fill-in form + live preview pane (600 ms debounce,
    server-cached by content hash + fingerprint — the letterPreviewCache pattern).
    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, and values on …/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.