think
16px
820px

Letters in the document browser — design plan

SUPERSEDED by 2026-08-18-letters-are-documents.md. This plan projects a letter into a
second documents row. 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 numberedsent (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:

  1. letter.sent / letter.registered → create document + version 1.
  2. letter.sealed (sign / meterai / stamp writes letter_seals rev 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.
  3. letter.meta_changed (subject / classification / number) → update title, classification,
    reference.
  4. letter.disposed / letter.assignment_revoked → re-derive ACL (§5).
  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 = true already 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 to everyone read to reproduce the
    central-registry model. Faithful, and no per-row work.
  • Confidential letterinherit_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 mayReadLetter is 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_processing gate 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.