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_idcolumns —letter_assignments,letter_dispositions,
letter_attachments,number_allocations— keep working, untouched. workflow.subject_idand 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_id ← letters.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 number — letters.number |
number_allocations + correspondence scheme |
"what did we officially call this when we sent it?" | 042/SK/IV/2026 |
Agenda number — letters.agenda_no |
incoming register | "what did the mailroom log it as?" | AG-2026-0311 |
Document ID — documents.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.readlives inmemberPermissions(wire.go) — held by the member roleeveryone(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:
- 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_atexists to prevent exactly that. - 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. - 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_idandfiled_at - mints the Document ID (existing deferred-assignment path)
- sets
retention_untilfrom 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
numberedrather thansentis 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 forsentwould leave numbered-but-undispatched letters unfiled
indefinitely, which is the completeness gap this design exists to close.
6b. Document → Letter: "Publish as Letter"
Flow:
- Document detail → Publish as Letter…
- Modal: type (surat/nota/memo) · direction (outbound default) · numbering scheme ·
letterhead (optional) · sifat/classification. - Guards — refuse with a specific code, never silently:
- the document already has a facet →409 correspondence.facet.exists
- caller lackscorrespondence.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. - Insert the facet row at the same id; reserve a number from the gapless ledger.
- Branch on whether the number can be printed into the bytes:
- docx source containing{{NOMOR}}→ substitute merge fields, convert to PDF, append
a newofficialversion, 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. - 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)
- Folders, filing, move, tree navigation — a letter can live in the records structure
- Document ACL — per-document and per-folder grants, groups, inheritance, DENY-WINS, and the access-explain UI
- Secure folders — at-rest encryption and step-up-gated subtrees
- Records management — retention floor, klasifikasi/JRA, records phase (aktif/inaktif), pemindahan arsip, ISO 16175 export
- Legal hold
- Version history UI + snapshots — seals become versions, so the seal trail gets the document version viewer
- Share links — external, expiring, watermarked, plus secure preview and ask-the-document on shares
- Tags, description, doc types, metadata schemas
- Trash + restore (letters have no trash today)
- Bulk ops, Quick Look, duplicate detection
- A Document ID — your explicit ask
- MCP
read_documentand contract intelligence, where applicable
Documents gain
- Gapless official numbering with tata naskah patterns (
{rom},{seq:N}) - Disposisi — routing to positions/users with instructions, and the correspondence inbox
- Agenda register for inbound records
- Letterhead application
- Correspondence approval presets, secretary triage/gatekeeper, external approval gate
- Read receipts, SLA and fast-track policy
- The correspondence register and its reports
Lost, or at risk — the honest list
- 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 toeveryone, 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. /lettersas a standalone register table (P4) — becomes a filtered documents view.
A UX regression unless the filtered view reproduces the current dense columns.- Deletion freedom — a dispatched letter becomes retention-floored and is no longer
freely deletable. Correct behaviour, but a change someone will notice. - ~~Letter comments and activity must migrate or be orphaned.~~ Resolved — see §7b. Both
turn out to be gains, not losses. - Search ranking/snippet behaviour changes when the two indexes become one. No
capability is lost: bothletters(migration 00134) anddocuments(migration 00021)
already carry acontent_text+ full-textsearch_tsv.
Explicitly NOT lost
- Egress forensic watermarking — letters already have it, via the synthetic-Document
shim athandlers_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
documentsand 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
- Live prod.
dms.val.idholds real letters, and Cloud isTENANCY_MODE=schema— the
backfill is a per-tenant migration. It must be idempotent, batched, reversible, and
verified against a tenant schema, not justpublic. Control-plane migrations run
Cloud-only and have hidden bugs behind clean prod deploys before. - The ACL flip is the dangerous moment. Wrong direction exposes confidential letters.
Mitigated by the shadow phase (P2) — never a blind cutover. letters_status_chk. A CHECK constraint must be dropped before rows are rewritten
under it.filed_atbackfill. Every existing document must getfiled_at = created_at, or the
new predicate empties the entire browser.- Go blast radius. The correspondence service touches
lettersthroughout. Contained
by keeping theletterstable 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_textcomes 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.sizeis 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_retentionbecomes a default feedingretention_until) - one version/seal history (
letter_sealsfolds intodocument_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.