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.
RecordShell.tsx— header + tab bar + action row + panel host. TakesRecordFacets.facets/document.tsx— everythingDocumentDetailViewrenders today, as a facet.DocumentDetailViewbecomes<RecordShell facets={{document}} />.LetterDetailViewuntouched 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=versionson 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 = 0for 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, asCorrespondencePanelalready argues in its own comments. - classification.
letter.classificationIS 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
- Retire the note.
correspondence.shareTab.noExternalbecomes false for issued letters.
Replace with a state-dependent line: unissued explains the refusal, issued says nothing
special. - Leave
ListShareLinksandRevokeShareLinkopen. Links minted during the silent window
must stay visible and revocable; gating the list would hide the exposure instead of closing it. - Survey before assuming zero.
SELECT count(*) FROM share_links s JOIN letters l ON l.id = s.document_idon 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
- "Open in Documents" (
correspondence.detail.openDocument,LetterDetailView.tsx:360) —
navigates to/documents/d/{id}, which after the merge is the current page. 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.