think
16px
820px

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

  1. ✅ 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 (WriteLetterSeal had no status
    guard). — go/internal/correspondence/app/service.go (WriteLetterSeal), resolvers.go seal arm.
    Fixed in 1fc26b5: the lock is re-checked inside the seal tx.

  2. [high] [flow] A SENT letter's content is still mutated by workflow reject/cancel:
    VoidSeals → VoidLetterSealsSince restores content_hash to base with no status filter;
    reject is reachable from MyTasksTab, cancel via POST /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 when letters.status='sent',
    and (paired with #6) refuse MarkSent while a run is active so this state can't arise.

  3. [high] [acl] In-review decision hijack / self-approval: built-in POST /workflows accepts
    subject_type=letter with 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. — Run guardLetterWorkflowStart on EVERY
    letter-subject start path (built-in POST /workflows and StartSubWorkflow,
    handlers_workflow_advanced.go:54-85).

  4. [high] [ux] The letter seal chain cannot be completed from the UI: nothing in the FE calls
    POST /letters/{id}/sign|meterai|stamp or GET/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.

  5. [high] [acl] User-targeted disposisi grants NO read on confidential letters: mayReadLetter
    and the ListLetters confidential filter only match AssigneePositionID, 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.UserID arm to both checks.

  6. [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 is submitted or a pending seal exists.

  7. ✅ 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 in 1fc26b5 (backend guards) + 7fa9004 (FE lock).

MEDIUM

  1. [med] [acl] AssignLetterNumber has 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.

  2. [med] [acl] SubmitLetter built-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.

  3. [med] [flow] Direct seal ceremonies don't require numbered: 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.

  4. [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.

  5. [med] [flow] Cancelling a letter workflow leaves the letter stuck in_review forever
    (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.

  6. [med] [acl] letterClassConfidential fails 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.

  7. [med] [ux] ~~Register status column lies (numbered shown as "Sent")~~ — ✅ FIXED in
    7fa9004 (real statuses + tab renamed Outbound). (Listed for completeness.)

  8. [med] [ux] Disposisi recipients have no inbox surface in the web app: GET /correspondence/inbox has 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.

  9. [med] [flow] Inbound scans are mishandled: DownloadLetterContent hardcodes
    application/pdf + .pdf filename even for image scans; the FE offers NO download button
    for registered letters; 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.

  10. [med] [flow] An approve-only definition workflow over a draft letter never reflects its
    outcome (letter stays draft; 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.

  11. [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.

  12. [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

  1. [low] [flow] Finalize-tx failure (or the loser of a concurrent double-number race) leaves
    the numbering allocation permanently reserved. — service.go:800-811, 881-891. — Void the
    reservation on finalize failure too.
  2. [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.
  3. [low] [dead-code] Disposisi INFO role, revoked status + 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.
  4. [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.
  5. [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.
  6. [low] [acl] Numbering schemes need correspondence.admin (which also unlocks reading every
    confidential letter) while classifications need correspondence.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.
  7. [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.
  8. [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.
  9. [low] [dead-code] FE LetterStatus '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.