think
16px
820px

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.ReplaceFields at 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)

  1. Letter numbering (exists) → swap its hardcoded map for Resolve(letter, letter_numbering).
  2. Letter draft preview (exists, live merge) → Resolve(letter, letter_preview).
  3. Document official copy (NEW): when rendering the official PDF from docx/HTML, run
    the replacer with Resolve(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 stays document_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.
  4. Document preview (optional, later): live merge in the doc preview like letters.
  5. 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

  1. Dedicated variables.manage permission (not config.manage): variables reach documents too.
    Seeded at boot beside letterhead.manage; the wildcard admin permission passes it.
  2. {{TODAY}} allowed at both the document official-copy render (freezes into the PDF) and
    preview (recomputes).
  3. Per-subject prompted variables are v2 — the kind column is reserved (CHECK allows only
    static in 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: convertViaOnlyOffice makes the doc-server re-fetch the STORED
    bytes, so a merged office source would be discarded. When (and only when) docx.HasFields finds
    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.
  • ReplaceHTML is 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 /variables returns static values to every authenticated user (same precedent as
    GET /letterheads handing out kop HTML) — the registry is not a place for secrets.

Still open (v2 candidates)

  • Live document PREVIEW merge: the document_preview resolve point exists and works but has no
    call site yet.
  • Prompted per-subject variables (decision 3).