think
16px
820px

OnlyOffice-Authored Letters — Implementation Plan (2026-07-24)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Contract: docs/superpowers/specs/2026-07-24-onlyoffice-letters-design.md (APPROVED). Where this plan and the spec disagree, the spec wins.

Goal

When the office module is enabled, correspondence letters are authored as docx in the embedded OnlyOffice editor (with a "quick text" HTML escape hatch), letterhead templates are OnlyOffice-edited docx files with lazy-seeded starters, and numbering merge-replaces {{NOMOR}} {{TANGGAL}} {{PERIHAL}} {{SIFAT}} into the docx before converting it to the official PDF.

Architecture

The letter (and the letterhead template) own a docx blob in the existing kernel.BlobStore — letters never become DMS documents. The office HTTP layer's HS256 token gains a sub claim (document default when absent — backward compatible; letter, letterhead, plus variant:"final" for the frozen merged copy), and OfficeContent/OfficeCallback branch per subject with save-time editability re-checks (F11 parity). Numbering keeps the exact three-phase gapless flow (reserve-in-tx → merge+convert out-of-tx with void-on-failure → finalize-in-tx); PDF conversion goes through the OnlyOffice ConvertService with a Gotenberg LibreOffice fallback, late-bound into the correspondence service as a DocxPDFRenderer port (mirroring the esignSvc.SetSealMarker idiom in wire.go).

Tech Stack

  • Backend: Go 1.25 modular monolith (go/), chi router, pgx v5, goose migrations (embedded), MinIO blob store via kernel.BlobStore, archive/zip (stdlib only — no new deps).
  • Conversion: OnlyOffice Document Server (ConvertService + editor, module-gated), Gotenberg (LibreOffice route) fallback.
  • Frontend: React 18 + Carbon (web/), react-query, react-router, raw-fetch req() API pattern, i18n en+id.
  • DB: PostgreSQL; migration 00128.

Global Constraints

  • NEVER run go test anywhere (the test DSN points at the LIVE demo Postgres). Go verification is exactly: cd go && go build ./... && go vet ./.... Pure functions are exercised via go run scratch programs under /tmp.
  • Web verification: cd web && npx tsc --noEmit && npx vite build. NO new npm dependencies. NO new Go dependencies.
  • npm run gen:api is BROKEN — if api/openapi.yaml is touched, regen with cd packages/api-client && npx openapi-typescript ../../api/openapi.yaml -o src/schema.ts.
  • Deploy ONLY via ssh valbox 'cd /home/efran/remote-development/obscura && ./deploy/update.sh -y' (snapshot→build→migrate→health-gate→auto-rollback). Never hand-rolled compose against the demo. Before deploying, re-check ls go/migrations | tail — if a co-agent landed 00128 first, renumber ours.
  • Commit per task on main with EXPLICIT file paths (NEVER git add -A / git add . — go/obscura-server is a tracked ELF that must never be staged). Every commit ends with trailer: Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>. Do NOT push — the controller pushes after e2e.
  • i18n: every new string exists in BOTH en and id; plain ASCII quotes in TS source (no smart quotes).
  • Keep the demo intact: no destructive docker/compose operations; single-service stop/start only if ever needed.

i18n pitfall (applies to Tasks 6–7): i18next treats literal {{…}} inside translation strings as interpolation. Helper copy must name the merge fields WITHOUT braces ("NOMOR, TANGGAL, PERIHAL and SIFAT, typed with double curly braces") — never put a literal {{NOMOR}} in a locale string.


Task 1: Migration 00128 + domain fields + pg adapters + letterhead BlobStore injection

Files:
- Create go/migrations/00128_letters_docx_authoring.sql
- Modify go/internal/correspondence/domain/letter.go
- Modify go/internal/correspondence/app/service.go (Repository interface only)
- Modify go/internal/correspondence/adapters/pg.go
- Modify go/internal/letterhead/domain/template.go
- Modify go/internal/letterhead/app/service.go (Repository interface + constructor only)
- Modify go/internal/letterhead/adapters/pg.go
- Modify go/cmd/obscura-server/wire.go (letterhead constructor arg)

Interfaces:
- Consumes: existing domain.Letter / domain.Template structs, kernel.BlobStore, nilStr helper in correspondence pg adapter (go/internal/correspondence/adapters/pg.go:55).
- Produces (later tasks depend on these EXACT names):
- correspondence domain: consts AuthoringHTML = "html", AuthoringDocx = "docx"; Letter fields Authoring string, DraftDocxHash string, DraftDocxRev int, FinalDocxHash string, LetterheadID string; func IndonesianLongDate(t time.Time) string; func SifatLabel(classification string) string.
- correspondence app.Repository gains: UpdateLetterDraftDocx(ctx context.Context, letterID, hash string, at time.Time) error and SetLetterFinalDocx(ctx context.Context, letterID, hash string, at time.Time) error.
- letterhead domain: Template fields DocxHash string (json:"-"), DocxRev int (json:"-"), HasDocx bool (json:"has_docx", derived at scan).
- letterhead app.Repository gains: UpdateDocx(ctx context.Context, id, hash string, at time.Time) error.
- letterhead app.NewService(repo Repository, blobs kernel.BlobStore) *Service (blobs stored, unused until Task 3).

Steps:

  • [x] Before writing the migration, run ls /home/efran/remote-development/obscura/go/migrations | tail -4 — the last file must be 00127_audit_key_epoch.sql. If a co-agent has landed a 00128_*, use the next free number everywhere this plan says 00128.

  • [x] Create go/migrations/00128_letters_docx_authoring.sql:

-- +goose Up
-- OnlyOffice-authored letters: a letter (and a letterhead template) can now own a
-- docx artifact in the blob store. authoring selects the compose channel — 'html'
-- (plain text → Gotenberg Chromium, the original path; all existing rows) or 'docx'
-- (OnlyOffice-authored). draft_docx_hash addresses the CURRENT editable docx;
-- draft_docx_rev bumps on every OnlyOffice save and feeds the editor cache key
-- (letter_{id}_{rev}) so the doc-server never serves a stale copy. final_docx_hash
-- freezes the merge-field-substituted docx at numbering (cleared again if the
-- convert step fails and the allocation is voided). letterhead_id records the docx
-- template the letter was seeded from (ON DELETE SET NULL: the seed was zero-copied
-- into draft_docx_hash, so deleting the template never breaks the letter).
ALTER TABLE letters
    ADD COLUMN authoring       text NOT NULL DEFAULT 'html',
    ADD COLUMN draft_docx_hash text,
    ADD COLUMN draft_docx_rev  integer NOT NULL DEFAULT 0,
    ADD COLUMN final_docx_hash text,
    ADD COLUMN letterhead_id   uuid REFERENCES letterhead_templates(id) ON DELETE SET NULL;
ALTER TABLE letters
    ADD CONSTRAINT letters_authoring_chk CHECK (authoring IN ('html', 'docx'));

-- Letterhead templates gain an OnlyOffice-edited docx artifact (the kop lives in the
-- native Word header section). docx_rev mirrors draft_docx_rev: editor cache key fuel.
ALTER TABLE letterhead_templates
    ADD COLUMN docx_hash text,
    ADD COLUMN docx_rev  integer NOT NULL DEFAULT 0;

-- +goose Down
ALTER TABLE letterhead_templates DROP COLUMN IF EXISTS docx_rev;
ALTER TABLE letterhead_templates DROP COLUMN IF EXISTS docx_hash;
ALTER TABLE letters DROP CONSTRAINT IF EXISTS letters_authoring_chk;
ALTER TABLE letters DROP COLUMN IF EXISTS letterhead_id;
ALTER TABLE letters DROP COLUMN IF EXISTS final_docx_hash;
ALTER TABLE letters DROP COLUMN IF EXISTS draft_docx_rev;
ALTER TABLE letters DROP COLUMN IF EXISTS draft_docx_hash;
ALTER TABLE letters DROP COLUMN IF EXISTS authoring;
  • [x] In go/internal/correspondence/domain/letter.go, add the authoring constants right after the ClassificationNone const block:
// Authoring channel for an outbound letter's content: 'html' (plain text wrapped to
// HTML, rendered by Gotenberg Chromium — the original path) or 'docx' (an
// OnlyOffice-authored Word document; numbering merge-substitutes the placeholder
// fields and converts the docx to the official PDF).
const (
    AuthoringHTML = "html"
    AuthoringDocx = "docx"
)
  • [x] In the same file, extend the Letter struct — add after the ContentHash field:
    // Docx authoring (OnlyOffice letters). Authoring is 'html' or 'docx'.
    // DraftDocxHash addresses the current editable docx in the BlobStore and
    // DraftDocxRev bumps on every editor save (it feeds the OnlyOffice cache key, so
    // a save always invalidates the doc-server's copy). FinalDocxHash freezes the
    // merge-field-substituted docx at numbering. LetterheadID records the docx
    // template chosen at creation. Blob hashes are internal — not serialized.
    Authoring     string `json:"authoring"`
    DraftDocxHash string `json:"-"`
    DraftDocxRev  int    `json:"-"`
    FinalDocxHash string `json:"-"`
    LetterheadID  string `json:"letterhead_id"`
  • [x] In the same file, add the two merge-field helpers at the end (extend the import block with "fmt"):
// indonesianMonths are the Indonesian month names for the {{TANGGAL}} merge field.
// A manual table, not a locale package — the format is fixed by tata naskah, not by
// the runtime's locale data.
var indonesianMonths = [...]string{
    "Januari", "Februari", "Maret", "April", "Mei", "Juni",
    "Juli", "Agustus", "September", "Oktober", "November", "Desember",
}

// IndonesianLongDate renders t as the Indonesian long date used by {{TANGGAL}},
// e.g. "24 Juli 2026". Uses the time value as given (the service clock).
func IndonesianLongDate(t time.Time) string {
    return fmt.Sprintf("%d %s %d", t.Day(), indonesianMonths[int(t.Month())-1], t.Year())
}

// SifatLabel is the {{SIFAT}} merge value: the classification code as stored,
// except the 'none' default (and empty) renders as "-" — a letter with no sifat
// prints a dash, not the internal sentinel.
func SifatLabel(classification string) string {
    if classification == "" || classification == ClassificationNone {
        return "-"
    }
    return classification
}
  • [x] In go/internal/correspondence/app/service.go, extend the Repository interface — add after the SetLetterStatusFromReview line:
    // Docx authoring. UpdateLetterDraftDocx lands an OnlyOffice save: it swaps the
    // draft blob hash and bumps draft_docx_rev, but ONLY while the letter is still
    // editable (status draft/rejected) — the SQL guard is the authoritative,
    // TOCTOU-free half of the F11-parity save re-check (0 rows → ErrConflict).
    // SetLetterFinalDocx records (hash != "") or clears (hash == "") the frozen
    // merged docx around the numbering conversion.
    UpdateLetterDraftDocx(ctx context.Context, letterID, hash string, at time.Time) error
    SetLetterFinalDocx(ctx context.Context, letterID, hash string, at time.Time) error
  • [x] In go/internal/correspondence/adapters/pg.go, replace InsertLetter with (new columns; authoring defaults like direction):
// InsertLetter persists a draft letter row.
func (s *Store) InsertLetter(ctx context.Context, l domain.Letter) error {
    direction := l.Direction
    if direction == "" {
        direction = domain.DirectionOutbound
    }
    authoring := l.Authoring
    if authoring == "" {
        authoring = domain.AuthoringHTML
    }
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `INSERT INTO letters (id, type, classification, subject, body_html, status, direction,
                              sender_type, sender_name, received_date, agenda_no, content_hash,
                              authoring, draft_docx_hash, draft_docx_rev, final_docx_hash, letterhead_id,
                              created_by, created_at, updated_at)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)`,
        l.ID, l.Type, l.Classification, l.Subject, l.BodyHTML, l.Status, direction,
        nilStr(l.SenderType), nilStr(l.SenderName), l.ReceivedDate, nilStr(l.AgendaNo), nilStr(string(l.ContentHash)),
        authoring, nilStr(l.DraftDocxHash), l.DraftDocxRev, nilStr(l.FinalDocxHash), nilStr(l.LetterheadID),
        l.CreatedBy, l.CreatedAt, l.UpdatedAt); err != nil {
        return fmt.Errorf("correspondence insert letter: %w", err)
    }
    return nil
}
  • [x] Replace GetLetter, applyLetterNullables and ListLetters in the same file (the SELECT list + scan gain the five new columns in ONE canonical order — keep the two queries identical):
// GetLetter loads a letter header.
func (s *Store) GetLetter(ctx context.Context, id string) (domain.Letter, error) {
    var l domain.Letter
    var number, hash, senderType, senderName, agendaNo, draftDocx, finalDocx, letterheadID *string
    err := s.db.Exec(ctx).QueryRow(ctx,
        `SELECT id, type, classification, subject, body_html, status, number, content_hash,
                direction, sender_type, sender_name, received_date, agenda_no,
                authoring, draft_docx_hash, draft_docx_rev, final_docx_hash, letterhead_id,
                created_by, created_at, updated_at
           FROM letters WHERE id = $1`, id).
        Scan(&l.ID, &l.Type, &l.Classification, &l.Subject, &l.BodyHTML, &l.Status, &number, &hash,
            &l.Direction, &senderType, &senderName, &l.ReceivedDate, &agendaNo,
            &l.Authoring, &draftDocx, &l.DraftDocxRev, &finalDocx, &letterheadID,
            &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt)
    if errors.Is(err, pgx.ErrNoRows) {
        return domain.Letter{}, &kernel.Error{Kind: kernel.ErrNotFound, Code: "correspondence.letter.not_found", Message: "letter not found"}
    }
    if err != nil {
        return domain.Letter{}, fmt.Errorf("correspondence get letter: %w", err)
    }
    applyLetterNullables(&l, number, hash, senderType, senderName, agendaNo, draftDocx, finalDocx, letterheadID)
    return l, nil
}

// applyLetterNullables copies scanned nullable columns onto a Letter.
func applyLetterNullables(l *domain.Letter, number, hash, senderType, senderName, agendaNo, draftDocx, finalDocx, letterheadID *string) {
    if number != nil {
        l.Number = *number
    }
    if hash != nil {
        l.ContentHash = kernel.ContentHash(*hash)
    }
    if senderType != nil {
        l.SenderType = *senderType
    }
    if senderName != nil {
        l.SenderName = *senderName
    }
    if agendaNo != nil {
        l.AgendaNo = *agendaNo
    }
    if draftDocx != nil {
        l.DraftDocxHash = *draftDocx
    }
    if finalDocx != nil {
        l.FinalDocxHash = *finalDocx
    }
    if letterheadID != nil {
        l.LetterheadID = *letterheadID
    }
}

// ListLetters returns all letter headers, newest first.
func (s *Store) ListLetters(ctx context.Context) ([]domain.Letter, error) {
    rows, err := s.db.Exec(ctx).Query(ctx,
        `SELECT id, type, classification, subject, body_html, status, number, content_hash,
                direction, sender_type, sender_name, received_date, agenda_no,
                authoring, draft_docx_hash, draft_docx_rev, final_docx_hash, letterhead_id,
                created_by, created_at, updated_at
           FROM letters ORDER BY created_at DESC`)
    if err != nil {
        return nil, fmt.Errorf("correspondence list letters: %w", err)
    }
    defer rows.Close()
    var out []domain.Letter
    for rows.Next() {
        var l domain.Letter
        var number, hash, senderType, senderName, agendaNo, draftDocx, finalDocx, letterheadID *string
        if err := rows.Scan(&l.ID, &l.Type, &l.Classification, &l.Subject, &l.BodyHTML, &l.Status, &number, &hash,
            &l.Direction, &senderType, &senderName, &l.ReceivedDate, &agendaNo,
            &l.Authoring, &draftDocx, &l.DraftDocxRev, &finalDocx, &letterheadID,
            &l.CreatedBy, &l.CreatedAt, &l.UpdatedAt); err != nil {
            return nil, fmt.Errorf("correspondence scan letter: %w", err)
        }
        applyLetterNullables(&l, number, hash, senderType, senderName, agendaNo, draftDocx, finalDocx, letterheadID)
        out = append(out, l)
    }
    return out, rows.Err()
}
  • [x] Add the two new store methods in the same file, right after SetLetterStatusFromReview:
// UpdateLetterDraftDocx lands an OnlyOffice save on a docx letter: new draft blob,
// rev+1 (invalidates the editor cache key), fresh updated_at. Status-guarded IN SQL:
// only a still-editable letter (draft/rejected, per D4) accepts a save — a letter
// numbered or submitted while an editor was open rejects the late save with a
// conflict instead of silently overwriting official content (F11 parity).
func (s *Store) UpdateLetterDraftDocx(ctx context.Context, letterID, hash string, at time.Time) error {
    tag, err := s.db.Exec(ctx).Exec(ctx,
        `UPDATE letters SET draft_docx_hash = $2, draft_docx_rev = draft_docx_rev + 1, updated_at = $3
           WHERE id = $1 AND status IN ('draft', 'rejected')`,
        letterID, hash, at)
    if err != nil {
        return fmt.Errorf("correspondence update letter draft docx: %w", err)
    }
    if tag.RowsAffected() == 0 {
        return &kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.letter.not_editable", Message: "the letter can no longer be edited (already submitted or numbered)"}
    }
    return nil
}

// SetLetterFinalDocx records (hash != "") or clears (hash == "") the frozen merged
// docx. Written just before the numbering conversion so the doc-server can fetch it,
// and cleared again when a conversion failure voids the allocation.
func (s *Store) SetLetterFinalDocx(ctx context.Context, letterID, hash string, at time.Time) error {
    tag, err := s.db.Exec(ctx).Exec(ctx,
        `UPDATE letters SET final_docx_hash = $2, updated_at = $3 WHERE id = $1`,
        letterID, nilStr(hash), at)
    if err != nil {
        return fmt.Errorf("correspondence set letter final docx: %w", err)
    }
    if tag.RowsAffected() == 0 {
        return &kernel.Error{Kind: kernel.ErrNotFound, Code: "correspondence.letter.not_found", Message: "letter not found"}
    }
    return nil
}
  • [x] In go/internal/letterhead/domain/template.go, extend Template — add after FooterHTML:
    // Docx template (OnlyOffice-edited letterhead). DocxHash addresses the .docx in
    // the blob store (empty = not seeded yet); DocxRev bumps on every editor save or
    // upload and feeds the doc-server cache key (letterhead_{id}_{rev}). HasDocx is
    // the derived flag surfaced to clients (the create-letter modal filters on it).
    DocxHash string `json:"-"`
    DocxRev  int    `json:"-"`
    HasDocx  bool   `json:"has_docx"`
  • [x] In go/internal/letterhead/app/service.go: add UpdateDocx(ctx context.Context, id, hash string, at time.Time) error to the Repository interface (after Update), give Service a blobs kernel.BlobStore field, and change the constructor:
// Service is the letterhead context's application service. blobs stores the
// OnlyOffice-edited docx templates (content-addressed, shared with dms/letters).
type Service struct {
    repo  Repository
    blobs kernel.BlobStore
    clock kernel.Clock
}

// NewService constructs the letterhead service.
func NewService(repo Repository, blobs kernel.BlobStore) *Service {
    return &Service{repo: repo, blobs: blobs, clock: kernel.SystemClock()}
}
  • [x] In go/internal/letterhead/adapters/pg.go: extend the column list, the scanner and Insert, and add UpdateDocx:
const letterheadCols = `id, name, header_html, footer_html, docx_hash, docx_rev, created_by, created_at, updated_at`
func scanLetterhead(row scanner) (domain.Template, error) {
    var t domain.Template
    var docxHash *string
    if err := row.Scan(&t.ID, &t.Name, &t.HeaderHTML, &t.FooterHTML, &docxHash, &t.DocxRev, &t.CreatedBy, &t.CreatedAt, &t.UpdatedAt); err != nil {
        return domain.Template{}, err
    }
    if docxHash != nil {
        t.DocxHash = *docxHash
    }
    t.HasDocx = t.DocxHash != ""
    return t, nil
}
// Insert persists a new letterhead row. A duplicate name is surfaced as kernel.ErrConflict.
func (s *Store) Insert(ctx context.Context, t domain.Template) error {
    _, err := s.db.Exec(ctx).Exec(ctx,
        `INSERT INTO letterhead_templates (`+letterheadCols+`)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
        t.ID, t.Name, t.HeaderHTML, t.FooterHTML, nilStr(t.DocxHash), t.DocxRev, t.CreatedBy, t.CreatedAt, t.UpdatedAt)
    if err != nil {
        if c := mapConflict(err); c != nil {
            return c
        }
        return fmt.Errorf("letterhead insert: %w", err)
    }
    return nil
}

// nilStr maps an empty string to a SQL NULL for nullable columns (docx_hash).
func nilStr(s string) *string {
    if s == "" {
        return nil
    }
    return &s
}
// UpdateDocx swaps the docx template blob and bumps docx_rev (invalidating the
// OnlyOffice editor cache key), stamping updated_at = at. NotFound when the id
// matches no row.
func (s *Store) UpdateDocx(ctx context.Context, id, hash string, at time.Time) error {
    tag, err := s.db.Exec(ctx).Exec(ctx,
        `UPDATE letterhead_templates SET docx_hash = $2, docx_rev = docx_rev + 1, updated_at = $3 WHERE id = $1`,
        id, hash, at)
    if err != nil {
        return fmt.Errorf("letterhead update docx: %w", err)
    }
    if tag.RowsAffected() == 0 {
        return &kernel.Error{Kind: kernel.ErrNotFound, Code: "letterhead.not_found", Message: "letterhead not found"}
    }
    return nil
}
  • [x] In go/cmd/obscura-server/wire.go line ~399, change the letterhead construction to pass the blob store (defined earlier in the function — it is already used by dmsSvc at ~line 233):
    letterheadSvc := letterheadapp.NewService(letterheadadapters.NewStore(database), blobStore)
  • [x] Verify: cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
  • [x] Commit:
cd /home/efran/remote-development/obscura && git add go/migrations/00128_letters_docx_authoring.sql go/internal/correspondence/domain/letter.go go/internal/correspondence/app/service.go go/internal/correspondence/adapters/pg.go go/internal/letterhead/domain/template.go go/internal/letterhead/app/service.go go/internal/letterhead/adapters/pg.go go/cmd/obscura-server/wire.go && git commit -m "feat(letters): docx authoring schema (mig 00128) + letterhead docx storage plumbing" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 2: platform/docx package — merge replacer, validator, runtime-assembled starters

Files:
- Create go/internal/platform/docx/replace.go
- Create go/internal/platform/docx/starter.go

Interfaces:
- Consumes: stdlib only (archive/zip, bytes, fmt, io, sort, strings, time).
- Produces (exact names later tasks import as docx.* from github.com/Virtue-Digital-Indonesia/obscura/internal/platform/docx):
- const MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- func ReplaceFields(docxZip []byte, fields map[string]string) ([]byte, error)
- func Validate(data []byte) error
- func LetterheadStarter() []byte
- func BlankLetterStarter() []byte

Note: the design brief suggested go/internal/correspondence/assets, but LetterheadStarter() is consumed by the LETTERHEAD context (lazy seed) and cross-context imports are forbidden — both starters therefore live in the shared platform/docx package alongside the replacer.

Steps:

  • [x] Create go/internal/platform/docx/replace.go — complete file:
// Package docx provides the small OOXML (docx) utilities the OnlyOffice-letters
// feature needs: merge-field replacement across the document/header/footer parts,
// upload validation, and the runtime-assembled starter templates. Pure stdlib, no
// dependency on any bounded context — both the correspondence and letterhead
// services consume it.
package docx

import (
    "archive/zip"
    "bytes"
    "fmt"
    "io"
    "sort"
    "strings"
)

// MIME is the canonical docx content type.
const MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"

// Validate checks that data is a plausible docx: a readable zip that contains
// [Content_Types].xml and word/document.xml. Used on letterhead uploads and on
// editor save-backs; anything deeper (schema validity) is the converter's problem —
// a corrupt part fails the ConvertService/Gotenberg step, which voids cleanly.
func Validate(data []byte) error {
    zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
    if err != nil {
        return fmt.Errorf("not a zip archive")
    }
    var hasTypes, hasDoc bool
    for _, f := range zr.File {
        switch f.Name {
        case "[Content_Types].xml":
            hasTypes = true
        case "word/document.xml":
            hasDoc = true
        }
    }
    if !hasTypes || !hasDoc {
        return fmt.Errorf("missing [Content_Types].xml or word/document.xml")
    }
    return nil
}

// isTargetPart reports whether a zip entry is one of the parts merge fields are
// replaced in: the main document plus every header/footer part.
func isTargetPart(name string) bool {
    if name == "word/document.xml" {
        return true
    }
    return (strings.HasPrefix(name, "word/header") || strings.HasPrefix(name, "word/footer")) && strings.HasSuffix(name, ".xml")
}

// ReplaceFields replaces {{FIELD}} placeholders with XML-escaped values across
// word/document.xml + word/header*.xml + word/footer*.xml, copying every other zip
// entry byte-for-byte. STRING-LEVEL surgery — never an encoding/xml re-serialization
// (which would reorder attributes/namespaces and break OnlyOffice fidelity).
//
// Word splits literal text across arbitrary <w:r> runs (rsid/spellcheck fragments),
// so per part the algorithm is: collect every <w:t> text node with its byte ranges,
// join their texts into one stream, find {{FIELD}} occurrences in the joined stream
// (placeholders contain no XML-escapable characters, so raw matching is safe), then
// splice the escaped replacement into the FIRST affected node's range and empty the
// rest of the span. Unknown fields are left untouched; a docx with no placeholders
// passes through unchanged.
func ReplaceFields(docxZip []byte, fields map[string]string) ([]byte, error) {
    zr, err := zip.NewReader(bytes.NewReader(docxZip), int64(len(docxZip)))
    if err != nil {
        return nil, fmt.Errorf("docx: not a zip archive: %w", err)
    }
    var out bytes.Buffer
    zw := zip.NewWriter(&out)
    for _, f := range zr.File {
        rc, err := f.Open()
        if err != nil {
            return nil, fmt.Errorf("docx: open %s: %w", f.Name, err)
        }
        data, err := io.ReadAll(rc)
        rc.Close()
        if err != nil {
            return nil, fmt.Errorf("docx: read %s: %w", f.Name, err)
        }
        if isTargetPart(f.Name) {
            data = replaceInPart(data, fields)
        }
        w, err := zw.CreateHeader(&zip.FileHeader{Name: f.Name, Method: zip.Deflate, Modified: f.Modified})
        if err != nil {
            return nil, fmt.Errorf("docx: create %s: %w", f.Name, err)
        }
        if _, err := w.Write(data); err != nil {
            return nil, fmt.Errorf("docx: write %s: %w", f.Name, err)
        }
    }
    if err := zw.Close(); err != nil {
        return nil, fmt.Errorf("docx: close: %w", err)
    }
    return out.Bytes(), nil
}

// tNode is one non-self-closing <w:t> text node in a part: the byte range of its
// open tag, the byte range of its content, and where its text starts in the joined
// per-part text stream.
type tNode struct {
    openStart  int // index of '<' of "<w:t"
    openEnd    int // index just after the open tag's '>'
    contentEnd int // index of "</w:t>"
    joinStart  int // offset of this node's text in the joined stream
    text       string
}

// scanTextNodes locates every <w:t> node with content. Exact-tag match: the byte
// after "<w:t" must be '>', ' ' (attributes) or '/' — so <w:tab/>, <w:tc>, <w:tr>
// never match, and w:delText / w:instrText never even reach the check (they do not
// start with "<w:t"). Self-closing <w:t/> carries no text and is skipped: it can
// never contain a placeholder character and emptying it would be a no-op.
func scanTextNodes(xml string) []tNode {
    var nodes []tNode
    join := 0
    for i := 0; ; {
        j := strings.Index(xml[i:], "<w:t")
        if j < 0 {
            break
        }
        start := i + j
        rest := start + 4
        if rest >= len(xml) {
            break
        }
        switch xml[rest] {
        case '>', ' ', '/':
        default: // <w:tab, <w:tc, <w:tbl, ...
            i = rest
            continue
        }
        gt := strings.IndexByte(xml[rest:], '>')
        if gt < 0 {
            break
        }
        openEnd := rest + gt + 1
        if xml[openEnd-2] == '/' { // self-closing <w:t/> (possibly with attributes)
            i = openEnd
            continue
        }
        closeIdx := strings.Index(xml[openEnd:], "</w:t>")
        if closeIdx < 0 {
            break
        }
        contentEnd := openEnd + closeIdx
        text := xml[openEnd:contentEnd]
        nodes = append(nodes, tNode{openStart: start, openEnd: openEnd, contentEnd: contentEnd, joinStart: join, text: text})
        join += len(text)
        i = contentEnd + len("</w:t>")
    }
    return nodes
}

// occurrence is one placeholder hit in a part's joined text stream.
type occurrence struct {
    start, end int    // [start, end) in the joined stream
    value      string // XML-escaped replacement
}

// xmlEscaper escapes replacement values for insertion into XML text content —
// subjects can legally contain &, <, >, quotes.
var xmlEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;", "'", "&apos;")

// replaceInPart runs the merge substitution over one XML part.
func replaceInPart(part []byte, fields map[string]string) []byte {
    xml := string(part)
    nodes := scanTextNodes(xml)
    if len(nodes) == 0 {
        return part
    }
    var joinedB strings.Builder
    for _, n := range nodes {
        joinedB.WriteString(n.text)
    }
    joined := joinedB.String()

    // Collect every occurrence of every known field on the ORIGINAL joined stream,
    // then apply edits RIGHT-TO-LEFT so earlier offsets stay valid while node texts
    // mutate. (The fixed field set has no overlapping placeholder shapes; the
    // prevStart guard below skips pathological overlaps defensively.)
    var occs []occurrence
    for field, value := range fields {
        ph := "{{" + field + "}}"
        esc := xmlEscaper.Replace(value)
        for from := 0; ; {
            k := strings.Index(joined[from:], ph)
            if k < 0 {
                break
            }
            s := from + k
            occs = append(occs, occurrence{start: s, end: s + len(ph), value: esc})
            from = s + len(ph)
        }
    }
    if len(occs) == 0 {
        return part
    }
    sort.Slice(occs, func(i, j int) bool { return occs[i].start > occs[j].start })

    texts := make([]string, len(nodes))
    for i, n := range nodes {
        texts[i] = n.text
    }
    // nodeAt maps a joined-stream offset to its node index (offsets are original —
    // right-to-left application keeps every prefix below the current edit intact).
    nodeAt := func(off int) int {
        for i, n := range nodes {
            if off >= n.joinStart && off < n.joinStart+len(n.text) {
                return i
            }
        }
        return -1
    }
    prevStart := len(joined) + 1
    for _, oc := range occs {
        if oc.end > prevStart { // overlap with the previously applied (righter) occurrence
            continue
        }
        prevStart = oc.start
        first := nodeAt(oc.start)
        last := nodeAt(oc.end - 1)
        if first < 0 || last < 0 {
            continue
        }
        if first == last {
            local := oc.start - nodes[first].joinStart
            localEnd := oc.end - nodes[first].joinStart
            texts[first] = texts[first][:local] + oc.value + texts[first][localEnd:]
            continue
        }
        // Placeholder split across runs: replacement lands in the first affected run
        // (keeping that run's formatting), the middle runs empty, the last keeps its tail.
        texts[first] = texts[first][:oc.start-nodes[first].joinStart] + oc.value
        for k := first + 1; k < last; k++ {
            texts[k] = ""
        }
        texts[last] = texts[last][oc.end-nodes[last].joinStart:]
    }

    // Reassemble: original bytes outside the text nodes, mutated text inside. When a
    // node's new text gained a boundary space and its open tag lacks
    // xml:space="preserve", add the attribute (Word/OnlyOffice trim otherwise).
    var out strings.Builder
    prev := 0
    for i, n := range nodes {
        out.WriteString(xml[prev:n.openStart])
        openTag := xml[n.openStart:n.openEnd]
        if needsPreserve(texts[i]) && !strings.Contains(openTag, "xml:space") {
            openTag = openTag[:len(openTag)-1] + ` xml:space="preserve">`
        }
        out.WriteString(openTag)
        out.WriteString(texts[i])
        prev = n.contentEnd
    }
    out.WriteString(xml[prev:])
    return []byte(out.String())
}

// needsPreserve reports whether a text needs xml:space="preserve" (leading or
// trailing space that XML whitespace handling would otherwise eat).
func needsPreserve(t string) bool {
    return t != "" && (t[0] == ' ' || t[len(t)-1] == ' ')
}
  • [x] Create go/internal/platform/docx/starter.go — complete file:
package docx

import (
    "archive/zip"
    "bytes"
    "time"
)

// Starter templates are assembled at RUNTIME from OOXML part constants — no binary
// .docx files in the repo. Fixed entry order + a fixed zip timestamp keep the output
// byte-stable within a build, so the content-addressed blob hash of a freshly seeded
// starter is deterministic. (A Go toolchain upgrade may change deflate output and
// thus the hash of NEW seeds — harmless: existing letters/templates keep the blobs
// they already reference.)

const starterContentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/header1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/></Types>`

const starterRootRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`

const starterDocumentRels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rId10" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/><Relationship Id="rId11" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/></Relationships>`

// Minimal styles: Times New Roman 12pt (w:sz is half-points) with a little
// paragraph spacing — the traditional tata-naskah look, all overridable in the editor.
const starterStyles = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="Times New Roman"/><w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="120"/></w:pPr></w:pPrDefault></w:docDefaults></w:styles>`

// Body: the standard tata-naskah opening block as editable guidance (the author can
// delete it), plus a sectPr wiring header1/footer1 (rIds match starterDocumentRels)
// on an A4 page (11906x16838 twips) with 1-inch margins. The header/footer
// references are what make OnlyOffice's header/footer editing immediately
// discoverable (spec D2).
const starterDocument = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:t xml:space="preserve">Nomor: {{NOMOR}}</w:t></w:r></w:p><w:p><w:r><w:t xml:space="preserve">Sifat: {{SIFAT}}</w:t></w:r></w:p><w:p><w:r><w:t xml:space="preserve">Perihal: {{PERIHAL}}</w:t></w:r></w:p><w:p/><w:p><w:pPr><w:jc w:val="right"/></w:pPr><w:r><w:t>{{TANGGAL}}</w:t></w:r></w:p><w:p/><w:sectPr><w:headerReference w:type="default" r:id="rId10"/><w:footerReference w:type="default" r:id="rId11"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/></w:sectPr></w:body></w:document>`

// Letterhead starter: sample kop in the header, sample text in the footer.
const starterLetterheadHeader = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:pPr><w:jc w:val="center"/></w:pPr><w:r><w:rPr><w:b/><w:sz w:val="28"/></w:rPr><w:t>NAMA INSTANSI / ORGANISASI</w:t></w:r></w:p><w:p><w:pPr><w:jc w:val="center"/></w:pPr><w:r><w:t>Alamat, telepon, surel — sunting kop ini di bagian header</w:t></w:r></w:p></w:hdr>`

const starterLetterheadFooter = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p><w:pPr><w:jc w:val="center"/></w:pPr><w:r><w:rPr><w:sz w:val="18"/></w:rPr><w:t>Contoh footer — sunting di bagian footer</w:t></w:r></w:p></w:ftr>`

// Blank starter: header/footer sections exist (so header/footer editing is
// discoverable) but contain a single empty paragraph.
const starterBlankHeader = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:hdr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p/></w:hdr>`

const starterBlankFooter = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:ftr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:p/></w:ftr>`

// starterZipTime is the fixed timestamp stamped on every starter zip entry.
var starterZipTime = time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)

// assembleStarter zips the OOXML parts into a docx. Inputs are static string
// constants and the writes go to an in-memory buffer, so errors are impossible in
// practice — a panic here means a broken constant, caught by the build-time scratch
// check, never at runtime with user data.
func assembleStarter(headerXML, footerXML string) []byte {
    var buf bytes.Buffer
    zw := zip.NewWriter(&buf)
    parts := []struct{ name, body string }{
        {"[Content_Types].xml", starterContentTypes},
        {"_rels/.rels", starterRootRels},
        {"word/document.xml", starterDocument},
        {"word/_rels/document.xml.rels", starterDocumentRels},
        {"word/styles.xml", starterStyles},
        {"word/header1.xml", headerXML},
        {"word/footer1.xml", footerXML},
    }
    for _, p := range parts {
        w, err := zw.CreateHeader(&zip.FileHeader{Name: p.name, Method: zip.Deflate, Modified: starterZipTime})
        if err != nil {
            panic("docx starter: " + err.Error())
        }
        if _, err := w.Write([]byte(p.body)); err != nil {
            panic("docx starter: " + err.Error())
        }
    }
    if err := zw.Close(); err != nil {
        panic("docx starter: " + err.Error())
    }
    return buf.Bytes()
}

// LetterheadStarter is the seed docx for a letterhead template: sample kop text in
// the native Word header section, sample footer, and the tata-naskah opening block
// in the body as editable guidance.
func LetterheadStarter() []byte { return assembleStarter(starterLetterheadHeader, starterLetterheadFooter) }

// BlankLetterStarter is the seed docx for a letter created without a letterhead:
// A4, empty header/footer sections wired, same opening block in the body.
func BlankLetterStarter() []byte { return assembleStarter(starterBlankHeader, starterBlankFooter) }
  • [x] Verify: cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...

  • [x] Verify the pure functions with a /tmp scratch go run program (NOT go test). The scratch module declares itself UNDER the obscura module path so Go's internal/ visibility rule admits the imports; the replace directive points at the real tree:

mkdir -p /tmp/docxscratch && cd /tmp/docxscratch
cat > go.mod <<'EOF'
module github.com/Virtue-Digital-Indonesia/obscura/scratch

go 1.25.7

require github.com/Virtue-Digital-Indonesia/obscura v0.0.0

replace github.com/Virtue-Digital-Indonesia/obscura => /home/efran/remote-development/obscura/go
EOF
cat > main.go <<'EOF'
package main

import (
    "archive/zip"
    "bytes"
    "fmt"
    "io"
    "os"
    "strings"

    "github.com/Virtue-Digital-Indonesia/obscura/internal/platform/docx"
)

func readPart(zipBytes []byte, name string) string {
    zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
    if err != nil {
        panic(err)
    }
    for _, f := range zr.File {
        if f.Name == name {
            rc, _ := f.Open()
            b, _ := io.ReadAll(rc)
            rc.Close()
            return string(b)
        }
    }
    panic("part not found: " + name)
}

func must(cond bool, what string) {
    if !cond {
        fmt.Println("FAIL:", what)
        os.Exit(1)
    }
}

func main() {
    // 1. Both starters validate and are stable.
    lh, blank := docx.LetterheadStarter(), docx.BlankLetterStarter()
    must(docx.Validate(lh) == nil, "letterhead starter validates")
    must(docx.Validate(blank) == nil, "blank starter validates")
    must(bytes.Equal(lh, docx.LetterheadStarter()), "starter assembly deterministic")
    must(docx.Validate([]byte("not a zip")) != nil, "garbage rejected")

    // 2. Merge over the real starter: values land, escaping applied, no {{ left.
    merged, err := docx.ReplaceFields(lh, map[string]string{
        "NOMOR":   "0007/UND/2026",
        "TANGGAL": "24 Juli 2026",
        "PERIHAL": `Undangan R&D <Divisi> "Alpha"`,
        "SIFAT":   "-",
    })
    must(err == nil, "ReplaceFields on starter")
    _ = os.WriteFile("/tmp/docxscratch/merged.docx", merged, 0o644)
    doc := readPart(merged, "word/document.xml")
    for _, want := range []string{
        "Nomor: 0007/UND/2026",
        "Sifat: -",
        "Perihal: Undangan R&amp;D &lt;Divisi&gt; &quot;Alpha&quot;",
        "24 Juli 2026",
    } {
        must(strings.Contains(doc, want), "document.xml contains "+want)
    }
    must(!strings.Contains(doc, "{{"), "no unreplaced placeholder")
    must(readPart(merged, "word/header1.xml") == readPart(lh, "word/header1.xml"), "header without placeholders untouched")

    // 3. Split-run placeholder (Word rsid fragmentation) + unknown field untouched.
    var buf bytes.Buffer
    zw := zip.NewWriter(&buf)
    add := func(name, body string) {
        w, _ := zw.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Deflate})
        _, _ = w.Write([]byte(body))
    }
    add("[Content_Types].xml", `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/></Types>`)
    add("word/document.xml", `<w:document xmlns:w="x"><w:body><w:p><w:r><w:t>Nomor: {{NO</w:t></w:r><w:r><w:t>MOR}}</w:t></w:r><w:r><w:t xml:space="preserve"> tetap {{LAIN}}</w:t></w:r></w:p><w:p><w:r><w:t>{{TANGGAL}} dan {{TANGGAL}}</w:t></w:r></w:p></w:body></w:document>`)
    _ = zw.Close()
    merged2, err := docx.ReplaceFields(buf.Bytes(), map[string]string{"NOMOR": "0001/X/2026", "TANGGAL": "1 Juli 2026"})
    must(err == nil, "ReplaceFields split-run")
    doc2 := readPart(merged2, "word/document.xml")
    must(strings.Contains(doc2, "<w:t>Nomor: 0001/X/2026</w:t>"), "split-run replacement lands in first run")
    must(strings.Contains(doc2, "<w:t></w:t>"), "remainder of split run emptied")
    must(strings.Contains(doc2, "{{LAIN}}"), "unknown field untouched")
    must(strings.Contains(doc2, "1 Juli 2026 dan 1 Juli 2026"), "double occurrence in one run")

    fmt.Println("OK — all docx scratch checks passed")
}
EOF
go mod tidy && go run .

Expected output: OK — all docx scratch checks passed. Also sanity-open the artifact: cd /tmp/docxscratch && python3 -c "import zipfile; z=zipfile.ZipFile('merged.docx'); print(z.namelist()); z.testzip()". Clean up with rm -rf /tmp/docxscratch afterwards.

  • [x] Commit:
cd /home/efran/remote-development/obscura && git add go/internal/platform/docx/replace.go go/internal/platform/docx/starter.go && git commit -m "feat(letters): platform/docx — merge-field replacer, validator, runtime-assembled starters" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 3: Correspondence + letterhead service methods, CreateLetter/Submit handler extensions

Files:
- Modify go/internal/correspondence/app/service.go
- Modify go/internal/letterhead/app/service.go
- Modify go/internal/httpapi/handlers_correspondence.go (CreateLetter + SubmitLetter handlers only)
- Modify go/internal/correspondence/adapters/pg_test.go (mechanical signature catch-up — go vet ./... TYPE-CHECKS test files even though tests are NEVER RUN)

Interfaces:
- Consumes (from Tasks 1–2): domain.AuthoringHTML/AuthoringDocx, Letter docx fields, IndonesianLongDate, SifatLabel, repo methods UpdateLetterDraftDocx/SetLetterFinalDocx/UpdateDocx, docx.MIME, docx.ReplaceFields, docx.Validate, docx.LetterheadStarter, docx.BlankLetterStarter; letterhead Template.DocxHash; writeProblemStatus(w, status int, detail, code string) in httpapi.
- Produces (Tasks 4–5 depend on these EXACT signatures):
- correspondence *Service:
- type CreateLetterParams struct { Type, Classification, Subject, BodyHTML, Authoring, LetterheadID, SeedDocxHash string }
- CreateLetter(ctx context.Context, p kernel.Principal, params CreateLetterParams) (string, error) (signature CHANGE — the only caller is the handler updated in this task)
- type DocxPDFRenderer interface { RenderLetterDocx(ctx context.Context, letterID string, docx []byte) ([]byte, error) }
- SetDocxRenderer(r DocxPDFRenderer)
- OpenDraftDocx(ctx context.Context, letterID string) (io.ReadCloser, domain.Letter, error)
- OpenFinalDocx(ctx context.Context, letterID string) (io.ReadCloser, domain.Letter, error)
- SaveDraftDocx(ctx context.Context, letterID, editorUserID string, docxBytes []byte) error
- AssignNumberDocx(ctx context.Context, p kernel.Principal, letterID, schemeCode string) (string, error)
- SubmitForApproval now allows status rejected as a source.
- letterhead *Service:
- EnsureTemplateDocx(ctx context.Context, id string) (domain.Template, error) (lazy-seeds the starter)
- OpenTemplateDocx(ctx context.Context, id string) (io.ReadCloser, domain.Template, error)
- SaveTemplateDocx(ctx context.Context, id string, docxBytes []byte) error
- HTTP: POST /api/v1/letters accepts authoring + letterhead_id; docx without office module → 503 office.disabled; letterhead without docx template → 422 correspondence.letterhead_no_docx.

Steps:

  • [x] In go/internal/correspondence/app/service.go, add the import "github.com/Virtue-Digital-Indonesia/obscura/internal/platform/docx" and extend the Service struct + add the port (below the PDFRenderer interface):
// DocxPDFRenderer converts a docx letter's merged bytes to PDF at numbering time.
// The composition root late-binds the httpapi server here (mirrors esign's
// SetSealMarker): OnlyOffice ConvertService when the office module is live, with a
// Gotenberg LibreOffice fallback — so docx letters stay NUMBERABLE even if the
// office license lapses; only editing needs the module.
type DocxPDFRenderer interface {
    RenderLetterDocx(ctx context.Context, letterID string, docx []byte) ([]byte, error)
}

Change the Service struct and add the setter after NewService:

type Service struct {
    repo         Repository
    renderer     PDFRenderer
    docxRenderer DocxPDFRenderer // nil until the composition root wires it
    blobs        kernel.BlobStore
    uow          kernel.UnitOfWork
    clock        kernel.Clock
}
// SetDocxRenderer late-binds the docx→PDF converter (set by the composition root
// after the HTTP server exists; the server implements the port).
func (s *Service) SetDocxRenderer(r DocxPDFRenderer) { s.docxRenderer = r }

(NewService keeps its current signature and field assignments; docxRenderer starts nil.)

  • [x] Replace CreateLetter with the params form + docx seeding:
// CreateLetterParams are the CreateLetter inputs. Authoring selects the compose
// channel: "" / html (plain HTML → Gotenberg, the original path) or docx
// (OnlyOffice-authored). For docx letters the TRANSPORT enforces the office module
// and resolves SeedDocxHash — the chosen letterhead's docx template hash
// (content-addressed ⇒ zero-copy seed; the first editor save diverges naturally).
// An empty SeedDocxHash on a docx letter seeds the embedded blank starter.
type CreateLetterParams struct {
    Type           string
    Classification string
    Subject        string
    BodyHTML       string
    Authoring      string
    LetterheadID   string
    SeedDocxHash   string
}

// CreateLetter creates a draft letter and returns its ID. An empty classification
// defaults to 'none'. The letter has no number and no rendered PDF until
// AssignNumber succeeds.
func (s *Service) CreateLetter(ctx context.Context, p kernel.Principal, params CreateLetterParams) (string, error) {
    switch params.Type {
    case domain.TypeSurat, domain.TypeNota, domain.TypeMemo:
    default:
        return "", &kernel.Error{Kind: kernel.ErrValidation, Code: "correspondence.letter.invalid_type", Message: "letter type must be surat, nota or memo"}
    }
    if params.Subject == "" {
        return "", &kernel.Error{Kind: kernel.ErrValidation, Code: "correspondence.letter.subject_required", Message: "letter subject is required"}
    }
    if params.Classification == "" {
        params.Classification = domain.ClassificationNone
    }
    switch params.Authoring {
    case "":
        params.Authoring = domain.AuthoringHTML
    case domain.AuthoringHTML, domain.AuthoringDocx:
    default:
        return "", &kernel.Error{Kind: kernel.ErrValidation, Code: "correspondence.letter.invalid_authoring", Message: "authoring must be html or docx"}
    }
    // Seed the editable docx. A letterhead seed arrives as its template hash
    // (zero-copy — the blob already exists, content-addressed); otherwise store the
    // embedded blank starter. Put-before-insert: an insert failure leaves at worst
    // an unreferenced blob, never a letter pointing at missing bytes.
    draftDocxHash := ""
    if params.Authoring == domain.AuthoringDocx {
        draftDocxHash = params.SeedDocxHash
        if draftDocxHash == "" {
            h, _, err := s.blobs.Put(ctx, bytes.NewReader(docx.BlankLetterStarter()), kernel.PutOpts{ContentType: docx.MIME})
            if err != nil {
                return "", err
            }
            draftDocxHash = string(h)
        }
    }
    now := s.clock.Now()
    id := kernel.NewID()
    l := domain.Letter{
        ID:             id,
        Type:           params.Type,
        Classification: params.Classification,
        Subject:        params.Subject,
        BodyHTML:       params.BodyHTML,
        Status:         domain.StatusDraft,
        Direction:      domain.DirectionOutbound,
        Authoring:      params.Authoring,
        DraftDocxHash:  draftDocxHash,
        LetterheadID:   params.LetterheadID,
        CreatedBy:      string(p.UserID),
        CreatedAt:      now,
        UpdatedAt:      now,
    }
    if err := s.repo.InsertLetter(ctx, l); err != nil {
        return "", err
    }
    return id, nil
}
  • [x] Add the docx open/save methods (after OpenLetterContent):
// OpenDraftDocx streams a docx letter's CURRENT editable docx (the OnlyOffice
// content fetch). Conflict when the letter is not docx-authored.
func (s *Service) OpenDraftDocx(ctx context.Context, letterID string) (io.ReadCloser, domain.Letter, error) {
    l, err := s.repo.GetLetter(ctx, letterID)
    if err != nil {
        return nil, domain.Letter{}, err
    }
    if l.Authoring != domain.AuthoringDocx || l.DraftDocxHash == "" {
        return nil, domain.Letter{}, &kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.letter.no_docx", Message: "letter has no docx draft"}
    }
    rc, err := s.blobs.Get(ctx, kernel.ContentHash(l.DraftDocxHash))
    if err != nil {
        return nil, domain.Letter{}, err
    }
    return rc, l, nil
}

// OpenFinalDocx streams the FROZEN merge-substituted docx written at numbering —
// read-only, only ever fetched by the numbering conversion (variant=final tokens).
func (s *Service) OpenFinalDocx(ctx context.Context, letterID string) (io.ReadCloser, domain.Letter, error) {
    l, err := s.repo.GetLetter(ctx, letterID)
    if err != nil {
        return nil, domain.Letter{}, err
    }
    if l.FinalDocxHash == "" {
        return nil, domain.Letter{}, &kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.letter.no_final_docx", Message: "letter has no frozen final docx"}
    }
    rc, err := s.blobs.Get(ctx, kernel.ContentHash(l.FinalDocxHash))
    if err != nil {
        return nil, domain.Letter{}, err
    }
    return rc, l, nil
}

// SaveDraftDocx lands an OnlyOffice editor save. The TRANSPORT re-checks the
// saver's identity (creator / correspondence.admin — it holds the authorizer);
// here the letter must be docx-authored and still editable, and the repository
// UPDATE re-guards status IN SQL so a numbering that races an open editor rejects
// the late save (F11 parity). editorUserID names the saver for the guard's error
// context; put-before-update keeps a failed update from stranding anything but an
// unreferenced blob.
func (s *Service) SaveDraftDocx(ctx context.Context, letterID, editorUserID string, docxBytes []byte) error {
    l, err := s.repo.GetLetter(ctx, letterID)
    if err != nil {
        return err
    }
    if l.Authoring != domain.AuthoringDocx {
        return &kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.letter.no_docx", Message: "letter is not docx-authored"}
    }
    if l.Status != domain.StatusDraft && l.Status != domain.StatusRejected {
        return &kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.letter.not_editable", Message: "the letter can no longer be edited (already submitted or numbered)"}
    }
    if err := docx.Validate(docxBytes); err != nil {
        return &kernel.Error{Kind: kernel.ErrValidation, Code: "correspondence.letter.docx_invalid", Message: "the saved file is not a valid docx", Err: err}
    }
    _ = editorUserID // identity re-check lives in the transport (it owns the authorizer)
    hash, _, err := s.blobs.Put(ctx, bytes.NewReader(docxBytes), kernel.PutOpts{ContentType: docx.MIME})
    if err != nil {
        return err
    }
    return s.repo.UpdateLetterDraftDocx(ctx, letterID, string(hash), s.clock.Now())
}
  • [x] Refactor the gapless flow: extract Tx A + void into helpers, rewrite AssignNumberWithLetterhead on top of them (behavior-identical), and add AssignNumberDocx. Replace the existing AssignNumberWithLetterhead body and add:
// reservation is the outcome of the numbering flow's Tx A: the letter + scheme
// snapshot and the reserved allocation the caller must resolve to assigned/voided.
type reservation struct {
    scheme    domain.NumberingScheme
    letter    domain.Letter
    allocID   string
    seq       int
    periodKey string
}

// reserveNumber is Tx A of the gapless flow (shared by the HTML and docx paths):
// validate inputs, take the per-scheme advisory lock, read MAX(seq)+1, INSERT a
// 'reserved' allocation. Commit releases the lock immediately — the slow render
// never holds it.
func (s *Service) reserveNumber(ctx context.Context, letterID, schemeCode string) (reservation, error) {
    if letterID == "" {
        return reservation{}, &kernel.Error{Kind: kernel.ErrValidation, Code: "correspondence.assign.letter_required", Message: "letter id is required"}
    }
    if schemeCode == "" {
        return reservation{}, &kernel.Error{Kind: kernel.ErrValidation, Code: "correspondence.assign.scheme_required", Message: "scheme code is required"}
    }
    var res reservation
    err := s.uow.Do(ctx, func(ctx context.Context) error {
        var err error
        res.letter, err = s.repo.GetLetter(ctx, letterID)
        if err != nil {
            return err
        }
        if res.letter.Status != domain.StatusDraft && res.letter.Status != domain.StatusApproved {
            return &kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.assign.not_numberable", Message: "letter is not in a numberable state (draft or approved)"}
        }
        res.scheme, err = s.repo.GetSchemeByCode(ctx, schemeCode)
        if err != nil {
            return err
        }
        // Serialize concurrent allocators on this scheme so MAX(seq)+1 is monotonic.
        if err := s.repo.AcquireSchemeLock(ctx, res.scheme.Code); err != nil {
            return err
        }
        res.periodKey = s.clock.Now().Format(res.scheme.PeriodFormat)
        res.seq, err = s.repo.NextSeqForPeriod(ctx, res.scheme.ID, res.periodKey)
        if err != nil {
            return err
        }
        res.allocID = kernel.NewID()
        return s.repo.InsertReservedAllocation(ctx, domain.Allocation{
            ID:          res.allocID,
            SchemeID:    res.scheme.ID,
            PeriodKey:   res.periodKey,
            Seq:         res.seq,
            State:       domain.AllocReserved,
            LetterID:    letterID,
            AllocatedAt: s.clock.Now(),
        })
    })
    if err != nil {
        return reservation{}, err
    }
    return res, nil
}

// voidAllocation resolves a reserved seq as consumed-but-void — the auditable
// record of a failed render/convert, never a silent gap.
func (s *Service) voidAllocation(ctx context.Context, allocID string) error {
    return s.uow.Do(ctx, func(ctx context.Context) error {
        return s.repo.MarkAllocationVoided(ctx, allocID, s.clock.Now())
    })
}

// AssignNumberWithLetterhead is AssignNumber with an OPTIONAL letterhead (kop/footer).
// Empty header AND footer render the bare body (identical to AssignNumber). Otherwise the
// renderer gets header + body + footer. The letterhead lives in the CORE letterhead store
// and is resolved by the httpapi layer, which passes its header/footer HTML in here. The
// gapless reserve->render->finalize ordering is UNCHANGED — only the HTML handed to the
// renderer differs. HTML letters only; docx letters number through AssignNumberDocx.
func (s *Service) AssignNumberWithLetterhead(ctx context.Context, p kernel.Principal, letterID, schemeCode, headerHTML, footerHTML string) (string, error) {
    res, err := s.reserveNumber(ctx, letterID, schemeCode)
    if err != nil {
        return "", err
    }

    // The HTML handed to the renderer: wrapped with the letterhead (header + body +
    // footer) when one was chosen, otherwise the bare body (current behavior).
    renderHTML := res.letter.BodyHTML
    if headerHTML != "" || footerHTML != "" {
        renderHTML = headerHTML + res.letter.BodyHTML + footerHTML
    }

    // Out of any tx: render the PDF (slow, network, fallible).
    pdf, renderErr := s.renderer.Render(ctx, renderHTML)
    if renderErr != nil {
        // Render failed: void the reserved seq (auditable, never a silent gap).
        if voidErr := s.voidAllocation(ctx, res.allocID); voidErr != nil {
            return "", voidErr
        }
        return "", renderErr
    }

    // Render OK: store the PDF, then finalize the allocation and the letter. A storage
    // failure here must NOT strand the reservation in 'reserved' forever — void it
    // (auditable), exactly like a render failure, so the gapless invariant holds.
    hash, _, err := s.blobs.Put(ctx, bytes.NewReader(pdf), kernel.PutOpts{ContentType: "application/pdf"})
    if err != nil {
        if voidErr := s.voidAllocation(ctx, res.allocID); voidErr != nil {
            return "", voidErr
        }
        return "", err
    }
    number := domain.FormatNumber(res.scheme.Pattern, res.seq, res.scheme.Code, res.periodKey)
    err = s.uow.Do(ctx, func(ctx context.Context) error {
        now := s.clock.Now()
        if err := s.repo.MarkAllocationAssigned(ctx, res.allocID, letterID, now); err != nil {
            return err
        }
        return s.repo.UpdateLetterNumbered(ctx, letterID, number, string(hash), now)
    })
    if err != nil {
        return "", err
    }
    return number, nil
}

// AssignNumberDocx numbers a DOCX-authored letter: same three-phase gapless flow,
// but phase 2 is merge-replace ({{NOMOR}} {{TANGGAL}} {{PERIHAL}} {{SIFAT}}) across
// document + header/footer parts, freeze the merged docx (final_docx_hash — written
// BEFORE the conversion so the doc-server can fetch it through a variant=final
// content token), then convert docx→PDF through the late-bound DocxPDFRenderer.
// ANY phase-2 failure voids the allocation and clears final_docx_hash.
func (s *Service) AssignNumberDocx(ctx context.Context, p kernel.Principal, letterID, schemeCode string) (string, error) {
    res, err := s.reserveNumber(ctx, letterID, schemeCode)
    if err != nil {
        return "", err
    }
    // fail voids the reservation (and clears the frozen docx when it was already
    // recorded), then surfaces cause. A failed void wins — a dangling reservation
    // would break the gapless invariant.
    fail := func(cause error, clearFinal bool) (string, error) {
        if clearFinal {
            _ = s.repo.SetLetterFinalDocx(ctx, letterID, "", s.clock.Now())
        }
        if voidErr := s.voidAllocation(ctx, res.allocID); voidErr != nil {
            return "", voidErr
        }
        return "", cause
    }
    if res.letter.Authoring != domain.AuthoringDocx || res.letter.DraftDocxHash == "" {
        return fail(&kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.assign.no_docx", Message: "letter has no docx draft to number"}, false)
    }
    if s.docxRenderer == nil {
        return fail(&kernel.Error{Kind: kernel.ErrProtectionUnavailable, Code: "correspondence.assign.docx_renderer_unavailable", Message: "no docx-to-PDF converter is configured"}, false)
    }

    number := domain.FormatNumber(res.scheme.Pattern, res.seq, res.scheme.Code, res.periodKey)

    rc, err := s.blobs.Get(ctx, kernel.ContentHash(res.letter.DraftDocxHash))
    if err != nil {
        return fail(err, false)
    }
    draft, err := io.ReadAll(rc)
    rc.Close()
    if err != nil {
        return fail(err, false)
    }
    merged, err := docx.ReplaceFields(draft, map[string]string{
        "NOMOR":   number,
        "TANGGAL": domain.IndonesianLongDate(s.clock.Now()),
        "PERIHAL": res.letter.Subject,
        "SIFAT":   domain.SifatLabel(res.letter.Classification),
    })
    if err != nil {
        return fail(err, false)
    }
    finalHash, _, err := s.blobs.Put(ctx, bytes.NewReader(merged), kernel.PutOpts{ContentType: docx.MIME})
    if err != nil {
        return fail(err, false)
    }
    // Persist the frozen docx BEFORE converting — the ConvertService fetches it by
    // letter id through a variant=final content token.
    if err := s.repo.SetLetterFinalDocx(ctx, letterID, string(finalHash), s.clock.Now()); err != nil {
        return fail(err, false)
    }
    pdf, err := s.docxRenderer.RenderLetterDocx(ctx, letterID, merged)
    if err != nil {
        return fail(err, true)
    }
    pdfHash, _, err := s.blobs.Put(ctx, bytes.NewReader(pdf), kernel.PutOpts{ContentType: "application/pdf"})
    if err != nil {
        return fail(err, true)
    }
    err = s.uow.Do(ctx, func(ctx context.Context) error {
        now := s.clock.Now()
        if err := s.repo.MarkAllocationAssigned(ctx, res.allocID, letterID, now); err != nil {
            return err
        }
        return s.repo.UpdateLetterNumbered(ctx, letterID, number, string(pdfHash), now)
    })
    if err != nil {
        return "", err
    }
    return number, nil
}

(AssignNumber at :379 keeps delegating to AssignNumberWithLetterhead unchanged. The p parameter stays unused in AssignNumberDocx for handler-signature symmetry — name it _ kernel.Principal if go vet complains; it will not, unused params are legal.)

  • [x] Update SubmitForApproval (draft OR rejected — spec D4's one new transition):
// SubmitForApproval moves an outbound draft OR rejected letter into 'in_review'.
// rejected is a legal source (D4): a rejected letter is revisable (docx letters are
// editable again) and this transition is its only outlet back into review. The
// caller (the httpapi layer) starts the core workflow over the letter; this just
// guards the transition and flips the letter status.
func (s *Service) SubmitForApproval(ctx context.Context, letterID string) error {
    l, err := s.repo.GetLetter(ctx, letterID)
    if err != nil {
        return err
    }
    if l.Status != domain.StatusDraft && l.Status != domain.StatusRejected {
        return &kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.submit.not_draft", Message: "only a draft or rejected letter can be submitted for approval"}
    }
    return s.repo.SetLetterStatus(ctx, letterID, domain.StatusInReview, s.clock.Now())
}
  • [x] In go/internal/letterhead/app/service.go, add imports (bytes, io, "github.com/Virtue-Digital-Indonesia/obscura/internal/platform/docx") and the three docx methods at the end:
// EnsureTemplateDocx returns the letterhead, LAZY-SEEDING the embedded starter docx
// (blob put + row update) when none exists yet — so "Edit in OnlyOffice" always has
// something to open. Reloads after seeding so DocxRev/HasDocx are fresh.
func (s *Service) EnsureTemplateDocx(ctx context.Context, id string) (domain.Template, error) {
    t, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return domain.Template{}, err
    }
    if t.DocxHash != "" {
        return t, nil
    }
    hash, _, err := s.blobs.Put(ctx, bytes.NewReader(docx.LetterheadStarter()), kernel.PutOpts{ContentType: docx.MIME})
    if err != nil {
        return domain.Template{}, err
    }
    if err := s.repo.UpdateDocx(ctx, id, string(hash), s.clock.Now()); err != nil {
        return domain.Template{}, err
    }
    return s.repo.GetByID(ctx, id)
}

// OpenTemplateDocx streams the letterhead's docx template. Conflict when no
// template exists (callers that need auto-seeding go through EnsureTemplateDocx).
func (s *Service) OpenTemplateDocx(ctx context.Context, id string) (io.ReadCloser, domain.Template, error) {
    t, err := s.repo.GetByID(ctx, id)
    if err != nil {
        return nil, domain.Template{}, err
    }
    if t.DocxHash == "" {
        return nil, domain.Template{}, &kernel.Error{Kind: kernel.ErrConflict, Code: "letterhead.no_docx", Message: "this letterhead has no docx template yet"}
    }
    rc, err := s.blobs.Get(ctx, kernel.ContentHash(t.DocxHash))
    if err != nil {
        return nil, domain.Template{}, err
    }
    return rc, t, nil
}

// SaveTemplateDocx replaces the docx template (editor save-back or upload):
// validate → content-addressed put → rev+1. Validation is here, not the transport,
// so EVERY write path enforces it.
func (s *Service) SaveTemplateDocx(ctx context.Context, id string, docxBytes []byte) error {
    if _, err := s.repo.GetByID(ctx, id); err != nil {
        return err // NotFound if absent
    }
    if err := docx.Validate(docxBytes); err != nil {
        return &kernel.Error{Kind: kernel.ErrValidation, Code: "letterhead.docx_invalid", Message: "the file is not a valid docx (zip with [Content_Types].xml and word/document.xml)", Err: err}
    }
    hash, _, err := s.blobs.Put(ctx, bytes.NewReader(docxBytes), kernel.PutOpts{ContentType: docx.MIME})
    if err != nil {
        return err
    }
    return s.repo.UpdateDocx(ctx, id, string(hash), s.clock.Now())
}
  • [x] In go/internal/httpapi/handlers_correspondence.go, replace the CreateLetter handler (the letterhead is resolved HERE — the correspondence service stays free of a letterhead dependency, mirroring AssignLetterNumber):
// CreateLetter drafts an e-office letter (status 'draft', no number yet). With
// authoring=docx (requires the office module) the server seeds the editable docx:
// from the chosen letterhead's docx template (zero-copy — content-addressed hash
// reuse) or from the embedded blank starter. A letterhead without a docx template
// is a 422 (the frontend filters on has_docx, so this is a backstop).
func (s *Server) CreateLetter(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    var body struct {
        Type           string `json:"type"`
        Classification string `json:"classification"`
        Subject        string `json:"subject"`
        BodyHTML       string `json:"body_html"`
        Authoring      string `json:"authoring"`
        LetterheadID   string `json:"letterhead_id"`
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "request.invalid_json", Message: "invalid request body"})
        return
    }
    params := correspondenceapp.CreateLetterParams{
        Type:           body.Type,
        Classification: body.Classification,
        Subject:        body.Subject,
        BodyHTML:       body.BodyHTML,
        Authoring:      body.Authoring,
        LetterheadID:   body.LetterheadID,
    }
    if body.Authoring == corrdomain.AuthoringDocx {
        if !s.officeEditEnabled() {
            writeProblem(w, &kernel.Error{Kind: kernel.ErrProtectionUnavailable, Code: "office.disabled", Message: "docx letters need the office editor, which is not enabled in this deployment"})
            return
        }
        if body.LetterheadID != "" {
            lh, lerr := s.letterhead.GetLetterhead(r.Context(), body.LetterheadID)
            if lerr != nil {
                writeProblem(w, lerr)
                return
            }
            if lh.DocxHash == "" {
                writeProblemStatus(w, http.StatusUnprocessableEntity, "the chosen letterhead has no Word (docx) template yet", "correspondence.letterhead_no_docx")
                return
            }
            params.SeedDocxHash = lh.DocxHash
        }
    }
    id, err := s.correspondence.CreateLetter(r.Context(), p, params)
    if err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
  • [x] In the same file, update SubmitLetter so a failed workflow start reverts to the letter's PRIOR status (draft or rejected), not blindly to draft — replace the transition block:
    // Remember the pre-submit status for the failure revert (submit is now legal
    // from BOTH draft and rejected, so "revert to draft" would lose the rejection).
    prior, err := s.correspondence.GetLetter(r.Context(), letterID)
    if err != nil {
        writeProblem(w, err)
        return
    }
    // Transition FIRST (SubmitForApproval atomically guards the legal source states
    // and flips to in_review), so a non-submittable letter can't be double-submitted
    // and no stray workflow is started. Then start the approval workflow; if that
    // fails, revert to the prior status so the letter can be retried (no orphan
    // workflow, no stuck letter).
    if err := s.correspondence.SubmitForApproval(r.Context(), letterID); err != nil {
        writeProblem(w, err)
        return
    }
    wfID, err := s.workflow.Start(r.Context(), p, "letter", letterID, body.ApproverPositionIDs)
    if err != nil {
        revert := prior.Status
        if revert != corrdomain.StatusDraft && revert != corrdomain.StatusRejected {
            revert = corrdomain.StatusDraft
        }
        _ = s.correspondence.SetLetterStatus(r.Context(), letterID, revert) // best-effort revert
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusCreated, map[string]any{"workflow_id": wfID})
  • [x] Update go/internal/correspondence/adapters/pg_test.go for the new CreateLetter signature. This is COMPILE-ONLY catch-up: go vet ./... type-checks _test.go files, but NEVER run go test (live-DB DSN — Global Constraints). There are 12 call sites of the shape X.CreateLetter(ctx, p, TYPE, CLASSIFICATION, SUBJECT, BODY) (receivers svc, okSvc, failSvc); rewrite each mechanically to:
    X.CreateLetter(ctx, p, app.CreateLetterParams{Type: TYPE, Classification: CLASSIFICATION, Subject: SUBJECT, BodyHTML: BODY})

e.g. line ~165 becomes id, err := svc.CreateLetter(ctx, p, app.CreateLetterParams{Type: domain.TypeSurat, Subject: "Subject A", BodyHTML: "<p>hello</p>"}) (zero-value fields may be omitted). The file already imports the app package. No other test file references the changed signatures (verified: no test calls SubmitForApproval, letterheadapp.NewService, or implements the correspondence/letterhead Repository interfaces).

  • [x] Verify: cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
  • [x] Commit:
cd /home/efran/remote-development/obscura && git add go/internal/correspondence/app/service.go go/internal/letterhead/app/service.go go/internal/httpapi/handlers_correspondence.go go/internal/correspondence/adapters/pg_test.go && git commit -m "feat(letters): docx create/save/number service flows + submit-from-rejected" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 4: Office layer generalization — sub-scoped tokens, content/callback branches, office-config endpoints, ConvertService extraction, DocxPDFRenderer wiring — ADVERSARIAL-REVIEW REQUIRED (security-sensitive: token scoping + save-time re-checks)

Files:
- Modify go/internal/httpapi/handlers_office.go
- Create go/internal/httpapi/handlers_office_subjects.go
- Modify go/internal/httpapi/handlers_publish.go
- Modify go/internal/httpapi/server.go (two routes)
- Modify go/cmd/obscura-server/wire.go (one line after NewServer)

Interfaces:
- Consumes (Task 3): s.correspondence.OpenDraftDocx/OpenFinalDocx/SaveDraftDocx(ctx, letterID, uid string, docxBytes []byte), s.letterhead.EnsureTemplateDocx/OpenTemplateDocx/SaveTemplateDocx(ctx, id string, docxBytes []byte), corrSvc.SetDocxRenderer(r); (Task 1) corrdomain.AuthoringDocx, letter.DraftDocxRev, t.DocxRev; existing mayReadLetter, isCorrespondenceAdmin, forbidLetterRead, sanitizeKey, signJWT, verifyJWTStatic, s.directory.PositionsForUser(ctx, uid, at), docx.MIME.
- Produces:
- Token model (Task 5's renderer and the FE rely on these EXACT claim names): claims {purpose, sub, doc, version, uid, variant?, exp}; subdocument|letter|letterhead, absent ⇒ document (backward compatible); for letters/letterheads version carries the docx REV (may be 0); variant:"final" marks the read-only frozen-letter fetch.
- func (s *Server) officeSubToken(purpose, sub, id string, version int, uid, variant string) string
- func (s *Server) parseOfficeClaims(purpose, token string) (officeClaims, error) with type officeClaims struct { Sub, ID string; Version int; UID, Variant string }
- GET /api/v1/letters/{letterID}/office-config (perm correspondence.read + mayReadLetter + officeEditEnabled, error code office.disabled when off) and GET /api/v1/letterheads/{id}/office-config (perm letterhead.manage + officeEditEnabled, lazy-seeds) — both return {api_js, config, editable} exactly like the document endpoint.
- func (s *Server) onlyofficeConvert(ctx context.Context, key, ext, title, srcURL string) ([]byte, error)
- func (s *Server) RenderLetterDocx(ctx context.Context, letterID string, docxBytes []byte) ([]byte, error) (implements the correspondence DocxPDFRenderer port) + corrSvc.SetDocxRenderer(api) in wire.go.
- Editor cache keys letter_{id}_{rev} / letterhead_{id}_{rev} (sanitized).

Steps:

  • [x] In go/internal/httpapi/handlers_office.go, replace the officeToken/parseOfficeToken pair with the generalized model (keep signJWT/verifyJWTStatic untouched):
// Office token subjects: the office layer serves DOCUMENTS (the original),
// LETTERS (correspondence docx drafts + the frozen merged copy at numbering) and
// LETTERHEAD templates. The sub claim scopes a token to exactly one subject kind —
// a letter content token can never fetch a document, and vice versa. Tokens minted
// before the claim existed carry no sub and parse as document (backward compatible
// with in-flight editing sessions across a deploy).
const (
    officeSubDocument   = "document"
    officeSubLetter     = "letter"
    officeSubLetterhead = "letterhead"
)

// officeClaims are the parsed claims of one of our office tokens.
type officeClaims struct {
    Sub     string // document | letter | letterhead
    ID      string // document / letter / letterhead id (the "doc" claim)
    Version int    // document version, or the letter/letterhead docx rev (revs may be 0)
    UID     string
    Variant string // "" | "final" — letters only: fetch the frozen merged docx
}

// officeSubToken mints our HS256 token authorizing the doc-server to fetch
// (purpose=content) or save to (purpose=callback) exactly one office subject, as
// one user, until exp. variant=final is only ever minted with purpose=content —
// the frozen copy is read-only by construction (no callback token names it).
func (s *Server) officeSubToken(purpose, sub, id string, version int, uid, variant string) string {
    claims := map[string]any{
        "purpose": purpose,
        "sub":     sub,
        "doc":     id,
        "version": version,
        "uid":     uid,
        "exp":     time.Now().Add(12 * time.Hour).Unix(),
    }
    if variant != "" {
        claims["variant"] = variant
    }
    return signJWT(claims, s.cfg.OnlyofficeJWTSecret)
}

// officeToken keeps the original document-scoped shape for the existing document
// call sites (OfficeConfig, convertViaOnlyOffice).
func (s *Server) officeToken(purpose, docID string, version int, uid string) string {
    return s.officeSubToken(purpose, officeSubDocument, docID, version, uid, "")
}

// parseOfficeClaims verifies an office token of the given purpose and returns its
// claims. A token without a sub claim is a document token (minted before subjects
// existed). Documents require version >= 1; letter/letterhead revs start at 0.
func (s *Server) parseOfficeClaims(purpose, token string) (officeClaims, error) {
    raw, err := verifyJWTStatic(token, s.cfg.OnlyofficeJWTSecret, time.Now())
    if err != nil {
        return officeClaims{}, err
    }
    if p, _ := raw["purpose"].(string); p != purpose {
        return officeClaims{}, fmt.Errorf("wrong token purpose")
    }
    c := officeClaims{Sub: officeSubDocument}
    if sub, _ := raw["sub"].(string); sub != "" {
        c.Sub = sub
    }
    switch c.Sub {
    case officeSubDocument, officeSubLetter, officeSubLetterhead:
    default:
        return officeClaims{}, fmt.Errorf("unknown token subject")
    }
    c.ID, _ = raw["doc"].(string)
    c.UID, _ = raw["uid"].(string)
    c.Variant, _ = raw["variant"].(string)
    if v, ok := raw["version"].(float64); ok {
        c.Version = int(v)
    }
    if c.ID == "" {
        return officeClaims{}, fmt.Errorf("token missing subject id")
    }
    if c.Sub == officeSubDocument && c.Version < 1 {
        return officeClaims{}, fmt.Errorf("token missing doc/version")
    }
    return c, nil
}
  • [x] In the same file, replace OfficeContent (letter/letterhead branches delegate to the new subjects file; document path byte-identical):
// OfficeContent streams an office subject's raw bytes to the doc-server. The office
// token in the path IS the authorization (unauthenticated caller). Public route.
func (s *Server) OfficeContent(w http.ResponseWriter, r *http.Request) {
    if !s.officeEditEnabled() {
        http.NotFound(w, r)
        return
    }
    claims, err := s.parseOfficeClaims("content", chi.URLParam(r, "token"))
    if err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrPermissionDenied, Code: "office.bad_token", Message: "invalid or expired token"})
        return
    }
    switch claims.Sub {
    case officeSubLetter:
        s.serveLetterDocx(w, r, claims)
        return
    case officeSubLetterhead:
        s.serveLetterheadDocx(w, r, claims)
        return
    }
    v, err := s.dms.GetVersion(r.Context(), claims.ID, claims.Version)
    if err != nil {
        writeProblem(w, err)
        return
    }
    rc, err := s.dms.OpenVersionContent(r.Context(), claims.ID, claims.Version)
    if err != nil {
        writeProblem(w, err)
        return
    }
    defer rc.Close()
    mime := v.MIME
    if mime == "" {
        mime = "application/octet-stream"
    }
    w.Header().Set("Content-Type", mime)
    w.Header().Set("Content-Disposition", "attachment")
    w.WriteHeader(http.StatusOK)
    _, _ = io.Copy(w, rc)
}
  • [x] Replace OfficeCallback — same doc-server-JWT authentication for every subject, then a per-subject save with save-time re-checks:
// OfficeCallback receives the doc-server's edit-status POST. On status 2 (MustSave —
// the last editor closed) it downloads the edited file and lands it on the token's
// subject: a new document VERSION, a letter's draft docx (rev+1), or a letterhead
// template (rev+1). A force-save (6) is deliberately ignored. Our office token
// (path) authorizes WHICH subject; the doc-server's JWT (body/header) proves the
// callback is genuine; and every subject's save path RE-CHECKS editability at save
// time (F11 parity) — a letter numbered (or a document locked) while an editor was
// open rejects the late save. Always answers {"error":0} on success, per the
// OnlyOffice contract. Public route.
func (s *Server) OfficeCallback(w http.ResponseWriter, r *http.Request) {
    if !s.officeEditEnabled() {
        http.NotFound(w, r)
        return
    }
    claims, err := s.parseOfficeClaims("callback", chi.URLParam(r, "token"))
    if err != nil {
        writeJSON(w, http.StatusOK, map[string]any{"error": 1})
        return
    }
    var body struct {
        Status   int    `json:"status"`
        URL      string `json:"url"`
        Key      string `json:"key"`
        Token    string `json:"token"`
        Filetype string `json:"filetype"`
    }
    raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
    if jerr := json.Unmarshal(raw, &body); jerr != nil {
        writeJSON(w, http.StatusOK, map[string]any{"error": 1})
        return
    }
    // Authenticate the callback itself: the doc-server signs it with the shared secret
    // (body.token, or the Authorization header). A callback we can't verify is rejected.
    auth := body.Token
    if auth == "" {
        auth = r.Header.Get("Authorization")
    }
    if _, verr := verifyJWTStatic(auth, s.cfg.OnlyofficeJWTSecret, time.Now()); verr != nil {
        s.logger.Warn("office callback: JWT verify failed", "sub", claims.Sub, "id", claims.ID, "err", verr)
        writeJSON(w, http.StatusOK, map[string]any{"error": 1})
        return
    }

    // Only status 2 (MustSave — all editors closed) lands a save; see doc above.
    if body.Status == 2 && body.URL != "" {
        switch claims.Sub {
        case officeSubLetter:
            if err := s.saveLetterOfficeEdit(r.Context(), claims.ID, claims.UID, body.URL); err != nil {
                s.logger.Warn("office callback: letter save rejected", "letter", claims.ID, "uid", claims.UID, "err", err)
                writeJSON(w, http.StatusOK, map[string]any{"error": 1})
                return
            }
            s.logger.Info("office edit saved letter draft docx", "letter", claims.ID)
        case officeSubLetterhead:
            if err := s.saveLetterheadOfficeEdit(r.Context(), claims.ID, claims.UID, body.URL); err != nil {
                s.logger.Warn("office callback: letterhead save rejected", "letterhead", claims.ID, "uid", claims.UID, "err", err)
                writeJSON(w, http.StatusOK, map[string]any{"error": 1})
                return
            }
            s.logger.Info("office edit saved letterhead docx template", "letterhead", claims.ID)
        default:
            // A document locked as "preserve original" (possibly locked AFTER a session
            // opened) rejects the save outright — its source bytes must never change
            // through the editor.
            if doc, derr := s.dms.GetDocument(r.Context(), claims.ID); derr == nil && doc.EditingLocked {
                s.logger.Warn("office callback: save rejected — document is editing-locked", "doc", claims.ID)
                writeJSON(w, http.StatusOK, map[string]any{"error": 1})
                return
            }
            if err := s.saveOfficeEdit(r.Context(), claims.ID, claims.UID, body.URL, claims.Version, body.Filetype); err != nil {
                s.logger.Error("office callback: save failed", "doc", claims.ID, "err", err)
                writeJSON(w, http.StatusOK, map[string]any{"error": 1})
                return
            }
            s.logger.Info("office edit saved as new version", "doc", claims.ID, "prev_version", claims.Version)
        }
    }
    writeJSON(w, http.StatusOK, map[string]any{"error": 0})
}
  • [x] Still in handlers_office.go, extract the doc-server file fetch out of saveOfficeEdit (the letter/letterhead saves reuse it) — replace saveOfficeEdit with:
// fetchOfficeFile downloads a doc-server-reported file (edit save output or convert
// result), rewriting the host to the backend-reachable internal base — the URL the
// doc-server reports may name an address only IT can resolve.
func (s *Server) fetchOfficeFile(ctx context.Context, fileURL string, limit int64) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.rewriteToInternalOffice(fileURL), nil)
    if err != nil {
        return nil, err
    }
    resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    if resp.StatusCode/100 != 2 {
        return nil, fmt.Errorf("fetch office file: HTTP %d", resp.StatusCode)
    }
    return io.ReadAll(io.LimitReader(resp.Body, limit))
}

// saveOfficeEdit downloads the edited file and stores it as a new DOCUMENT version
// authored by the editing user.
func (s *Server) saveOfficeEdit(ctx context.Context, docID, uid, fileURL string, sessionVersion int, filetype string) error {
    data, err := s.fetchOfficeFile(ctx, fileURL, 200<<20)
    if err != nil {
        return err
    }
    // MIME follows the ACTUAL saved bytes: the doc-server names the output format in the
    // callback ("filetype") — editing a legacy .xls/.ppt/.odt usually saves back as OOXML.
    // Fall back to the format of the version the session OPENED (the office source named
    // in the callback token) — never the current version's MIME: after a publish the
    // current version is a PDF, and stamping docx bytes as application/pdf would corrupt
    // the representation chain.
    mime := officeOutMIME[strings.ToLower(strings.TrimSpace(filetype))]
    if mime == "" {
        src, _ := s.dms.GetVersion(ctx, docID, sessionVersion)
        mime = src.MIME
    }
    if mime == "" {
        mime = "application/octet-stream"
    }
    // Author the version as the editing user (the token was minted for a session that
    // held write access). AddVersion records authorship; the write authorization already
    // happened when the editor was opened.
    p := kernel.Principal{UserID: kernel.UserID(uid)}
    _, err = s.dms.AddVersion(ctx, p, docID, bytes.NewReader(data), mime)
    return err
}
  • [x] Create go/internal/httpapi/handlers_office_subjects.go — complete file:
package httpapi

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "strings"
    "time"

    "github.com/go-chi/chi/v5"

    corrdomain "github.com/Virtue-Digital-Indonesia/obscura/internal/correspondence/domain"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/kernel"
    "github.com/Virtue-Digital-Indonesia/obscura/internal/platform/docx"
    rbacdomain "github.com/Virtue-Digital-Indonesia/obscura/internal/rbac/domain"
)

// Office subjects beyond documents: LETTERS (correspondence docx drafts) and
// LETTERHEAD templates. This file holds their editor-config endpoints, the
// content/callback branch targets, and the letter-numbering docx→PDF renderer.
//
// Trust model additions over handlers_office.go: the letter config endpoint is
// gated by mayReadLetter (confidential letters stay restricted) and mints a
// callback token ONLY for an editable session (letter in draft/rejected AND caller
// is creator/correspondence.admin); the letterhead endpoints sit behind
// letterhead.manage. Both save paths RE-CHECK those conditions at save time — the
// F11 lesson: a validly-signed callback token alone must never authorize a write.

// errOfficeDisabled is the spec-mandated error for letter/letterhead office
// endpoints when the office module/secret is off.
func errOfficeDisabled() *kernel.Error {
    return &kernel.Error{Kind: kernel.ErrProtectionUnavailable, Code: "office.disabled", Message: "the office editor is not enabled in this deployment"}
}

// letterEditableStatus reports whether a docx letter's content may still change
// (spec D4): draft or rejected only — in_review/approved must show approvers
// exactly what they approve, and numbered is frozen forever.
func letterEditableStatus(status string) bool {
    return status == corrdomain.StatusDraft || status == corrdomain.StatusRejected
}

// principalWithPositions rebuilds a minimal principal for an office-callback uid.
// Callbacks arrive with no session, so position-bound role grants (the common way
// correspondence.admin / letterhead.manage are held) need the uid's positions
// resolved the same way the Authenticator middleware does. A lookup failure means
// "no positions" — the check then falls back to user-bound grants only (narrower,
// never wider).
func (s *Server) principalWithPositions(ctx context.Context, uid string) kernel.Principal {
    p := kernel.Principal{UserID: kernel.UserID(uid)}
    if pos, err := s.directory.PositionsForUser(ctx, uid, time.Now()); err == nil {
        p.Positions = pos
    }
    return p
}

// mayEditLetter reports whether p may CHANGE this letter's docx content: its
// creator or a correspondence.admin (registry/TU supervisor). Deliberately narrower
// than mayReadLetter (a disposition recipient may read, never edit).
func (s *Server) mayEditLetter(ctx context.Context, p kernel.Principal, letter corrdomain.Letter) bool {
    if letter.CreatedBy != "" && letter.CreatedBy == string(p.UserID) {
        return true
    }
    return s.isCorrespondenceAdmin(ctx, p)
}

// canManageLetterhead mirrors requirePerm("letterhead.manage") for the save-time
// re-check (requirePerm authorizes against the same Can() with a document-typed
// resource; global grants ignore the resource anyway).
func (s *Server) canManageLetterhead(ctx context.Context, p kernel.Principal) bool {
    dec, err := s.authz.Can(ctx, p, rbacdomain.Action("letterhead.manage"), rbacdomain.Resource{Type: rbacdomain.ResourceType("document")})
    return err == nil && dec.Allowed
}

// officeEditorConfig assembles the {api_js, config, editable} payload for a
// letter/letterhead editor session — the same shape OfficeConfig returns for
// documents, so the SPA mounts all three identically. The callback URL (and its
// token) exists ONLY on editable sessions.
func (s *Server) officeEditorConfig(sub, id string, rev int, uid, userName, title, key string, editable bool) map[string]any {
    base := strings.TrimRight(s.cfg.OnlyofficeObscuraURL, "/") + "/api/v1"
    editorConfig := map[string]any{
        "mode": map[bool]string{true: "edit", false: "view"}[editable],
        "lang": "en",
        "user": map[string]any{"id": uid, "name": userName},
        "customization": map[string]any{
            "autosave": true,
            // forcesave OFF, same rationale as documents: one editing session = one
            // save, landed when the last participant closes.
            "forcesave": false,
        },
    }
    if editable {
        editorConfig["callbackUrl"] = base + "/office/callback/" + s.officeSubToken("callback", sub, id, rev, uid, "")
    }
    config := map[string]any{
        "document": map[string]any{
            "fileType": "docx",
            "key":      key,
            "title":    title,
            "url":      base + "/office/content/" + s.officeSubToken("content", sub, id, rev, uid, ""),
            "permissions": map[string]any{
                "edit":     editable,
                "download": true,
                "print":    true,
            },
        },
        "documentType": "word",
        "editorConfig": editorConfig,
        "exp":          time.Now().Add(12 * time.Hour).Unix(),
    }
    config["token"] = signJWT(config, s.cfg.OnlyofficeJWTSecret)
    return map[string]any{
        "api_js":   strings.TrimRight(s.cfg.AppBaseURL, "/") + "/web-apps/apps/api/documents/api.js",
        "config":   config,
        "editable": editable,
    }
}

// LetterOfficeConfig returns the OnlyOffice editor config for a docx letter.
// correspondence.read is on the route; mayReadLetter gates confidential letters;
// editability per D4 (draft/rejected AND creator/correspondence.admin). View mode
// doubles as the approver/post-approval previewer.
func (s *Server) LetterOfficeConfig(w http.ResponseWriter, r *http.Request) {
    if !s.officeEditEnabled() {
        writeProblem(w, errOfficeDisabled())
        return
    }
    ctx := r.Context()
    p, _ := PrincipalFrom(ctx)
    letter, err := s.correspondence.GetLetter(ctx, chi.URLParam(r, "letterID"))
    if err != nil {
        writeProblem(w, err)
        return
    }
    if ok, err := s.mayReadLetter(ctx, p, letter); err != nil {
        writeProblem(w, err)
        return
    } else if !ok {
        writeProblem(w, forbidLetterRead())
        return
    }
    if letter.Authoring != corrdomain.AuthoringDocx || letter.DraftDocxHash == "" {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "correspondence.letter.not_docx", Message: "this letter is not docx-authored"})
        return
    }
    editable := letterEditableStatus(letter.Status) && s.mayEditLetter(ctx, p, letter)
    title := letter.Subject
    if !strings.HasSuffix(strings.ToLower(title), ".docx") {
        title += ".docx"
    }
    // key: MUST change when content changes or the doc-server serves a stale cached
    // copy — draft_docx_rev bumps on every save, giving that for free.
    key := sanitizeKey(fmt.Sprintf("letter_%s_%d", letter.ID, letter.DraftDocxRev))
    writeJSON(w, http.StatusOK, s.officeEditorConfig(officeSubLetter, letter.ID, letter.DraftDocxRev, string(p.UserID), p.Subject, title, key, editable))
}

// LetterheadOfficeConfig returns the OnlyOffice editor config for a letterhead
// template, LAZY-SEEDING the embedded starter when no docx exists yet so "Edit in
// OnlyOffice" always works. letterhead.manage is on the route; the session is
// always editable (that permission IS the edit grant).
func (s *Server) LetterheadOfficeConfig(w http.ResponseWriter, r *http.Request) {
    if !s.officeEditEnabled() {
        writeProblem(w, errOfficeDisabled())
        return
    }
    ctx := r.Context()
    p, _ := PrincipalFrom(ctx)
    t, err := s.letterhead.EnsureTemplateDocx(ctx, chi.URLParam(r, "id"))
    if err != nil {
        writeProblem(w, err)
        return
    }
    title := t.Name
    if !strings.HasSuffix(strings.ToLower(title), ".docx") {
        title += ".docx"
    }
    key := sanitizeKey(fmt.Sprintf("letterhead_%s_%d", t.ID, t.DocxRev))
    writeJSON(w, http.StatusOK, s.officeEditorConfig(officeSubLetterhead, t.ID, t.DocxRev, string(p.UserID), p.Subject, title, key, true))
}

// serveLetterDocx streams a letter docx to the doc-server (content token already
// verified). variant=final serves the frozen merged copy (numbering conversion);
// otherwise the current editable draft.
func (s *Server) serveLetterDocx(w http.ResponseWriter, r *http.Request, claims officeClaims) {
    var (
        rc  io.ReadCloser
        err error
    )
    if claims.Variant == "final" {
        rc, _, err = s.correspondence.OpenFinalDocx(r.Context(), claims.ID)
    } else {
        rc, _, err = s.correspondence.OpenDraftDocx(r.Context(), claims.ID)
    }
    if err != nil {
        writeProblem(w, err)
        return
    }
    defer rc.Close()
    w.Header().Set("Content-Type", docx.MIME)
    w.Header().Set("Content-Disposition", "attachment")
    w.WriteHeader(http.StatusOK)
    _, _ = io.Copy(w, rc)
}

// serveLetterheadDocx streams a letterhead docx template to the doc-server.
func (s *Server) serveLetterheadDocx(w http.ResponseWriter, r *http.Request, claims officeClaims) {
    rc, _, err := s.letterhead.OpenTemplateDocx(r.Context(), claims.ID)
    if err != nil {
        writeProblem(w, err)
        return
    }
    defer rc.Close()
    w.Header().Set("Content-Type", docx.MIME)
    w.Header().Set("Content-Disposition", "attachment")
    w.WriteHeader(http.StatusOK)
    _, _ = io.Copy(w, rc)
}

// saveLetterOfficeEdit lands a letter editor save with the FULL save-time re-check
// (F11 parity): the letter must STILL be draft/rejected and the saver STILL its
// creator or a correspondence.admin. The service's SQL status guard backs this
// re-check transactionally — a numbering that races the fetch below still rejects.
func (s *Server) saveLetterOfficeEdit(ctx context.Context, letterID, uid, fileURL string) error {
    letter, err := s.correspondence.GetLetter(ctx, letterID)
    if err != nil {
        return err
    }
    if !letterEditableStatus(letter.Status) {
        return fmt.Errorf("letter is no longer editable (status %s)", letter.Status)
    }
    if uid == "" || !s.mayEditLetter(ctx, s.principalWithPositions(ctx, uid), letter) {
        return fmt.Errorf("saver may not edit this letter")
    }
    data, err := s.fetchOfficeFile(ctx, fileURL, 200<<20)
    if err != nil {
        return err
    }
    return s.correspondence.SaveDraftDocx(ctx, letterID, uid, data)
}

// saveLetterheadOfficeEdit lands a letterhead editor save; the saver must STILL
// hold letterhead.manage at save time.
func (s *Server) saveLetterheadOfficeEdit(ctx context.Context, id, uid, fileURL string) error {
    if uid == "" || !s.canManageLetterhead(ctx, s.principalWithPositions(ctx, uid)) {
        return fmt.Errorf("saver lacks letterhead.manage")
    }
    data, err := s.fetchOfficeFile(ctx, fileURL, 200<<20)
    if err != nil {
        return err
    }
    return s.letterhead.SaveTemplateDocx(ctx, id, data)
}

// RenderLetterDocx implements the correspondence DocxPDFRenderer port (late-bound
// in wire.go, mirroring esign's sealTimeMarker). Primary: the OnlyOffice
// ConvertService — the SAME layout engine the author edited in, fetching the frozen
// merged docx through a sub=letter, variant=final content token (final_docx_hash is
// persisted before this is called). Fallback (and the module-off path): Gotenberg's
// LibreOffice route over the bytes we already hold — docx letters stay numberable
// even if the office license lapses.
func (s *Server) RenderLetterDocx(ctx context.Context, letterID string, docxBytes []byte) ([]byte, error) {
    if s.officeEditEnabled() {
        base := strings.TrimRight(s.cfg.OnlyofficeObscuraURL, "/") + "/api/v1"
        // Per-attempt unique key: the conversion cache must never return a stale
        // artifact for a re-run after a voided attempt.
        key := sanitizeKey(fmt.Sprintf("letter_%s_final_%d", letterID, time.Now().UnixNano()))
        srcURL := base + "/office/content/" + s.officeSubToken("content", officeSubLetter, letterID, 0, "numbering", "final")
        pdf, err := s.onlyofficeConvert(ctx, key, "docx", "letter.docx", srcURL)
        if err == nil {
            return pdf, nil
        }
        s.logger.Warn("letter numbering: onlyoffice convert failed; falling back to gotenberg", "letter", letterID, "err", err)
    }
    if s.office == nil {
        return nil, fmt.Errorf("no PDF converter configured")
    }
    return s.office.ToPDF(ctx, "letter.docx", docxBytes)
}
  • [x] In go/internal/httpapi/handlers_publish.go, split convertViaOnlyOffice into the URL-based core + the document wrapper:
// convertViaOnlyOffice renders an office DOCUMENT version to PDF through the
// doc-server's conversion service (used by publish). The core lives in
// onlyofficeConvert so the letter-numbering path can reuse it with its own
// token/key.
func (s *Server) convertViaOnlyOffice(ctx context.Context, docID string, version int, mime, title string) ([]byte, error) {
    _, ext := officeDocType(mime, title)
    if ext == "" {
        return nil, fmt.Errorf("not an office format")
    }
    base := strings.TrimRight(s.cfg.OnlyofficeObscuraURL, "/") + "/api/v1"
    // The conversion cache is keyed on this; make it unique per publish so a
    // re-publish of the same version (e.g. after a failed run) can't return a
    // stale cached artifact.
    key := sanitizeKey(fmt.Sprintf("%s_%d_pub%d", docID, version, time.Now().UnixNano()))
    srcURL := base + "/office/content/" + s.officeToken("content", docID, version, "publish")
    return s.onlyofficeConvert(ctx, key, ext, title+"."+ext, srcURL)
}

// onlyofficeConvert drives the doc-server's ConvertService for ONE source URL: the
// doc-server fetches the source through our token-authorized content URL, we fetch
// the converted PDF back over the compose network. Both the request and our fetch
// are JWT-authenticated with the shared secret. key must be unique per attempt
// (conversion cache); title only needs the right extension.
func (s *Server) onlyofficeConvert(ctx context.Context, key, ext, title, srcURL string) ([]byte, error) {
    payload := map[string]any{
        "async":      false,
        "filetype":   ext,
        "outputtype": "pdf",
        "key":        key,
        "title":      title,
        "url":        srcURL,
    }
    // Sign the request both ways the doc-server may be configured to check: a "token"
    // claim set in the body, and an Authorization header wrapping the body in "payload".
    payload["token"] = signJWT(payload, s.cfg.OnlyofficeJWTSecret)
    reqBody, _ := json.Marshal(payload)
    convURL := strings.TrimRight(s.cfg.OnlyofficeInternalURL, "/") + "/ConvertService.ashx"
    req, err := http.NewRequestWithContext(ctx, http.MethodPost, convURL, bytes.NewReader(reqBody))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Accept", "application/json")
    req.Header.Set("Authorization", "Bearer "+signJWT(map[string]any{"payload": payload}, s.cfg.OnlyofficeJWTSecret))

    client := &http.Client{Timeout: 120 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    out, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
    if resp.StatusCode/100 != 2 {
        return nil, fmt.Errorf("convert service HTTP %d: %.200s", resp.StatusCode, out)
    }
    var conv struct {
        FileURL    string `json:"fileUrl"`
        EndConvert bool   `json:"endConvert"`
        Error      int    `json:"error"`
    }
    if err := json.Unmarshal(out, &conv); err != nil {
        return nil, fmt.Errorf("convert service: unparseable response %.200s", out)
    }
    if conv.Error != 0 || !conv.EndConvert || conv.FileURL == "" {
        return nil, fmt.Errorf("convert service error=%d endConvert=%v", conv.Error, conv.EndConvert)
    }
    fetch, err := http.NewRequestWithContext(ctx, http.MethodGet, s.rewriteToInternalOffice(conv.FileURL), nil)
    if err != nil {
        return nil, err
    }
    fresp, err := client.Do(fetch)
    if err != nil {
        return nil, err
    }
    defer fresp.Body.Close()
    if fresp.StatusCode/100 != 2 {
        return nil, fmt.Errorf("fetch converted file: HTTP %d", fresp.StatusCode)
    }
    pdf, err := io.ReadAll(io.LimitReader(fresp.Body, 200<<20))
    if err != nil {
        return nil, err
    }
    if len(pdf) < 5 || string(pdf[:5]) != "%PDF-" {
        return nil, fmt.Errorf("converted file is not a PDF")
    }
    return pdf, nil
}
  • [x] In go/internal/httpapi/server.go: inside the letters group (after the /letters/{letterID}/effective-policy route, ~line 909) add:
                // OnlyOffice editor config for a docx letter (view mode doubles as the
                // approver previewer). Confidential letters stay behind mayReadLetter
                // inside the handler.
                r.With(s.requirePerm("correspondence.read")).Get("/letters/{letterID}/office-config", s.LetterOfficeConfig)

and in the core letterheads block (after the Delete /letterheads/{id} route, ~line 960):

            // OnlyOffice-edited letterhead docx template (lazy-seeded starter).
            r.With(s.requirePerm("letterhead.manage")).Get("/letterheads/{id}/office-config", s.LetterheadOfficeConfig)
  • [x] In go/cmd/obscura-server/wire.go, immediately AFTER the api := httpapi.NewServer(httpapi.Deps{ ... }) statement closes, add:
    // Late-bound docx→PDF renderer for letter numbering (mirrors esignSvc.SetSealMarker
    // above): the HTTP server implements the port — OnlyOffice ConvertService when the
    // office module is live, Gotenberg LibreOffice otherwise — and the correspondence
    // service consumes it without importing the transport.
    corrSvc.SetDocxRenderer(api)
  • [x] Verify: cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./...
  • [x] ADVERSARIAL REVIEW (do not skip): dispatch a reviewer over this task's diff with the brief: "Attack the office token scoping and save paths. Specifically verify: (1) a letter/letterhead content token cannot fetch a document and vice versa (sub branch is exhaustive; default case is document and demands version>=1); (2) variant=final tokens are content-purpose only and no code path mints a letter callback token with a variant; (3) view-mode letter sessions get NO callbackUrl; (4) both save paths re-check at save time (letter: status + creator/admin; letterhead: letterhead.manage) and the letter status guard also exists IN SQL (UpdateLetterDraftDocx WHERE status IN); (5) pre-existing document tokens (no sub claim) still parse and behave identically; (6) the callback still verifies the doc-server JWT before any save for ALL subjects; (7) principalWithPositions failure narrows (never widens) authority." Fix every finding before committing.
  • [x] Commit:
cd /home/efran/remote-development/obscura && git add go/internal/httpapi/handlers_office.go go/internal/httpapi/handlers_office_subjects.go go/internal/httpapi/handlers_publish.go go/internal/httpapi/server.go go/cmd/obscura-server/wire.go && git commit -m "feat(letters): sub-scoped office tokens, letter/letterhead editor endpoints, ConvertService letter renderer" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 5: Numbering docx branch + letterhead docx upload/download + OpenAPI

Files:
- Modify go/internal/httpapi/handlers_correspondence.go (AssignLetterNumber)
- Modify go/internal/httpapi/handlers_letterhead.go
- Modify go/internal/httpapi/server.go (two routes)
- Modify api/openapi.yaml + regen packages/api-client/src/schema.ts

Interfaces:
- Consumes (Task 3) s.correspondence.AssignNumberDocx(ctx, p, letterID, schemeCode), s.letterhead.SaveTemplateDocx(ctx, id, docxBytes), s.letterhead.OpenTemplateDocx(ctx, id); (Task 1) corrdomain.AuthoringDocx; docx.MIME; existing maxUploadMemory.
- Produces:
- POST /api/v1/letters/{letterID}/number on a docx letter runs the merge+convert branch (letterhead_id ignored there); HTML letters byte-identical behavior.
- POST /api/v1/letterheads/{id}/docx (multipart field file, ≤20MB, validated zip) and GET /api/v1/letterheads/{id}/docx (download), both letterhead.manage.
- GET /letters/{id} + list already emit authoring/letterhead_id and GET /letterheads emits has_docx via the Task-1 json tags — this task only documents the letters side in OpenAPI (letterhead paths are not in the yaml; office-config endpoints stay out, raw-fetch pattern).

Steps:

  • [x] In go/internal/httpapi/handlers_correspondence.go, replace AssignLetterNumber:
// AssignLetterNumber allocates a gapless number for the letter under a scheme,
// rendering and storing its PDF. Returns the assigned number.
//
// DOCX letters take the merge branch: {{NOMOR}} {{TANGGAL}} {{PERIHAL}} {{SIFAT}}
// are substituted into the draft docx and the result is converted to PDF
// (ConvertService, Gotenberg fallback). The letterhead was fixed at creation (it IS
// the docx the letter was seeded from), so letterhead_id is ignored on this branch.
//
// HTML letters keep the original path verbatim: an optional letterhead_id wraps the
// body in a CORE letterhead (kop/footer) before rendering. The letterhead is
// resolved here (like document Finalize-to-PDF) so the correspondence service stays
// free of a letterhead dependency and both flows share ONE letterhead store.
func (s *Server) AssignLetterNumber(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    letterID := chi.URLParam(r, "letterID")
    var body struct {
        SchemeCode   string `json:"scheme_code"`
        LetterheadID string `json:"letterhead_id"`
    }
    if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "request.invalid_json", Message: "invalid request body"})
        return
    }
    letter, err := s.correspondence.GetLetter(r.Context(), letterID)
    if err != nil {
        writeProblem(w, err)
        return
    }
    if letter.Authoring == corrdomain.AuthoringDocx {
        number, err := s.correspondence.AssignNumberDocx(r.Context(), p, letterID, body.SchemeCode)
        if err != nil {
            writeProblem(w, err)
            return
        }
        writeJSON(w, http.StatusOK, map[string]any{"number": number})
        return
    }
    var headerHTML, footerHTML string
    if body.LetterheadID != "" {
        lh, lerr := s.letterhead.GetLetterhead(r.Context(), body.LetterheadID)
        if lerr != nil {
            writeProblem(w, lerr)
            return
        }
        headerHTML, footerHTML = lh.HeaderHTML, lh.FooterHTML
    }
    number, err := s.correspondence.AssignNumberWithLetterhead(r.Context(), p, letterID, body.SchemeCode, headerHTML, footerHTML)
    if err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusOK, map[string]any{"number": number})
}
  • [x] In go/internal/httpapi/handlers_letterhead.go, add imports (io, strings, "github.com/Virtue-Digital-Indonesia/obscura/internal/platform/docx") and the two handlers:
// maxLetterheadDocxBytes caps an uploaded letterhead docx template (20 MB).
const maxLetterheadDocxBytes = 20 << 20

// UploadLetterheadDocx replaces a letterhead's docx template (multipart field
// "file"; ≤20MB; must be a zip with [Content_Types].xml + word/document.xml —
// validated in the service so the editor save path enforces the same rule).
// Requires letterhead.manage.
func (s *Server) UploadLetterheadDocx(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, maxLetterheadDocxBytes+(1<<20))
    if err := r.ParseMultipartForm(maxUploadMemory); err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "request.invalid_form", Message: "invalid multipart form (is the file under 20 MB?)"})
        return
    }
    file, _, err := r.FormFile("file")
    if err != nil {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "letterhead.docx_required", Message: "a .docx file is required in the \"file\" field"})
        return
    }
    defer file.Close()
    data, err := io.ReadAll(io.LimitReader(file, maxLetterheadDocxBytes+1))
    if err != nil {
        writeProblem(w, err)
        return
    }
    if len(data) > maxLetterheadDocxBytes {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "letterhead.docx_too_large", Message: "the docx template must be 20 MB or smaller"})
        return
    }
    if err := s.letterhead.SaveTemplateDocx(r.Context(), chi.URLParam(r, "id"), data); err != nil {
        writeProblem(w, err)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

// DownloadLetterheadDocx streams the current docx template. Requires letterhead.manage.
func (s *Server) DownloadLetterheadDocx(w http.ResponseWriter, r *http.Request) {
    rc, t, err := s.letterhead.OpenTemplateDocx(r.Context(), chi.URLParam(r, "id"))
    if err != nil {
        writeProblem(w, err)
        return
    }
    defer rc.Close()
    name := strings.NewReplacer("/", "-", "\\", "-", "\"", "").Replace(t.Name)
    w.Header().Set("Content-Type", docx.MIME)
    w.Header().Set("Content-Disposition", "attachment; filename=\""+name+".docx\"")
    w.WriteHeader(http.StatusOK)
    _, _ = io.Copy(w, rc)
}
  • [x] In go/internal/httpapi/server.go, in the letterheads block right after the Task-4 office-config route:
            r.With(s.requirePerm("letterhead.manage")).Post("/letterheads/{id}/docx", s.UploadLetterheadDocx)
            r.With(s.requirePerm("letterhead.manage")).Get("/letterheads/{id}/docx", s.DownloadLetterheadDocx)
  • [x] In api/openapi.yaml (letters paths ARE in the yaml; letterhead/office-config paths are NOT and stay out):
  • In components.schemas.CreateLetterRequest.properties, after body_html, add:
        authoring:
          type: string
          enum: [html, docx]
          description: >-
            Compose channel. `html` (default) is the quick-text path; `docx`
            creates an OnlyOffice-authored letter (requires the office module),
            seeded from the chosen letterhead's Word template or the embedded
            blank starter.
        letterhead_id:
          type: string
          description: >-
            Letterhead chosen at creation (docx letters). With `authoring: docx`
            the letterhead must carry a Word template — otherwise 422
            `correspondence.letterhead_no_docx`.
  • In components.schemas.Letter.properties (the existing PascalCase entries are stale doc-debt; the wire format is snake_case — add the two new fields in wire form), after UpdatedAt, add:
        authoring:
          type: string
          description: >-
            How the letter body is authored — `html` or `docx`. (Wire format is
            snake_case; the PascalCase entries above are legacy documentation.)
        letterhead_id:
          type: string
          description: Letterhead template bound at creation (docx letters; empty otherwise).
  • [x] Regen the client (gen:api is broken): cd /home/efran/remote-development/obscura/packages/api-client && npx openapi-typescript ../../api/openapi.yaml -o src/schema.ts
  • [x] Verify: cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./... AND cd /home/efran/remote-development/obscura/web && npx tsc --noEmit (the regenerated schema is consumed by web).
  • [x] Commit:
cd /home/efran/remote-development/obscura && git add go/internal/httpapi/handlers_correspondence.go go/internal/httpapi/handlers_letterhead.go go/internal/httpapi/server.go api/openapi.yaml packages/api-client/src/schema.ts && git commit -m "feat(letters): docx numbering branch + letterhead docx upload/download endpoints" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 6: FE data layer + create/assign modals

Files:
- Modify web/src/api/correspondence.ts
- Modify web/src/api/letterhead.ts
- Modify web/src/features/correspondence/NewLetterModal.tsx (full rewrite below)
- Modify web/src/features/correspondence/LetterDetailModal.tsx (AssignNumberModal only)
- Modify web/src/features/correspondence/i18n.ts (compose strings, en + id)

Interfaces:
- Consumes: backend endpoints from Tasks 3–5; existing useMe() (me.officeEdit already maps office_edit — no change to web/src/api/me.ts); OfficeConfig type exported by web/src/api/office.ts.
- Produces (Task 7 depends on these EXACT names):
- correspondence.ts: Letter gains authoring: string + letterheadId: string; NewLetter gains authoring?: 'html' | 'docx' + letterheadId?: string; useCreateLetter() mutation resolves { id: string }; useLetterOfficeConfig(letterId: string, enabled: boolean) returning OfficeConfig.
- letterhead.ts: Letterhead gains hasDocx: boolean; useLetterheadOfficeConfig(id: string, enabled: boolean); useUploadLetterheadDocx() ({id, file}); async function downloadLetterheadDocx(id: string, name: string): Promise<void>.
- i18n keys correspondence.compose.{modeDocx,modeText,letterhead,letterheadNone,letterheadHint,docxHint,createAndOpen}.

Steps:

  • [x] web/src/api/correspondence.ts:
  • Extend Letter (after createdAt): authoring: string and letterheadId: string.
  • Extend ApiLetter with authoring?: string and letterhead_id?: string; extend toLetter with authoring: l.authoring ?? 'html', and letterheadId: l.letterhead_id ?? '',.
  • Replace NewLetter + useCreateLetter:
export interface NewLetter {
  type: string
  classification: string
  subject: string
  bodyHtml: string
  authoring?: 'html' | 'docx'
  letterheadId?: string
}
export function useCreateLetter() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: (v: NewLetter) =>
      req('/letters', {
        method: 'POST',
        body: JSON.stringify({
          type: v.type,
          classification: v.classification,
          subject: v.subject,
          body_html: v.bodyHtml,
          authoring: v.authoring ?? 'html',
          letterhead_id: v.letterheadId ?? '',
        }),
      }) as Promise<{ id: string }>,
    onSuccess: () => qc.invalidateQueries({ queryKey: ['correspondence'] }),
  })
}
  • Add at the end (import type { OfficeConfig } from './office' at the top):
// Office editor config for a DOCX letter — mirrors useOfficeConfig (documents).
// Short-lived signed tokens inside: never cache across mounts.
export function useLetterOfficeConfig(letterId: string, enabled: boolean) {
  return useQuery<OfficeConfig>({
    queryKey: ['letter-office-config', letterId],
    enabled: enabled && !!letterId,
    staleTime: 0,
    gcTime: 0,
    retry: false,
    queryFn: async () => {
      const token = await getToken()
      const res = await fetch(`/api/v1/letters/${letterId}/office-config`, {
        headers: token ? { Authorization: `Bearer ${token}` } : {},
      })
      if (!res.ok) {
        const b = (await res.json().catch(() => null)) as { detail?: string; title?: string } | null
        throw new Error(b?.detail || b?.title || `HTTP ${res.status}`)
      }
      const r = (await res.json()) as { api_js: string; editable: boolean; config: Record<string, unknown> }
      return { apiJs: r.api_js, editable: r.editable, config: r.config }
    },
  })
}
  • [x] web/src/api/letterhead.ts:
  • Letterhead gains hasDocx: boolean; ApiLetterhead gains has_docx?: boolean; toLetterhead gains hasDocx: !!l.has_docx,.
  • Add at the end (import type { OfficeConfig } from './office'):
// Office editor config for a letterhead docx template (letterhead.manage). The
// backend lazy-seeds the starter docx on first call.
export function useLetterheadOfficeConfig(id: string, enabled: boolean) {
  return useQuery<OfficeConfig>({
    queryKey: ['letterhead-office-config', id],
    enabled: enabled && !!id,
    staleTime: 0,
    gcTime: 0,
    retry: false,
    queryFn: async () => {
      const token = await getToken()
      const res = await fetch(`/api/v1/letterheads/${id}/office-config`, {
        headers: token ? { Authorization: `Bearer ${token}` } : {},
      })
      if (!res.ok) {
        const b = (await res.json().catch(() => null)) as { detail?: string; title?: string } | null
        throw new Error(b?.detail || b?.title || `HTTP ${res.status}`)
      }
      const r = (await res.json()) as { api_js: string; editable: boolean; config: Record<string, unknown> }
      return { apiJs: r.api_js, editable: r.editable, config: r.config }
    },
  })
}

// Replace a letterhead's docx template (multipart — raw fetch, like intake).
export function useUploadLetterheadDocx() {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: async ({ id, file }: { id: string; file: File }) => {
      const token = await getToken()
      const fd = new FormData()
      fd.append('file', file)
      const res = await fetch(`/api/v1/letterheads/${id}/docx`, {
        method: 'POST',
        headers: token ? { Authorization: `Bearer ${token}` } : {},
        body: fd,
      })
      if (!res.ok) {
        const b = (await res.json().catch(() => null)) as { detail?: string; title?: string } | null
        throw new Error(b?.detail || b?.title || `HTTP ${res.status}`)
      }
      return null
    },
    onSuccess: () => qc.invalidateQueries({ queryKey: ['letterheads'] }),
  })
}

// downloadLetterheadDocx fetches the docx template (auth-gated) and saves it.
export async function downloadLetterheadDocx(id: string, name: string): Promise<void> {
  const token = await getToken()
  const res = await fetch(`/api/v1/letterheads/${id}/docx`, {
    headers: token ? { Authorization: `Bearer ${token}` } : {},
  })
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
  const blob = new Blob([await res.blob()], { type: 'application/octet-stream' })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = `${(name || 'letterhead').replace(/[/\\"]/g, '-')}.docx`
  a.rel = 'noopener'
  document.body.appendChild(a)
  a.click()
  a.remove()
  setTimeout(() => URL.revokeObjectURL(url), 4000)
}
  • [x] Replace web/src/features/correspondence/NewLetterModal.tsx entirely:
// Compose a new e-office letter (draft). With the office module on, the default is
// docx authoring in the Word (OnlyOffice) editor — pick type/classification/subject
// plus an optional docx-ready letterhead, then jump straight into the editor.
// "Quick text" keeps the original plain-TextArea → HTML path. Numbering (the
// official gapless number) and PDF rendering happen later, from the detail view.
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { ContentSwitcher, Dropdown, InlineNotification, Modal, Switch, TextArea, TextInput } from '@carbon/react'
import { useCreateLetter, useLetterClassifications, htmlFromPlainText } from '@/api/correspondence'
import { useLetterheads } from '@/api/letterhead'
import { useMe } from '@/api/me'

const LETTER_TYPES = [
  { id: 'surat', labelKey: 'correspondence.type.surat' },
  { id: 'nota', labelKey: 'correspondence.type.nota' },
  { id: 'memo', labelKey: 'correspondence.type.memo' },
]

const NO_LETTERHEAD = '__none__'

export function NewLetterModal({ onClose }: { onClose: () => void }) {
  const { t } = useTranslation()
  const navigate = useNavigate()
  const create = useCreateLetter()
  const { data: me } = useMe()
  const { data: classifications = [] } = useLetterClassifications()
  const { data: letterheads = [] } = useLetterheads()
  const [type, setType] = useState('surat')
  const [classification, setClassification] = useState('')
  const [subject, setSubject] = useState('')
  const [body, setBody] = useState('')
  const [mode, setMode] = useState<'docx' | 'text'>('docx')
  const [letterheadId, setLetterheadId] = useState(NO_LETTERHEAD)
  const [error, setError] = useState<string | null>(null)

  // Docx authoring needs the office module; without it this is exactly the old modal.
  const officeOn = !!me?.officeEdit
  const effectiveMode = officeOn ? mode : 'text'

  const typeItem = LETTER_TYPES.find((o) => o.id === type) ?? LETTER_TYPES[0]
  const classItems = classifications.map((c) => ({ id: c.code, label: c.label }))
  const classItem = classItems.find((c) => c.id === classification) ?? null
  // Only docx-ready letterheads are offered (a template-less one would 422 server-side).
  const letterheadItems = [
    { id: NO_LETTERHEAD, label: t('correspondence.compose.letterheadNone') },
    ...letterheads.filter((l) => l.hasDocx).map((l) => ({ id: l.id, label: l.name })),
  ]
  const letterheadItem = letterheadItems.find((l) => l.id === letterheadId) ?? letterheadItems[0]
  const valid = subject.trim() !== '' && type !== ''

  const close = () => {
    setError(null)
    onClose()
  }

  const submit = () => {
    if (!valid) return
    setError(null)
    if (effectiveMode === 'docx') {
      create.mutate(
        {
          type,
          classification,
          subject: subject.trim(),
          bodyHtml: '',
          authoring: 'docx',
          letterheadId: letterheadId === NO_LETTERHEAD ? '' : letterheadId,
        },
        {
          onSuccess: (d) => {
            onClose()
            navigate(`/letters/${d.id}/edit`)
          },
          onError: (e) => setError(e instanceof Error ? e.message : t('correspondence.compose.saveFailed')),
        },
      )
      return
    }
    create.mutate(
      { type, classification, subject: subject.trim(), bodyHtml: htmlFromPlainText(body) },
      {
        onSuccess: onClose,
        onError: (e) => setError(e instanceof Error ? e.message : t('correspondence.compose.saveFailed')),
      },
    )
  }

  return (
    <Modal
      open
      size="md"
      modalHeading={t('correspondence.compose.title')}
      primaryButtonText={effectiveMode === 'docx' ? t('correspondence.compose.createAndOpen') : t('correspondence.compose.save')}
      secondaryButtonText={t('detail.cancel')}
      primaryButtonDisabled={!valid || create.isPending}
      onRequestClose={close}
      onRequestSubmit={submit}
    >
      {officeOn && (
        <div className="newdoc__field">
          <ContentSwitcher
            size="sm"
            selectedIndex={effectiveMode === 'docx' ? 0 : 1}
            onChange={(d) => setMode(d.name === 'text' ? 'text' : 'docx')}
          >
            <Switch name="docx" text={t('correspondence.compose.modeDocx')} />
            <Switch name="text" text={t('correspondence.compose.modeText')} />
          </ContentSwitcher>
        </div>
      )}
      <Dropdown
        id="letter-type"
        titleText={t('correspondence.compose.type')}
        label={t(typeItem.labelKey)}
        items={LETTER_TYPES}
        selectedItem={typeItem}
        itemToString={(i) => (i ? t(i.labelKey) : '')}
        onChange={({ selectedItem }) => selectedItem && setType(selectedItem.id)}
      />
      <div className="newdoc__field">
        <Dropdown
          id="letter-class"
          titleText={t('correspondence.compose.classification')}
          label={classItem?.label ?? t('correspondence.compose.classificationNone')}
          items={classItems}
          selectedItem={classItem}
          itemToString={(i) => i?.label ?? ''}
          onChange={({ selectedItem }) => setClassification(selectedItem?.id ?? '')}
        />
      </div>
      <div className="newdoc__field">
        <TextInput
          id="letter-subject"
          labelText={t('correspondence.compose.subject')}
          value={subject}
          onChange={(e) => setSubject(e.target.value)}
          data-modal-primary-focus
        />
      </div>
      {effectiveMode === 'docx' ? (
        <div className="newdoc__field">
          <Dropdown
            id="letter-letterhead"
            titleText={t('correspondence.compose.letterhead')}
            helperText={t('correspondence.compose.letterheadHint')}
            label={letterheadItem?.label ?? ''}
            items={letterheadItems}
            selectedItem={letterheadItem}
            itemToString={(i) => i?.label ?? ''}
            onChange={({ selectedItem }) => selectedItem && setLetterheadId(selectedItem.id)}
          />
          <p className="muted">{t('correspondence.compose.docxHint')}</p>
        </div>
      ) : (
        <div className="newdoc__field">
          <TextArea
            id="letter-body"
            labelText={t('correspondence.compose.body')}
            helperText={t('correspondence.compose.bodyHint')}
            rows={10}
            value={body}
            onChange={(e) => setBody(e.target.value)}
          />
        </div>
      )}
      {error && <InlineNotification kind="error" lowContrast hideCloseButton title={error} />}
    </Modal>
  )
}
  • [x] In web/src/features/correspondence/LetterDetailModal.tsx, pass authoring into the assign modal and hide the letterhead dropdown for docx letters:
  • Change the invocation: {assigning && letter && (<AssignNumberModal letterId={letter.id} authoring={letter.authoring} onClose={() => setAssigning(false)} />)}
  • Change the component signature and wrap the letterhead field:
function AssignNumberModal({ letterId, authoring, onClose }: { letterId: string; authoring: string; onClose: () => void }) {

In submit, send no letterhead for docx: assign.mutate({ id: letterId, schemeCode, letterheadId: authoring === 'docx' || letterheadId === NO_TEMPLATE ? '' : letterheadId }, ...).
Wrap the letterhead <div className="newdoc__field">…</div> block in {authoring !== 'docx' && ( … )} (a docx letter's letterhead was fixed at creation).

  • [x] In web/src/features/correspondence/i18n.ts, add inside compose: { … } of the en block (after bodyHint):
      modeDocx: 'Word editor',
      modeText: 'Quick text',
      letterhead: 'Letterhead',
      letterheadNone: 'None (blank page)',
      letterheadHint: 'Only letterheads with a Word template are listed.',
      docxHint: 'The letter opens in the Word editor after it is created. The merge fields NOMOR, TANGGAL, PERIHAL and SIFAT (typed with double curly braces) are filled in when the number is assigned.',
      createAndOpen: 'Create & open editor',

and the mirror inside compose of the id block:

      modeDocx: 'Editor Word',
      modeText: 'Teks cepat',
      letterhead: 'Kop surat',
      letterheadNone: 'Tanpa kop (halaman kosong)',
      letterheadHint: 'Hanya kop dengan templat Word yang ditampilkan.',
      docxHint: 'Surat terbuka di editor Word setelah dibuat. Merge field NOMOR, TANGGAL, PERIHAL dan SIFAT (ditulis dengan kurung kurawal ganda) diisi saat nomor ditetapkan.',
      createAndOpen: 'Buat & buka editor',

(Reminder: NO literal {{…}} in these strings — i18next would interpolate them away.)

  • [x] Verify: cd /home/efran/remote-development/obscura/web && npx tsc --noEmit && npx vite build
  • [x] Commit:
cd /home/efran/remote-development/obscura && git add web/src/api/correspondence.ts web/src/api/letterhead.ts web/src/features/correspondence/NewLetterModal.tsx web/src/features/correspondence/LetterDetailModal.tsx web/src/features/correspondence/i18n.ts && git commit -m "feat(letters-ui): docx compose mode, docx-aware assign modal, letter/letterhead office hooks" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 7: FE editor routes + detail/admin surfaces + remaining i18n

Files:
- Create web/src/features/correspondence/LetterOfficeEditorView.tsx
- Modify web/src/features/documents/OfficeEditorView.tsx (export the script loader)
- Modify web/src/App.tsx
- Modify web/src/features/correspondence/LetterDetailModal.tsx
- Modify web/src/features/admin/LetterheadTab.tsx
- Modify web/src/features/correspondence/i18n.ts + web/src/features/admin/i18n.ts

Interfaces:
- Consumes (Task 6): useLetterOfficeConfig, useLetterheadOfficeConfig, useUploadLetterheadDocx, downloadLetterheadDocx, Letterhead.hasDocx, Letter.authoring; existing i18n keys office.{loading,loadFailed,scriptFailed,editingHint,readOnly} and docview.back (already in en+id).
- Produces: routes /letters/:id/edit and /letterheads/:id/edit (standalone, outside AppShell); LetterOfficeEditorView({ kind }: { kind: 'letter' | 'letterhead' }); loadOfficeScript exported from OfficeEditorView; detail-modal Open button; letterhead admin docx controls; i18n keys correspondence.detail.{editLetter,viewLetter} and admin.letterhead.{wordEdit,wordUpload,wordDownload,docxBadge,docxHint}.

Steps:

  • [x] In web/src/features/documents/OfficeEditorView.tsx, export the loader: change function loadScript(src: string): Promise<void> { to export function loadOfficeScript(src: string): Promise<void> { and update its one internal call site (loadScript(data.apiJs)loadOfficeScript(data.apiJs)). Nothing else changes (the declare global DocsAPI type is ambient project-wide).

  • [x] Create web/src/features/correspondence/LetterOfficeEditorView.tsx:

// Standalone OnlyOffice editor for correspondence subjects: a docx LETTER
// (/letters/:id/edit) or a LETTERHEAD template (/letterheads/:id/edit). Same
// DocsAPI mount/destroy pattern as the document OfficeEditorView — one slim bar,
// the editor owns the rest of the viewport. The config endpoint enforces the ACL
// (mayReadLetter / letterhead.manage) and editability (draft/rejected + creator or
// correspondence.admin), so view mode doubles as the approver previewer.
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Button, InlineLoading, Tag } from '@carbon/react'
import { ArrowLeft } from '@carbon/icons-react'
import { loadOfficeScript } from '@/features/documents/OfficeEditorView'
import { useLetterOfficeConfig } from '@/api/correspondence'
import { useLetterheadOfficeConfig } from '@/api/letterhead'

export function LetterOfficeEditorView({ kind }: { kind: 'letter' | 'letterhead' }) {
  const { t } = useTranslation()
  const { id = '' } = useParams()
  const navigate = useNavigate()
  // Both hooks always run (rules of hooks); only the matching one is enabled.
  const letterQ = useLetterOfficeConfig(id, kind === 'letter')
  const letterheadQ = useLetterheadOfficeConfig(id, kind === 'letterhead')
  const { data, isLoading, isError, error } = kind === 'letter' ? letterQ : letterheadQ
  const holderRef = useRef<HTMLDivElement | null>(null)
  const editorRef = useRef<{ destroyEditor?: () => void } | null>(null)
  const [scriptError, setScriptError] = useState(false)

  const backTo = kind === 'letter' ? '/correspondence' : '/admin?section=letterhead'
  const title = ((data?.config as { document?: { title?: string } } | undefined)?.document?.title ?? '').replace(/\.docx$/i, '')

  useEffect(() => {
    if (!data || !holderRef.current) return
    let cancelled = false
    loadOfficeScript(data.apiJs)
      .then(() => {
        if (cancelled || !window.DocsAPI || !holderRef.current) return
        editorRef.current = new window.DocsAPI.DocEditor('office-editor', {
          ...data.config,
          width: '100%',
          height: '100%',
          type: 'desktop',
          events: {
            onOutdatedVersion: () => window.location.reload(),
            onRequestClose: () => navigate(backTo),
          },
        })
      })
      .catch(() => {
        if (!cancelled) setScriptError(true)
      })
    return () => {
      cancelled = true
      try {
        editorRef.current?.destroyEditor?.()
      } catch {
        /* editor already torn down */
      }
      editorRef.current = null
    }
  }, [data])

  return (
    <div className="office-editor">
      <div className="office-editor__bar">
        <Button kind="ghost" size="sm" renderIcon={ArrowLeft} onClick={() => navigate(backTo)}>
          {t('docview.back')}
        </Button>
        <span className="office-editor__title">{title}</span>
        {data && !data.editable && <Tag size="sm" type="cool-gray">{t('office.readOnly')}</Tag>}
        {data?.editable && <span className="muted office-editor__hint">{t('office.editingHint')}</span>}
      </div>
      {isLoading && <InlineLoading className="office-editor__loading" description={t('office.loading')} />}
      {(isError || scriptError) && (
        <p className="office-editor__error edit-attrs__error">{scriptError ? t('office.scriptFailed') : (error as Error)?.message || t('office.loadFailed')}</p>
      )}
      <div className="office-editor__frame">
        <div id="office-editor" ref={holderRef} />
      </div>
    </div>
  )
}
  • [x] In web/src/App.tsx: add import { LetterOfficeEditorView } from '@/features/correspondence/LetterOfficeEditorView' (after the OfficeEditorView import) and, directly under the /documents/d/:docId/office route (authed, outside AppShell):
            {/* Standalone correspondence editors — docx letters and letterhead docx
                templates share the office-editor chrome. Server-side gating: the
                office-config endpoints enforce module, ACL and editability. */}
            <Route path="/letters/:id/edit" element={<LetterOfficeEditorView kind="letter" />} />
            <Route path="/letterheads/:id/edit" element={<LetterOfficeEditorView kind="letterhead" />} />
  • [x] In web/src/features/correspondence/LetterDetailModal.tsx:
  • Add imports: useNavigate from react-router-dom; Edit to the @carbon/icons-react import list; const navigate = useNavigate() inside LetterDetailModal.
  • Replace the body iframe block — a docx letter opens in the editor instead of rendering bodyHtml (which is empty for docx):
          {letter.authoring === 'docx' ? (
            <div className="letter-detail__actions">
              <Button size="sm" kind="tertiary" renderIcon={Edit} onClick={() => navigate(`/letters/${letter.id}/edit`)}>
                {status === 'draft' || status === 'rejected' ? t('correspondence.detail.editLetter') : t('correspondence.detail.viewLetter')}
              </Button>
            </div>
          ) : (
            <iframe className="letter-detail__body" title={t('correspondence.detail.body')} sandbox="" srcDoc={letter.bodyHtml} />
          )}

(keep the <h4> body title above it unchanged).
- Submit-from-rejected: change the submit button condition from {status === 'draft' && ( to {(status === 'draft' || status === 'rejected') && (.

  • [x] In web/src/features/admin/LetterheadTab.tsx:
  • Extend imports: useRef from react; Tag from @carbon/react; DocumentTasks, Upload, Download from @carbon/icons-react (keep Add, Edit, TrashCan); useNavigate from react-router-dom; useMe from @/api/me'; useUploadLetterheadDocx, downloadLetterheadDocx added to the @/api/letterhead import.
  • Inside LetterheadTab() add:
  const navigate = useNavigate()
  const { data: me } = useMe()
  const upload = useUploadLetterheadDocx()
  const docxInputRef = useRef<HTMLInputElement>(null)
  const uploadTargetRef = useRef<string | null>(null)
  const [docxError, setDocxError] = useState<string | null>(null)
  const officeOn = !!me?.officeEdit

  const pickDocx = (id: string) => {
    setDocxError(null)
    uploadTargetRef.current = id
    docxInputRef.current?.click()
  }
  const onDocxPicked = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0]
    const id = uploadTargetRef.current
    if (file && id) upload.mutate({ id, file }, { onError: (err) => setDocxError((err as Error).message) })
    e.target.value = ''
  }
  • Under the lead paragraph add the docx hint + the shared hidden input + error surface:
      <p className="page__lead muted">{t('admin.letterhead.docxHint')}</p>
      <input ref={docxInputRef} type="file" hidden accept=".docx" onChange={onDocxPicked} />
      {docxError && <InlineNotification kind="error" lowContrast hideCloseButton title={docxError} />}
  • In the name cell, badge docx-ready templates: <TableCell>{l.name} {l.hasDocx && <Tag size="sm" type="blue">{t('admin.letterhead.docxBadge')}</Tag>}</TableCell>
  • In the actions cell, BEFORE the existing Edit button add:
                    {officeOn && (
                      <Button kind="ghost" size="sm" hasIconOnly iconDescription={t('admin.letterhead.wordEdit')} renderIcon={DocumentTasks} onClick={() => navigate(`/letterheads/${l.id}/edit`)} />
                    )}
                    <Button kind="ghost" size="sm" hasIconOnly iconDescription={t('admin.letterhead.wordUpload')} renderIcon={Upload} disabled={upload.isPending} onClick={() => pickDocx(l.id)} />
                    {l.hasDocx && (
                      <Button kind="ghost" size="sm" hasIconOnly iconDescription={t('admin.letterhead.wordDownload')} renderIcon={Download} onClick={() => void downloadLetterheadDocx(l.id, l.name)} />
                    )}
  • [x] i18n. In web/src/features/correspondence/i18n.ts add inside detail: { … } (en block after assign):
      editLetter: 'Edit letter (Word)',
      viewLetter: 'View letter (Word)',

and in the id block:

      editLetter: 'Sunting surat (Word)',
      viewLetter: 'Lihat surat (Word)',

In web/src/features/admin/i18n.ts add inside letterhead: { … } of the en block (after deleteError):

      wordEdit: 'Edit in Word (OnlyOffice)',
      wordUpload: 'Upload .docx template',
      wordDownload: 'Download .docx template',
      docxBadge: 'docx',
      docxHint: 'Docx letters use the Word template: put the kop in the header section and the footer in the footer section. Merge fields NOMOR, TANGGAL, PERIHAL and SIFAT (typed with double curly braces) are filled in at numbering. The HTML header/footer below still serve quick-text letters and document finalize.',

and the mirror in the id block:

      wordEdit: 'Sunting di Word (OnlyOffice)',
      wordUpload: 'Unggah templat .docx',
      wordDownload: 'Unduh templat .docx',
      docxBadge: 'docx',
      docxHint: 'Surat docx memakai templat Word: letakkan kop di bagian header dan footer di bagian footer. Merge field NOMOR, TANGGAL, PERIHAL dan SIFAT (ditulis dengan kurung kurawal ganda) diisi saat penomoran. Header/footer HTML di bawah tetap dipakai surat teks cepat dan finalisasi dokumen.',
  • [x] Verify: cd /home/efran/remote-development/obscura/web && npx tsc --noEmit && npx vite build
  • [x] Commit:
cd /home/efran/remote-development/obscura && git add web/src/features/correspondence/LetterOfficeEditorView.tsx web/src/features/documents/OfficeEditorView.tsx web/src/App.tsx web/src/features/correspondence/LetterDetailModal.tsx web/src/features/admin/LetterheadTab.tsx web/src/features/correspondence/i18n.ts web/src/features/admin/i18n.ts && git commit -m "feat(letters-ui): shared letter/letterhead office editor view, detail open button, letterhead docx admin controls" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 8: Deploy + live e2e — CONTROLLER-DRIVEN CHECKLIST

This task is executed PERSONALLY by the orchestrator/controller (not delegated to a subagent). The x056 demo has the office module ON and OnlyOffice running.

  • [x] Pre-flight: working tree clean (git status — go/obscura-server may show modified, that is the tracked ELF; NEVER stage it), all Task 1–7 commits present on main.
  • [x] Migration collision check: ls /home/efran/remote-development/obscura/go/migrations | tail -4. If a co-agent landed another 00128_* after ours was written, renumber our file to the next free number (git mv + commit) BEFORE deploying — goose orders by number and the demo DB already has co-agents' versions.
  • [x] Deploy: ssh valbox 'cd /home/efran/remote-development/obscura && ./deploy/update.sh -y'. Watch for snapshot → build → migrate → health-gate; a post-deploy readyz 503 can be transient (retry before assuming rollback).
  • [x] E2E via Playwright (NODE_PATH=/usr/local/lib/node_modules node /tmp/x.cjs, dev-login as director@obscura.local — dev-login auto-grants admin, which covers correspondence.admin + letterhead.manage):
    1. Letterhead lazy-seed + kop edit: Admin → Letterhead tab → on any template click "Edit in Word (OnlyOffice)". Expect the editor to open on the starter (sample kop). Edit the kop text, close the editor tab (triggers status-2 save). Reload the tab: template shows the docx badge (has_docx: true); GET /api/v1/letterheads confirms. Download .docx works.
    2. Create docx letter: Correspondence → New letter → default mode "Word editor" → subject E2E OnlyOffice {timestamp} → pick the letterhead from (1) → Create & open editor → lands on /letters/{id}/edit with the edited kop visible in the header. Type a body line, close (save). GET /api/v1/letters/{id} shows authoring: "docx".
    3. Submit → approve → number: submit (pick an approver position held by director or approve via the inbox), approve, then Assign number (scheme only — NO letterhead dropdown visible for the docx letter). Expect a number back.
    4. PDF assertion (pdfium via the stego venv): download GET /api/v1/letters/{id}/content to /tmp/letter-e2e.pdf, then extract text and assert the assigned number string, the Indonesian date (e.g. Juli 2026) and the subject appear:
python3 - <<'PY'   # run wherever pypdfium2 is installed (stego venv on the rig)
import pypdfium2 as pdfium
doc = pdfium.PdfDocument("/tmp/letter-e2e.pdf")
text = "\n".join(p.get_textpage().get_text_range() for p in doc)
for want in ["<ASSIGNED_NUMBER>", "Juli 2026"]:
    assert want in text, f"missing {want!r}"
print("PDF text OK")
PY
  1. Quick-text regression: New letter → Quick text mode → body text → save; assign number via the HTML path WITH a letterhead; download PDF and confirm it renders (existing behavior intact).
  2. Late-save rejection (F11 re-check): while a FRESH docx letter is still draft, GET /api/v1/letters/{id}/office-config and capture the callbackUrl token from config.editorConfig.callbackUrl. Number the letter (or submit it). Then replay a save: sign a doc-server-style JWT with the shared secret (read ONLYOFFICE_JWT_SECRET from the deploy env on valbox) and
curl -s -X POST "https://x056.../api/v1/office/callback/<TOKEN>" -H 'Content-Type: application/json' \
  -d '{"status":2,"url":"http://onlyoffice/cache/files/whatever/output.docx","token":"<JWT signed with the shared secret>"}'

Expect {"error":1} and the letter's draft_docx_rev unchanged. Also: GET .../office-config now returns editable: false and NO callbackUrl.
7. Upload validation: curl -F "file=@/tmp/notzip.txt" .../api/v1/letterheads/{id}/docx (with a bearer token) → 400 letterhead.docx_invalid. A real docx uploads → 204 and rev bumps.
8. Document-editor regression: open an existing DOCX document in the office editor, edit, close → a new version lands (proves the sub-less token path + callback branch for documents is untouched).
- [x] On any failed step: diagnose; if the deploy itself is broken, update.sh auto-rolls back — fix forward locally and redeploy. Never hand-roll compose.
- [x] After e2e passes: the controller pushes (git push) and updates memory (workflow + letters notes).


Self-review notes (performed at plan-writing time)

  • Spec coverage check: D1 (default docx + quick-text: Task 6 modal, me.officeEdit gate), D2 (OnlyOffice-edited letterhead docx + starters + upload/download: Tasks 2/3/4/5/7), D3 (four merge fields, no LAMPIRAN: Task 3 AssignNumberDocx map), D4 + submit-from-rejected (Tasks 3/4/6/7), migration 00128 (Task 1), sub-scoped tokens + variant=final + F11 save re-checks (Task 4), ConvertService w/ Gotenberg fallback + license-lapse numberability (Task 4 RenderLetterDocx), 422 correspondence.letterhead_no_docx (Task 3 handler), office.disabled (Tasks 3/4), lazy-seed (Task 4 via Task 3 EnsureTemplateDocx), has_docx/authoring/letterhead_id response fields (Task 1 json tags + Task 5 OpenAPI), editor keys letter_{id}_{rev}/letterhead_{id}_{rev} (Task 4), zero-copy seed (Task 3), void-on-failure + clear final (Task 3), non-goals respected (no HTML-letter migration, no metadata editing, intake untouched).
  • Cross-task consistency: repo methods UpdateLetterDraftDocx/SetLetterFinalDocx/UpdateDocx (T1) match T3 call sites; SaveDraftDocx(ctx, letterID, uid, []byte)/SaveTemplateDocx(ctx, id, []byte) (T3) match T4 callback callers; claim names {purpose, sub, doc, version, uid, variant} (T4) are what RenderLetterDocx mints and what nothing on the FE parses (the FE treats config as opaque — correct); OfficeConfig FE type is reused by both new hooks (T6) and consumed by the shared view (T7); hasDocx/authoring/letterheadId FE names consistent across T6/T7.
  • go vet vs tests: go vet ./... type-checks _test.go files even though tests are never run; the only test file touching a changed signature is correspondence/adapters/pg_test.go (12 CreateLetter calls, mechanical rewrite in Task 3). httpapi/server_test.go uses correspondenceapp.NewService, whose signature is unchanged.
  • Deviations from the scouting brief, deliberate: (1) starter assets live in go/internal/platform/docx (not correspondence/assets) because the LETTERHEAD context seeds LetterheadStarter() and cross-context imports are forbidden; (2) the late binding is corrSvc.SetDocxRenderer(api) in wire.go right after NewServer — the sealTimeMarker RESOLVER TYPE lives in resolvers.go but its late bind is also in wire.go (esignSvc.SetSealMarker, wire.go:707), and here the Server itself implements the port so no new resolver type is needed; (3) 422 is emitted via writeProblemStatus (the kernel error taxonomy has no 422 kind); (4) SubmitLetter's failure revert restores the PRIOR status instead of hardcoded draft (required once rejected is a legal submit source).