think
16px
820px

Letters are documents — unification spec

Supersedes 2026-08-18-letters-in-documents.md (one-way projection). That plan solved
letter → /documents by copying. It could not solve document → letter, and copying was
always the wrong shape for records that are conceptually the same thing.

Thesis. A letter is a document with two extra faculties: a gapless official number
and a correspondence lifecycle (intake → disposisi → approval → dispatch). Everything
else it has — bytes, versions, ACL, retention, classification, search, comments, activity —
the document model already has, and currently has twice.

So: one record, two faces. documents is the record spine. "Letter" becomes a facet
— a 1:1 extension row — not a separate aggregate.

The codebase already believes this. handlers_correspondence.go:108 builds a synthetic
dmsdomain.Document{ID: letter.ID, Classification: letter.Classification} to push a letter
through the forensic-watermark chokepoint — it fakes a document, at the letter's own id,
because the letter is one. This spec makes that real and deletes the shim.


0. The mechanic that makes this cheap

letters.id and documents.id are both uuid PRIMARY KEY.

The backfill inserts a documents row using the letter's own id.

INSERT INTO documents (id, title, doc_type, classification, owner_id, created_at, filed_at, ...)
SELECT l.id, l.subject, l.type, map_class(l.classification), l.created_by, l.created_at, NULL, ...
  FROM letters l
 WHERE NOT EXISTS (SELECT 1 FROM documents d WHERE d.id = l.id);

From that moment letter_id == document_id, permanently. Consequences:

  • The three FKs to letters(id)letter_seals, letter_comments,
    letter_document_links — keep working, untouched.
  • The loose letter_id columns — letter_assignments, letter_dispositions,
    letter_attachments, number_allocations — keep working, untouched.
  • workflow.subject_id and every esign/notification reference keep resolving.
  • No ID remapping. No FK rewrites. No byte copy (the blob store is content-addressed).

letters stops being an aggregate root and becomes the correspondence facet of a
document, keyed by the same id. That is the entire structural change.


1. Why not two-way mirroring

The obvious reading of "both directions" is: keep two rows, sync both ways. Don't.

Two-way sync forces an authority decision per field (who owns the title? the
classification? the bytes?), and then you own conflict resolution, update loops, and
permanent drift. It is strictly worse than either a one-way projection or a real merge.

With one row, the sync problem does not exist. There is nothing to reconcile.


2. Ownership — who owns what

Left to me, so, decided:

The document owns the BYTES

document_versions is already an append-only, role-tagged, snapshot-capable history with
voiding, dedup, at-rest encryption and egress protection wired through it.
letters.content_hash + letter_seals is a strictly weaker copy of the same idea, and
every byte-serving surface in the repo (preview, share, sign, zip export, transfer package,
stego) already takes a dmsdomain.Document.

So the facet holds zero bytes:

Letter byte column Becomes
draft_docx_hash version, role source
body_html version, role working
content_hash (numbered PDF) version, role official
each letter_seals rev subsequent official versions — Version.VoidedAt already exists and matches LetterSeal.VoidedAt exactly

The facet owns the NUMBER and the LIFECYCLE

type · direction · sender_type/sender_name · received_date · number ·
agenda_no · letterhead_id · authoring · draft_docx_rev · letter_status

The document owns the PERSON

documents.owner_idletters.created_by. One owner, break-glass Manage, one model.
(Note the known gotcha: documents.owner_id is TEXT, not uuid.)

Attachments stay letter-side — decided

letter_attachments (lampiran) keep their current shape: blobs hanging off the facet, not
documents in their own right.

The cost is real and accepted: an enclosure stays invisible to document search, ACL,
retention and the ISO 16175 export. It is taken deliberately, because the alternative —
promoting each lampiran to a document — gives every enclosure its own ACL, and there is
no document→document ACL inheritance in this system. An enclosure whose visibility drifts
from the letter that transmits it is a worse failure than one that is merely unindexed.

Revisitable after the merge is live, without a data migration: the blobs are already
content-addressed, so promotion later is a row insert, not a copy.

Two status vocabularies, both kept

  • letter_status — where this is in the correspondence workflow
    (draft/in_review/approved/rejected/numbered/sent/registered)
  • documents.status — where the record stands with the outside world
    (draft/received/final/sent/superseded)

documents.status is derived from letter_status by a pure function, so the retention
floor (IsIssued) engages on sent/registered without correspondence knowing that
records management exists.


3. A letter may carry BOTH identifiers

They are different registries answering different questions, and both are gapless with their
own ledger:

Source Answers Example
Letter numberletters.number number_allocations + correspondence scheme "what did we officially call this when we sent it?" 042/SK/IV/2026
Agenda numberletters.agenda_no incoming register "what did the mailroom log it as?" AG-2026-0311
Document IDdocuments.reference document_id_formats "what is this record's handle in the archive?" DOC-2026-0042

They coexist and are displayed as three distinct fields. Never conflate them into one
reference.
A draft letter has neither; an issued one has both (it is filed at the same
moment it is numbered — §6a).

The Document ID is minted at issuance, reusing the endpoint that already exists for
exactly this "authored document gets its ID later" case:
POST /documents/{docID}/assign-id. No new numbering code.


4. Two workflow entry points — one engine

workflow already keys on (subject_type, subject_id). After the merge both "letter" and
"document" subject rows resolve to the same id, so a letter's approval chain and a
document's approval chain are one engine over one record. Nothing to migrate on day one.
Collapsing subject_type into "has a letter facet?" is optional cleanup, not required.


5. Access control — one model, replacing two

The biggest win and the biggest risk. Today there are two independent authorization paths:
mayReadLetter (registry-wide, with a confidential gate) and the document ACL. After the
merge there must be exactly one, or you inherit the weaknesses of both.

Resolution: the correspondence read model is expressed as document ACL.

Letter today Document ACL after
Routine letter, readable registry-wide Correspondence folder carries an everyone read grant (migration 00118 subject). Close to the registry model, but not identical — see the correction below.
Confidential, creator owner_id — Manage, break-glass, already automatic
Confidential, non-revoked assignment Explicit grant, user subject or position subject
Confidential, correspondence.admin Group subject grant
Confidential, pending workflow task holder The standard document-approver grant (transient — see below)

Disposisi becomes an ACL-granting act. Dispose = grant read. Revoke = remove grant. That
is what it already means; the merge makes it literal, and removes the class of bug where
the two paths disagree.

The approver visa already exists — nothing to build

The pending-workflow-task arm must not be frozen into a stored ACL: it is per-instance and
transient, and a stored grant would outlive the task, leaving a former approver with standing
read on a confidential letter.

It does not need building. requireAccess already implements it for documents and the code
calls it a visa, with a tighter scope than this spec would have specified:

  • READ only, never editor or above
  • alive only while a pending task exists — the visa expires with the duty
  • an explicit deny still wins; deny-wins outranks a workflow duty
  • a derivative visa lets a gatekeeper read what their principals must currently decide —
    non-transitive, with DLP and step-up still evaluated against the gatekeeper

Once a letter is read through the document gate, approvers keep their access for free.

🔴 The one gap the flip must close. The visa asks workflow for a pending task on
(subject_type, subject_id). A letter's instances are recorded under subject_type = 'letter' while requireAccess asks for 'document'. The merge makes those two names for one
record, so the predicate must accept either — otherwise the visa silently misses every letter
approval, and the people who lose access are exactly the ones being asked to approve.

Classification vocabularies (correspondence_classifications vs document
classifications) stay separate — one is a correspondence policy (SLA, read receipt,
fast-track), the other a sensitivity label. The facet keeps its policy code; the document
carries the mapped sensitivity label, and protection_floor ratchets as normal so a merged
letter can never be marked down below what it arrived as.

🔴 Correction: everyone is WIDER than today's registry

This section originally claimed the everyone grant reproduces the registry model exactly.
It does not, and the difference does not shrink with letter count — it is a population
question, true whether you hold one letter or ten thousand:

  • correspondence.read lives in memberPermissions (wire.go) — held by the member role
  • everyone (migration 00118) reaches every authenticated principal, including one
    holding no position, belonging to no group, and carrying a role that grants no
    correspondence access at all

So a signed-in user who cannot read letters today would reach them through /documents.
The mitigation is not cleverness: write the grant as a real, visible row on the archive
folder's ACL
rather than as magic, so an administrator can narrow it in one click and can
see that it is there. With a single letter on production the practical exposure is one
letter, and this is the moment to choose the safe default rather than the compatible one.

Decided: the migration preserves today's visibility exactly. The correspondence archive
root gets an everyone read grant, reproducing the registry model as-is. Rationale: never
change two things at once. A structural migration that also silently narrows who can see
letters is a migration whose bug reports are unreadable. Admins can tighten afterwards, as a
deliberate, separate, reviewable act.

Do not flip this blind. See P2.


6. The two flows

6a. Letter → Document: automatic filing at issuance

The document row already exists (§0), so filing is not a copy — it is admission to the
archive
. And it is automatic.

New column: documents.filed_at timestamptz (NULL = exists but not browsable). Normal
uploads default now(); letter-born rows start NULL. /documents gains one predicate:
WHERE filed_at IS NOT NULL. This is needed because folder_id IS NULL is already a real
listing — the unfiled/root view — so unfiled letters would otherwise appear there.

Do NOT ask for a folder at letter creation

Three reasons, in order of weight:

  1. A draft is not a record. §6c says a draft is deletable precisely because nothing was
    issued. Filing at creation puts half-written letters in the archive, visible to everyone
    with folder read. filed_at exists to prevent exactly that.
  2. Wrong moment, wrong person. At creation the author knows the subject and the
    recipient. Filing is a records decision about a record that does not exist yet. Asking
    first puts friction on the hardest step — starting — and gets answered with whatever is
    at the top of the list.
  3. It is derivable. Correspondence filing follows convention: a surat keluar goes to the
    outgoing register, a nota dinas somewhere else. The destination is a rule, not a
    decision.

The destination is a per-letter-type default

Configured once by an admin, beside the existing per-type retention (migration 00136
already keys on letter type, and the admin screen already exists):

surat  → /Correspondence/Keluar   + path template {yyyy}/{rom}
nota   → /Correspondence/Nota     + path template {yyyy}
memo   → /Correspondence/Memo     + path template {yyyy}

Rendered with platform/numbering.ReplaceDateTokens — the same token vocabulary as letter
numbers and Document IDs, no third syntax. Missing folders auto-create under the configured
root only.

Hard requirement: the destination must always resolve headlessly. Letters are numbered
from workflow tasks (AssignNumberFromTask, MarkSentFromTask) where no human is present
and no modal can be shown. So a missing per-type config must never block issuance — it
falls back to a single system root /Correspondence, to be tidied later. A filing config gap
must not stop a letter getting its number.

Flow

Creation — unchanged. Type, subject, scheme, letterhead. No folder question.
filed_at stays NULL; the record is invisible in /documents.

Issuance — the first state that consumes a number: numbered (outbound) or registered
(inbound). Same threshold as §6c's deletability rule, so "is it a record?" is asked once and
answered once. In one transaction the system:

  • resolves the destination from the per-type default + path template
  • sets folder_id and filed_at
  • mints the Document ID (existing deferred-assignment path)
  • sets retention_until from the per-type retention policy
  • recomputes the document ACL for the new folder chain (§5)

Override — the number-assignment modal and the intake modal already exist and are already
the moment of decision. Each gains one pre-filled field:

File in: /Correspondence/Keluar/2026/IV

Derived by default, changeable, never blank. Zero new steps in the flow. Keep it visible
rather than behind "Advanced": automatic filing means an ACL is applied automatically, and
the one thing that must not be invisible is where the record is about to land.

Re-filing afterwards is just Move — the ordinary document operation. There is no separate
"Save As" verb, and no unfiled backlog to chase.

Filing at numbered rather than sent is deliberate. A numbered letter has consumed a
number and is already undeletable (§6c) — it is a record whether or not it has been
dispatched. Waiting for sent would leave numbered-but-undispatched letters unfiled
indefinitely, which is the completeness gap this design exists to close.

6b. Document → Letter: "Publish as Letter"

Flow:

  1. Document detail → Publish as Letter…
  2. Modal: type (surat/nota/memo) · direction (outbound default) · numbering scheme ·
    letterhead (optional) · sifat/classification.
  3. Guards — refuse with a specific code, never silently:
    - the document already has a facet → 409 correspondence.facet.exists
    - caller lacks correspondence.write, or Manage on the document → 403
    - an active workflow is running on the document → 409 (the number would land mid-ceremony)
    - the latest official version is signed/sealed → 409. A number must be assigned
    before the artifact is sealed; restamping would invalidate the signature /ByteRange.
  4. Insert the facet row at the same id; reserve a number from the gapless ledger.
  5. Branch on whether the number can be printed into the bytes:
    - docx source containing {{NOMOR}} → substitute merge fields, convert to PDF, append
    a new official version, assign the allocation. The full existing numbering path.
    - anything else (uploaded PDF, scan, docx without the field) → register-only: the
    number is recorded against the record, the bytes are untouched. The UI must say this
    plainly — "the number will be recorded against this record, not printed on the file."
    This is not a compromise; it is exactly how inbound agenda numbering already works.
  6. On any failure, void the allocation — the existing gapless machinery, unchanged.

There is no "unpublish". A gapless number cannot be un-issued, only voided with an
audit trail (AllocVoided, already modelled). Voiding the facet leaves the document intact.


6c. Deletion — "if it's a letter, it can't be deleted"

The instinct is right; the blanket rule is too strong. A blanket ban makes an abandoned draft
undeletable, makes a duplicate mailroom intake permanent, and contradicts the retention
module — which already implements pemusnahan once a JRA schedule expires.

The correct rule is the one the document model already has. IsIssued(status) decides
whether the retention floor applies, and the facet only has to feed it:

letter_status documents.status Deletable?
draft, in_review, rejected draft Yes — nothing was issued. Trash + purge, ordinary document rules.
numbered final No. Issued: a number was consumed.
sent sent No. Issued and dispatched.
registered (inbound) received No. Issued: an agenda number was consumed.

So: "a letter that has been numbered or registered cannot be deleted; a draft can." That is
RetentionStatus + IsIssued, both of which already exist and are already enforced for
documents. Zero new mechanism — the facet just supplies the right documents.status.

Legal hold keeps blocking everything regardless, as today.

The one genuinely new guard: never orphan an allocation

The numbering domain states the invariant explicitly — "every number is accounted for as
exactly one of reserved/assigned/voided."
So removal of a numbered record must not simply
drop the row:

A numbered or registered letter cannot be trashed. It can only be VOIDED — the entry
stays in the register, marked void, and its allocation transitions assigned → voided with
a reason and an actor. A register with a hole in it is not a register.

This also covers the real-world case that motivates deletion in the first place: a duplicate
mailroom intake. You void it. The agenda number stays consumed and audibly void, which is
correct records practice.

Disposal after retention expiry runs through the existing pemusnahan path, which must void
the allocation as it goes.

Consequence for "Publish as Letter"

Publishing makes a previously deletable document undeletable. The modal must say so:

Publishing assigns an official number. This record will become part of the correspondence
register and can no longer be deleted — only voided.


7. What we gain and what we lose

Letters gain (all verified absent from the current /letters/* route set)

  1. Folders, filing, move, tree navigation — a letter can live in the records structure
  2. Document ACL — per-document and per-folder grants, groups, inheritance, DENY-WINS, and the access-explain UI
  3. Secure folders — at-rest encryption and step-up-gated subtrees
  4. Records management — retention floor, klasifikasi/JRA, records phase (aktif/inaktif), pemindahan arsip, ISO 16175 export
  5. Legal hold
  6. Version history UI + snapshots — seals become versions, so the seal trail gets the document version viewer
  7. Share links — external, expiring, watermarked, plus secure preview and ask-the-document on shares
  8. Tags, description, doc types, metadata schemas
  9. Trash + restore (letters have no trash today)
  10. Bulk ops, Quick Look, duplicate detection
  11. A Document ID — your explicit ask
  12. MCP read_document and contract intelligence, where applicable

Documents gain

  1. Gapless official numbering with tata naskah patterns ({rom}, {seq:N})
  2. Disposisi — routing to positions/users with instructions, and the correspondence inbox
  3. Agenda register for inbound records
  4. Letterhead application
  5. Correspondence approval presets, secretary triage/gatekeeper, external approval gate
  6. Read receipts, SLA and fast-track policy
  7. The correspondence register and its reports

Lost, or at risk — the honest list

  1. Registry-wide read by default — the #1 regression risk. Today any correspondence
    reader sees any routine letter. After the merge, visibility is the folder ACL. If the
    correspondence folder is not granted to everyone, letters become less visible than
    they are now. This must be reproduced deliberately, and it is the thing P2's shadow phase
    exists to catch.
  2. /letters as a standalone register table (P4) — becomes a filtered documents view.
    A UX regression unless the filtered view reproduces the current dense columns.
  3. Deletion freedom — a dispatched letter becomes retention-floored and is no longer
    freely deletable. Correct behaviour, but a change someone will notice.
  4. ~~Letter comments and activity must migrate or be orphaned.~~ Resolved — see §7b. Both
    turn out to be gains, not losses.
  5. Search ranking/snippet behaviour changes when the two indexes become one. No
    capability is lost: both letters (migration 00134) and documents (migration 00021)
    already carry a content_text + full-text search_tsv.

Explicitly NOT lost

  • Egress forensic watermarking — letters already have it, via the synthetic-Document
    shim at handlers_correspondence.go:108. The merge deletes the shim, not the feature.
  • Any correspondence machinery. Numbering, disposisi, intake, letterhead, seals, triage
    and the inbox all keep running unchanged — they simply key on a document id.

7b. Comments and activity

Both were listed as losses. Neither is. The second one inverts entirely.

Comments — a one-statement migration

The schemas are near-identical, and 00135 says so in its own header ("the letter twin of
document_comments (00019)"):

document_comments (id, document_id TEXT, version INT NULL, author_id, body, created_at)
letter_comments   (id, letter_id   UUID,                    author_id, body, created_at)

Since letter_id == document_id, the migration is:

INSERT INTO document_comments (id, document_id, version, author_id, body, created_at)
SELECT id, letter_id::text, NULL, author_id, body, created_at FROM letter_comments;

Ids preserved, threading preserved, timestamps preserved. version = NULL means "on the
document generally", which is exactly what a letter comment already is (00135: "comments
are letter-general"). document_comments.document_id has no FK, so nothing to satisfy.

Then drop letter_comments and point the letter comment endpoints at the document ones.

Activity — not data, a read-model. And documents don't have one.

There is no letter_activity table. handlers_letter_activity.go composes the
timeline at read time from workflow instances, seals, dispositions, comments and letter
status transitions. There is nothing to migrate.

Meanwhile there is no /documents/{docID}/activity endpoint at all — documents have the
append-only audit chain, but no user-facing timeline.

So the work is to generalize the composer, not move rows: rename letterActivityEvent
recordActivityEvent, have it take a document id, and merge the union of both worlds —
versions, workflow instances, seals, dispositions, comments, esign ceremonies, status
transitions. The facet arms simply return nothing for a document without a letter facet.

Result: letters keep the timeline they have, and documents gain a timeline they never
had.
Move this from the "lost" column to the "documents gain" column.


8. Licensing

dms is core; correspondence is a paid module. The merge makes this cleaner than today:

  • The record is core — it lives in documents and stays fully readable in /documents
    when the licence lapses. The letter number stays visible: it is part of the record.
  • The faculties are paid — numbering, intake, disposisi, agenda stop.
  • A facet row must never break core document rendering when the module is off: every
    document read path treats the facet as optional decoration.

Today a lapsed licence makes letters vanish entirely. After the merge, records survive and
only the faculties stop. That is the correct behaviour for a records system.


9. Risks, stated plainly

  1. Live prod. dms.val.id holds real letters, and Cloud is TENANCY_MODE=schema — the
    backfill is a per-tenant migration. It must be idempotent, batched, reversible, and
    verified against a tenant schema, not just public. Control-plane migrations run
    Cloud-only and have hidden bugs behind clean prod deploys before.
  2. The ACL flip is the dangerous moment. Wrong direction exposes confidential letters.
    Mitigated by the shadow phase (P2) — never a blind cutover.
  3. letters_status_chk. A CHECK constraint must be dropped before rows are rewritten
    under it.
  4. filed_at backfill. Every existing document must get filed_at = created_at, or the
    new predicate empties the entire browser.
  5. Go blast radius. The correspondence service touches letters throughout. Contained
    by keeping the letters table shape stable through P1–P4 — the documents row is added
    alongside, and no letter column is dropped until P5.

10. Phasing

# Slice Est.
P0 Spike the backfill against a copy of prod data. Dry-run, count rows, diff the computed ACL against mayReadLetter for every existing letter. Decides whether P2 is 2 days or 5. 0.5 day
~~P1~~ SHIPPED (c832940a). Migration 00205 + the filed_at predicate on 6 browsing surfaces. See §12.
P2 ACL unification, two steps: (a) shadow — compute the document ACL and log every disagreement with mayReadLetter on live traffic; (b) flip at zero disagreements, then delete mayReadLetter. 2–3 days
P3 Automatic filing at issuance (§6a: per-type destination config, headless resolver, override field on the two existing modals) + Publish as Letter (§6b). 3 days
P4 FE consolidation: one detail view that grows a Correspondence panel when the facet exists; /letters becomes a filtered documents view; number / agenda / Document ID rendered as three distinct fields. 2–3 days
P5 De-duplicate: retire the letter search index and the letter arm of Ask-the-Archive; fold letter_comments into document_comments (§7b, one statement); generalize the activity composer so documents get a timeline too; drop the migrated letter columns. 2–3 days

~2–3 weeks. P1 is independently shippable and independently revertible: it only adds
rows and one nullable column.

Note the ordering: P3 delivers both buttons, and it only needs P1 + P2. If P4/P5 slip,
you still have the feature.


12. P0 + P1 as built

Branch worktree-letters-are-documents-p0. 999bc528 (P0), c832940a (P1).

Confirmed by rehearsal before any of it was written: zero id collisions, and exactly the
three predicted FKs into letters(id). The core assumption holds.

P1 is invisible by two independent guards, not one. Letter-born rows land with filed_at
NULL and with no document_acl_read rows at all. The missing ACL hides them from every
ordinary caller even through a query that forgets the new predicate; filed_at also covers
the content-admin bypass, which walks straight past the ACL. Measured: 400 letters, 0 visible
to an admin listing; without the predicate, all 400 appear.

filed_at earns its place because folder_id IS NULL is already a real listing (the
unfiled/root view), so it could not have carried "not yet admitted to the archive".

Two findings from rehearsing it

A replay would have published every draft letter. The statement stamping pre-existing
documents said WHERE filed_at IS NULL — which, once this migration has run once, means
every unissued letter. A goose down/up, a manual replay or a partially-applied deploy
would have filed the lot. Now scoped with NOT EXISTS (SELECT 1 FROM letters …).

letter_seals rev 0 IS the numbered PDF — the same blob content_hash addresses — so
projecting both double-counts every letter numbered since migration 00131. content_hash
still cannot be dropped: letters numbered before 00131 have no seal ledger and it is their
only official copy. The P0 spike had been predicting 333 versions where the migration writes
290; its arithmetic now mirrors the migration exactly.

Also decided while building

  • content_text comes across in the backfill, so a filed letter is findable by content,
    not just by title. Leaving it empty would have read as a broken index.
  • The embedding sweep skips unfiled records. No leak either way (retrieval is already
    guarded), but it would have spent embedding budget on records the archive does not contain,
    every sweep, forever.
  • Known gap, deliberately visible: document_versions.size is written as 0. Sizes live in
    the blob store, which has no table to join. P3's filing pass runs in Go with blob access and
    backfills them; the rows are invisible for that whole window, so nobody sees the wrong
    number.

Verified

Up → Down → Up on a throwaway schema. Replay is a no-op (INSERT 0 0). Version numbering
1..n with no gaps; current_version = max(version); the status and classification mappings
land exactly as specified; a pre-existing document stays visible throughout.


11. What this kills

The real payoff. After P5 there is one of each, not two:

  • one full-text search index · one Ask-the-Archive retrieval arm (no double-citation)
  • one comment system · one activity feed
  • one retention model (letter_type_retention becomes a default feeding retention_until)
  • one version/seal history (letter_seals folds into document_versions)
  • one ACL path · one office-editor subject kind
  • one synthetic-Document shim, deleted

Two numbering engines, three identifiers and two lifecycle vocabularies survive on
purpose
— those are real distinctions, not duplication.