Letters in the document browser — design plan
SUPERSEDED by
2026-08-18-letters-are-documents.md. This plan projects a letter into a
seconddocumentsrow. It works one-way only, and once the requirement became
document → letter as well, the copy became the wrong shape. Kept for the trade-off analysis
in §1 and §6, which still holds.
Goal. A letter registered or dispatched in the Correspondence module also appears in
/documents, as a real record, without the documents surfaces growing a correspondence
special case.
Recommendation. Project each terminal letter into a real documents row whose version
points at the letter's existing PDF blob. Do not teach /documents to union the
letters table.
1. Why a projection, not a federated list
The obvious cheap move is to make GET /documents union letters and render virtual rows.
It fails on contact with this codebase:
| Documents feature | Works on a virtual letter row? |
|---|---|
Folder tree (materialized path prefix scans, folder_acl_read) |
No — letters have no folder |
| Move / file / sort position | No |
| Retention floor, klasifikasi/JRA, records phase, ISO 16175 export | No — all keyed on documents |
| Semantic search + Ask-the-Archive | Duplicated (separate letter arm already exists) |
| Share links, Quick Look, preview cache, zip export, bulk ops | Each needs a branch |
| Pagination | Two tables, one ORDER BY created_at DESC, offsets drift |
That is ~40 surfaces each acquiring an if isLetter branch, forever. The projection buys all
of them at once, because after it a letter is a document.
It also matches the doctrine already in dms/domain/document.go: StatusReceived is
described in-tree as "a counterparty's signed contract, an invoice, an incoming letter".
The document model was already designed to hold these.
Cost of the projection: two rows for one thing, so drift and ACL bleed are the risks.
Sections 4 and 5 are about containing exactly those.
Storage cost: zero. The blob store is content-addressed and dedups, so the mirror's
version reuses the letter's PDF bytes. Only the row is new.
2. What gets mirrored, and when
Mirror on terminal state only — never a draft. A draft letter in the documents browser is
a half-written record, and it would churn on every keystroke-save.
| Letter | Trigger | Document status |
|---|---|---|
| Outbound | numbered → sent (explicit "Mark as sent") |
sent + sent_at |
| Inbound | registered (mailroom intake) |
received |
| Outbound, never dispatched | numbered only, opt-in per config |
final |
All three are IsIssued() → the retention floor engages automatically. That is the point:
today a letter's retention lives in letter_type_retention and is invisible to records
management; after this it is a retention_until on a document like everything else.
Field mapping:
| Document | From |
|---|---|
title |
letter.subject |
reference |
letter.number (outbound) / letter.agenda_no (inbound) — formatID = ptr("") so DMS numbering does not mint a second ID |
doc_type |
surat / nota / memo (makes the existing GET /documents?doc_type= facet work for free) |
classification |
mapped, see §5 |
retention_until |
letter_type_retention months, counted from numbered/received date |
owner_id |
letter.created_by |
editing_locked |
true, always |
| version 1 | role = official, MIME application/pdf, content hash = letter's current PDF |
Attachments (lampiran) stay letter-side in v1. Mirroring them as sibling documents is a v2
option, flagged in §8.
3. Where they land
An admin-configured archive root plus a path template, rendered with the existing
platform/numbering.ReplaceDateTokens (same token vocabulary as letter numbers and Document
IDs — no third syntax):
correspondence.archive_folder_id = <folder uuid>
correspondence.archive_path = "{direction}/{yyyy}/{type}"
→ /Correspondence/outbound/2026/surat
Missing folders are auto-created on first use, inside the configured root only. If no root is
configured the projector is off — no surprise folders appearing in anyone's tree.
4. Keeping the two in sync
One-way, letter → document, driven through the existing transactional outbox
(outbox_events, migration 00001) so a projection can never half-happen and is retried on
failure. Never an inline best-effort call in the request path.
New table (migration 00205):
CREATE TABLE letter_archive_documents (
letter_id text PRIMARY KEY,
document_id text NOT NULL UNIQUE,
synced_rev int NOT NULL DEFAULT 0, -- highest letter_seals.rev projected
acl_synced_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
Plus documents.origin text NOT NULL DEFAULT 'upload' ('letter') so the hot list path can
badge and guard a row without a join.
Events consumed by the projector:
letter.sent/letter.registered→ create document + version 1.letter.sealed(sign / meterai / stamp writesletter_sealsrev n) → for every
rev > synced_rev, append a version. Reuses the seal-rev ledger that already exists;
a voided seal projects a voided version rather than deleting one.letter.meta_changed(subject / classification / number) → update title, classification,
reference.letter.disposed/letter.assignment_revoked→ re-derive ACL (§5).letter.deleted→ soft-delete the mirror, subject to the normal retention guard.
Guards on the mirror (origin = 'letter'), so the copy can never disagree with the
letter:
AddVersion, finalize, office save, set-official → 409. Content comes from the letter.editing_locked = truealready blocks the office editor; the 409 closes the upload path it
leaves open.- Delete → refused while the letter lives; the letter is the deletion authority.
- Move, rename, tags, description, klasifikasi, folder ACL → allowed. Filing is the
archivist's job, and none of it contradicts the letter.
5. Access control — the part that must not be got wrong
mayReadLetter and the document ACL are different models. Projecting naively publishes every
confidential letter to whoever can read the archive folder.
Rule: the mirror never widens access.
- Routine letter (
confidential = false) — registry-wide readable today. Mirror inherits
the archive folder ACL; the admin sets that folder toeveryoneread to reproduce the
central-registry model. Faithful, and no per-row work. - Confidential letter —
inherit_access = false+ an explicit document ACL derived from
the letter's actual readers: letter.created_by→ owner (Manage, break-glass, already automatic)- each non-revoked assignment → user subject or position subject
- the correspondence-admin role → group subject
- the pending-workflow-task arm of
mayReadLetteris not projected — it is a transient,
per-instance grant, and freezing it into an ACL would outlive the task. Approvers keep
reading the letter on the letter side.
Re-derived on every dispose/revoke via event 4. A revoke must remove the ACL row, or the
document keeps a grant the letter has withdrawn.
Classification mapping. Letter classifications (correspondence_classifications: code,
label, confidential) and document classifications are separate vocabularies. v1: carry the
code across when a document classification of the same code exists; otherwise fall back to
the tenant's confidential-tier label when confidential, else none. The
protection_floor ratchet then applies as usual — a mirror can never be marked down below
what it arrived as.
6. Search and AI — the duplication trap
Letters already have their own full-text search (letter_search, content_text) and their
own arm in Ask-the-Archive (askArchiveLetterPrefilter in handlers_ai.go). Once mirrored,
the same letter is reachable twice, and a chat answer will cite it twice.
Fix, in the same phase as the backfill — not later:
- Ask-the-Archive / semantic search: drop from the letter arm any letter that has a row in
letter_archive_documents. The document arm covers it, with the sharper ACL. - Documents search: no change — the mirror is an ordinary document and the DLP
allow_ai_processinggate applies to it normally.
Text extraction and embedding come free: the mirror gets a version blob, so the standard
document enrichment pipeline runs.
7. Licensing
correspondence is a paid module; dms is core.
- Projection runs only while the module is licensed.
- Existing mirrors stay when the licence lapses. They are records; a licence expiry must
not make records vanish from the archive. - The "Open in Correspondence" action on the mirror is hidden when the module is off.
8. Phasing
| # | Slice | Est. |
|---|---|---|
| 1 | Vertical slice: migration 00205, projector + outbox consumer, non-confidential outbound sent letters only, fixed archive folder, no reseal sync. Proves the whole path end to end. |
~1 day |
| 2 | Confidential ACL derivation (§5) + dispose/revoke resync + classification mapping. | 1–2 days |
| 3 | Inbound registered, seal-revision versions, metadata resync, path template + admin settings UI. |
2 days |
| 4 | FE: origin badge in DocumentsTable, "Open in Correspondence" on DocumentDetailView, suppress content-mutating actions, i18n. |
1 day |
| 5 | Idempotent batched backfill of existing letters (dry-run first) + search/AI dedup (§6). | 1–2 days |
~1.5–2 weeks at this cadence. Phase 1 is independently shippable and independently
revertible (drop the config → projection stops; the rows that exist are ordinary documents).
Deferred to v2: lampiran as sibling documents; klasifikasi/JRA auto-assignment per letter
type; document → letter direction (never — the letter is always the authority).
9. Open decision
Automatic or opt-in? Automatic-on-terminal (recommended: records completeness is the
whole point, and "some letters are filed" is worse than either extreme) fills the archive
with every letter the organisation has ever sent — thousands of rows on prod. The alternative
is an explicit "Archive to documents" action on the letter, which keeps the browser curated
and the record incomplete. A middle option is automatic-per-letter-type, configured beside
the existing per-type retention.