Configurable variable (merge-field) system — design draft
Goal: turn the letters' {{NOMOR}}-style merge fields into one configurable, subject-aware
variable system shared by Correspondence AND Documents — built-in variables, admin-defined
custom variables, and clear replacement points.
What exists today (the seed)
- Letters:
{{NOMOR}} {{TANGGAL}} {{SIFAT}} {{PERIHAL}}replaced in the draft docx via
platform/docx.ReplaceFieldsat NUMBERING (frozen artifact) and — since today — live in
DRAFT PREVIEWS (all but NOMOR). - Letterheads: header/footer HTML injection at official-copy render / finalize for HTML content.
- The replacer only touches listed fields; unknown placeholders round-trip untouched (this
property is load-bearing — keep it).
Design
1. Variable registry (new variables context)
One table variables:
- key (UPPER_SNAKE, unique; the {{KEY}} token)
- scope: letter | document | any
- kind: builtin | static | expression
- value (static kinds: the literal org value, e.g. NPWP, company address)
- label, description (surfaced in editors' placeholder-reference panels)
- updated_by/at
Built-ins are code-registered, not rows (they resolve from the subject at render time),
but they LIST through the same registry API so every editor/reference panel shows one
catalogue. Proposed built-ins:
| Key | Scope | Resolves to |
|---|---|---|
| NOMOR | letter | official number (numbering time ONLY) |
| TANGGAL | letter/any | issuance date (Indonesian long date) |
| SIFAT | letter | classification label |
| PERIHAL | letter | subject |
| AGENDA | letter | inbound agenda number |
| DOCUMENT_ID | document | assigned doc-ID (reference) |
| TITLE | document | document title |
| CLASSIFICATION | document | classification label |
| DOC_TYPE | document | document type label |
| OWNER | document/any | owner display name |
| TODAY | any | render-time date |
| ORG_NAME etc. | any | admin static variables (examples) |
2. Admin surface
Admin → Variables tab (new): CRUD for static variables (key/label/value/scope), read-only
listing of built-ins. Permission: reuse correspondence.config.manage-class capability or a
new variables.manage; static values are org-wide, not per-user.
3. Resolution engine
variables.Resolve(ctx, scope Subject, when ResolvePoint) map[string]string:
- merges: built-ins for the subject → static registry rows (scope-matched) → point-specific
overrides (e.g. NOMOR only at numbering).
- ResolvePoint: letter_numbering | letter_preview | document_publish | document_preview
— each point declares which keys are ALLOWED to resolve (NOMOR only at letter_numbering;
DOCUMENT_ID only once assigned).
- Unresolvable keys stay literal (the existing round-trip property).
4. Replacement points (wired incrementally)
- Letter numbering (exists) → swap its hardcoded map for
Resolve(letter, letter_numbering). - Letter draft preview (exists, live merge) →
Resolve(letter, letter_preview). - Document official copy (NEW): when rendering the official PDF from docx/HTML, run
the replacer withResolve(document, document_publish)— so a proposal template can
carry{{DOCUMENT_ID}},{{TITLE}},{{ORG_NPWP}}and have them land in the official
copy. Docx path:docx.ReplaceFields(same engine); HTML path: string replacement on
the working copy before Gotenberg.
⚠️ The ResolvePoint identifier staysdocument_publish. The document lifecycle
vocabulary was renamed later (publish → make official copy / sent; see
docs/VERSION_ROLES_DEPLOY.md), but this string is a PERSISTED enum — it is stored on
variable rows and is part of the OpenAPI contract — so it deliberately keeps its name.
Do not "fix" it. - Document preview (optional, later): live merge in the doc preview like letters.
- Letterhead HTML injection: gains the same map (static + subject vars usable inside
header/footer HTML).
5. Editor affordances
- The "merge fields" reference panels (letter editor + letterhead editor already have one)
read the registry API so custom variables appear automatically, with copy buttons. - Document web editor (TipTap) + OO editor: same reference panel addition on the docs side.
6. Custom per-document/letter variables (v2, optional)
expression kind or per-subject values (e.g. a CONTRACT_VALUE prompted when the official
copy is made) — out of scope v1; the registry schema reserves kind for it.
Enforcement & safety
- Key syntax validated (
^[A-Z][A-Z0-9_]{1,40}$); collisions with built-ins rejected. - Replacement NEVER fails a ceremony or an official-copy render: unresolved → literal,
resolve errors → log + skip (matching today's best-effort merge). - The frozen-artifact rule stands: once numbered, or once the official copy exists, the
stored PDF never re-merges.
Effort
Registry + resolve engine + admin tab ~1.5d · wire points 1-3 + reference panels ~1.5d ·
document-preview live merge (optional) ~0.5d · e2e ~0.5d.
Decisions (settled 2026-07-27) — v1 SHIPPED
- Dedicated
variables.managepermission (not config.manage): variables reach documents too.
Seeded at boot besideletterhead.manage; the wildcardadminpermission passes it. {{TODAY}}allowed at both the document official-copy render (freezes into the PDF) and
preview (recomputes).- Per-subject prompted variables are v2 — the
kindcolumn is reserved (CHECK allows only
staticin v1; widening it is all v2 needs).
As-built notes (v1, commit 958ed40, migration 00140)
- Built where the spec said, plus document finalize (it is another official-copy render
path and injects letterheads) and letterhead injection on the letter side. - HTML letter BODIES are deliberately NOT merged at numbering — author-typed prose; the docx
channel is where merge fields live. The letterhead kop/footer DOES merge on both sides, so the
number still prints. The compose UI only offers the variable panel where merging happens. - Official copy + OnlyOffice:
convertViaOnlyOfficemakes the doc-server re-fetch the STORED
bytes, so a merged office source would be discarded. When (and only when)docx.HasFieldsfinds
a real placeholder, the official-copy render merges and goes through gotenberg instead, logging
that layout may differ from the editor. With no placeholders the path is byte-identical to before. - Preview cache key folds a SHA-256 digest of the resolved map, so editing a static value
invalidates stale previews automatically. ReplaceHTMLis a single-pass alternation regex: values are never re-scanned (a value containing
{{TODAY}}cannot resolve), output is deterministic, and values are HTML-escaped.GET /variablesreturns static values to every authenticated user (same precedent as
GET /letterheadshanding out kop HTML) — the registry is not a place for secrets.
Still open (v2 candidates)
- Live document PREVIEW merge: the
document_previewresolve point exists and works but has no
call site yet. - Prompted per-subject variables (decision 3).