Correspondence flow audit — 2026-07-25
Systematic audit of the correspondence (letters) subsystem: every flow, button, action and
ACL rule checked for consistency between the FE, the HTTP handlers, and the service layer.
Each finding carries file:line evidence (verified against b6fa556). This is a cross-check
list — items are proposals to confirm or reject, not silently-applied changes. Three
findings marked ✅ FIXED were closed in the same batch that produced this audit because they
completed the already-approved sent terminal lock; everything else is untouched.
Legend: [severity] [area] — problem — evidence — suggested fix.
HIGH
-
✅ FIXED
[high] [acl]Sent-lock was bypassable via async seal completion: an OTP seal job
started pre-sent landed on the letter after Mark-as-sent (WriteLetterSealhad no status
guard). — go/internal/correspondence/app/service.go (WriteLetterSeal), resolvers.go seal arm.
— Fixed in1fc26b5: the lock is re-checked inside the seal tx. -
[high] [flow]A SENT letter's content is still mutated by workflow reject/cancel:
VoidSeals → VoidLetterSealsSincerestorescontent_hashto base with no status filter;
reject is reachable from MyTasksTab, cancel viaPOST /workflow/instances/{id}/cancel.
— go/internal/workflow/app/service.go:621-623, 2396-2398; resolvers.go:198-206;
correspondence/adapters/pg.go:293-309. — Skip/refuse the void whenletters.status='sent',
and (paired with #6) refuse MarkSent while a run is active so this state can't arise. -
[high] [acl]In-review decision hijack / self-approval: built-inPOST /workflowsaccepts
subject_type=letterwith NO letter guard (no creator/corr-admin, no sent-lock, no
mayReadLetter), and any letter-subject instance terminating flips an in_review letter to
approved/rejected — any member can start a workflow over someone else's in_review letter
naming their own position and "approve" it. — handlers_workflow.go:51-94 (document-only
gates at 66-87), 116-132; adapters/pg.go:185-192. — RunguardLetterWorkflowStarton EVERY
letter-subject start path (built-in POST /workflows and StartSubWorkflow,
handlers_workflow_advanced.go:54-85). -
[high] [ux]The letter seal chain cannot be completed from the UI: nothing in the FE calls
POST /letters/{id}/sign|meterai|stamporGET/DELETE /letters/{id}/pending-seal; a letter
sign/meterai/stamp workflow task dead-ends in the inbox (title "—", buttons disabled —
subjectId resolution is documents-only). — server.go:971-983; web approvals/data.ts:86;
MyTasksTab.tsx:142, 243, 387, 408, 428. — Resolve letter subjects in approvals data (title +
/letters/{id} deep-link) and add the letter ceremony actions to the task UI. -
[high] [acl]User-targeted disposisi grants NO read on confidential letters:mayReadLetter
and the ListLetters confidential filter only matchAssigneePositionID, never
AssigneeUserID— a person-targeted dispose leaves the recipient with an inbox item they
cannot open (403). — letter_access.go:36-40; handlers_correspondence.go:280-286. — Add the
AssigneeUserID == p.UserIDarm to both checks. -
[med→high pairing with #2] [flow]Mark-as-sent is allowed mid-ceremony: no active-workflow
or pending-seal check, so a running seal chain gets stranded (and the only outs — reject/
cancel — mutate the sent letter, #2). — handlers_correspondence.go:353-370; service
MarkSent. — Refuse MarkSent while an instance issubmittedor a pending seal exists. -
✅ FIXED
[high] [flow]Attachments were mutable on a SENT letter (no guard in handlers or
service; FE showed the buttons). — handlers_letter_attachments.go; LetterDetailView. —
Fixed in1fc26b5(backend guards) +7fa9004(FE lock).
MEDIUM
-
[med] [acl]AssignLetterNumberhas no per-letter authz: any correspondence.write holder
can irreversibly number ANY draft/approved letter — including a confidential one they cannot
read — while the less consequential Mark-as-sent requires mayEditLetter.
— handlers_correspondence.go:304-344. — Gate numbering on mayEditLetter. -
[med] [acl]SubmitLetterbuilt-in path has no creator/read check (anyone with write can
push someone else's draft into review with approvers of their choosing); the definition
branch of the same handler DOES enforce creator/corr-admin. — handlers_correspondence.go:
495-525 vs 465-493. — Apply the same gate to the built-in path. -
[med] [flow]Direct seal ceremonies don't requirenumbered: a REGISTERED inbound letter
with a stored PDF scan can be signed / have real e-Meterai burned on it (number-first is
enforced only on the workflow-start path). — handlers_correspondence.go:602-805;
service.go:518-543. — Require status==numbered in the three ceremony handlers. -
[med] [flow]Approvers cannot read the confidential letters they must approve:
mayReadLetter has no workflow-task arm → preview/office-view/GetLetter 403 for the assigned
approver (approve blind). — letter_access.go:16-47. — Grant read to holders of a pending
task on an active instance over the letter. -
[med] [flow]Cancelling a letter workflow leaves the letter stuckin_reviewforever
(cancel never calls ReflectApprovalState; in_review letters can't be edited/resubmitted/
numbered). — handlers_workflow.go:220-227 vs 116-132. — Mirror the returned/cancelled→draft
reflection in the cancel handler. -
[med] [acl]letterClassConfidentialfails OPEN: a classifications lookup error returns
false → a confidential letter downloads WITHOUT the forensic egress mark.
— handlers_correspondence.go:27-41. — Return the error (fail closed) instead. -
[med] [ux]~~Register status column lies (numbered shown as "Sent")~~ — ✅ FIXED in
7fa9004(real statuses + tab renamed Outbound). (Listed for completeness.) -
[med] [ux]Disposisi recipients have no inbox surface in the web app:GET /correspondence/inboxhas zero FE consumers; the Inbox hub shows only workflow tasks; the
dispose modal still promises "They will see it in their inbox".
— server.go:991; web inbox/InboxPage.tsx:29-42. — Add a disposisi tab to the Inbox hub. -
[med] [flow]Inbound scans are mishandled: DownloadLetterContent hardcodes
application/pdf+.pdffilename even for image scans; the FE offers NO download button
forregisteredletters; an image-scan letter shows a blank hero.
— handlers_correspondence.go (download); LetterDetailView (hasPdf = numbered||sent). —
Serve the stored MIME, add a registered-letter download button, add an image preview
fallback. -
[med] [flow]An approve-only definition workflow over a draft letter never reflects its
outcome (letter staysdraft; approval invisible; numberable regardless). — known v1 gap,
now with the generic-start hole (#3) it also affects arbitrary letters. — Flip letters to
in_review when an approve-chain starts from draft. -
[med] [flow]Letter attachments + intake scans bypass the upload size caps and storage
quotas documents enforce (no MaxBytesReader, no QuotaGuard).
— handlers_letter_attachments.go:19; correspondence service (no CheckQuota). — Apply both. -
[med] [ux]Draft letter metadata is immutable: no update endpoint at all — a typo'd
subject or WRONG classification (drives confidentiality + merge fields) can only be fixed
by recreating the letter. — server.go:941-997. — Add a draft-only metadata PATCH gated on
mayEditLetter.
LOW
[low] [flow]Finalize-tx failure (or the loser of a concurrent double-number race) leaves
the numbering allocation permanentlyreserved. — service.go:800-811, 881-891. — Void the
reservation on finalize failure too.[low] [ux]Lifecycle buttons ignore identity: Mark-as-sent/Submit/Assign/Dispose are shown
to every reader; non-creators get 403 after clicking. — LetterDetailView. — Gate on
me.userId === letter.createdBy || corr-admin.[low] [dead-code]Disposisi INFO role,revokedstatus + i18n label have no producer (no
revoke endpoint); actioned assignments still count as read grants — a mistaken dispose of a
confidential letter is an irrevocable read grant. — domain/disposisi.go:13,20,67;
letter_access.go:36-40. — Add revoke; filter by status in read checks.[low] [ux]On numbered/sent letters the office view-mode session serves the STALE pre-merge
draft docx (unfilled {{NOMOR}} placeholders), not the official letter. Mostly moot now the
view button is gone, but the /letters/{id}/edit URL still opens it. — handlers_office_
subjects.go:307,347-366. — Serve the final docx variant for numbered/sent view sessions.[low] [dead-code]Zero-consumer endpoints:GET /letters/{id}/effective-policy;
SubmitLetter's definition_id/steps branch (FE uses /workflow/definitions/{id}/start).
— server.go:957; handlers_correspondence.go:465-493. — Wire up or remove.[low] [acl]Numbering schemes needcorrespondence.admin(which also unlocks reading every
confidential letter) while classifications needcorrespondence.config.manage— scheme
management can't be delegated without granting confidential-read. — server.go:936-940 vs
1006-1010. — Move schemes to correspondence.config.manage.[low] [ux]Workflow tab on a REGISTERED inbound letter says "Assign the official number
first…" — impossible advice for inbound. — LetterDetailView:340-353. — Inbound-specific
empty state.[low] [flow]ActionAssignment(mark disposisi done) writes new timeline rows on a sent
letter. — service.go:968-990. — Decide: allow completing pre-sent work explicitly, or
auto-close pending assignments at MarkSent.[low] [dead-code]FELetterStatus'archived'can never occur. — data.ts:11. — Remove.
Status × action matrix
Legend: agree = FE/BE consistent · FE-only = FE shows, BE rejects · BE-only = BE allows, FE
never surfaces · blocked = both deny · HOLE = both allow but the model says block.
| Status | Edit docx | Submit | Assign # | Direct seal | Start def-wf | Dispose | Mark sent | Download | Attach +/− |
|---|---|---|---|---|---|---|---|---|---|
| draft | agree | agree | agree | blocked | BE-only | agree | blocked | blocked | agree |
| in_review | blocked | blocked | blocked | blocked | BE-only | agree | blocked | blocked | agree |
| approved | blocked | blocked | agree | blocked | BE-only | agree | blocked | blocked | agree |
| rejected | agree | agree | blocked | blocked | BE-only | agree | blocked | blocked | agree |
| numbered | blocked (edit-only now) | blocked | blocked | BE-only (#4) | agree | agree | agree | agree | agree |
| sent | blocked | blocked | blocked | blocked (✅ #1/#7 fixed; #2 void residual) | blocked | blocked | blocked | agree | blocked ✅ |
| registered | n/a | blocked | blocked | HOLE (#10) | BE-only (#3) | agree | blocked | BE-only (#16) | agree |
Resolution — fix batch landed the same day (486526c..e8df7df)
Fixed & live-verified on the demo: #1 ✅ #2 ✅ (void skips sent letters — proven by cancelling
a stale run over a sent letter: seals stayed unvoided) #3 ✅ (generic starts guarded + draft→
in_review flip) #4 ✅ (inbox letter-task titles/deep-links + the letter-page ceremony banner with
sign/e-Meterai/e-Stamp modals, OTP resume; direct ceremony buttons on numbered letters) #5 ✅
6 ✅ (plain instance walk — the e2e caught guardActiveWorkflow's admin override waving dispatch
through; fixed in e8df7df) #7 ✅ #8 ✅ #9 ✅ #10 ✅ (409 not_numbered proven) #11 ✅ (pending-task
read arm, fail-closed) #12 ✅ #13 ✅ #14 ✅ #15 ✅ (Disposisi inbox tab) #16 ✅ (sniffed MIME —
inbound PNG downloads as image/png; FE download button + image preview) #17 ✅ #18 ✅ (cap +
QuotaGuard parity) #19 ✅ (PATCH + Edit-details modal) #20 ✅ #21 ✅ (creator/corr-admin gating in
detail + context menu) #22 ✅ (revoke endpoint + UI; revoked assignments grant nothing) #23 ✅
(final-docx view sessions) #25 ✅ (schemes → correspondence.config.manage) #26 ✅ #28 ✅ (was
already clean).
Deliberate judgment calls (not bugs): #24 left in place — GET /letters/{id}/effective-policy
and SubmitLetter's definition branch stay as sanctioned API (the latter now coherent via the
in_review flip); #27 allowed — completing an EXISTING assignment on a sent letter is task
closure, not content mutation (new dispositions stay blocked); actioned disposisi retain
confidential read (worked-the-letter history), only REVOKED ones lose it.