think
16px
820px

Letters through the full workflow chain (approve → sign → e-Meterai → e-Stamp)

For agentic workers: written for INLINE execution by the session controller. Checkbox
(- [ ]) tracking per task; commit per task on main, explicit paths only, NO push
(the controller pushes after e2e). Every task must leave the tree green:
cd go && go build ./... && go vet ./... (and from T8: cd web && npx tsc --noEmit && npx vite build).
Tasks marked 🔴 ADVERSARIAL-REVIEW REQUIRED touch the PROD document sign/meterai/stamp
path — the document behavior must stay BYTE-IDENTICAL.

Goal. Give a correspondence letter the same definition-driven workflow chain a
document already gets — an ordered run of approve → sign → e-Meterai → e-Stamp — instead of
the current single-round approval-only path. The CORE workflow engine and the esign crypto +
evidence ledger are reused unchanged; the only real work is a letter-subject content
adapter for the seal PDF I/O
, because the ceremony read/write is today hard-coupled to DMS
document_versions and a letter has no version table (its PDF is one content-addressed blob at
letters.content_hash).

Architecture. Introduce one subject-generic port in esign/app:

type SealSubject interface {
    CurrentPDF(ctx, subjectType, subjectID) (pdf []byte, rev int, err error)
    WriteSealedPDF(ctx, signer, in SealWrite) (rev int, err error)
}

Two impls, dispatched by subjectType in the composition root:
document → today's s.dms.* code verbatim (byte-identical); letter → read
letters.content_hash, write a new content_hash + a letter_seals history row. This port
replaces the esign service's VersionReader/VersionWriter fields and the sync ceremony
handlers' direct dms.OpenVersionContent/dms.AddVersion; the hard-coded "document" literals
handed to Complete{Signature,Meterai,Stamp}ForSubject become the real (subjectType, subjectID). Letters run through the existing StartCustomWorkflow/StartFromDefinition +
ActDefinition path (already subject-agnostic; the placement-required gating is already
subjectType=="document"-guarded, so letters are already exempt). Numbering stays the gate
that produces the base PDF; the chain seals the numbered PDF in place and the letter stays
numbered.

Tech Stack. Go modular monolith (go/, hexagonal: domain pure → app ports+service owning
the kernel.UnitOfWork tx boundary → adapters pgx / httpapi), Postgres + goose migrations
(go/migrations/), React 18 + Carbon + TanStack Query SPA (web/).


Global Constraints

HARD CONSTRAINTS (from the task contract — do not deviate):

  • This modifies the PROD document signing/meterai/stamp path — every task that touches
    esign/ceremony MUST be marked ADVERSARIAL-REVIEW REQUIRED, and the document path behavior must
    stay BYTE-IDENTICAL
    (the document SealSubject impl is today's code behind the new interface).
  • NEVER go test (test DSN == live Postgres); verify with cd go && go build ./... && go vet ./.... Web: cd web && npx tsc --noEmit && npx vite build. NO new deps.
  • Deploy ONLY via ssh valbox 'cd /home/efran/remote-development/obscura && ./deploy/update.sh -y';
    re-check ls go/migrations | tail before deploy (co-agent migration-number RACE). Commit
    per task on main, explicit paths (NEVER git add -Ago/obscura-server is a tracked
    ELF binary), trailer Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>; do NOT push
    (controller pushes after e2e).
  • e2e drives the DOCUMENT sign/meterai/stamp path first (regression: must be unchanged) THEN a
    letter chain end-to-end.

Standard repo constraints:

  • NEVER docker compose down -v — single-service teardown only (docker compose stop/start <svc>).
  • i18n: add BOTH web/src/features/correspondence/i18n.ts locales (watch the smart-quote gotcha).
  • Back-compat is non-negotiable: every existing jsonb step row, in-flight instance, and pending
    seal must behave EXACTLY as today. The SealSubject document branch reproduces both the
    synchronous handler write AND the async dmsSignedVersionWriter write, byte-for-byte.

Reality Check — every anchor verified against the code (main, mig tail 00130_oauth)

Confirmed exactly as briefed unless noted. Trust the symbol names; line numbers may drift.

Briefed anchor Verdict
SubmitLetter (handlers_correspondence.go:306) calls s.workflow.Start(ctx, p, "letter", letterID, approverPositionIDs); reverts status on failure ✅ exact (:336)
ActWorkflow (handlers_workflow.go:98) mirrors terminal state via ReflectApprovalState for inst.SubjectType=="letter" (:116-131) ✅ exact — but the built-in Act path only. ActDefinition (handlers_designer.go:392) does NOT reflect to letter status yet (T6 adds it)
esign ports VersionReader.CurrentVersionPDF(docID) / VersionWriter.AddSignedVersion(docID,pdf,kind) (esign/app/service.go:314-325), fields :553-554, wired wire.go:479-480 ✅ exact
esign version-port USE sites: CompletePendingSeal:1345, local-envelope OTP submit :1827/:1843, envelope complete :2247, RequestExternalSignStep:3699 ✅ exact — all pass only SubjectID (never SubjectType); env.SubjectType/ps.SubjectType are available
Complete{Signature,Meterai,Stamp}ForSubject (workflow/app/service.go:1070/1240/1302) + AdvanceExternalSignStep:1584 are subject-generic ✅ exact
Sync handlers SignDocumentVersion:472, AffixDocumentMeterai:1072, AffixDocumentStamp:1232 do their OWN dms.OpenVersionContent(docID,version) → seal → dms.AddVersion(docID,…) and pass literal "document" to Complete*ForSubject + verify + list ✅ exact
🔴 sign/meterai sync writes derive a version role (WithVersionRole(s.derivedRole(ctx,docID,version)), :597/:1154) but stamp sync write passes NO role (:1321) CONFIRMED — the port must preserve this asymmetry (T5 passes SourceRev=version for sign/meterai, SourceRev=0 for stamp)
derivedRole = GetVersion(docID,version).Role else RoleSource (handlers_publish.go:257) ✅ exact
async dmsSignedVersionWriter.AddSignedVersion (resolvers.go:34) does idempotent-reuse + empty-signer→owner fallback + AddVersion no role + SetVersionStatus + SetDocumentStatus(draft); dmsVersionReader:86 ✅ exact — this is the async twin; the port's document branch is the union (role only when SourceRev>0)
placement-required gating is subjectType=="document"-guarded (StartFromDefinition:793, StartCustomWorkflow:1837) letters ALREADY exempt — decision #1 needs NO service change
seal void guards inst.SubjectType == "document" (Act:621, ActDefinition:970, CancelInstance:2396); workflowSealVoider.VoidSeals early-returns for non-document (resolvers.go:156) ✅ exact — T6 relaxes the three guards to document||letter; T4 teaches the voider the letter branch
esign ledger + esign_pending_seals + esign_sign_envelopes are all (subject_type,subject_id) generic; VoidSealsSince (esign/adapters/pg.go:267) voids meterai+stamp by subject; esign.Verify + classifySealSignature (handlers_esign.go:639/685) ✅ exact
RequestExternalSignStep (esign/app/service.go:3687) hard-rejects SubjectType != "document" sign_external over letters already blocked → decision #5 (defer) is enforced for free
letter model: Letter.ContentHash blob, NO version table (correspondence/domain/letter.go:86); statuses draft/in_review/approved/rejected/numbered/registered (mig 00053), CHECK has no sealed (decision #3: reuse numbered); OpenLetterContent:486, AssignNumber*, UpdateLetterNumbered (adapters/pg.go:150), SetLetterStatusFromReview guards WHERE status='in_review' (:185) ✅ exact
FE: LetterDetailModal.tsx has "Submit for approval" (SubmitModal:281, useSubmitLetter); StartInstanceModal/CustomWorkflowModal already carry subjectType∈{document,letter,other} and a fixedSubject prop (pin subject, hide picker) — placement rows offered for documents only ✅ exact — letters reuse these with fixedSubject
next free migration = 00131 (00130_oauth taken) re-check ls go/migrations \| tail before deploy

Resolved decisions (baked in)

  1. Placement: letters v1 use the signer's/provider default appearance (a fixed footer-style
    block) — NO interactive page-placement UI. Letters are EXEMPT from placement-required gating
    (already true in the service). Ceremony calls for letters pass a nil placement.
  2. Numbering vs. chain: NUMBER FIRST (AssignNumber produces the base PDF), THEN the
    chain seals the numbered PDF. Enforced by a number-first guard (T6): a workflow containing any
    seal step over a letter requires letter.status == numbered.
  3. Terminal status: a fully-sealed letter stays numberedno new status in v1. The
    letter_seals history + the workflow instance convey the chain. SetLetterStatusFromReview's
    WHERE status='in_review' guard makes terminal reflection a safe no-op for a numbered letter.
  4. Definition source: reuse the CORE builder + saved workflows with subject_type='letter'
    (the same UI documents use) — NOT a fixed preset.
  5. External signers (sign_external) for letters: DEFER (out of scope v1) — already blocked
    by RequestExternalSignStep's document-only guard.

File Map

File Task Purpose
go/migrations/00131_letter_seals.sql (new) T1 letter seal-history table
go/internal/correspondence/domain/letter.go T2 LetterSeal type + SealKind* consts
go/internal/correspondence/app/service.go T2 CurrentLetterPDF, WriteLetterSeal, ListLetterSeals, VoidLetterSealsSince + Repository methods
go/internal/correspondence/adapters/pg.go T2 UpdateLetterContentHash, InsertLetterSeal, ListLetterSeals, MaxLetterSealRev, VoidLetterSealsSince
go/internal/esign/app/service.go T3 🔴 SealSubject iface + SealWrite; retire versionReader/versionWriter; rewrite 5 use-sites
go/cmd/obscura-server/resolvers.go T4 🔴 sealSubjectRouter (document verbatim + letter dispatch); workflowSealVoider letter branch
go/cmd/obscura-server/wire.go T4 🔴 SetSealSubject instead of SetVersionReader/Writer; voider gains corr
go/internal/httpapi/server.go T4,T5,T7 hold sealSubject; mount letter ceremony + seal/verify routes
go/internal/httpapi/handlers_esign.go T5 🔴 generic seal tail (sealAndLand); doc handlers keep preamble, route the write through the port; retire "document" literals; generalize guardActiveWorkflow
go/internal/httpapi/handlers_correspondence.go T5,T6,T7 letter ceremony handlers; SubmitLetter definition mode; guardLetterWorkflowStart; letter seal/verify handlers
go/internal/httpapi/handlers_designer.go T6 letter guards on StartCustomWorkflow/StartFromDefinition; ActDefinition letter reflection
go/internal/workflow/app/service.go T6 🔴 relax 3 void guards documentdocument||letter
web/src/api/correspondence.ts T8 useLetterSeals, useVerifyLetter, extend useSubmitLetter
web/src/features/correspondence/LetterDetailModal.tsx T8 "Start workflow" (reuse CustomWorkflowModal/StartInstanceModal w/ fixedSubject), seal-history + verify section
web/src/features/correspondence/i18n.ts T8 en + id strings

Task 1 — Migration 00131_letter_seals.sql (letter seal-history table)

  • [x] Done00131_letter_seals.sql written; committed 2ec2317.

Files: go/migrations/00131_letter_seals.sql (new).

⚠️ Before writing: ls go/migrations | tail — if a co-agent took 00131, use the next free
number and rename consistently. The embed picks it up automatically.

The letter equivalent of document_versions for the seal trail. letters.content_hash always
points at the newest non-voided sealed PDF; this table is the history + the void/restore floor.
rev 0 (kind base) captures the pre-chain numbered PDF so a voided run restores it.

-- +goose Up
-- Letter seal history (letter workflow chain). A letter has no document_versions row -- its PDF
-- is one content-addressed blob at letters.content_hash. When a workflow seals a numbered letter
-- (sign / e-Meterai / digital stamp) the sealed PDF becomes the new content_hash AND appends one
-- row here, so the letter keeps an auditable trail of its sealed revisions. rev 0 (kind 'base') is
-- the pre-chain numbered PDF, captured on the first seal; it is never voided and is the restore
-- floor when a rejected/cancelled run voids the seals it produced (mirrors dms.VoidSealedVersions
-- for documents, which mark-and-keep; letters mark-and-restore because they overwrite in place).
CREATE TABLE letter_seals (
    id           uuid PRIMARY KEY,
    letter_id    uuid NOT NULL REFERENCES letters(id) ON DELETE CASCADE,
    rev          int  NOT NULL,                     -- 0 = base (numbered PDF), 1..N = sealed revisions
    kind         text NOT NULL,                     -- 'base' | 'sign' | 'meterai' | 'stamp'
    content_hash text NOT NULL,                     -- addresses the PDF for this rev in the BlobStore
    created_by   uuid,                              -- signer/affixer (NULL for an attributed-less landing)
    created_at   timestamptz NOT NULL DEFAULT now(),
    voided_at    timestamptz                        -- set when a failed run voids this rev; base stays NULL
);

-- One row per (letter, rev); rev is gapless-monotonic per letter (MAX(rev)+1 under the letter row).
CREATE UNIQUE INDEX letter_seals_letter_rev_uniq ON letter_seals (letter_id, rev);
-- History + restore reads walk newest-first per letter.
CREATE INDEX letter_seals_letter_idx ON letter_seals (letter_id, rev DESC);

-- +goose Down
DROP TABLE letter_seals;

Verify: file only — cd go && go build ./... (embed compiles; the SQL runs on next deploy).

Commit: git add go/migrations/00131_letter_seals.sql && git commit
feat(letters-chain): mig 00131 letter_seals history table.


Task 2 — Correspondence: the letter side of the seal I/O

  • [x] Done — domain LetterSeal/SealKind*, 4 service methods, 5 store methods (this commit).

Files: go/internal/correspondence/domain/letter.go,
go/internal/correspondence/app/service.go, go/internal/correspondence/adapters/pg.go.

The correspondence service gains the read/write/void the composition root's letter branch calls.
It imports no esign/workflow — it stays a pure content adapter over the BlobStore + letters +
letter_seals. Byte-safe: all-new code, touches no document path.

2a. Domain (domain/letter.go) — append near the status consts

// Seal kinds recorded in letter_seals. 'base' is the pre-chain numbered PDF (rev 0); the rest
// mirror the workflow ceremony that produced the sealed revision.
const (
    SealKindBase    = "base"
    SealKindSign    = "sign"
    SealKindMeterai = "meterai"
    SealKindStamp   = "stamp"
)

// LetterSeal is one revision in a letter's seal history — the letter equivalent of a
// document_versions row for the seal trail. Rev 0 (SealKindBase) is the numbered PDF; VoidedAt is
// set when a rejected/cancelled workflow run voids the revision it produced.
type LetterSeal struct {
    ID          string     `json:"id"`
    LetterID    string     `json:"letter_id"`
    Rev         int        `json:"rev"`
    Kind        string     `json:"kind"`
    ContentHash string     `json:"-"` // internal blob hash, never serialized
    CreatedBy   string     `json:"created_by"`
    CreatedAt   time.Time  `json:"created_at"`
    VoidedAt    *time.Time `json:"voided_at"`
}

2b. Repository port (app/service.go) — add to the // Letters. block

    // Letter seal history (letter workflow chain). UpdateLetterContentHash swaps the current
    // PDF WITHOUT touching status (the letter stays 'numbered'). InsertLetterSeal appends a
    // history row; MaxLetterSealRev is MAX(rev) for the letter (-1 when none, so the first
    // real seal is rev 1 after the rev-0 base). VoidLetterSealsSince marks the run's rows voided
    // and restores content_hash to the newest surviving rev (base is the floor).
    UpdateLetterContentHash(ctx context.Context, letterID, contentHash string, at time.Time) error
    InsertLetterSeal(ctx context.Context, ls domain.LetterSeal) error
    ListLetterSeals(ctx context.Context, letterID string) ([]domain.LetterSeal, error)
    MaxLetterSealRev(ctx context.Context, letterID string) (int, error)
    VoidLetterSealsSince(ctx context.Context, letterID string, since time.Time) error

2c. Service methods (app/service.go) — append after OpenLetterContent

// CurrentLetterPDF reads a numbered letter's current PDF bytes + its seal rev (0 when no seal has
// landed yet). It is the letter arm of the esign SealSubject.CurrentPDF port: the ceremony reads
// "the current sealable PDF" here instead of a document version. A letter with no content (draft)
// yields ErrConflict, exactly like OpenLetterContent.
func (s *Service) CurrentLetterPDF(ctx context.Context, letterID string) ([]byte, int, error) {
    l, err := s.repo.GetLetter(ctx, letterID)
    if err != nil {
        return nil, 0, err
    }
    if l.ContentHash == "" {
        return nil, 0, &kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.letter.no_content", Message: "letter has no stored document yet"}
    }
    rc, err := s.blobs.Get(ctx, l.ContentHash)
    if err != nil {
        return nil, 0, err
    }
    defer rc.Close()
    b, err := io.ReadAll(rc)
    if err != nil {
        return nil, 0, err
    }
    rev, err := s.repo.MaxLetterSealRev(ctx, letterID)
    if err != nil {
        return nil, 0, err
    }
    if rev < 0 {
        rev = 0
    }
    return b, rev, nil
}

// WriteLetterSeal lands a freshly-sealed PDF as the letter's new content, appending a letter_seals
// history row — the letter arm of esign SealSubject.WriteSealedPDF. On the FIRST seal it also
// captures the pre-chain numbered PDF as rev 0 (SealKindBase) so a later void can restore it. All
// in one tx: capture-base → store sealed blob → append rev → swap content_hash (status untouched;
// the letter stays 'numbered'). signerUserID is the acting user ("" tolerated -> stored NULL).
// Returns the new rev.
func (s *Service) WriteLetterSeal(ctx context.Context, signerUserID, letterID, kind string, pdf []byte) (int, error) {
    l, err := s.repo.GetLetter(ctx, letterID)
    if err != nil {
        return 0, err
    }
    if l.ContentHash == "" {
        return 0, &kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.letter.no_content", Message: "letter has no stored document to seal yet"}
    }
    hash, _, err := s.blobs.Put(ctx, bytes.NewReader(pdf), kernel.PutOpts{ContentType: "application/pdf"})
    if err != nil {
        return 0, err
    }
    var newRev int
    err = s.uow.Do(ctx, func(ctx context.Context) error {
        now := s.clock.Now()
        maxRev, err := s.repo.MaxLetterSealRev(ctx, letterID)
        if err != nil {
            return err
        }
        if maxRev < 0 {
            // First seal: freeze the current numbered PDF as the rev-0 base (the void restore floor).
            if err := s.repo.InsertLetterSeal(ctx, domain.LetterSeal{
                ID: kernel.NewID(), LetterID: letterID, Rev: 0, Kind: domain.SealKindBase,
                ContentHash: string(l.ContentHash), CreatedBy: signerUserID, CreatedAt: now,
            }); err != nil {
                return err
            }
            maxRev = 0
        }
        newRev = maxRev + 1
        if err := s.repo.InsertLetterSeal(ctx, domain.LetterSeal{
            ID: kernel.NewID(), LetterID: letterID, Rev: newRev, Kind: kind,
            ContentHash: string(hash), CreatedBy: signerUserID, CreatedAt: now,
        }); err != nil {
            return err
        }
        return s.repo.UpdateLetterContentHash(ctx, letterID, string(hash), now)
    })
    if err != nil {
        return 0, err
    }
    return newRev, nil
}

// ListLetterSeals returns a letter's seal history, newest first (base last).
func (s *Service) ListLetterSeals(ctx context.Context, letterID string) ([]domain.LetterSeal, error) {
    return s.repo.ListLetterSeals(ctx, letterID)
}

// VoidLetterSealsSince void-marks the seal revisions a failed (rejected/cancelled) run produced and
// restores content_hash to the newest surviving revision (rev 0 base is the floor, never voided).
// The letter arm of the workflow SealVoider; idempotent. The esign meterai/stamp LEDGER void is
// done separately by esign.VoidSealsSince (the composition root calls both).
func (s *Service) VoidLetterSealsSince(ctx context.Context, letterID string, since time.Time) error {
    return s.repo.VoidLetterSealsSince(ctx, letterID, since)
}

bytes, io, and time are already imported by service.go (AssignNumber uses them); confirm
and add none new.

2d. Adapter (adapters/pg.go) — append after SetLetterFinalDocx

// UpdateLetterContentHash swaps the letter's current PDF blob WITHOUT changing status (a seal
// keeps the letter 'numbered'). Distinct from UpdateLetterNumbered, which also sets status+number.
func (s *Store) UpdateLetterContentHash(ctx context.Context, letterID, contentHash string, at time.Time) error {
    _, err := s.db.Exec(ctx).Exec(ctx,
        `UPDATE letters SET content_hash = $2, updated_at = $3 WHERE id = $1`, letterID, contentHash, at)
    if err != nil {
        return fmt.Errorf("correspondence update letter content hash: %w", err)
    }
    return nil
}

// InsertLetterSeal appends a seal-history row.
func (s *Store) InsertLetterSeal(ctx context.Context, ls domain.LetterSeal) error {
    var createdBy any
    if ls.CreatedBy != "" {
        createdBy = ls.CreatedBy
    }
    _, err := s.db.Exec(ctx).Exec(ctx,
        `INSERT INTO letter_seals (id, letter_id, rev, kind, content_hash, created_by, created_at)
         VALUES ($1, $2, $3, $4, $5, $6, $7)`,
        ls.ID, ls.LetterID, ls.Rev, ls.Kind, ls.ContentHash, createdBy, ls.CreatedAt)
    if err != nil {
        return fmt.Errorf("correspondence insert letter seal: %w", err)
    }
    return nil
}

// ListLetterSeals returns a letter's seal history newest-first.
func (s *Store) ListLetterSeals(ctx context.Context, letterID string) ([]domain.LetterSeal, error) {
    rows, err := s.db.Exec(ctx).Query(ctx,
        `SELECT id, letter_id, rev, kind, content_hash, COALESCE(created_by::text, ''), created_at, voided_at
           FROM letter_seals WHERE letter_id = $1 ORDER BY rev DESC`, letterID)
    if err != nil {
        return nil, fmt.Errorf("correspondence list letter seals: %w", err)
    }
    defer rows.Close()
    var out []domain.LetterSeal
    for rows.Next() {
        var ls domain.LetterSeal
        if err := rows.Scan(&ls.ID, &ls.LetterID, &ls.Rev, &ls.Kind, &ls.ContentHash, &ls.CreatedBy, &ls.CreatedAt, &ls.VoidedAt); err != nil {
            return nil, fmt.Errorf("correspondence scan letter seal: %w", err)
        }
        out = append(out, ls)
    }
    return out, rows.Err()
}

// MaxLetterSealRev returns MAX(rev) for the letter, or -1 when there are no seal rows yet (so the
// service captures the rev-0 base on the first seal and the first sealed rev is 1).
func (s *Store) MaxLetterSealRev(ctx context.Context, letterID string) (int, error) {
    var rev *int
    if err := s.db.Exec(ctx).QueryRow(ctx,
        `SELECT MAX(rev) FROM letter_seals WHERE letter_id = $1`, letterID).Scan(&rev); err != nil {
        return 0, fmt.Errorf("correspondence max letter seal rev: %w", err)
    }
    if rev == nil {
        return -1, nil
    }
    return *rev, nil
}

// VoidLetterSealsSince void-marks the non-base rows created at/after `since` and restores
// content_hash to the newest surviving (non-voided) revision — rev 0 (base) is never voided, so a
// row always survives. Idempotent (already-void rows are skipped by the status guard).
func (s *Store) VoidLetterSealsSince(ctx context.Context, letterID string, since time.Time) error {
    ex := s.db.Exec(ctx)
    if _, err := ex.Exec(ctx,
        `UPDATE letter_seals SET voided_at = $3
           WHERE letter_id = $1 AND rev > 0 AND voided_at IS NULL AND created_at >= $2`,
        letterID, since, since); err != nil {
        return fmt.Errorf("correspondence void letter seals: %w", err)
    }
    // Restore the current PDF to the newest surviving revision (base survives as the floor).
    if _, err := ex.Exec(ctx,
        `UPDATE letters SET content_hash = sub.content_hash, updated_at = $2
           FROM (SELECT content_hash FROM letter_seals
                  WHERE letter_id = $1 AND voided_at IS NULL ORDER BY rev DESC LIMIT 1) sub
          WHERE letters.id = $1`, letterID, since); err != nil {
        return fmt.Errorf("correspondence restore letter content hash: %w", err)
    }
    return nil
}

Match the existing adapter's DB accessor exactly — the file uses s.db.Exec(ctx).Exec/Query/QueryRow
(see UpdateLetterNumbered/ListSchemes). fmt is already imported. voided_at is *time.Time.
The void's second $2 reuses since as the updated_at timestamp (a wall-clock value in the
failed run's tx) — acceptable; use s.clock-sourced time if the adapter already threads one.

Verify: cd go && go build ./... && go vet ./....

Commit: git add go/internal/correspondence/ && git commit
feat(letters-chain): letter_seals store + CurrentLetterPDF/WriteLetterSeal/void.


Task 3 🔴 ADVERSARIAL — esign SealSubject port; retire VersionReader/VersionWriter

  • [x] DoneSealSubject/SealWrite iface + sealSubject field/SetSealSubject; 5 async use-sites now thread the real subjectType (ps.SubjectType/env.SubjectType/in.SubjectType), byte-identical doc writeback preserved via SourceRev 0 + Note "". esign pkg builds+vets green; whole tree stays red at wire.go until T4 (as designed). Committed with T4's message (this commit).

Files: go/internal/esign/app/service.go.

Replace the two document-coupled ports with one subject-generic port and rewrite the five use-sites
to pass the real SubjectType. No crypto/ledger changes. The document behavior is preserved by
the composition-root impl (T4), which is today's code behind the interface.

3a. Define the port (replace the VersionWriter/VersionReader interface block, :314-325)

// SealSubject is the subject-generic seal I/O port: it reads "the current sealable PDF" of a
// subject and writes a freshly-sealed PDF back as the subject's new revision. Two impls dispatch
// on subjectType at the composition root: "document" (DMS document_versions, byte-identical to the
// prior VersionReader/VersionWriter) and "letter" (letters.content_hash + a letter_seals row).
// Wired post-construction via SetSealSubject; nil on the synchronous mock path.
type SealSubject interface {
    // CurrentPDF reads the subject's current sealable PDF + its revision (a document's current
    // version; a letter's seal rev, 0 when unsealed).
    CurrentPDF(ctx context.Context, subjectType, subjectID string) (pdf []byte, rev int, err error)
    // WriteSealedPDF lands the sealed PDF as the subject's new revision and returns it.
    WriteSealedPDF(ctx context.Context, signer kernel.Principal, in SealWrite) (rev int, err error)
}

// SealWrite is the sealed-PDF landing request. SubjectType/SubjectID + PDF + Kind are universal;
// SourceRev and Note are DOCUMENT-only bookkeeping the letter impl ignores. SourceRev > 0 makes the
// document impl derive the new version's ROLE from that source version (the synchronous sign/meterai
// path); SourceRev == 0 writes with NO role (the async writeback + the synchronous STAMP path) —
// exactly reproducing today's two behaviors. Note is the version-status note ("" -> the impl uses
// its kind-default, matching the async twin).
type SealWrite struct {
    SubjectType string
    SubjectID   string
    SourceRev   int
    PDF         []byte
    Kind        string // "sign" | "meterai" | "stamp"
    Note        string
}

3b. Struct field + setter (replace versionWriter/versionReader fields :553-554 and their setters :617-623)

    // sealSubject is the subject-generic seal I/O port (async completion + in-house local sign).
    // Replaces the former versionWriter/versionReader; nil until SetSealSubject.
    sealSubject SealSubject
// SetSealSubject wires the subject-generic seal I/O port (composition root). Replaces
// SetVersionWriter/SetVersionReader.
func (s *Service) SetSealSubject(ss SealSubject) { s.sealSubject = ss }

Delete SetVersionWriter/SetVersionReader. Keep SetWorkflowAdvancer (:626) unchanged.

3c. Rewrite the five use-sites (symbol-anchored; keep everything else identical)

(i) CompletePendingSeal (:1331-1345) — the async writeback:

        if s.sealSubject == nil {
            return false, &kernel.Error{Kind: kernel.ErrConflict, Code: "esign.async.no_writer", Message: "async completion is not wired (no seal subject)"}
        }
        // ... ClaimPendingSeal unchanged ...
        signer := kernel.Principal{UserID: kernel.UserID(ps.SignerUserID), Subject: ps.SignerSubject}
        newVersion, err := s.sealSubject.WriteSealedPDF(ctx, signer, SealWrite{
            SubjectType: ps.SubjectType, SubjectID: ps.SubjectID, PDF: sealedPDF, Kind: ps.Kind,
            // SourceRev 0 + Note "" -> async: no role, kind-default note (byte-identical to the old writer).
        })

(ii) local-envelope OTP submit (:1813 guard, :1827 read, :1843 write):

        if s.sealSubject == nil {
            return false, 0, &kernel.Error{Kind: kernel.ErrConflict, Code: "esign.local.unwired", Message: "local signing is not fully configured"}
        }
        // ... ClaimSignerForSubmit unchanged ...
        curPDF, curVer, rerr := s.sealSubject.CurrentPDF(ctx, env.SubjectType, env.SubjectID)
        // ... preSealMarkEmail(env.SubjectType, env.SubjectID, curVer, curPDF, ...) unchanged (it no-ops for non-document) ...
        newVersion, aerr := s.sealSubject.WriteSealedPDF(ctx, kernel.Principal{}, SealWrite{
            SubjectType: env.SubjectType, SubjectID: env.SubjectID, PDF: signedPDF, Kind: "sign",
        })

(iii) envelope complete (:2226 guard, :2247 write):

        if s.sealSubject == nil {
            return false, 0, &kernel.Error{Kind: kernel.ErrConflict, Code: "esign.async.no_writer", Message: "async completion is not wired (no seal subject)"}
        }
        // ... ClaimSignEnvelope + author selection unchanged ...
        newVersion, err := s.sealSubject.WriteSealedPDF(ctx, author, SealWrite{
            SubjectType: env.SubjectType, SubjectID: env.SubjectID, PDF: sealedPDF, Kind: "sign",
        })

(iv) RequestExternalSignStep (:3696 guard, :3699 read) — still document-only (the guard at
:3687 rejects non-document), so this just swaps the port name:

        if s.sealSubject == nil {
            return "", fmt.Errorf("esign: seal subject not wired for external sign step")
        }
        pdf, version, err := s.sealSubject.CurrentPDF(ctx, in.SubjectType, in.SubjectID)

Every err/variable name, claim/release ordering, preSealMark*, InsertSignature,
MarkSealCompleted, advanceWorkflowOnEnvelope, and Finalize* call stays exactly as-is —
only the reader/writer calls change. preSealMark/preSealMarkEmail keep their
subjectType != "document" early-return (:588/:605) so letters are never word-gap-marked at
seal time (letters have no protection policy chain) — intended.

Verify: cd go && go build ./... will fail until T4 wires SetSealSubject and removes the
old setters' callers — that's expected; T3+T4 land together conceptually. Run
go vet ./internal/esign/... to type-check this package in isolation, then proceed to T4 and build
the tree green before committing both.

Commit (with T4): see T4.


Task 4 🔴 ADVERSARIAL — composition root: sealSubjectRouter (byte-identical document + letter)

  • [x] DonesealSubjectRouter (dispatch on subjectType) replaces dmsSignedVersionWriter/dmsVersionReader; the document branch is the old writer verbatim (owner-fallback + idempotent-version-reuse + 23505→conflict + SetVersionStatus + SetDocumentStatus(draft)), extended only to WithVersionRole(derivedRole(SourceRev)) when SourceRev>0 (else no role) and to honour an explicit Note (else kind-default) — byte-identical to today since every async use-site passes SourceRev 0 + Note "". letter branch → corr.CurrentLetterPDF/WriteLetterSeal. workflowSealVoider gained a letter arm (corr.VoidLetterSealsSince + esign.VoidSealsSince); document arm unchanged. Wiring: esignSvc.SetSealSubject/workflowSvc.SetSealVoider moved to just after corrSvc is built (the letter branch needs corrSvc, declared ~180 lines below the old wiring site; matches the existing post-corrSvc SetSealMarker pattern). server.go field DEFERRED to T5 (not needed for a green tree now; parent scoped this commit to resolvers.go + wire.go). Whole tree go build ./... && go vet ./... green. Amended into T3's commit (this commit).

Files: go/cmd/obscura-server/resolvers.go, go/cmd/obscura-server/wire.go,
go/internal/httpapi/server.go (hold the port for T5).

Replace dmsVersionReader + dmsSignedVersionWriter with one sealSubjectRouter implementing
esignapp.SealSubject. The document branch is the old code verbatim (idempotent-reuse +
empty-signer→owner fallback + AddVersion + SetVersionStatus + SetDocumentStatus(draft)), plus
the SourceRev>0 → derive role rule that reproduces the synchronous handler's derivedRole write.
The letter branch delegates to the correspondence service (T2).

4a. resolvers.go — replace the dmsSignedVersionWriter + dmsVersionReader types

// sealSubjectRouter implements the esign SealSubject port over BOTH the DMS (document subjects) and
// the correspondence service (letter subjects), dispatching on subjectType. The document branch is
// byte-identical to the former dmsVersionReader/dmsSignedVersionWriter — it IS that code behind the
// new interface. The letter branch reads/writes letters.content_hash + a letter_seals row.
type sealSubjectRouter struct {
    dms  *dmsapp.Service
    corr *correspondenceapp.Service
}

func (r sealSubjectRouter) CurrentPDF(ctx context.Context, subjectType, subjectID string) ([]byte, int, error) {
    switch subjectType {
    case "letter":
        return r.corr.CurrentLetterPDF(ctx, subjectID)
    default: // "document" (and any legacy subject that used the DMS reader)
        doc, err := r.dms.GetDocument(ctx, subjectID)
        if err != nil {
            return nil, 0, err
        }
        rc, err := r.dms.OpenVersionContent(ctx, subjectID, doc.CurrentVersion)
        if err != nil {
            return nil, 0, err
        }
        defer rc.Close()
        b, err := io.ReadAll(rc)
        if err != nil {
            return nil, 0, err
        }
        return b, doc.CurrentVersion, nil
    }
}

func (r sealSubjectRouter) WriteSealedPDF(ctx context.Context, signer kernel.Principal, in esignapp.SealWrite) (int, error) {
    if in.SubjectType == "letter" {
        return r.corr.WriteLetterSeal(ctx, string(signer.UserID), in.SubjectID, in.Kind, in.PDF)
    }
    return r.writeDocument(ctx, signer, in)
}

// writeDocument is the former dmsSignedVersionWriter.AddSignedVersion, byte-identical, extended only
// to derive the new version's role from in.SourceRev when SourceRev>0 (the synchronous sign/meterai
// write did WithVersionRole(derivedRole); SourceRev==0 keeps the async + sync-stamp no-role write).
func (r sealSubjectRouter) writeDocument(ctx context.Context, signer kernel.Principal, in esignapp.SealWrite) (int, error) {
    docID := in.SubjectID
    // Empty signer (all-external envelope): attribute the version to the document owner.
    if signer.UserID == "" {
        if doc, derr := r.dms.GetDocument(ctx, docID); derr == nil && doc.OwnerID != "" {
            signer = kernel.Principal{UserID: kernel.UserID(doc.OwnerID)}
        }
    }
    // Idempotent land: reuse the current version if it is already this exact sealed content.
    sum := sha256.Sum256(in.PDF)
    hash := hex.EncodeToString(sum[:])
    v := 0
    if doc, derr := r.dms.GetDocument(ctx, docID); derr == nil && doc.CurrentVersion > 0 {
        if cur, verr := r.dms.GetVersion(ctx, docID, doc.CurrentVersion); verr == nil && string(cur.ContentHash) == hash {
            v = doc.CurrentVersion
        }
    }
    if v == 0 {
        opts := []dmsapp.VersionOpt{}
        if in.SourceRev > 0 {
            opts = append(opts, dmsapp.WithVersionRole(r.derivedRole(ctx, docID, in.SourceRev)))
        }
        nv, err := r.dms.AddVersion(ctx, signer, docID, bytes.NewReader(in.PDF), "application/pdf", opts...)
        if err != nil {
            var pgErr *pgconn.PgError
            if errors.As(err, &pgErr) && pgErr.Code == "23505" {
                return 0, &kernel.Error{Kind: kernel.ErrConflict, Code: "esign.version.concurrent_update", Message: "another signature just landed on this document — please try again"}
            }
            return 0, err
        }
        v = nv
    }
    status, note := r.docStatusNote(in.Kind, in.Note)
    _ = r.dms.SetVersionStatus(ctx, signer, docID, v, status, note)
    _ = r.dms.SetDocumentStatus(ctx, docID, dmsdomain.StatusDraft)
    return v, nil
}

// derivedRole mirrors httpapi.derivedRole: a sealed PDF inherits its source version's role.
func (r sealSubjectRouter) derivedRole(ctx context.Context, docID string, version int) string {
    if v, err := r.dms.GetVersion(ctx, docID, version); err == nil && v.Role != "" {
        return v.Role
    }
    return dmsdomain.RoleSource
}

// docStatusNote maps a seal kind to the version status enum + a note. An explicit note (the sync
// handlers pass their exact one) wins; "" falls back to the kind-default that the async writeback
// used — byte-identical to dmsSignedVersionWriter's old status/note.
func (r sealSubjectRouter) docStatusNote(kind, note string) (string, string) {
    switch kind {
    case "meterai":
        if note == "" {
            note = "e-Meterai affixed (external provider)"
        }
        return "meteraied", note
    case "stamp":
        if note == "" {
            note = "Digital stamp affixed (external provider)"
        }
        return "stamped", note
    default: // "sign"
        if note == "" {
            note = "Signed (external provider)"
        }
        return "signed", note
    }
}

Add imports to resolvers.go if missing: correspondenceapp "…/internal/correspondence/app",
dmsdomain (already imported), sha256/hex/bytes/errors/pgconn (already imported — the old
writer used them). Delete the old dmsSignedVersionWriter + dmsVersionReader types.

4b. resolvers.go — teach workflowSealVoider the letter branch

type workflowSealVoider struct {
    dms   *dmsapp.Service
    esign *esignapp.Service
    corr  *correspondenceapp.Service
}

func (v workflowSealVoider) VoidSeals(ctx context.Context, subjectType, subjectID string, since time.Time, reason string) error {
    switch subjectType {
    case "letter":
        // Void the letter's sealed revisions (restore content_hash to the base) + the esign
        // meterai/stamp ledger rows for the letter (already subject-generic).
        if err := v.corr.VoidLetterSealsSince(ctx, subjectID, since); err != nil {
            return err
        }
        return v.esign.VoidSealsSince(ctx, subjectType, subjectID, since)
    case "document":
        if _, err := v.dms.VoidSealedVersionsSince(ctx, subjectID, since, reason); err != nil {
            return err
        }
        return v.esign.VoidSealsSince(ctx, subjectType, subjectID, since)
    default:
        return nil
    }
}

4c. wire.go — swap the wiring (:479-485)

    sealRouter := sealSubjectRouter{dms: dmsSvc, corr: correspondenceSvc}
    esignSvc.SetSealSubject(sealRouter)
    esignSvc.SetWorkflowAdvancer(workflowSvc)
    // ... (unchanged lines) ...
    workflowSvc.SetSealVoider(workflowSealVoider{dms: dmsSvc, esign: esignSvc, corr: correspondenceSvc})

Use the actual correspondence service variable name in wire.go (grep correspondence.NewService
/ the var passed to the httpapi server — likely correspondenceSvc or corrSvc). Delete the two
old SetVersionWriter/SetVersionReader lines.

4d. httpapi/server.go — hold the port for the sync handlers (T5)

The Server needs the same port for the synchronous ceremony write. Add a field + wire it where the
server is constructed (grep the Server{…} literal in wire.go):

// in the Server struct:
    sealSubject esignapp.SealSubject
// in wire.go where the httpapi Server is built, pass sealRouter into that field
// (or add `func (s *Server) SetSealSubject(ss esignapp.SealSubject){ s.sealSubject = ss }` and call it).

Verify: cd go && go build ./... && go vet ./... (T3 + T4 now green together).

Commit (T3+T4): git add go/internal/esign/app/service.go go/cmd/obscura-server/resolvers.go go/cmd/obscura-server/wire.go go/internal/httpapi/server.go && git commit
feat(letters-chain): subject-generic SealSubject port (document byte-identical + letter).


Task 5 🔴 ADVERSARIAL — sync ceremony handlers → subject-generic seal tail + letter ceremony routes

  • [x] Done (9ff60a8) — extracted sealAndLand (subject-generic write+status+workflow-advance tail) in handlers_esign.go; the three document handlers keep their whole preamble (ACL/version-read/OTP/esign crypto) and route ONLY the write tail through the port — byte-identical: sign sealAndLand("document",docID,"sign",signed,version,"Signed by "+signerName), meterai ("meterai",sealed,version,"e-Meterai affixed ("+rec.Serial+")"), stamp ("stamp",sealed,0,"Digital stamp affixed") (sourceRev=0 keeps the roleless write asymmetry; the router maps note→"signed"/"meteraied"/"stamped" status = the former versionStatus* consts). Removed now-unused bytes/dmsapp/dmsdomain imports (their only uses were the replaced tails). guardActiveWorkflow(r, subjectType, subjectID) generalized; 3 doc call-sites pass "document" verbatim. New SignLetter/AffixLetterMeterai/AffixLetterStamp in handlers_correspondence.go mirror the doc handlers over letters.content_hash (nil placement, sourceRev=0, note="") reusing the same tail. Routes mounted INSIDE the correspondence group (so requireModule("correspondence")) with requireModule("esign") + requirePerm("correspondence.write")deviation from 5d's literal requirePerm("esign.sign"/"meterai.affix"/"stamp.affix"): doc ceremony routes actually gate on requireAccess(AccessReadWrite) (a doc ACL, N/A to letters) not those perm slugs, and esign.sign is not a defined permission (would 403 all non-admins); the seal-capability authz lives in-handler (canOfficialSign/canAffixMeterai/canAffixStamp) exactly as documents do. Wired the deferred Server.sealSubject field + Deps.SealSubject (server.go) and passed sealRouter in wire.go (T4-deferred). go build ./... && go vet ./... green.

Files: go/internal/httpapi/handlers_esign.go, go/internal/httpapi/handlers_correspondence.go,
go/internal/httpapi/server.go.

The three document handlers keep their entire preamble (guard, permission checks, URL-version
read, placement parse) and change ONLY the write tail: dms.AddVersion + SetVersionStatus + SetDocumentStatus + Complete*ForSubject("document") → one s.sealAndLand(...) call that routes the
write through the port with the real subjectType. New letter ceremony handlers reuse the same
tail with ("letter", letterID).

5a. handlers_esign.go — the shared tail

// sealAndLand lands an already-sealed PDF for a subject and advances any waiting workflow ceremony
// step. It is the subject-generic tail extracted from the document sign/meterai/stamp handlers: the
// caller sealed `sealed` via s.esign.* (with the real subjectType/subjectID) and here we write it as
// the subject's new revision through the SealSubject port and fire the matching Complete*ForSubject
// hook. sourceRev>0 makes the DOCUMENT write derive a version role (sign/meterai); pass 0 to write
// with no role (STAMP, and every letter write). note is the version-status note (documents only).
// Returns the new revision.
func (s *Server) sealAndLand(ctx context.Context, p kernel.Principal, subjectType, subjectID, kind string, sealed []byte, sourceRev int, note string) (int, error) {
    rev, err := s.sealSubject.WriteSealedPDF(ctx, p, esignapp.SealWrite{
        SubjectType: subjectType, SubjectID: subjectID, SourceRev: sourceRev, PDF: sealed, Kind: kind, Note: note,
    })
    if err != nil {
        return 0, err
    }
    switch kind {
    case "meterai":
        _ = s.workflow.CompleteMeteraiForSubject(ctx, p, subjectType, subjectID)
    case "stamp":
        _ = s.workflow.CompleteStampForSubject(ctx, p, subjectType, subjectID)
    default:
        _ = s.workflow.CompleteSignatureForSubject(ctx, p, subjectType, subjectID)
    }
    return rev, nil
}

Document handlers — replace ONLY the write tail (everything above stays byte-identical):

SignDocumentVersion (replace :597-613):

    // signed = res.Signed / SignPDF result — unchanged above.
    newVersion, err := s.sealAndLand(r.Context(), p, "document", docID, "sign", signed, version, "Signed by "+signerName)
    if err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusCreated, map[string]any{"signed_version": newVersion})

AffixDocumentMeterai (replace :1154-1166):

    newVersion, err := s.sealAndLand(r.Context(), p, "document", docID, "meterai", sealed, version, "e-Meterai affixed ("+rec.Serial+")")
    if err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusCreated, map[string]any{"meterai_version": newVersion, "serial": rec.Serial, "cost_idr": rec.CostIDR})

AffixDocumentStamp (replace :1321-1332) — note sourceRev = 0 to preserve today's no-role write:

    newVersion, err := s.sealAndLand(r.Context(), p, "document", docID, "stamp", sealed, 0, "Digital stamp affixed")
    if err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusCreated, map[string]any{"stamp_version": newVersion, "order_ref": rec.OrderRef})

Byte-identical proof. sign/meterai: sourceRev=version>0 → the port derives
derivedRole(version) and writes the same explicit note → same AddVersion+SetVersionStatus+
SetDocumentStatus; the extra idempotent-reuse + empty-signer guards are no-ops here (fresh bytes,
real principal). stamp: sourceRev=0 → no role opt (matching :1321's roleless AddVersion) +
explicit note. The Complete*ForSubject("document", docID) calls are unchanged in effect.

5b. Generalize guardActiveWorkflow (:428) to any subject

func (s *Server) guardActiveWorkflow(r *http.Request, subjectType, subjectID string) *kernel.Error {
    ctx := r.Context()
    p, _ := PrincipalFrom(ctx)
    insts, err := s.workflow.InstancesForSubject(ctx, subjectType, subjectID)
    // ... body unchanged, but every `docID`→`subjectID`, `"document"`→`subjectType` ...
}

Update its three document call-sites to s.guardActiveWorkflow(r, "document", docID). The message
text stays; it reads naturally for letters too.

5c. Letter ceremony handlers (handlers_correspondence.go)

Mounted on /letters/{letterID}/…. Each mirrors the document handler's preamble but reads the
letter's current PDF via the port and passes a nil placement (decision #1) + sourceRev=0 +
note="" (letters ignore both). Permission gates reuse the same canOfficialSign/canAffixMeterai/
canAffixStamp helpers + a read gate on the letter.

// SignLetter signs a numbered letter's current PDF (internal or official tier) as the next seal
// revision. Mirrors SignDocumentVersion but over letters.content_hash (no version param, no
// interactive placement — letters use the default footer appearance). Requires the letter be
// numbered and the caller be permitted to seal it.
func (s *Server) SignLetter(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    letterID := chi.URLParam(r, "letterID")
    letter, err := s.correspondence.GetLetter(r.Context(), letterID)
    if err != nil {
        writeProblem(w, err)
        return
    }
    if ok, err := s.mayReadLetter(r.Context(), p, letter); err != nil {
        writeProblem(w, err)
        return
    } else if !ok {
        writeProblem(w, forbidLetterRead())
        return
    }
    if gerr := s.guardActiveWorkflow(r, "letter", letterID); gerr != nil {
        writeProblem(w, gerr)
        return
    }
    pdf, _, err := s.sealSubject.CurrentPDF(r.Context(), "letter", letterID)
    if err != nil {
        writeProblem(w, err)
        return
    }
    var body struct {
        SignatureID string `json:"signature_id"`
        Assurance   string `json:"assurance"`
        SignKind    string `json:"sign_kind"`
        SignerPhone string `json:"signer_phone"`
    }
    if r.Body != nil {
        _ = json.NewDecoder(r.Body).Decode(&body)
    }
    signerName := p.Subject
    if signerName == "" {
        signerName = string(p.UserID)
    }
    if body.SignatureID == "" && !(body.Assurance == string(esigndomain.AssuranceOfficial) && body.SignKind == "psre") {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "esign.signature_required", Message: "add a signature in your profile and choose it to sign with"})
        return
    }
    var signed []byte
    if body.Assurance == string(esigndomain.AssuranceOfficial) {
        if !s.esign.ExternalAvailable() {
            writeProblem(w, &kernel.Error{Kind: kernel.ErrConflict, Code: "esign.external.not_configured", Message: "no external signature provider is configured"})
            return
        }
        if !s.canOfficialSign(r.Context(), p) {
            writeProblem(w, &kernel.Error{Kind: kernel.ErrPermissionDenied, Code: "esign.official.forbidden", Message: "you are not permitted to request an official signature"})
            return
        }
        res, serr := s.esign.SignOfficial(r.Context(), p, "letter", letterID, 0, signerName, pdf, body.SignatureID, nil, body.SignKind, body.SignerPhone)
        if serr != nil {
            writeProblem(w, serr)
            return
        }
        if res.OTPRequired {
            writeJSON(w, http.StatusAccepted, map[string]any{"status": "otp_required", "job_id": res.JobID, "otp_channel": res.OTPChannel})
            return
        }
        if res.Pending {
            writeJSON(w, http.StatusAccepted, map[string]any{"status": "pending", "signing_url": res.SigningURL, "job_id": res.JobID})
            return
        }
        signed = res.Signed
    } else {
        signed, err = s.esign.SignPDF(r.Context(), p, "letter", letterID, signerName, pdf, body.SignatureID, nil)
        if err != nil {
            writeProblem(w, err)
            return
        }
    }
    rev, err := s.sealAndLand(r.Context(), p, "letter", letterID, "sign", signed, 0, "")
    if err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusCreated, map[string]any{"seal_rev": rev})
}
// AffixLetterMeterai / AffixLetterStamp mirror AffixDocumentMeterai / AffixDocumentStamp over a
// numbered letter (nil placement -> provider default square; async OTP/redirect handled identically
// -> the seal lands via the completion path, which routes through the letter arm of the port).
func (s *Server) AffixLetterMeterai(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    letterID := chi.URLParam(r, "letterID")
    letter, err := s.correspondence.GetLetter(r.Context(), letterID)
    if err != nil { writeProblem(w, err); return }
    if ok, err := s.mayReadLetter(r.Context(), p, letter); err != nil { writeProblem(w, err); return } else if !ok { writeProblem(w, forbidLetterRead()); return }
    if gerr := s.guardActiveWorkflow(r, "letter", letterID); gerr != nil { writeProblem(w, gerr); return }
    if !s.esign.MeteraiAvailable() { writeProblem(w, &kernel.Error{Kind: kernel.ErrConflict, Code: "esign.external.not_configured", Message: "no e-Meterai provider is configured"}); return }
    if !s.canAffixMeterai(r.Context(), p) { writeProblem(w, &kernel.Error{Kind: kernel.ErrPermissionDenied, Code: "esign.meterai.forbidden", Message: "you are not permitted to affix an e-Meterai"}); return }
    pdf, _, err := s.sealSubject.CurrentPDF(r.Context(), "letter", letterID)
    if err != nil { writeProblem(w, err); return }
    res, err := s.esign.AffixMeterai(r.Context(), p, "letter", letterID, 0, pdf, nil)
    if err != nil { writeProblem(w, err); return }
    if res.Pending { writeJSON(w, http.StatusAccepted, map[string]any{"status": "pending", "job_id": res.JobID}); return }
    rev, err := s.sealAndLand(r.Context(), p, "letter", letterID, "meterai", res.Sealed, 0, "")
    if err != nil { writeProblem(w, err); return }
    writeJSON(w, http.StatusCreated, map[string]any{"seal_rev": rev, "serial": res.Record.Serial, "cost_idr": res.Record.CostIDR})
}

func (s *Server) AffixLetterStamp(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    letterID := chi.URLParam(r, "letterID")
    letter, err := s.correspondence.GetLetter(r.Context(), letterID)
    if err != nil { writeProblem(w, err); return }
    if ok, err := s.mayReadLetter(r.Context(), p, letter); err != nil { writeProblem(w, err); return } else if !ok { writeProblem(w, forbidLetterRead()); return }
    if gerr := s.guardActiveWorkflow(r, "letter", letterID); gerr != nil { writeProblem(w, gerr); return }
    if !s.esign.StampAvailable() { writeProblem(w, &kernel.Error{Kind: kernel.ErrConflict, Code: "esign.external.not_configured", Message: "no digital-stamp provider is configured"}); return }
    if !s.canAffixStamp(r.Context(), p) { writeProblem(w, &kernel.Error{Kind: kernel.ErrPermissionDenied, Code: "esign.stamp.forbidden", Message: "you are not permitted to affix a digital stamp"}); return }
    pdf, _, err := s.sealSubject.CurrentPDF(r.Context(), "letter", letterID)
    if err != nil { writeProblem(w, err); return }
    fileName := letter.Number
    if fileName == "" { fileName = "letter" }
    res, err := s.esign.AffixStamp(r.Context(), p, "letter", letterID, 0, pdf, fileName, nil)
    if err != nil { writeProblem(w, err); return }
    if res.OTPRequired { writeJSON(w, http.StatusAccepted, map[string]any{"status": "otp_required", "job_id": res.JobID, "otp_channel": res.OTPChannel}); return }
    if res.Pending { writeJSON(w, http.StatusAccepted, map[string]any{"status": "pending", "job_id": res.JobID}); return }
    rev, err := s.sealAndLand(r.Context(), p, "letter", letterID, "stamp", res.Sealed, 0, "")
    if err != nil { writeProblem(w, err); return }
    writeJSON(w, http.StatusCreated, map[string]any{"seal_rev": rev, "order_ref": res.Record.OrderRef})
}

The async OTP/redirect providers (Global sign, PSrE, TERA stamp) land via CompletePendingSeal,
which now routes through the letter arm of the port (ps.SubjectType == "letter") — no extra work.
The pending-seal resume endpoint (GET /documents/{id}/pending-seal) has a letter twin in T7.
Inbox note: esign_pending_seals inbox join is LEFT JOIN documents ON subject_type='document'
(inbox_pg.go:65), so a letter pending-seal shows no doc title — cosmetic; the FE falls back to the
subject id. Report, do not fix in v1.

5d. server.go — mount the letter ceremony routes

Next to the correspondence routes (module correspondence + esign), gated by the same perms the
document ceremonies use:

    r.With(s.requireModule("esign")).Group(func(r chi.Router) {
        r.With(s.requirePerm("esign.sign")).Post("/letters/{letterID}/sign", s.SignLetter)
        r.With(s.requirePerm("meterai.affix")).Post("/letters/{letterID}/meterai", s.AffixLetterMeterai)
        r.With(s.requirePerm("stamp.affix")).Post("/letters/{letterID}/stamp", s.AffixLetterStamp)
    })

Match the exact perm slugs the document routes use (grep SignDocumentVersion/AffixDocumentMeterai
mounts in server.go); reuse them verbatim so letters inherit the same authorization surface.

Verify: cd go && go build ./... && go vet ./....

Commit: git add go/internal/httpapi/handlers_esign.go go/internal/httpapi/handlers_correspondence.go go/internal/httpapi/server.go && git commit
feat(letters-chain): letter sign/meterai/stamp ceremonies via the SealSubject port.


Task 6 🔴 ADVERSARIAL — start letters on the definition path; terminal reflection; void guard

  • [x] DoneguardLetterWorkflowStart (author/admin access + number-first seal gate; nil for non-letters) added in handlers_correspondence.go; SubmitLetter gained a definition mode (definition_id / inline stepsStartFromDefinition/StartCustomWorkflow over subject "letter", guarded), built-in single-round path unchanged; both designer start handlers now call the guard (letter-only — document path untouched); ActDefinition mirrors the terminal state onto the letter via ReflectApprovalState (letter-only, appended AFTER the byte-identical document-publish block); the three seal-void guards (Act/ActDefinition/CancelInstance) relaxed documentdocument||letter (document branch + reason strings byte-identical; only a letter alternative added). No _test.go needed (workflow-adapter tests register no sealVoider, so the void guard short-circuits). go build ./... && go vet ./... green (this commit).

Files: go/internal/httpapi/handlers_designer.go,
go/internal/httpapi/handlers_correspondence.go, go/internal/workflow/app/service.go.

Let a letter run a real definition-driven chain via the existing endpoints, add the number-first
+ access guards, mirror the terminal state onto the letter status in ActDefinition, and relax the
three seal-void guards to fire for letters.

6a. handlers_correspondence.go — the letter workflow-start guard

// guardLetterWorkflowStart authorizes and validates starting a workflow over a letter: the caller
// must be permitted to drive the letter (its creator or a correspondence admin) AND — because a
// letter is sealed in place — any workflow that contains a SEAL step (sign/meterai/stamp) requires
// the letter to be NUMBERED first (decision: number-then-seal). Approve-only chains may run from a
// draft/rejected letter. Returns nil (no guard) for non-letter subjects.
func (s *Server) guardLetterWorkflowStart(ctx context.Context, p kernel.Principal, subjectType, subjectID string, steps []workflowdomain.StepSpec) *kernel.Error {
    if subjectType != "letter" {
        return nil
    }
    letter, err := s.correspondence.GetLetter(ctx, subjectID)
    if err != nil {
        return toProblem(err) // helper that coerces a kernel.Error / not-found to *kernel.Error
    }
    // Access: creator or correspondence admin (mirrors the document editor gate).
    if letter.CreatedBy != string(p.UserID) && !s.isCorrespondenceAdmin(ctx, p) {
        return &kernel.Error{Kind: kernel.ErrPermissionDenied, Code: "correspondence.workflow.forbidden", Message: "only the letter's author or a correspondence admin can start a workflow over it"}
    }
    hasSeal := false
    for _, st := range steps {
        switch st.Kind() {
        case workflowdomain.StepKindSign, workflowdomain.StepKindMeterai, workflowdomain.StepKindStamp, workflowdomain.StepKindSignExternal:
            hasSeal = true
        }
    }
    if hasSeal && letter.Status != corrdomain.StatusNumbered {
        return &kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.workflow.number_first", Message: "number the letter before starting a signing / e-Meterai / stamp workflow"}
    }
    return nil
}

If a toProblem/asProblem helper doesn't exist, the codebase's writeProblem accepts a bare
error; here just return err.(*kernel.Error) when it is one, else wrap. Confirm
isCorrespondenceAdmin exists (handlers_correspondence.go uses it in ListLetters).

6b. handlers_designer.go — call the guard from both start handlers

In StartCustomWorkflow, after decoding body and before s.workflow.StartCustomWorkflow:

    if gerr := s.guardLetterWorkflowStart(r.Context(), p, body.SubjectType, body.SubjectID, toStepSpecs(body.Steps)); gerr != nil {
        writeProblem(w, gerr)
        return
    }

In StartFromDefinition, load the version's steps for the guard (the caller is starting it, so may
read it), then guard:

    if body.SubjectType == "letter" {
        ver, verr := s.workflow.LatestDefinitionVersion(r.Context(), p, chi.URLParam(r, "definitionID"), s.isWorkflowAdmin(r.Context(), p))
        if verr != nil {
            writeProblem(w, verr)
            return
        }
        if gerr := s.guardLetterWorkflowStart(r.Context(), p, body.SubjectType, body.SubjectID, ver.Steps); gerr != nil {
            writeProblem(w, gerr)
            return
        }
    }

6c. handlers_designer.goActDefinition reflects terminal state onto the letter

Append after the existing document-publish block (:413-418), mirroring ActWorkflow:116-131:

    // Approval-via-definition for letters: mirror a terminal state onto the letter status (guarded
    // to in_review inside the service, so a numbered letter running a SEAL chain is a safe no-op and
    // stays 'numbered'). Best-effort — the act already committed.
    if inst, err := s.workflow.GetInstance(r.Context(), instanceID); err == nil && inst.SubjectType == "letter" && inst.SubjectID != "" {
        var letterStatus string
        switch inst.State {
        case workflowdomain.StateApproved:
            letterStatus = "approved"
        case workflowdomain.StateRejected:
            letterStatus = "rejected"
        case workflowdomain.StateReturned, workflowdomain.StateCancelled:
            letterStatus = "draft"
        }
        if letterStatus != "" {
            _ = s.correspondence.ReflectApprovalState(r.Context(), inst.SubjectID, letterStatus)
        }
    }

6d. handlers_correspondence.goSubmitLetter gains an optional definition mode

Keep the built-in single-round approval as the default; when the body carries a definition_id (a
saved workflow) or inline steps, forward to the definition path. The existing
SubmitForApprovalin_review→revert scaffolding is reused for the approve-only draft flow.

    var body struct {
        ApproverPositionIDs []string           `json:"approver_position_ids"`
        DefinitionID        string             `json:"definition_id"`
        Steps               []stepInput        `json:"steps"`
        Name                string             `json:"name"`
    }
    // ... decode ...
    // Definition mode: run a real chain over the letter instead of the built-in approval.
    if body.DefinitionID != "" || len(body.Steps) > 0 {
        var steps []workflowdomain.StepSpec
        if body.DefinitionID != "" {
            ver, verr := s.workflow.LatestDefinitionVersion(r.Context(), p, body.DefinitionID, s.isWorkflowAdmin(r.Context(), p))
            if verr != nil { writeProblem(w, verr); return }
            steps = ver.Steps
        } else {
            steps = toStepSpecs(body.Steps)
        }
        if gerr := s.guardLetterWorkflowStart(r.Context(), p, "letter", letterID, steps); gerr != nil {
            writeProblem(w, gerr)
            return
        }
        // Approve-only chains from a draft flip to in_review (mirrors the built-in); a seal chain
        // runs over a numbered letter and leaves status untouched (the guard enforced numbered).
        var wfID string
        var serr error
        if body.DefinitionID != "" {
            wfID, serr = s.workflow.StartFromDefinition(r.Context(), p, body.DefinitionID, "letter", letterID, nil, nil, nil)
        } else {
            wfID, serr = s.workflow.StartCustomWorkflow(r.Context(), p, body.Name, steps, "letter", letterID, nil, nil)
        }
        if serr != nil { writeProblem(w, serr); return }
        writeJSON(w, http.StatusCreated, map[string]any{"workflow_id": wfID})
        return
    }
    // ... existing built-in single-round approval path unchanged ...

stepInput/toStepSpecs live in handlers_designer.go (same package) — reuse them.

6e. workflow/app/service.go — relax the three seal-void guards to include letters

Act (:621), ActDefinition (:970), CancelInstance (:2396):

    if s.sealVoider != nil && (inst.SubjectType == "document" || inst.SubjectType == "letter") {
        _ = s.sealVoider.VoidSeals(ctx, inst.SubjectType, inst.SubjectID, inst.CreatedAt, "workflow "+string(to))
    }

(the CancelInstance site keeps its "workflow cancelled" reason string). The workflowSealVoider
letter branch (T4b) does the actual letter void; documents are unchanged.

Verify: cd go && go build ./... && go vet ./....

Commit: git add go/internal/httpapi/handlers_designer.go go/internal/httpapi/handlers_correspondence.go go/internal/workflow/app/service.go && git commit
feat(letters-chain): start letters on the definition path + terminal reflection + seal void.


Task 7 — Letter seal-history + verify endpoints; pending-seal twin

  • [x] Done (55fd8b0) — ListLetterSeals ({"seals":[…]}, newest-first) + VerifyLetter ({"signatures":[…]}, over sealSubject.CurrentPDF("letter",id)esign.Verify + meterai/stamp ledger attribution) + the pending-seal twin MyLetterPendingSeal/CancelMyLetterPendingSeal (elevated from optional to shipped per the parent, so async letter OTP ceremonies are resumable like documents). All four gate on mayReadLetter (fail-closed) inside; routes mounted in the correspondence group with requireModule("esign") + requirePerm("correspondence.read"). Extracted the shared toVerifyDTO(esigndomain.VerifyResult, meteraiAt, stampAt) from VerifyDocumentVersion (both handlers now call it — document /verify output byte-identical: same struct{Signatures} + make(…,0,len) empty→[]) and added nonNilSeals (mirror nonNilDocs). Deviations: (1) staged handlers_esign.go too — the required toVerifyDTO refactor lives there, and the committed tree must build; (2) added requirePerm("correspondence.read") beyond the plan's literal requireModule("esign")-only snippet, per the section header ("module correspondence, read perm") + every sibling route + the parent's gating instruction; (3) toVerifyDTO takes esigndomain.VerifyResult (what esign.Verify actually returns), not the snippet's esignapp.VerifyResult. go build ./... && go vet ./... green (this commit).

Files: go/internal/httpapi/handlers_correspondence.go, go/internal/httpapi/server.go.

Read surfaces for the FE: the seal history, an honest verify over the letter's current PDF (mirroring
VerifyDocumentVersion), and the pending-seal resume twin (for async OTP letter ceremonies).

// ListLetterSeals returns a letter's seal history (newest first), for the letter detail chain view.
func (s *Server) ListLetterSeals(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    letterID := chi.URLParam(r, "letterID")
    letter, err := s.correspondence.GetLetter(r.Context(), letterID)
    if err != nil { writeProblem(w, err); return }
    if ok, err := s.mayReadLetter(r.Context(), p, letter); err != nil { writeProblem(w, err); return } else if !ok { writeProblem(w, forbidLetterRead()); return }
    seals, err := s.correspondence.ListLetterSeals(r.Context(), letterID)
    if err != nil { writeProblem(w, err); return }
    writeJSON(w, http.StatusOK, map[string]any{"seals": nonNilSeals(seals)})
}

// VerifyLetter re-checks the letter's CURRENT PDF and returns honest per-signature signals, reusing
// the same verifier + seal-attribution the document verify uses (subject "letter").
func (s *Server) VerifyLetter(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    letterID := chi.URLParam(r, "letterID")
    letter, err := s.correspondence.GetLetter(r.Context(), letterID)
    if err != nil { writeProblem(w, err); return }
    if ok, err := s.mayReadLetter(r.Context(), p, letter); err != nil { writeProblem(w, err); return } else if !ok { writeProblem(w, forbidLetterRead()); return }
    pdf, _, err := s.sealSubject.CurrentPDF(r.Context(), "letter", letterID)
    if err != nil { writeProblem(w, err); return }
    res, err := s.esign.Verify(r.Context(), pdf)
    if err != nil { writeProblem(w, err); return }
    var meteraiAt, stampAt []time.Time
    if recs, lerr := s.esign.ListMeterai(r.Context(), "letter", letterID); lerr == nil {
        for _, rec := range recs { meteraiAt = append(meteraiAt, rec.AffixedAt) }
    }
    if recs, lerr := s.esign.ListStamps(r.Context(), "letter", letterID); lerr == nil {
        for _, rec := range recs { stampAt = append(stampAt, rec.AffixedAt) }
    }
    out := struct{ Signatures []verifySignatureDTO `json:"signatures"` }{Signatures: make([]verifySignatureDTO, 0, len(res.Signatures))}
    for _, c := range res.Signatures {
        // identical DTO mapping + classifySealSignature as VerifyDocumentVersion (copy the loop body)
    }
    writeJSON(w, http.StatusOK, out)
}

Extract the DTO-mapping loop from VerifyDocumentVersion into a shared
func toVerifyDTO(res esignapp.VerifyResult, meteraiAt, stampAt []time.Time) []verifySignatureDTO
and call it from both, so they can't drift (small refactor of the existing handler; document output
unchanged). Add a nonNilSeals helper (mirror nonNilDocs).

Optionally add GET /letters/{letterID}/pending-seal + its cancel, thin twins of MyPendingSeal/
CancelMyPendingSeal with "letter" — reuse those handlers by parameterizing the subject if cheap;
else copy. Mount (module correspondence, read perm):

    r.With(s.requireModule("esign")).Get("/letters/{letterID}/seals", s.ListLetterSeals)
    r.With(s.requireModule("esign")).Get("/letters/{letterID}/verify", s.VerifyLetter)

Verify: cd go && go build ./... && go vet ./....

Commit: git add go/internal/httpapi/handlers_correspondence.go go/internal/httpapi/server.go && git commit
feat(letters-chain): letter seal-history + verify endpoints.


Task 8 — FE: letter detail "Start workflow" + seal history + verify

  • [x] Done (21d6eec) — useLetterSeals + useVerifyLetter (raw req() cast pattern, reusing the document VerifySignature type) in api/correspondence.ts; LetterDetailModal gained a Start-workflow button on a numbered letter (gated on me.customWorkflowsEnabled), a Signatures & seals section (GET /seals, voided revisions struck through), and an on-demand Verify (GET /verify) rendered with a faithful inline copy of the document verify rows (same sigv classes + docview.sig.* copy); en+id correspondence.chain.* keys (plain ASCII). Deviations from the drifted plan/task premise (code wins): (1) StartInstanceModal has no fixedSubject prop and no definition-less usage, and CustomWorkflowModal.fixedSubject hard-coded subjectType='document' — so I reused only CustomWorkflowModal (the ad-hoc builder documents actually use with fixedSubject), adding an additive fixedSubjectType prop (default 'document', backward-compatible) so a fixed LETTER subject submits subject_type='letter' and skips placement rows; the ad-hoc builder alone fully delivers approve→sign→e-Meterai→e-Stamp over the numbered letter (saved defs over letters remain startable from the Workflows page). (2) Did not extend useSubmitLetter with definitionId/steps (8a) — the reused modal starts via useStartCustomWorkflow/workflows/custom (already letter-guarded by T6), so a submit-definition path would be dead FE code. (3) Verify display is inline (the document one is not an extractable component). npx tsc --noEmit + npx vite build green.

Files: web/src/api/correspondence.ts,
web/src/features/correspondence/LetterDetailModal.tsx,
web/src/features/correspondence/i18n.ts. Modest v1 (the paired document-view spec is the richer
surface). Reuse the existing workflow modals — they already accept subjectType='letter' +
fixedSubject.

8a. api/correspondence.ts

export function useLetterSeals(letterId: string | null) {
  return useQuery({
    queryKey: ['letter-seals', letterId],
    enabled: !!letterId,
    queryFn: () => req<{ seals: LetterSeal[] }>(`/letters/${letterId}/seals`).then((r) => r.seals ?? []),
  })
}
export function useVerifyLetter(letterId: string | null, enabled: boolean) {
  return useQuery({
    queryKey: ['letter-verify', letterId],
    enabled: !!letterId && enabled,
    queryFn: () => req<{ signatures: VerifySignature[] }>(`/letters/${letterId}/verify`).then((r) => r.signatures ?? []),
  })
}

Extend useSubmitLetter to pass an optional definitionId/steps through to POST /letters/{id}/submit.
Reuse the document VerifySignature type (import from the esign/documents data module).

8b. LetterDetailModal.tsx

  • On a numbered letter, add a "Start workflow" button next to Download/Dispose that opens the
    existing CustomWorkflowModal (ad-hoc builder) or StartInstanceModal (saved workflow) with
    fixedSubject={{ id: letter.id, label: letter.number }} and subjectType="letter". The modals
    already hide placement rows for non-document subjects (decision #1).
  • Add a "Seals" / chain section listing useLetterSeals rows (rev, kind → localized label, date;
    a void tag when voided_at), and a "Verify" button that fetches useVerifyLetter and renders
    the same signature-signal rows the document verify uses (reuse the document VerifySignatures
    presentation component if extractable; else a small inline list).
  • The letter's ceremony tasks appear in the normal workflow inbox already (subject-generic) — no
    change needed there.

8c. i18n.ts — add en + id keys

correspondence.chain.start ("Start workflow"), correspondence.chain.title ("Signatures & seals"),
correspondence.chain.seal.sign|meterai|stamp|base, correspondence.chain.voided ("Voided"),
correspondence.chain.verify ("Verify"), correspondence.chain.numberFirst (the number-first
message). Add BOTH locales; watch the smart-quote gotcha.

Verify: cd web && npx tsc --noEmit && npx vite build.

Commit: git add web/src/api/correspondence.ts web/src/features/correspondence/LetterDetailModal.tsx web/src/features/correspondence/i18n.ts && git commit
feat(letters-chain): letter detail Start-workflow + seal history + verify (FE).


Task 9 — Full verify, e2e checklist, deploy

Files: none (verification + deploy).

  1. cd go && go build ./... && go vet ./... ✓ ; cd web && npx tsc --noEmit && npx vite build ✓.
  2. Re-check ls go/migrations | tail — confirm 00131 is still free / renumber if a co-agent
    raced. Then deploy: ssh valbox 'cd /home/efran/remote-development/obscura && ./deploy/update.sh -y'.
    Post-deploy assert /me enabled_modules includes correspondence + esign.
  3. e2e — DOCUMENT regression FIRST (must be byte-identical):
    - Upload → finalize a PDF; run a document custom workflow approve → sign → e-Meterai → e-Stamp;
    confirm each ceremony lands a new version with the same role/status/note as before, the
    verify tab shows the seals, and a reject voids the sealed versions + ledger. Confirm the
    async paths (Global OTP sign / TERA stamp) still land via CompletePendingSeal.
  4. e2e — LETTER chain:
    - Create + number a letter (docx or html). Start a subject=letter workflow (approve → sign →
    e-Meterai → e-Stamp) from the letter detail. Confirm: the number-first guard blocks a seal chain
    on an un-numbered letter; each ceremony appends a letter_seals row and swaps content_hash;
    the letter stays numbered; GET /letters/{id}/seals shows the trail; GET /letters/{id}/verify
    shows valid Peruri/CA signals; rejecting the run voids the letter seals and restores the
    numbered PDF (content_hash back to the rev-0 base). Confirm sign_external over a letter is
    refused (decision #5).
  5. Report the SHAs to the controller; do NOT push (controller pushes after e2e).

Commit: none (or a memory-note commit if the controller requests).


Self-review — spec coverage & cross-task type consistency

  • Spec §1 content port → T3 (SealSubject/SealWrite) + T4 (sealSubjectRouter, document
    byte-identical + letter). §2 definition path → T6 (start guards + reflection + SubmitLetter
    mode; placement gating already exempts letters). §3 migration + void → T1 + T2 + T4b + T6e.
    §4 number-first → decision #2, enforced in guardLetterWorkflowStart (T6). What does NOT
    change
    → engine, crypto, ledger, OTP/KEYLA, inbox/task/history untouched; document path proven
    byte-identical in T5's proof + T9 regression.
  • Decisions: #1 nil placement + already-exempt gating; #2 number-first guard; #3 reuse numbered
    (no status migration — SetLetterStatusFromReview's in_review guard makes reflection a safe
    no-op); #4 reuse builder/saved workflows subject=letter; #5 sign_external already blocked.
  • Type consistency: SealWrite{SubjectType,SubjectID,SourceRev,PDF,Kind,Note} is the single
    cross-package DTO — produced by sealAndLand (T5) + the esign use-sites (T3), consumed by
    sealSubjectRouter (T4). SourceRev>0⇒role, Note==""⇒kind-default are the two reconciliation
    rules, exercised: sign/meterai sync (SourceRev=version, explicit Note), stamp sync
    (SourceRev=0), async (SourceRev=0, Note=""), letters (ignored). LetterSeal (domain) flows
    domain→adapter→service→ListLetterSeals handler→FE.
  • Risks to the document path (for adversarial review): (a) the sync write now runs the port's
    idempotent-reuse + empty-signer guards — argued no-ops for the sync caller, must be confirmed;
    (b) the async writeback now flows through the same method — its role/note/status must equal the old
    dmsSignedVersionWriter exactly (they do: SourceRev=0+Note=""); (c) guardActiveWorkflow
    generalized — document call-sites must pass "document" verbatim; (d) the three void-guard edits
    must not alter the document reject/cancel behavior (only add a letter branch). T9 step 3 is the gate.