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 onmain, 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 documentSealSubjectimpl is today's code behind the new interface). - NEVER
go test(test DSN == live Postgres); verify withcd 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-checkls go/migrations | tailbefore deploy (co-agent migration-number RACE). Commit
per task onmain, explicit paths (NEVERgit add -A—go/obscura-serveris a tracked
ELF binary), trailerCo-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.tslocales (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. TheSealSubjectdocument branch reproduces both the
synchronous handler write AND the asyncdmsSignedVersionWriterwrite, 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)
- 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 anilplacement. - Numbering vs. chain: NUMBER FIRST (
AssignNumberproduces 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 requiresletter.status == numbered. - Terminal status: a fully-sealed letter stays
numbered— no new status in v1. The
letter_sealshistory + the workflow instance convey the chain.SetLetterStatusFromReview's
WHERE status='in_review'guard makes terminal reflection a safe no-op for a numbered letter. - Definition source: reuse the CORE builder + saved workflows with
subject_type='letter'
(the same UI documents use) — NOT a fixed preset. - External signers (
sign_external) for letters: DEFER (out of scope v1) — already blocked
byRequestExternalSignStep'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 document → document||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] Done —
00131_letter_seals.sqlwritten; committed2ec2317.
Files: go/migrations/00131_letter_seals.sql (new).
⚠️ Before writing:
ls go/migrations | tail— if a co-agent took00131, 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, andtimeare already imported byservice.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
(seeUpdateLetterNumbered/ListSchemes).fmtis already imported.voided_atis*time.Time.
The void's second$2reusessinceas theupdated_attimestamp (a wall-clock value in the
failed run's tx) — acceptable; uses.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] Done —
SealSubject/SealWriteiface +sealSubjectfield/SetSealSubject; 5 async use-sites now thread the real subjectType (ps.SubjectType/env.SubjectType/in.SubjectType), byte-identical doc writeback preserved viaSourceRev0 +Note"". esign pkg builds+vets green; whole tree stays red atwire.gountil 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. KeepSetWorkflowAdvancer(: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, andFinalize*call stays exactly as-is —
only the reader/writer calls change.preSealMark/preSealMarkEmailkeep 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] Done —
sealSubjectRouter(dispatch on subjectType) replacesdmsSignedVersionWriter/dmsVersionReader; thedocumentbranch is the old writer verbatim (owner-fallback + idempotent-version-reuse + 23505→conflict +SetVersionStatus+SetDocumentStatus(draft)), extended only toWithVersionRole(derivedRole(SourceRev))whenSourceRev>0(else no role) and to honour an explicitNote(else kind-default) — byte-identical to today since every async use-site passesSourceRev0 +Note"".letterbranch →corr.CurrentLetterPDF/WriteLetterSeal.workflowSealVoidergained aletterarm (corr.VoidLetterSealsSince+esign.VoidSealsSince); document arm unchanged. Wiring:esignSvc.SetSealSubject/workflowSvc.SetSealVoidermoved to just aftercorrSvcis built (the letter branch needs corrSvc, declared ~180 lines below the old wiring site; matches the existing post-corrSvcSetSealMarkerpattern).server.gofield DEFERRED to T5 (not needed for a green tree now; parent scoped this commit to resolvers.go + wire.go). Whole treego 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.goif missing:correspondenceapp "…/internal/correspondence/app",
dmsdomain(already imported),sha256/hex/bytes/errors/pgconn(already imported — the old
writer used them). Delete the olddmsSignedVersionWriter+dmsVersionReadertypes.
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(grepcorrespondence.NewService
/ the var passed to the httpapi server — likelycorrespondenceSvcorcorrSvc). Delete the two
oldSetVersionWriter/SetVersionReaderlines.
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) — extractedsealAndLand(subject-generic write+status+workflow-advance tail) inhandlers_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: signsealAndLand("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 formerversionStatus*consts). Removed now-unusedbytes/dmsapp/dmsdomainimports (their only uses were the replaced tails).guardActiveWorkflow(r, subjectType, subjectID)generalized; 3 doc call-sites pass"document"verbatim. NewSignLetter/AffixLetterMeterai/AffixLetterStampinhandlers_correspondence.gomirror the doc handlers overletters.content_hash(nil placement, sourceRev=0, note="") reusing the same tail. Routes mounted INSIDE the correspondence group (sorequireModule("correspondence")) withrequireModule("esign")+requirePerm("correspondence.write")— deviation from 5d's literalrequirePerm("esign.sign"/"meterai.affix"/"stamp.affix"): doc ceremony routes actually gate onrequireAccess(AccessReadWrite)(a doc ACL, N/A to letters) not those perm slugs, andesign.signis 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 deferredServer.sealSubjectfield +Deps.SealSubject(server.go) and passedsealRouterinwire.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 → sameAddVersion+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 rolelessAddVersion) +
explicit note. TheComplete*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_sealsinbox join isLEFT 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 inserver.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] Done —
guardLetterWorkflowStart(author/admin access + number-first seal gate; nil for non-letters) added inhandlers_correspondence.go;SubmitLettergained a definition mode (definition_id/ inlinesteps→StartFromDefinition/StartCustomWorkflowover subject"letter", guarded), built-in single-round path unchanged; both designer start handlers now call the guard (letter-only — document path untouched);ActDefinitionmirrors the terminal state onto the letter viaReflectApprovalState(letter-only, appended AFTER the byte-identical document-publish block); the three seal-void guards (Act/ActDefinition/CancelInstance) relaxeddocument→document||letter(document branch + reason strings byte-identical; only a letter alternative added). No_test.goneeded (workflow-adapter tests register nosealVoider, 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/asProblemhelper doesn't exist, the codebase'swriteProblemaccepts a bare
error; here just returnerr.(*kernel.Error)when it is one, else wrap. Confirm
isCorrespondenceAdminexists (handlers_correspondence.gouses it inListLetters).
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.go — ActDefinition 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.go — SubmitLetter 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
SubmitForApproval→in_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/toStepSpecslive inhandlers_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":[…]}, oversealSubject.CurrentPDF("letter",id)→esign.Verify+ meterai/stamp ledger attribution) + the pending-seal twinMyLetterPendingSeal/CancelMyLetterPendingSeal(elevated from optional to shipped per the parent, so async letter OTP ceremonies are resumable like documents). All four gate onmayReadLetter(fail-closed) inside; routes mounted in the correspondence group withrequireModule("esign")+requirePerm("correspondence.read"). Extracted the sharedtoVerifyDTO(esigndomain.VerifyResult, meteraiAt, stampAt)fromVerifyDocumentVersion(both handlers now call it — document/verifyoutput byte-identical: samestruct{Signatures}+make(…,0,len)empty→[]) and addednonNilSeals(mirrornonNilDocs). Deviations: (1) stagedhandlers_esign.gotoo — the requiredtoVerifyDTOrefactor lives there, and the committed tree must build; (2) addedrequirePerm("correspondence.read")beyond the plan's literalrequireModule("esign")-only snippet, per the section header ("module correspondence, read perm") + every sibling route + the parent's gating instruction; (3)toVerifyDTOtakesesigndomain.VerifyResult(whatesign.Verifyactually returns), not the snippet'sesignapp.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
VerifyDocumentVersioninto 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 anonNilSealshelper (mirrornonNilDocs).
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(rawreq()cast pattern, reusing the documentVerifySignaturetype) inapi/correspondence.ts;LetterDetailModalgained a Start-workflow button on a numbered letter (gated onme.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 (samesigvclasses +docview.sig.*copy); en+idcorrespondence.chain.*keys (plain ASCII). Deviations from the drifted plan/task premise (code wins): (1)StartInstanceModalhas nofixedSubjectprop and no definition-less usage, andCustomWorkflowModal.fixedSubjecthard-codedsubjectType='document'— so I reused onlyCustomWorkflowModal(the ad-hoc builder documents actually use withfixedSubject), adding an additivefixedSubjectTypeprop (default'document', backward-compatible) so a fixed LETTER subject submitssubject_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 extenduseSubmitLetterwithdefinitionId/steps(8a) — the reused modal starts viauseStartCustomWorkflow→/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 buildgreen.
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
existingCustomWorkflowModal(ad-hoc builder) orStartInstanceModal(saved workflow) with
fixedSubject={{ id: letter.id, label: letter.number }}andsubjectType="letter". The modals
already hide placement rows for non-document subjects (decision #1). - Add a "Seals" / chain section listing
useLetterSealsrows (rev, kind → localized label, date;
avoidtag whenvoided_at), and a "Verify" button that fetchesuseVerifyLetterand renders
the same signature-signal rows the document verify uses (reuse the documentVerifySignatures
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).
cd go && go build ./... && go vet ./...✓ ;cd web && npx tsc --noEmit && npx vite build✓.- Re-check
ls go/migrations | tail— confirm00131is 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/meenabled_modulesincludescorrespondence+esign. - 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 viaCompletePendingSeal. - e2e — LETTER chain:
- Create + number a letter (docx or html). Start asubject=letterworkflow (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 aletter_sealsrow and swapscontent_hash;
the letter staysnumbered;GET /letters/{id}/sealsshows the trail;GET /letters/{id}/verify
shows valid Peruri/CA signals; rejecting the run voids the letter seals and restores the
numbered PDF (content_hashback to the rev-0 base). Confirmsign_externalover a letter is
refused (decision #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 inguardLetterWorkflowStart(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'sin_reviewguard makes reflection a safe
no-op); #4 reuse builder/saved workflows subject=letter; #5sign_externalalready blocked. - Type consistency:
SealWrite{SubjectType,SubjectID,SourceRev,PDF,Kind,Note}is the single
cross-package DTO — produced bysealAndLand(T5) + the esign use-sites (T3), consumed by
sealSubjectRouter(T4).SourceRev>0⇒role,Note==""⇒kind-defaultare the two reconciliation
rules, exercised: sign/meterai sync (SourceRev=version, explicitNote), stamp sync
(SourceRev=0), async (SourceRev=0,Note=""), letters (ignored).LetterSeal(domain) flows
domain→adapter→service→ListLetterSealshandler→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
dmsSignedVersionWriterexactly (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.