think
16px
820px

One record, one page — implementation notes

Working detail behind 2026-08-20-one-record-one-page.md. That one is the decision; this one is
what gets built from. Read that first.


1. Facet contracts

A facet is a capability a record either has or does not. The shell asks the record what it
has and renders accordingly. Nothing is conditional on "is this a letter" outside the facet's own
resolution — that is the rule that keeps this from becoming the same if (isLetter) sprawl one
layer up.

// The record, as the shell sees it. One fetch (GET /documents/{id}) already returns this shape
// today: DocumentDetail + the optional `letter` facet added in P4.
interface RecordFacets {
  document: DocumentDetail        // always present — it IS the record
  letter?: LetterFacet            // present iff a correspondence facet exists
  contract?: ContractFacet        // already conditional today (contracts module)
}

// A facet contributes four things and nothing else.
interface Facet<T> {
  // Header chips/identity this facet adds. Rendered after the document's own.
  header(f: T): HeaderBit[]
  // Tabs this facet adds, with a stable key so ?tab= deep links survive.
  tabs(f: T): TabSpec[]
  // Actions this facet contributes to the action row, with a priority so the shell can pick
  // ONE primary. Priority is the record's need, not the facet's opinion of itself.
  actions(f: T, ctx: ActionCtx): ActionSpec[]
  // Surfaces this facet SUPPRESSES on the shared page, with a reason for the tooltip.
  suppress(f: T): SuppressSpec[]
}

suppress is load-bearing and easy to miss. A letter must hide: version upload (server refuses —
correspondence.letter.bytes), delete when issued (correspondence.letter.void_not_delete), and
in-app editing (editing_locked). Those refusals already exist server-side; the facet's job is to
stop offering what will be refused. Today that logic is inline in DocumentDetailView from P4 —
it moves here.

Action priority

One number, resolved by the shell, highest wins the primary slot:

priority condition action
100 letter, draft/rejected, docx Edit
90 letter, numbered, not sent Mark as sent
80 letter, has pending task for me Act on task
50 any record with an official version Download
40 any record with a preview Preview

Everything else goes to the overflow. A record with no facet resolves exactly as today (Download
primary), so the document path is unchanged by construction.


2. Tab keys

Stable keys, because ?tab= is deep-linked from notifications.

overview versions access share signatures workflow contract activity related correspondence

correspondence sorts after workflow, before activity — same reasoning the contract tab
already uses: the substance before the log.

LETTER_TABS is ['overview','workflow','signatures','activity','related','share']
(LetterDetailView.tsx:127). Every one of those keys already exists in DOCUMENT_TABS. The
third tab is labelled with the correspondence.chain.title string but is keyed signatures and
renders SignaturesTab (ceremonies + seal history) — so there is no orphan approval-chain tab.

The difference is content inside shared tabs, not the tab set. Specifically:

tab letter content the document tab does not have
overview 9 letter fields · disposisi assignments · disposition timeline · attachments (lampiran)
workflow none — WorkflowTab already mirrors WorkflowSection deliberately
signatures none of substance — same ceremonies, same seal history
activity none — one composer since P5
related none
share inverse: the letter tab is internal-link ONLY, plus an explicit no-external note

So correspondence as a new tab exists to hold what the letter Overview carries beyond
document metadata: disposisi assignments, the disposition timeline, and the register fields.
Attachments and comments are separate problems (§8).

Default tab. A record with a letter facet opens on correspondence, not overview. Today
a letter opens on its Overview, which IS the disposisi view; defaulting to the document Overview
would put a tab click in front of the thing every inbound letter exists for. Records with no
letter facet default to overview, unchanged.


3. Step 1 — extraction, no behaviour change

The safety property: after step 1, both pages render byte-identically to today. That is what makes
the step reviewable and independently shippable.

  1. RecordShell.tsx — header + tab bar + action row + panel host. Takes RecordFacets.
  2. facets/document.tsx — everything DocumentDetailView renders today, as a facet.
  3. DocumentDetailView becomes <RecordShell facets={{document}} />.
  4. LetterDetailView untouched in this step.

Hook order is the hazard: the shell must own all hooks. Facets are rendered as components, not
called as functions returning JSX mid-body — otherwise a facet that mounts conditionally changes
the hook count and reproduces the #310 that took the page down. hooks-guard.cjs covers the
early-return shape; it does NOT cover a conditionally-mounted facet, so this is a review rule,
not a check.

4. Step 2 — Correspondence as a facet

facets/correspondence.tsx absorbing, verbatim where possible:
DisposisiSection · LetterTaskBanner · AssignNumberModal · approval chain · seal history.

Verbatim matters: these are dense and load-bearing. Anything rewritten "while we're in there" is
where the regression enters.

5. Step 3 — redirect

/letters/{id}/documents/d/{id} (replace, not push, so Back does not bounce).
/letters/{id}/edit keeps its own route — it is an editor, not a detail view.

Then sweep inbound links: disposisi notification link, inbox task rows, register rows, the
CorrespondencePanel button (which becomes redundant and is deleted), and docview.correspondence.*
i18n keys that describe the other page.


6. Verification

  • hooks-guard, ui-guards, i18n-guard, tsc, vite build — all already in the build.
  • Browser load of: plain document · draft letter · numbered letter · inbound letter · a letter
    the caller may read only via disposisi. Screenshot each. #310 only ever appears in a browser.
  • Deep links: ?tab=correspondence, ?tab=versions on a letter, and an old /letters/{id} link.

7. Deliberately out of scope

  • Merging the two LISTS. Declined in P5; the register's dense columns are the reason.
  • document_versions.size = 0 for letter-born versions — a separate known gap.
  • Anything in the correspondence module's own screens (register, intake, inbox).

8. Zero-loss inventory

Every action and every field the letter view renders today, and where it lands. Source:
LetterDetailView.tsx action row (lines 325–447) and OverviewTab (616–747).

8a. Action row — 11 items

letter action gate today destination note
Download hasContent action row, primary document has it
Edit letter editable && canManage action row, primary document's "Open in web editor" slot, but must route /letters/{id}/edit
Edit details (draft\|rejected) && canManage action row, tertiary not the document's Edit — different field set (§8c)
Assign number numberable && canManage action row, tertiary letter-only
Dispose canManage && !sent action row, tertiary — unchanged §8b
Mark as sent canMarkSent && canManage action row, tertiary document has it; the letter's stricter gate wins on a letter
Duplicate direction==='outbound' action row, tertiary letter-only; deliberately outside the sent lock
Open in Documents !draft && !rejected DELETE links to the page you are on
awaiting-save inline awaitingSave already exists same .docview__await-save
sent-lock badge sent keep as-is document has no equivalent — new to the shell
next-step guidance nextStepKey merge key sets correspondence.nextStep.* + docview.nextStep.*

A letter's row therefore reads: Download · Preview · │ · Edit details · Assign number · Dispose ·
Mark as sent · │ · kebab. Seven visible + kebab, against the document's current eight + kebab —
because the letter-irrelevant document actions suppress (§8e).

8b. Where Dispose goes, and why not the tab

The house rule established in P4 is: section-scoped actions live in their tab (Start workflow
moved into the Workflow tab), direct lifecycle actions stay in the action row.

Dispose reads as section-scoped — it creates a disposisi assignment, and the assignments list is
what the Correspondence tab shows. But it is the single most-used action on an inbound letter,
it is a lifecycle verb (route this to a person), and it sits in the action row today. Demoting it
into a tab would be a real cost paid for a consistency argument.

Decision: it stays in the action row. The Correspondence tab shows the RESULT — assignments,
Mark done, Revoke, timeline — exactly as the Overview tab does today. The button does not move
and is not duplicated into the tab.

8c. Header + Overview fields — 9 register fields

number · agendaNo · type · classification (sifat) · direction · counterparty ·
createdAt · receivedDate (inbound) · letter status.

All nine are new to the document page. createdAt is the only one the document header already
carries.

Two collisions to resolve, not merge away:

  • status. The document header shows the RECORDS status; the letter carries a correspondence
    lifecycle status (draft → numbered → sent). Both are true at once and mean different things.
    Show both, as CorrespondencePanel already argues in its own comments.
  • classification. letter.classification IS sifat, and the document header already renders a
    classification tag. Same field, same value — one tag, not two. This is the one genuine merge.

"Edit details" and the document's "Edit" (editAttrs) therefore both survive: they edit different
columns. On a letter, Edit details wins the row and editAttrs moves to the kebab.

8d. Sections

section destination
disposisi assignments + Mark done + Revoke (inline confirm) Correspondence tab
disposition timeline Correspondence tab
attachments (lampiran) NEW section — the document view has NO attachments UI at all
letter comments document Comments tab (P5 unified the composer)
LetterTaskBanner (pending tasks + resumable-seal strip + ceremony modals) top of shell, beside MyDocTaskBanner
LetterHeroPreview preview column
AssignNumberModal mounted by the correspondence facet

Attachments is the one item with no home. grep -i attach DocumentDetailView.tsx returns only a
stale header comment — the section exists on letters and nowhere else. It must be built into the
shell, not assumed present.

8e. What must NOT be gained

Zero-loss cuts both ways: a letter must not silently acquire document powers it was denied.

capability rule
external share links letter Share is internal-link only, with an explicit no-external note (LetterDetailView.tsx:1535). Already leaking — see below
version upload server refuses (correspondence.letter.bytes); drag-drop already guarded at DocumentDetailView.tsx:427
delete issued letter is void-not-delete; suppressed at :897
Convert to PDF a letter becomes official through numbering, not this button
Page setup spreadsheet-only; never a letter

Live gap, not hypothetical. The Share tab is gated on doc.myAccess === 'manage' and nothing
else (DocumentDetailView.tsx:1001); handlers_sharing.go contains no letter guard. Since P0–P5
shipped, a Manage-level user can open /documents/d/{letterId} and mint a public external share
link for a letter — which correspondence states, in its own UI, does not exist. This predates the
single-page work. See §9 for the fix.

8g. Approved removals — execute in step 3, not now

Both removals in §8f are approved. They must NOT land before the pages merge: until
/letters/{id} redirects, "Open in Documents" and CorrespondencePanel are the ONLY crossings
between the two faces. Deleting them early strands users on whichever face they entered.


9. The external-share gap

9a. What is actually exposed

Less than the UI note implies. Migration 00210:106-117 maps a letter's sifat into
documents.classification — the code itself when the document registry knows it, else
'confidential' when letter_classifications.confidential, else 'none'. protection_floor is
seeded to the same value.

So for a letter, all of these already resolve correctly, because they read the document's
classification: the DLP forward gate (handlers_sharing.go:102-117), the protection floor
ratchet, the egress watermark, and the share access log. A confidential letter is already
governed exactly as a confidential document is.

What is NOT governed is the correspondence-specific concern: status. A letter in
draft / in_review / approved / rejected has no number, no approval, and no dispatch
record. An external link puts a document that officially does not exist yet outside the building,
with nothing in the correspondence trail saying it left.

(The residual PermissiveDLP hazard — a classification level with no dlp_policies row is wide
open, sharing.go:267 — applies to letters exactly as it applies to documents. Known issue, not
this fix.)

9b. Don't nuke it — the premise changed

correspondence.shareTab.noExternal was written when letters were NOT documents. A letter had no
folder, no ACL, no retention, so "no external sharing" was the only available answer. After the
merge a filed letter is an archive record, and refusing to share records externally is not a rule
the documents side holds — nor should it, since an expiring, watermarked, access-logged link is
strictly better than the alternative people actually use, which is emailing the PDF.

Nuking would also make the audit case worse: the share log is the only record of an external
handover. Blocking the link does not stop the handover, it stops the evidence.

9c. The fix

One predicate beside refuseSecureFolderShare in CreateShareLink, before the DLP gate:

// A letter that has not been ISSUED is not yet a record: no number, no approval, no entry
// in the dispatch trail. Refuse the link — an external party would be reading a document
// that officially does not exist, and nothing in correspondence would say it left.
//
// NOT admin-exempt, for the same reason refuseSecureFolderShare is not: this enforces the
// record's construction, which no role can satisfy. The DLP gate below exempts content
// admins because that one enforces org policy on people.
//
// Once numbered / sent / registered the ordinary document rules take over, and they already
// work — 00210 carries the letter's sifat into documents.classification, so DLP forward,
// protection floor, watermark and access log all resolve on a letter today.
if serr := s.refuseUnissuedLetterShare(r.Context(), docID); serr != nil {
    writeProblem(w, serr)
    return
}

Refusal code correspondence.letter.not_issued, joining the existing
correspondence.letter.bytes / .void_not_delete family.

9d. Three follow-ons

  1. Retire the note. correspondence.shareTab.noExternal becomes false for issued letters.
    Replace with a state-dependent line: unissued explains the refusal, issued says nothing
    special.
  2. Leave ListShareLinks and RevokeShareLink open. Links minted during the silent window
    must stay visible and revocable; gating the list would hide the exposure instead of closing it.
  3. Survey before assuming zero. SELECT count(*) FROM share_links s JOIN letters l ON l.id = s.document_id on demo and prod. Any hits were minted under the old silence — surface them for
    a decision rather than mass-revoking, since some may be deliberate.

8f. Removals — both are self-links

  1. "Open in Documents" (correspondence.detail.openDocument, LetterDetailView.tsx:360) —
    navigates to /documents/d/{id}, which after the merge is the current page.
  2. CorrespondencePanel (web/src/features/documents/CorrespondencePanel.tsx) — a summary
    plus an "Open in Correspondence" button pointing at /letters/{id}, which redirects back here.
    Its four facts (register number, direction, lifecycle status, type) are all preserved by §8c;
    only the wrapper and the button die.

Nothing else is removed. Every other action and field either already exists on the document page
or moves to a named destination above.