think
16px
820px

"Assign number" as a workflow step — number step kind

For agentic workers: written for INLINE execution by the session controller. Checkbox
(- [ ]) tracking per task; implementers do NOT commit — the controller commits/pushes
after review + e2e.
Every task must leave the tree green:
cd go && go build ./... && go vet ./... (T1–T3) and cd web && npx tsc --noEmit && npx vite build (T4–T5). Spec: docs/superpowers/specs/2026-07-26-letter-number-step-design.md
(user-approved, plus one strengthening: the engine ENFORCES the chain order).

Goal. New workflow step kind number for letter subjects: a registrar-assigned task
that completes by running the existing gapless AssignNumber path with task-holder
authorization
(holding the pending task IS the authority), so ONE chain carries a letter
draft → approve → NUMBER → sign / e-Meterai / e-Stamp. MarkSent stays the manual terminal
act. Enforced order (user strengthening): in any chain — all approve steps precede the
number step; the number step precedes all seal steps; at most one number step; a chain with
seal steps and NO number step still requires an already-numbered letter (existing guard); a
chain WITH a number step requires a NOT-yet-numbered letter and a LETTER subject. Direct
numbering of simple letters (POST /letters/{id}/number, creator/corr-admin gated) is
unchanged.

Architecture. The engine already fans out action_required = step.Kind() generically
(fanOutStep, workflow/app/service.go:1497) and advances seal steps through per-kind
Complete*ForSubject mirrors — the number kind rides exactly those rails:

  • Domain: StepKindNumber = "number" + ActionNumber (recorded-only) + an exported
    NumberStepOrder(steps) shape-check shared by ValidateSteps (every authoring path) and
    guardLetterWorkflowStart (every letter start path). StepSpec gains an optional
    SchemeCode (jsonb scheme_code,omitempty — the spec's "definition may pin a scheme").
  • Advance: new CompleteNumberForSubject mirroring CompleteMeteraiForSubject
    (workflow/app/service.go:1240) with ONE deliberate difference: task-holder only, no
    any-permitted-user override
    (numbering has no org permission of its own). Plus a read-only
    NumberTaskContext validator for the endpoint.
  • Numbering: reserveNumber's status gate (correspondence/app/service.go:819,
    draft|approved only) is the one blocker — mid-chain the letter is in_review (MarkInReview
    flipped it at start). The two public AssignNumber methods keep their exact signatures and
    become 2-line delegates onto unexported cores that thread a fromWorkflow bool; new
    ...ForTask twins pass true, which additionally accepts in_review. Public path
    byte-identical.
  • Endpoint: POST /workflow/tasks/{taskID}/number {scheme_code, letterhead_id?} in
    handlers_correspondence.go (needs s.correspondence + s.letterhead + s.workflow, all on
    Server), routed with requireModule("correspondence") + requirePerm("workflow.act").
    Already-numbered ⇒ auto-complete (decision, justified in T3).
  • FE: builder palette + graphs gain the kind; the letter Start-workflow picker gains a
    "Full correspondence flow" preset that opens the EXISTING CustomWorkflowModal
    (initialSteps + fixedSubject props already exist — CustomWorkflowModal.tsx:69–95)
    prefilled Approve → Number → Sign; the inbox/letter-banner render number tasks; the
    AssignNumberModal is extracted from LetterDetailView for reuse with the task submit target.

Preset mechanism decision. "Standard" picker entries are admin template definitions
(DocumentDetailView.tsx:1750–1752) whose assignees are baked in at define time — a shipped
preset cannot know org positions, so a seeded definition is impossible. The existing prefill
mechanism (CustomWorkflowModal initialStepsdraftsFromSteps → run-once
POST /workflow/custom) is exactly the "copy a shared workflow" path already in production —
the preset is an FE picker entry that opens it. Prefer-existing-mechanism satisfied; no new
engine surface.

NO MIGRATION NEEDED. workflow_tasks.action_required is plain text NOT NULL
(migrations/00007_workflow.sql:23) with no CHECK constraint anywhere (grep CHECK go/migrations/*.sql shows none on workflow tables); step kinds live in the
workflow_definition_versions.steps jsonb (mig 00016; MarshalSteps,
domain/definition.go:511). Migrations dir currently ends at 00132_letter_sent_state.sql
(next free = 00133) — irrelevant unless a CHECK is later discovered; re-check
ls go/migrations | tail before ever writing one (co-agent number race).

openapi.yaml: no entry, no client regen. The recent letter-chain surface
(/letters/{id}/sign|seals|sent|pending-seal) is deliberately absent from api/openapi.yaml
(grep: no hits) and the only spec test is validity, not route parity
(httpapi/openapi_test.go:15–32). The FE calls the new endpoint through the raw req() helper
(api/correspondence.ts), not the typed client. StepInput.kind in the spec is a free-form
string (api/openapi.yaml:14608–14611 — no enum), so kind:"number" needs no regen either.
OPTIONAL 1-line polish: extend that kind description to mention number (doc-only; still no
regen — gen:api via npm is broken anyway, see memory).

Scouted anchors (the code wins)

# What Where
1 Step-kind constants + IsValidStepKind + StepSpec + ValidateSteps go/internal/workflow/domain/definition.go:13–33, 162–168, 105–143, 333–411
1 Wire step shape stepInput / toStepSpecs go/internal/httpapi/handlers_designer.go:22–38, 40–68
1 FE palette (ContentSwitcher) + StepKind union web/src/features/workflows/StepListEditor.tsx:380–399; web/src/features/workflows/data.ts:163, 304–312
2 Sign advance: esign port + workflow methods go/internal/esign/app/service.go:344–360 (WorkflowAdvancer); go/internal/workflow/app/service.go:1070 (CompleteSignatureForSubject), 1240 (CompleteMeteraiForSubject — the mirror), 990 (advanceApprovedInstance), 855 (ActDefinition; ceremony-task switch :894–899)
3 Task fan-out ActionRequired: step.Kind() go/internal/workflow/app/service.go:1434–1527 (:1497); repo query FindPendingTasksForSubjectAction go/internal/workflow/adapters/pg.go:530–542; no DB CHECK (go/migrations/00007_workflow.sql:19–27)
4 guardLetterWorkflowStart + its 5 callers go/internal/httpapi/handlers_correspondence.go:540–575 (seal scan :564–570); callers: handlers_workflow.go:94 (nil steps, built-in), handlers_workflow_advanced.go:83 (nil, sub-workflow), handlers_designer.go:240 (ver.Steps), :295 (toStepSpecs), handlers_correspondence.go:617 (SubmitLetter definition mode; MarkInReview :633)
5 AssignNumber paths + authz go/internal/correspondence/app/service.go:787 (AssignNumber), 805–850 (reserveNumber; status gate :819), 866 (AssignNumberWithLetterhead), 926 (AssignNumberDocx); handler AssignLetterNumber handlers_correspondence.go:394–441 (creator gate mayEditLetter :413; defined handlers_office_subjects.go:216)
6 Start picker + preset host web/src/features/documents/DocumentDetailView.tsx:1713–1887 (StartWorkflowOnDocModal; standard=templates :1750); letter button gate LetterDetailView.tsx:259 (numbered-only today — must widen); CustomWorkflowModal initialSteps/fixedSubjectType:'letter' web/src/features/workflows/CustomWorkflowModal.tsx:69–95, seeding :147–170
7 Task UI web/src/features/workflows/MyTasksTab.tsx:400–418 (letter ceremony deep-link row), :192 (detail tag), :246–250 (Open letter); action whitelist web/src/features/approvals/data.ts:32, :120; LetterTaskBanner.tsx:120–156 (strip) ; LetterTask web/src/api/correspondence.ts:454–501; AssignNumberModal (LOCAL, unexported) LetterDetailView.tsx:1016–1099
8 Definition storage + save validation jsonb workflow_definition_versions.steps via MarshalSteps definition.go:511–518; domain.ValidateSteps called from service DefineWorkflow:647, ReviseWorkflow:688, StartCustomWorkflow:1830, SaveCustomWorkflow:1939
Status reflection (verified for T2) correspondence/app/service.go:1189–1200 (MarkInReview — draft
Reject voiding workflow/app/service.go:970–972 (sealVoider on reject — voids SEALS only; a number is never voided)
Module gate + routes server.go:931–932 (letters group behind requireModule("correspondence")), :1283 (/workflow/tasks/{taskID}/sla — the new route's neighbor)

Status-reflection interaction (VERIFIED, no new reflection logic — as the spec requires).
Approve steps before the number step still flip draft/rejected → in_review at start
(MarkInReview, called by SubmitLetter :633 and POST /workflows :107–109; T2 adds the SAME
best-effort call to the two definition-start handlers that today miss it). The number step's
completion sets numbered via AssignNumber. Because ReflectApprovalState only updates rows
still in_review (SetLetterStatusFromReview), every later terminal reflection is a natural
no-op on the numbered letter. Reject matrix that falls out with ZERO new code:
- Reject AT the number step, single chain (letter in_review) → workflow rejected →
reflection flips letter to rejected (editable + resubmittable — the standard loop; no
number burned, nothing to void). (The spec's "stays approved" sentence holds only for the
two-chain entry below; the spec's own "no new reflection logic" rule wins.)

- Reject AT the number step over a previously-approved letter (separate approve chain ran
first; letter approved, MarkInReview no-ops) → reflection no-op → letter stays
approved
— exactly the spec sentence.
- Reject AFTER numbering (at a sign/affix step) → seals void via sealVoider (:970), the
reflection no-ops on the numbered letter → the number stays, letter stays numbered.

Tech Stack. Go modular monolith (go/, hexagonal: pure domainapp ports/services
owning kernel.UnitOfWorkadapters pgx / httpapi chi), Postgres + goose migrations,
React 18 + Carbon + TanStack Query SPA (web/), i18n en+id per-feature slices.


Global Constraints

HARD CONSTRAINTS (from the task contract — copy is verbatim, do not deviate):

  • NEVER go test (test DSN = live demo Postgres). Verify Go: cd go && go build ./... && go vet ./... . Web: cd web && npx tsc --noEmit && npx vite build. NO new deps.
    Migrations: re-check ls go/migrations | tail immediately before writing one (co-agent
    number race); only if a CHECK constraint forces it.
  • Never git add -A (tracked ELF go/obscura-server); explicit paths; commit trailer
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>. Implementers do NOT commit
    (controller does).
  • Document seal/workflow paths must stay byte-identical — the number kind is ADDITIVE; every
    switch on step kind gets an explicit new case, never a default-fallthrough change.
  • i18n en+id for all new strings (features' i18n slices; no literal {{ }}).

Repo norms that apply here: no openapi.yaml/client-regen work (see header note);
kernel.Error kinds map to HTTP by writeProblem (Validation→4xx, PermissionDenied→403,
Conflict→409, NotFound→404); Go domain stays stdlib-only.


T1 — Engine: number step kind (domain + fan-out + advance + save-validation)

  • [ ] Files: go/internal/workflow/domain/definition.go,
    go/internal/workflow/domain/workflow.go, go/internal/workflow/domain/graph.go,
    go/internal/workflow/app/service.go, go/internal/httpapi/handlers_designer.go.

1a. definition.go — kind constant, StepSpec field, order rules

Append to the step-kind const block (after StepKindStamp, definition.go:32):

    // StepKindNumber routes the ASSIGN-NUMBER duty for a LETTER subject: holders of the
    // step's positions and/or its named users each get a "number" task, and completing it
    // runs the existing gapless AssignNumber path with TASK-HOLDER authorization (holding
    // the pending task IS the authority — no correspondence-creator gate, no org-wide
    // permission). Letter subjects only — enforced at START time by
    // guardLetterWorkflowStart, because definitions are subject-agnostic. Chain shape is
    // enforced by NumberStepOrder: at most one number step, after every approve step,
    // before every seal step (the numbered PDF is the base the chain seals in place).
    StepKindNumber = "number"

Extend IsValidStepKind (definition.go:162–168) — explicit case, per the additive rule:

    case "", StepKindApprove, StepKindSign, StepKindMeterai, StepKindSignExternal, StepKindStamp, StepKindNumber:

Add the optional scheme pin to StepSpec (after Placements, definition.go:142):

    // SchemeCode optionally PINS the numbering scheme a NUMBER step must use. Empty (the
    // default) = the registrar picks the scheme in the completion modal at task time.
    // Read only on a number step. omitempty keeps every existing jsonb step
    // byte-identical (the back-compat contract of this struct).
    SchemeCode string `json:"scheme_code,omitempty"`

Add the exported shape rule (new function, above ValidateSteps):

// NumberStepOrder enforces the CHAIN-SHAPE invariants of the assign-number step, shared by
// ValidateSteps (every authoring path) and the letter start guard (defense in depth for a
// definition authored before a rule existed): a definition may carry at most ONE number
// step; every approve step must PRECEDE it; every seal step (sign / sign_external /
// meterai / stamp) must FOLLOW it. A list with no number step always passes. Returns
// ok=false plus a human reason on the first violation.
func NumberStepOrder(steps []StepSpec) (ok bool, reason string) {
    sealSeen := false
    numberSeen := false
    for _, st := range steps {
        switch st.Kind() {
        case StepKindNumber:
            if numberSeen {
                return false, "Definition may have at most one Assign-number step"
            }
            if sealSeen {
                return false, "The Assign-number step must come before any signature, e-Meterai or stamp step"
            }
            numberSeen = true
        case StepKindSign, StepKindSignExternal, StepKindMeterai, StepKindStamp:
            sealSeen = true
        default: // approve (including the legacy empty kind)
            if numberSeen {
                return false, "Approval steps must come before the Assign-number step"
            }
        }
    }
    return true, ""
}

Wire it into ValidateSteps (definition.go:333): right after the empty-list check
(:334–336) insert:

    if ok, reason := NumberStepOrder(steps); !ok {
        return false, reason
    }

and add the explicit kind case to the per-step switch (:367–408), between
case StepKindStamp: and default::

        case StepKindNumber:
            // Assignees exactly like an approval: positions and/or named users. The
            // letter-only + not-yet-numbered rules are START-time checks
            // (guardLetterWorkflowStart) — a definition is subject-agnostic; the
            // ordering/count rules ran up front (NumberStepOrder).
            if !named {
                return false, "Step has no approver positions"
            }

Add the explicit no-appearance case to StepPlacementsComplete (definition.go:454–480),
before default::

    case StepKindNumber:
        // Numbering has no on-page appearance — nothing to pre-place, ever.
        return true

Also update the doc comment on ValidateSteps (:311–332) with one line:
// Assign-number ordering: see NumberStepOrder (at most one; approve* → number → seal*).

1b. workflow.go — recorded-only action

After ActionExternalSign (workflow.go:64) add:

    // ActionNumber is recorded when an ASSIGN-NUMBER step is completed by its task holder
    // numbering the subject letter (the engine-scoped AssignNumber path). Advances like
    // ActionApprove; reads as a numbering in the chain-of-custody. Recorded-only: absent
    // from Next().
    ActionNumber Action = "number"

1c. graph.go — server-side fallback label

stepKindLabel (graph.go:118–131) — explicit case before default:

    case StepKindNumber:
        return "Assign number"

1d. app/service.go — fan-out, ceremony-reject, advance

  1. taskPlacement (service.go:1401–1412) — explicit case (additive rule; behavior equals
    today's default):
    switch step.Kind() {
    case domain.StepKindSign, domain.StepKindMeterai, domain.StepKindStamp:
    case domain.StepKindNumber:
        // Numbering has no on-page appearance — never a placement.
        return nil
    default:
        return nil
    }
  1. fanOutStep needs no functional changeActionRequired: step.Kind() (:1497)
    already stamps "number", and assignee resolution (positions via ResolveActors +
    availability substitution, named users verbatim, :1440–1492) is kind-agnostic. Update the
    two comments only: :1497// "approve", "sign", "meterai", "stamp" or "number". There
    is deliberately NO permission pre-check for number steps (unlike the meterai/stamp
    affixPermChecker blocks :1454–1481): task-holdership is the whole authority — any
    resolved assignee can complete.

  2. ActDefinition ceremony-task switch (service.go:894–899) — the registrar may REJECT the
    run from any task surface (mirrors declining a sign/affix), but completes their step only
    through the numbering endpoint:

            case "sign", "meterai", "stamp", "number":
                callerHasCeremonyTask = true

(Also extend the authorization comment at :885–888: "…sign / e-Meterai / stamp / number
task holders complete their step through its ceremony — numbering completes through the
task endpoint — but may Reject from here.")

  1. New advance mirror — place directly after CompleteStampForSubject (:1302–1356):
// CompleteNumberForSubject advances any definition-driven workflow whose CURRENT step is an
// ASSIGN-NUMBER step over (subjectType, subjectID), on behalf of p who just numbered the
// subject letter through the task endpoint. Mirror of CompleteMeteraiForSubject with ONE
// deliberate difference: numbering carries no org-wide permission of its own — holding a
// pending number task IS the authority — so only a HOLDER advances the step (no
// any-permitted-user override; a non-holder is skipped silently — the endpoint already
// 403'd them, this in-tx re-check is the concurrency backstop). No-op (nil) when nothing is
// waiting, so a retry after a half-completed call (letter numbered, advance lost) converges
// instead of failing.
func (s *Service) CompleteNumberForSubject(ctx context.Context, p kernel.Principal, subjectType, subjectID string) error {
    if subjectType == "" || subjectID == "" {
        return nil
    }
    return s.uow.Do(ctx, func(ctx context.Context) error {
        tasks, err := s.repo.FindPendingTasksForSubjectAction(ctx, subjectType, subjectID, domain.StepKindNumber)
        if err != nil {
            return err
        }
        if len(tasks) == 0 {
            return nil
        }
        now := s.clock.Now()
        seen := map[string]bool{}
        for _, t := range tasks {
            if seen[t.InstanceID] {
                continue
            }
            seen[t.InstanceID] = true

            holder := false
            for _, tk := range tasks {
                if tk.InstanceID == t.InstanceID && tk.AssigneeUserID == string(p.UserID) {
                    holder = true
                    break
                }
            }
            if !holder {
                continue // task-holder only: numbering has no rank/permission override
            }
            inst, err := s.repo.GetInstance(ctx, t.InstanceID)
            if err != nil {
                return err
            }
            if inst.DefinitionVersionID == nil || inst.State != domain.StateSubmitted {
                continue
            }
            ver, err := s.repo.GetDefinitionVersion(ctx, *inst.DefinitionVersionID)
            if err != nil {
                return err
            }
            // Re-check the CURRENT step really is the number step (the pending task
            // implies it, but the instance may have moved between query and lock).
            if inst.CurrentStep < 1 || inst.CurrentStep > len(ver.Steps) || ver.Steps[inst.CurrentStep-1].Kind() != domain.StepKindNumber {
                continue
            }
            if err := s.advanceApprovedInstance(ctx, inst, ver, string(p.UserID), "number assigned", domain.ActionNumber, now); err != nil {
                return err
            }
        }
        return nil
    })
}

// NumberTaskContext loads and validates the caller's pending NUMBER task for the completion
// endpoint: the task must exist, be pending, carry action_required="number" and be assigned
// to p; its instance must be definition-driven, still submitted, and sitting ON the number
// step. Returns the instance (subject routing) and the number step's spec (its optional
// pinned scheme_code). Read-only — completion + advance run in CompleteNumberForSubject
// AFTER the numbering itself succeeded.
func (s *Service) NumberTaskContext(ctx context.Context, p kernel.Principal, taskID string) (domain.Instance, domain.StepSpec, error) {
    var zi domain.Instance
    var zs domain.StepSpec
    if taskID == "" {
        return zi, zs, &kernel.Error{Kind: kernel.ErrValidation, Code: "workflow.number.task_required", Message: "task id is required"}
    }
    tk, err := s.repo.GetTask(ctx, taskID)
    if err != nil {
        return zi, zs, err
    }
    if tk.ActionRequired != domain.StepKindNumber {
        return zi, zs, &kernel.Error{Kind: kernel.ErrConflict, Code: "workflow.number.not_number_task", Message: "this task is not an assign-number task"}
    }
    if tk.AssigneeUserID != string(p.UserID) {
        return zi, zs, &kernel.Error{Kind: kernel.ErrPermissionDenied, Code: "workflow.number.not_assignee", Message: "only this task's assignee can assign the number"}
    }
    if tk.State != domain.TaskStatePending {
        return zi, zs, &kernel.Error{Kind: kernel.ErrConflict, Code: "workflow.number.task_done", Message: "this task is already completed"}
    }
    inst, err := s.repo.GetInstance(ctx, tk.InstanceID)
    if err != nil {
        return zi, zs, err
    }
    if inst.DefinitionVersionID == nil || inst.State != domain.StateSubmitted {
        return zi, zs, &kernel.Error{Kind: kernel.ErrConflict, Code: "workflow.number.not_active", Message: "the workflow is not waiting on this step"}
    }
    ver, err := s.repo.GetDefinitionVersion(ctx, *inst.DefinitionVersionID)
    if err != nil {
        return zi, zs, err
    }
    if inst.CurrentStep < 1 || inst.CurrentStep > len(ver.Steps) || ver.Steps[inst.CurrentStep-1].Kind() != domain.StepKindNumber {
        return zi, zs, &kernel.Error{Kind: kernel.ErrConflict, Code: "workflow.number.not_active", Message: "the workflow is not waiting on this step"}
    }
    return inst, ver.Steps[inst.CurrentStep-1], nil
}
  1. Verified-no-change list (state in the task commit message; every one was checked):
    requireSignLicensed (:314–327 — number is NOT esign-gated, correct: numbering is core
    correspondence), requireMeteraiAffixable (:390+ — meterai/stamp cases only),
    completeSignOverride (:1168 — sign-kind-scoped), StepPlacementsComplete callers,
    EscalateOverdue (kind-agnostic, action_required carried verbatim :2898), Dispose/Forward
    (role tasks, not steps), esign WorkflowAdvancer (esign/app/service.go:344 — untouched;
    esign never advances number steps).

1e. handlers_designer.go — wire passthrough for the scheme pin

stepInput (:22–38) gains SchemeCode string \json:"scheme_code"`(afterContactSlots);toStepSpecs(:40–68) copies it:SchemeCode: s.SchemeCode,`. (The FE builder does not expose
pinning in v1 — API authors can; the completion endpoint honors it in T3.)

Verify: cd go && go build ./... && go vet ./... — green.
Grep audit (must be clean): grep -rn "StepKindSign\b\|StepKindMeterai\|StepKindStamp" go/internal/workflow go/internal/httpapi | grep -v _test — re-read every hit and confirm each
switch/if now either has an explicit number case or is on the verified-no-change list above.


T2 — Start-time guards (+ the in_review flip on the two definition-start paths)

  • [ ] Files: go/internal/httpapi/handlers_correspondence.go,
    go/internal/httpapi/handlers_designer.go.

2a. Replace guardLetterWorkflowStart (handlers_correspondence.go:534–575)

Full replacement (doc comment + body — the access/sent parts are verbatim today's):

// guardLetterWorkflowStart authorizes and validates starting a workflow over a letter: the caller
// must be permitted to drive the letter (its creator or a correspondence admin) AND the chain
// shape must fit the letter's numbering state. Because a letter is sealed in place, any workflow
// containing a SEAL step (sign / meterai / stamp / sign_external) needs a numbered base PDF:
// either the letter is ALREADY numbered (chains without a number step — the original rule), or
// the chain itself carries an ASSIGN-NUMBER step that will number it first (approve* → number →
// seal*, shape re-checked here via NumberStepOrder as defense in depth over ValidateSteps).
// A chain WITH a number step additionally requires a NOT-yet-numbered, outbound letter — and a
// LETTER subject at all: for any other subject a number step is refused before the early return,
// so the document start paths stay otherwise untouched.
func (s *Server) guardLetterWorkflowStart(ctx context.Context, p kernel.Principal, subjectType, subjectID string, steps []workflowdomain.StepSpec) *kernel.Error {
    // One shape scan drives every rule below.
    hasSeal := false
    hasNumber := false
    for _, st := range steps {
        switch st.Kind() {
        case workflowdomain.StepKindSign, workflowdomain.StepKindMeterai, workflowdomain.StepKindStamp, workflowdomain.StepKindSignExternal:
            hasSeal = true
        case workflowdomain.StepKindNumber:
            hasNumber = true
        }
    }
    if subjectType != "letter" {
        // An assign-number step runs the LETTER numbering ledger — it is meaningless (and
        // would strand the chain) over any other subject. Everything else about non-letter
        // subjects is none of this guard's business.
        if hasNumber {
            return &kernel.Error{Kind: kernel.ErrValidation, Code: "correspondence.workflow.number_letter_only", Message: "an Assign-number step can only run over a letter"}
        }
        return nil
    }
    letter, err := s.correspondence.GetLetter(ctx, subjectID)
    if err != nil {
        // Coerce a kernel.Error (e.g. not-found) through; wrap anything else.
        var ke *kernel.Error
        if errors.As(err, &ke) {
            return ke
        }
        return &kernel.Error{Kind: kernel.ErrUnknown, Code: "correspondence.workflow.lookup_failed", Message: err.Error()}
    }
    // Access: creator or correspondence admin (mirrors the document editor gate for workflow start).
    if letter.CreatedBy != string(p.UserID) && !s.isCorrespondenceAdmin(ctx, p) {
        return &kernel.Error{Kind: kernel.ErrPermissionDenied, Code: "correspondence.workflow.forbidden", Message: "only the letter's author or a correspondence admin can start a workflow over it"}
    }
    // A dispatched (sent) letter is terminally locked — no workflow (approval OR seal chain)
    // may start over it.
    if gerr := guardLetterNotSent(letter); gerr != nil {
        return gerr
    }
    if hasNumber {
        // Shape defense-in-depth: ValidateSteps enforced this on every AUTHORING path; the
        // re-check catches a definition authored before the rule existed.
        if ok, reason := workflowdomain.NumberStepOrder(steps); !ok {
            return &kernel.Error{Kind: kernel.ErrValidation, Code: "correspondence.workflow.number_order", Message: reason}
        }
        switch letter.Status {
        case corrdomain.StatusNumbered:
            // Numbers are never re-assigned: run a seal-only chain instead.
            return &kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.workflow.already_numbered", Message: "the letter already has its official number — remove the Assign-number step or run a seal-only workflow"}
        case corrdomain.StatusRegistered:
            return &kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.workflow.number_inbound", Message: "an inbound (registered) letter never takes an outbound number"}
        }
        // Seal steps are fine here: the in-chain number step numbers the letter first.
        return nil
    }
    // Number-first (UNCHANGED rule for chains without a number step): any seal-bearing step
    // requires an already-numbered letter (the numbered PDF is the base the chain seals in
    // place).
    if hasSeal && letter.Status != corrdomain.StatusNumbered {
        return &kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.workflow.number_first", Message: "number the letter before starting a signing / e-Meterai / stamp workflow"}
    }
    return nil
}

Caller coverage (no signature change, so all five compile untouched):
- handlers_designer.go:240 (StartFromDefinition, real ver.Steps) and :295
(StartCustomWorkflow, real toStepSpecs) — get all new rules.
- handlers_correspondence.go:617 (SubmitLetter definition mode, real steps) — ditto.
- handlers_workflow.go:94 and handlers_workflow_advanced.go:83 pass nil steps by design
(built-in Start / sub-workflows carry approve steps only — can never contain a number
step); hasNumber=false keeps them byte-identical.

2b. in_review flip parity on the two definition-start handlers

SubmitLetter's definition branch flips draft/rejected → in_review after a successful start
(handlers_correspondence.go:633), and so does the built-in POST /workflows
(handlers_workflow.go:107–109) — but the two generic definition starts do NOT, and the T4
preset makes POST /workflow/custom a PRIMARY letter path. Add the same best-effort flip:

In StartFromDefinition (handlers_designer.go), after the successful
s.workflow.StartFromDefinition(...) (:245–249), before writeJSON:

    // An approve-bearing chain starting over a draft/rejected letter flips it to in_review
    // (parity with SubmitLetter's definition branch and POST /workflows; MarkInReview
    // no-ops on any other status). Best-effort — the instance already exists.
    if body.SubjectType == "letter" {
        _ = s.correspondence.MarkInReview(r.Context(), body.SubjectID)
    }

In StartCustomWorkflow (handlers_designer.go), after the successful
s.workflow.StartCustomWorkflow(...) (:299–303), before writeJSON: same 4 lines.

(Correctness note for the reviewer: the number completion does NOT depend on this flip —
T3's widened gate accepts draft|approved|in_review — the flip only keeps the register honest
("Under review") and gives ReflectApprovalState its in_review row, exactly like the existing
paths.)

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


T3 — Completion endpoint: POST /workflow/tasks/{taskID}/number

  • [ ] Files: go/internal/correspondence/app/service.go,
    go/internal/httpapi/handlers_correspondence.go, go/internal/httpapi/server.go.
    Optional 1-liner: api/openapi.yaml (StepInput.kind description — see header; NO regen).

3a. Correspondence service — engine-scoped status gate, public paths byte-identical

  1. reserveNumber (service.go:805) gains the flag — signature becomes:
func (s *Service) reserveNumber(ctx context.Context, letterID, schemeCode string, fromWorkflow bool) (reservation, error) {

and the status check (:819–821) becomes:

        numberable := res.letter.Status == domain.StatusDraft || res.letter.Status == domain.StatusApproved
        if fromWorkflow && res.letter.Status == domain.StatusInReview {
            // The workflow NUMBER step completes MID-CHAIN: the approve steps flipped the
            // letter to in_review at start and the terminal reflection has not run yet
            // (the instance is still submitted). Only the engine-scoped task path may
            // number an in_review letter — the public endpoint keeps draft|approved.
            numberable = true
        }
        if !numberable {
            return &kernel.Error{Kind: kernel.ErrConflict, Code: "correspondence.assign.not_numberable", Message: "letter is not in a numberable state (draft or approved)"}
        }
  1. Rename the two big method bodies to unexported cores threading the flag, and keep the
    public methods as 2-line delegates (public signatures, error codes and behavior
    byte-identical — httpapi + adapters/pg_test.go compile untouched):
// AssignNumberWithLetterhead is AssignNumber with an OPTIONAL letterhead (kop/footer). …
// (existing doc comment stays verbatim)
func (s *Service) AssignNumberWithLetterhead(ctx context.Context, p kernel.Principal, letterID, schemeCode, headerHTML, footerHTML string) (string, error) {
    return s.assignNumberWithLetterhead(ctx, p, letterID, schemeCode, headerHTML, footerHTML, false)
}

// AssignNumberWithLetterheadForTask is the ENGINE-SCOPED twin used by the workflow
// number-step completion: the identical gapless reserve→render→finalize flow, but the
// status gate additionally accepts an in_review letter (the chain's approve steps put it
// there). Authorization happened at the transport — the caller holds the pending number
// task; there is deliberately no creator/corr-admin check here.
func (s *Service) AssignNumberWithLetterheadForTask(ctx context.Context, p kernel.Principal, letterID, schemeCode, headerHTML, footerHTML string) (string, error) {
    return s.assignNumberWithLetterhead(ctx, p, letterID, schemeCode, headerHTML, footerHTML, true)
}

func (s *Service) assignNumberWithLetterhead(ctx context.Context, p kernel.Principal, letterID, schemeCode, headerHTML, footerHTML string, fromWorkflow bool) (string, error) {
    res, err := s.reserveNumber(ctx, letterID, schemeCode, fromWorkflow)
    // … body from today's AssignNumberWithLetterhead (:867–917) VERBATIM from here on …
}

Exactly the same pattern for docx (:926–1001): public AssignNumberDocx delegates with
false; new AssignNumberDocxForTask delegates with true; body moves verbatim into
assignNumberDocx(ctx, p, letterID, schemeCode string, fromWorkflow bool) whose first
line calls s.reserveNumber(ctx, letterID, schemeCode, fromWorkflow).
AssignNumber (:787) already delegates to AssignNumberWithLetterhead — untouched.

3b. HTTP handler — place directly after AssignLetterNumber (handlers_correspondence.go:441)

// CompleteNumberTask completes a workflow ASSIGN-NUMBER task: the caller must be the task's
// assignee (holding the pending task IS the authority — the engine-scoped twin of
// AssignLetterNumber, which stays creator/correspondence-admin gated and is UNCHANGED), the
// instance must still be waiting on the number step, and the subject must be a letter. Runs
// the existing gapless AssignNumber path (docx merge-fields or HTML render, with the same
// optional letterhead resolution as the public endpoint), then completes the task and
// advances the chain (CompleteNumberForSubject).
//
// DECISION — an already-numbered letter does NOT fail the task: it AUTO-COMPLETES. Reached
// two ways: a concurrent DIRECT numbering (creator raced the registrar on the public
// endpoint), or a retry after a crash between numbering and advancing. In both, the step's
// postcondition — "the letter is numbered before the seal steps" — already holds; numbers
// are never un-assigned, so a 409 would strand the run on a task that can never complete
// (the dead-end-task class of bug). Only the task's assignee can take this branch, and it
// allocates nothing — the gapless ledger is untouched. Response then carries the EXISTING
// number with already_numbered=true.
func (s *Server) CompleteNumberTask(w http.ResponseWriter, r *http.Request) {
    p, _ := PrincipalFrom(r.Context())
    taskID := chi.URLParam(r, "taskID")
    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
    }
    inst, step, err := s.workflow.NumberTaskContext(r.Context(), p, taskID)
    if err != nil {
        writeProblem(w, err)
        return
    }
    if inst.SubjectType != "letter" {
        // Unreachable when the start guard did its job (number steps are letter-only);
        // refuse honestly for any instance that predates the guard.
        writeProblem(w, &kernel.Error{Kind: kernel.ErrConflict, Code: "workflow.number.not_letter", Message: "this workflow does not run over a letter"})
        return
    }
    letter, err := s.correspondence.GetLetter(r.Context(), inst.SubjectID)
    if err != nil {
        writeProblem(w, err)
        return
    }
    // Concurrent direct numbering / retry-after-crash: skip allocation, finish the step
    // (see the doc comment). Sent implies numbered and is unreachable mid-run (MarkSent
    // refuses while an instance is submitted) — handled for defense anyway.
    if letter.Status == corrdomain.StatusNumbered || letter.Status == corrdomain.StatusSent {
        if err := s.workflow.CompleteNumberForSubject(r.Context(), p, "letter", inst.SubjectID); err != nil {
            writeProblem(w, err)
            return
        }
        writeJSON(w, http.StatusOK, map[string]any{"number": letter.Number, "already_numbered": true})
        return
    }
    // Scheme: a definition-pinned scheme wins (the author's intent); otherwise the
    // registrar picked one in the completion modal.
    schemeCode := step.SchemeCode
    if schemeCode == "" {
        schemeCode = body.SchemeCode
    }
    if schemeCode == "" {
        writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "correspondence.assign.scheme_required", Message: "scheme code is required"})
        return
    }
    var number string
    if letter.Authoring == corrdomain.AuthoringDocx {
        // The letterhead was fixed at creation for a docx letter (it IS the seed docx) —
        // letterhead_id is ignored on this branch, exactly like AssignLetterNumber.
        number, err = s.correspondence.AssignNumberDocxForTask(r.Context(), p, inst.SubjectID, schemeCode)
    } else {
        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.AssignNumberWithLetterheadForTask(r.Context(), p, inst.SubjectID, schemeCode, headerHTML, footerHTML)
    }
    if err != nil {
        writeProblem(w, err)
        return
    }
    // Report an advance failure honestly: the letter IS numbered but the task still pends —
    // a retry lands in the already-numbered branch above and converges (idempotent).
    if err := s.workflow.CompleteNumberForSubject(r.Context(), p, "letter", inst.SubjectID); err != nil {
        writeProblem(w, err)
        return
    }
    writeJSON(w, http.StatusOK, map[string]any{"number": number})
}

3c. Route (server.go, next to PUT /workflow/tasks/{taskID}/sla, :1283)

            // Complete an ASSIGN-NUMBER workflow task: the registrar (task assignee) picks
            // the scheme and the engine runs the letter's gapless numbering, completes the
            // task and advances the chain. Task-holder authorized — the engine-scoped twin
            // of POST /letters/{id}/number (which stays creator/corr-admin gated). Module-
            // gated like the letters group (letter tasks only exist with correspondence on).
            r.With(s.requireModule("correspondence"), s.requirePerm("workflow.act")).Post("/workflow/tasks/{taskID}/number", s.CompleteNumberTask)

Rejection matrix (all via existing writeProblem kind mapping): unknown task → 404; wrong
assignee → 403 workflow.number.not_assignee; not a number task / task done / instance not
waiting → 409; non-letter subject → 409 workflow.number.not_letter; missing scheme → 4xx
validation correspondence.assign.scheme_required; already numbered → 200 auto-complete
(decision above); render/convert failure → the AssignNumber error surfaces and the allocation
voids exactly like the public path (task stays pending → registrar retries).

Verify: cd go && go build ./... && go vet ./... — green. Confirm with
grep -n "AssignNumberWithLetterhead\|AssignNumberDocx" go/internal -r | grep -v _test that
the only NEW callers are the ForTask pair in the new handler (public handler untouched).


T4 — FE: builder palette, graphs, letter Start-workflow picker + "Full correspondence flow" preset

  • [ ] Files: web/src/features/workflows/data.ts,
    web/src/features/workflows/StepListEditor.tsx, web/src/features/workflows/WorkflowGraph.tsx,
    web/src/features/workflows/builder.css, web/src/styles/app.css,
    web/src/features/workflows/i18n.ts, web/src/features/documents/DocumentDetailView.tsx,
    web/src/features/correspondence/LetterDetailView.tsx,
    web/src/features/correspondence/i18n.ts.

4a. data.ts

  • :163 → export type StepKind = 'approve' | 'sign' | 'meterai' | 'sign_external' | 'stamp' | 'number'
  • useLatestDefinitionVersion kind mapping (:304–312) — insert before the 'approve'
    fallback: : s.kind === 'number' ? 'number' so a saved number step renders as itself:
          kind: (s.kind === 'sign'
            ? 'sign'
            : s.kind === 'meterai'
              ? 'meterai'
              : s.kind === 'sign_external'
                ? 'sign_external'
                : s.kind === 'stamp'
                  ? 'stamp'
                  : s.kind === 'number'
                    ? 'number'
                    : 'approve') as StepKind,

4b. StepListEditor.tsx

  • KIND_TAG (:68–74): widen the record's value union with 'cyan' and add
    number: 'cyan',.
  • placementRefs (:99–121): explicit case with case 'approve':
    case 'number': return [] (numbering has no appearance; also keeps prunePlacements
    clearing boxes if a kind is switched to number).
  • Kind ContentSwitcher (:383–395): append index 5 —
  • selectedIndex={… step.kind === 'stamp' ? 4 : step.kind === 'number' ? 5 : 0}
  • kind: index === 1 ? 'sign' : index === 2 ? 'meterai' : index === 3 ? 'sign_external' : index === 4 ? 'stamp' : index === 5 ? 'number' : 'approve',
  • <Switch name="number" text={t('workflows.builder.kind.number')} /> after the stamp Switch.
  • Hint (after the stamp hint, :398):
    {step.kind === 'number' && <p className="wf-builder__kind-hint muted">{t('workflows.builder.numberHint')}</p>}
  • No change needed to stepValid/stepsPayload/draftsFromSteps: number takes the generic
    non-external branch (positions/users required; kind emitted verbatim) — note this in the
    commit message.

4c. Graphs

WorkflowGraph.tsx:
- nodeClass (:30–42): insert : n.stepKind === 'number' ? 'number' before the 'approve'
fallback.
- Add const hasNumber = nodes.some((n) => n.kind === 'step' && n.stepKind === 'number')
next to :48–51, extend the hasApprove exclusion list (:52–59) with
n.stepKind !== 'number' &&, add hasNumber to the legend-visibility OR (:196), and a
legend item after the sign one:

          {hasNumber && (
            <span className="wf-graph__legend-item">
              <span className="wf-graph__swatch wf-graph__swatch--number" />
              {t('workflows.graph.number')}
            </span>
          )}

web/src/styles/app.css — after the --external node block (:4696–4705) and after
.wf-graph__swatch--external (:4748–4751), cyan (matches the Tag color):

/* Assign-number step: cyan — the registrar issues the official letter number. */
.wf-graph__node--number rect {
  fill: var(--cds-tag-background-cyan, #bae6ff);
  stroke: var(--cds-tag-color-cyan, #00539a);
}
.wf-graph__node--number text {
  fill: var(--cds-tag-color-cyan, #00539a);
}
.wf-graph__node--number .wf-graph__glyph circle {
  fill: var(--cds-tag-color-cyan, #00539a);
}
.wf-graph__swatch--number {
  background: var(--cds-tag-background-cyan, #bae6ff);
  border-color: var(--cds-tag-color-cyan, #00539a);
}

builder.css — after the --stamp accent (:178–181):

.wf-builder__steps .wf-builder__step--number {
  border-left: 3px solid var(--cds-tag-color-cyan, #00539a);
}

4d. Picker preset (DocumentDetailView.tsx, StartWorkflowOnDocModal :1713–1887)

  • WorkflowPick.kind (:1716) → 'standard' | 'custom' | 'preset'.
  • Module const near the type: const PRESET_FULL_CORRESPONDENCE = '__preset_full_correspondence__'.
  • Props (:1731): add optional onPreset:
export function StartWorkflowOnDocModal({ open, docId, onClose, subjectType = 'document', onPreset }: { open: boolean; docId: string; onClose: () => void; subjectType?: string; onPreset?: () => void }) {
  • items memo (:1749–1763): prepend the preset for letter subjects (gated on the same
    custom-workflows flag the customs list uses, since it starts a run-once custom):
    const preset: WorkflowPick[] =
      !isDoc && onPreset && me?.customWorkflowsEnabled
        ? [{ id: PRESET_FULL_CORRESPONDENCE, name: t('workflows.picker.presetFullCorr'), kind: 'preset' as const, ownerName: '', mine: false }]
        : []
    // Preset first, then standards, then customs; group headings via firstOfGroup as before.
    return [...preset, ...standard, ...custom].map((it, i, all) => ({ ...it, firstOfGroup: i === 0 || all[i - 1].kind !== it.kind }))

(extend the memo deps with isDoc, onPreset, me?.customWorkflowsEnabled, t).
- Don't fetch a version for the synthetic id (:1746):
useLatestDefinitionVersion(open && picked && picked.kind !== 'preset' ? picked.id : null).
- onSubmit (:1772): first lines —

    if (!picked || start.isPending) return
    if (picked.kind === 'preset') {
      // Hand off to the letter page, which opens the custom builder prefilled with
      // Approve → Assign number → Sign over this letter.
      onPreset?.()
      onClose()
      return
    }
  • WorkflowPickTags (:1850): prepend
    if (pick.kind === 'preset') return <Tag type="cyan" size="sm">{t('workflows.picker.tagPreset')}</Tag>.
  • WorkflowPickOption group heading (:1878):
    {pick.kind === 'preset' ? t('workflows.picker.groupPreset') : pick.kind === 'standard' ? t('workflows.picker.groupStandard') : t('workflows.picker.groupCustom')}.

4e. Letter page (LetterDetailView.tsx)

  • Widen the Start-workflow gate (:259) — today numbered && … hides the picker on
    drafts, making the preset unreachable:
              {(status === 'draft' || status === 'rejected' || status === 'approved' || numbered) && canManage && me?.customWorkflowsEnabled && (

(Inbound letters are registered → still never see it. Update the comment at :255–258:
the picker now also serves pre-number letters, where the "Full correspondence flow" preset
numbers mid-chain.)
- Imports: import { CustomWorkflowModal } from '@/features/workflows/CustomWorkflowModal',
import type { StepInput } from '@/features/workflows/data', and useQueryClient if not
already present.
- Module-level preset steps (near STATUS_META):

// The "Full correspondence flow" preset: Approve → Assign number → Sign. Assignees are
// picked in the builder; names stay empty so steps auto-label by kind in both locales.
const FULL_CORRESPONDENCE_STEPS: StepInput[] = [
  { name: '', approver_position_ids: [], kind: 'approve' },
  { name: '', approver_position_ids: [], kind: 'number' },
  { name: '', approver_position_ids: [], kind: 'sign' },
]
  • State const [presetWf, setPresetWf] = useState(false) next to startingWf (:97); wire
    the modals (:326–328):
      {startingWf && (
        <StartWorkflowOnDocModal
          open
          docId={letter.id}
          subjectType="letter"
          onClose={() => setStartingWf(false)}
          onPreset={() => {
            setStartingWf(false)
            setPresetWf(true)
          }}
        />
      )}
      {/* The preset opens the EXISTING run-once builder prefilled over THIS letter: the
          user only picks WHO approves / numbers / signs. No scheme input anywhere at start
          — the registrar picks the scheme when completing the number task. */}
      {presetWf && (
        <CustomWorkflowModal
          open
          onClose={() => setPresetWf(false)}
          fixedSubject={{ id: letter.id, label: letter.subject }}
          fixedSubjectType="letter"
          initialName={t('correspondence.chain.presetName')}
          initialSteps={FULL_CORRESPONDENCE_STEPS}
          onStarted={() => {
            qc.invalidateQueries({ queryKey: ['letter', letter.id] })
            qc.invalidateQueries({ queryKey: ['letter-tasks', letter.id] })
            qc.invalidateQueries({ queryKey: ['workflow-instances'] })
            qc.invalidateQueries({ queryKey: ['correspondence'] })
          }}
        />
      )}

4f. i18n (en + id, both slices)

web/src/features/workflows/i18n.ts — en: tasks.action.number: 'Numbering';
builder.kind.number: 'Assign number';
builder.numberHint: 'Letters only — the assignee gives the letter its official number (they pick the numbering scheme when completing the task). Place it after the approvals and before any signature or stamp step.';
graph.number: 'Numbering'; picker.groupPreset: 'Recommended flow';
picker.tagPreset: 'Preset';
picker.presetFullCorr: 'Full correspondence flow (Approve → Number → Sign)'.
id (mirror positions): tasks.action.number: 'Penomoran'; builder.kind.number: 'Beri nomor';
builder.numberHint: 'Khusus surat — penerima tugas memberi nomor resmi surat (skema penomoran dipilih saat menyelesaikan tugas). Letakkan setelah persetujuan dan sebelum langkah tanda tangan atau stempel.';
graph.number: 'Penomoran'; picker.groupPreset: 'Alur yang disarankan';
picker.tagPreset: 'Preset';
picker.presetFullCorr: 'Alur korespondensi lengkap (Setujui → Nomor → Tanda tangan)'.

web/src/features/correspondence/i18n.ts — en: chain.presetName: 'Full correspondence flow';
update nextStep.draft (:243) to
'Next: edit the letter, then submit it for approval — or run the Full correspondence flow (approve → number → sign) from Start workflow. Simple letters can still be numbered directly.'
id: chain.presetName: 'Alur korespondensi lengkap'; nextStep.draft mirror:
'Berikutnya: sunting surat lalu ajukan persetujuan — atau jalankan Alur korespondensi lengkap (setujui → nomor → tanda tangan) dari Mulai alur. Surat sederhana tetap bisa diberi nomor langsung.'

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


T5 — FE task surfaces: inbox rows + letter banner + reusable AssignNumberModal

  • [ ] Files: web/src/features/approvals/data.ts, web/src/features/workflows/MyTasksTab.tsx,
    web/src/api/correspondence.ts, new web/src/features/correspondence/AssignNumberModal.tsx,
    web/src/features/correspondence/LetterDetailView.tsx,
    web/src/features/correspondence/LetterTaskBanner.tsx,
    web/src/features/correspondence/i18n.ts, web/src/features/workflows/i18n.ts (done in 4f).

5a. approvals/data.ts — stop number tasks masquerading as approve tasks

Today the fallback maps ANY unknown ActionRequired to 'approve' (:120) — a number task
would render Approve/Reject buttons whose approve would 403 (workflow.act_def.ceremony_task).
- :32 → action: 'approve' | 'sign' | 'meterai' | 'stamp' | 'number' (comment: “… affix, or
assign the official letter number”).
- :120 →

          action: t.ActionRequired === 'sign' ? 'sign' : t.ActionRequired === 'meterai' ? 'meterai' : t.ActionRequired === 'stamp' ? 'stamp' : t.ActionRequired === 'number' ? 'number' : 'approve',

5b. MyTasksTab.tsx — letter deep-link row (the established letter-ceremony pattern)

A number task is letter-only, and every letter ceremony task already renders an "Open letter"
deep-link (the letter page owns the ceremony) — reuse that row EXACTLY:
- :400 → if (item.subjectType === 'letter' && (item.action === 'sign' || item.action === 'meterai' || item.action === 'stamp' || item.action === 'number')) {
(extend the comment: number tasks complete via the letter banner's scheme modal).
- Detail-panel tag (:191–192) →
task.action === 'sign' ? 'teal' : task.action === 'meterai' ? 'purple' : task.action === 'stamp' ? 'magenta' : task.action === 'number' ? 'cyan' : 'blue'.
- The detail-panel primary button needs NO change — :246 (task.action !== 'approve' && task.subjectType === 'letter') already deep-links number tasks. The doc-side
meterai/stamp/sign branches (:422–480) are unreachable for number (letter-only) — leave
untouched; note it in the commit message.
- workflows.tasks.action.number label was added in 4f (used by the detail panel tag).

5c. api/correspondence.ts — LetterTask kind + the completion mutation

  • LetterTask.action (:460) → 'approve' | 'sign' | 'meterai' | 'stamp' | 'number'.
  • Mapping (:488–489) →
              action:
                t.ActionRequired === 'sign' ? 'sign' : t.ActionRequired === 'meterai' ? 'meterai' : t.ActionRequired === 'stamp' ? 'stamp' : t.ActionRequired === 'number' ? 'number' : 'approve',
  • New hook after useAssignNumber (:190–203):
// Complete an ASSIGN-NUMBER workflow task (the registrar's engine-scoped numbering):
// POST /workflow/tasks/{taskID}/number with the picked scheme (+ optional letterhead for an
// HTML letter; ignored for docx, whose letterhead is its seed document). The backend runs
// the gapless AssignNumber path with task-holder authorization, completes the task and
// advances the chain — so every task/letter/register surface refreshes.
export function useCompleteNumberTask(letterId: string) {
  const qc = useQueryClient()
  return useMutation({
    mutationFn: ({ taskId, schemeCode, letterheadId }: { taskId: string; schemeCode: string; letterheadId: string }) =>
      req(`/workflow/tasks/${taskId}/number`, {
        method: 'POST',
        body: JSON.stringify({ scheme_code: schemeCode, letterhead_id: letterheadId }),
      }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ['letter', letterId] })
      qc.invalidateQueries({ queryKey: ['letter-tasks', letterId] })
      qc.invalidateQueries({ queryKey: ['letter-seals', letterId] })
      qc.invalidateQueries({ queryKey: ['correspondence'] })
      qc.invalidateQueries({ queryKey: ['approvals'] })
      qc.invalidateQueries({ queryKey: ['workflow-instances'] })
    },
  })
}

5d. Extract AssignNumberModal (new file web/src/features/correspondence/AssignNumberModal.tsx)

Move the component + NO_TEMPLATE const VERBATIM out of LetterDetailView.tsx (:1016–1099)
and add the optional task target — full file:

// AssignNumberModal — scheme picker + live next-number preview + (HTML letters) optional
// letterhead. Extracted from LetterDetailView so BOTH numbering surfaces share one modal:
//  - direct numbering (creator/corr-admin): POST /letters/{id}/number (useAssignNumber)
//  - workflow NUMBER task (taskId set): POST /workflow/tasks/{taskId}/number — task-holder
//    authorized; completing it advances the chain.
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dropdown, InlineNotification, Modal } from '@carbon/react'
import {
  useAssignNumber,
  useCompleteNumberTask,
  useNumberingSchemes,
  useSchemeNumberPreview,
} from '@/api/correspondence'
import { useLetterheads } from '@/api/letterhead'

const NO_TEMPLATE = '__none__'

export function AssignNumberModal({
  letterId,
  authoring,
  taskId,
  onClose,
}: {
  letterId: string
  authoring: string
  // When set, submitting completes THIS workflow task instead of the direct endpoint.
  taskId?: string
  onClose: () => void
}) {
  const { t } = useTranslation()
  const assign = useAssignNumber()
  const completeTask = useCompleteNumberTask(letterId)
  const { data: schemes = [] } = useNumberingSchemes()
  const { data: letterheads = [] } = useLetterheads()
  const [schemeCode, setSchemeCode] = useState('')
  const [letterheadId, setLetterheadId] = useState(NO_TEMPLATE)
  const [error, setError] = useState<string | null>(null)
  const { data: previewNumber } = useSchemeNumberPreview(schemeCode)

  const schemeItems = schemes.map((s) => ({ id: s.code, label: `${s.code}${s.pattern}` }))
  const schemeItem = schemeItems.find((s) => s.id === schemeCode) ?? null
  const letterheadItems = [{ id: NO_TEMPLATE, label: t('correspondence.assign.noTemplate') }, ...letterheads.map((l) => ({ id: l.id, label: l.name }))]
  const letterheadItem = letterheadItems.find((l) => l.id === letterheadId) ?? letterheadItems[0]
  const valid = schemeCode !== ''
  const busy = assign.isPending || completeTask.isPending

  const submit = () => {
    if (!valid || busy) return
    setError(null)
    const lh = authoring === 'docx' || letterheadId === NO_TEMPLATE ? '' : letterheadId
    const opts = { onSuccess: onClose, onError: (e: unknown) => setError((e as Error).message) }
    if (taskId) completeTask.mutate({ taskId, schemeCode, letterheadId: lh }, opts)
    else assign.mutate({ id: letterId, schemeCode, letterheadId: lh }, opts)
  }

  return (
    <Modal
      open
      size="sm"
      modalHeading={t('correspondence.assign.title')}
      primaryButtonText={t('correspondence.assign.confirm')}
      secondaryButtonText={t('detail.cancel')}
      primaryButtonDisabled={!valid || busy}
      onRequestClose={onClose}
      onRequestSubmit={submit}
    >
      <p className="page__lead muted">{t(taskId ? 'correspondence.assign.taskLead' : 'correspondence.assign.lead')}</p>
      {/* Numbering freezes the content — say so BEFORE the irreversible click, in the
          repo's quiet low-contrast warning style. */}
      <InlineNotification
        kind="warning"
        lowContrast
        hideCloseButton
        className="assign-lock-note"
        title={t('correspondence.assign.lockWarning')}
      />
      {/* autoAlign floats the menu (floating-ui, position:fixed) so it escapes the modal's
          overflow clip instead of rendering under the footer buttons — the same fix the
          workflow/MCP/EditFolder modal pickers use (commit 15d05ac). */}
      <Dropdown
        id="assign-scheme"
        autoAlign
        titleText={t('correspondence.assign.scheme')}
        label={schemeItem?.label ?? t('correspondence.assign.schemePlaceholder')}
        items={schemeItems}
        selectedItem={schemeItem}
        itemToString={(i) => i?.label ?? ''}
        onChange={({ selectedItem }) => setSchemeCode(selectedItem?.id ?? '')}
      />
      {schemeCode !== '' && previewNumber && (
        <p className="assign-preview">
          {t('correspondence.assign.nextNumber')} <span className="mono assign-preview__num">{previewNumber}</span>
          <span className="muted assign-preview__note"> {t('correspondence.assign.nextNumberNote')}</span>
        </p>
      )}
      {authoring !== 'docx' && (
        <div className="newdoc__field">
          <Dropdown
            id="assign-template"
            autoAlign
            titleText={t('correspondence.assign.template')}
            label={letterheadItem?.label ?? ''}
            items={letterheadItems}
            selectedItem={letterheadItem}
            itemToString={(i) => i?.label ?? ''}
            onChange={({ selectedItem }) => selectedItem && setLetterheadId(selectedItem.id)}
          />
        </div>
      )}
      {error && <InlineNotification kind="error" lowContrast hideCloseButton title={error} />}
    </Modal>
  )
}

LetterDetailView.tsx: delete the local AssignNumberModal + NO_TEMPLATE (:1016–1099),
add import { AssignNumberModal } from './AssignNumberModal', and prune now-unused imports
(useAssignNumber, useSchemeNumberPreview, useNumberingSchemes, useLetterheads — keep
any that other code in the file still uses; npx tsc --noEmit is the arbiter). The existing
call site (:317) is prop-compatible (no taskId) — unchanged behavior.

5e. LetterTaskBanner.tsx — the number task strip button + modal

  • Imports: add Number_1 as NumberIcon to the @carbon/icons-react import (:21) and
    import { AssignNumberModal } from './AssignNumberModal'.
  • State next to rejecting (:63): const [numberTask, setNumberTask] = useState<LetterTask | null>(null).
  • In the per-task actions block (after the meterai/stamp button, :147–151):
            {task.action === 'number' && (
              <Button size="sm" kind="primary" renderIcon={NumberIcon} onClick={() => setNumberTask(task)}>
                {t('correspondence.detail.assign')}
              </Button>
            )}
  • Mount the modal next to the other ceremony modals (:223–228):
      {/* The NUMBER task's ceremony: the shared scheme-picker modal, submitting to the
          engine-scoped task endpoint (completes the task + advances the chain). */}
      {numberTask && (
        <AssignNumberModal letterId={letter.id} authoring={letter.authoring} taskId={numberTask.id} onClose={() => setNumberTask(null)} />
      )}
  • The strip title t(\correspondence.task.${task.action}`)` (:123) resolves via the new
    i18n key; Reject already covers every task kind (:152–154) and now legally rejects number
    tasks thanks to T1's ActDefinition case.

5f. i18n (correspondence/i18n.ts, en + id)

en — under task: (:198): number: 'Assign the official number to this letter.'; under
assign: (:112): taskLead: 'Completing this task assigns the official number and moves the workflow to its next step.'
id — under task: (:447): number: 'Beri nomor resmi pada surat ini.'; under assign:
(:362): taskLead: 'Menyelesaikan tugas ini memberi nomor resmi pada surat dan melanjutkan alur ke langkah berikutnya.'

Verify: cd web && npx tsc --noEmit && npx vite build — green. Also re-run the Go gates
once more from a clean tree state (T1–T3 untouched by T4/T5): cd go && go build ./... && go vet ./....


T6 — e2e on the demo (CONTROLLER-DRIVEN — this plan only lists the script)

  • [ ] Deploy per memory (deploy/update.sh on valbox — read the deploy memory FIRST; no
    migration in this feature, but still re-check ls go/migrations | tail against the version
    stamp before deploying). Then drive the chain over a FRESH letter. $API = the demo
    /api/v1 base; login/token per the standing demo recipe (dev-login auto-grants admin —
    see the groups-feature memory); DIR = the director's user id, POS = a position the
    director holds (GET /positions).
# 0) sanity: schemes exist (STD)
curl -sf -H "$AUTH" "$API/numbering-schemes" | jq -r '.schemes[].code'   # expect STD

# 1) fresh draft letter (docx if the office fixture is handy, else HTML — both paths are wired)
LETTER=$(curl -sf -H "$AUTH" -H 'Content-Type: application/json' -d '{"subject":"e2e number step","body_html":"<p>halo</p>","classification":"Internal"}' "$API/letters" | jq -r .id)

# 2) start the Full correspondence flow (approve → number → sign), director on every step
INST=$(curl -sf -H "$AUTH" -H 'Content-Type: application/json' -d "{\"name\":\"Full correspondence flow\",\"subject_type\":\"letter\",\"subject_id\":\"$LETTER\",\"steps\":[
  {\"name\":\"\",\"approver_position_ids\":[],\"kind\":\"approve\",\"assignee_user_ids\":[\"$DIR\"]},
  {\"name\":\"\",\"approver_position_ids\":[],\"kind\":\"number\",\"assignee_user_ids\":[\"$DIR\"]},
  {\"name\":\"\",\"approver_position_ids\":[],\"kind\":\"sign\",\"assignee_user_ids\":[\"$DIR\"]}]}" \
  "$API/workflow/custom" | jq -r .instance_id)
curl -sf -H "$AUTH" "$API/letters/$LETTER" | jq -r .status          # in_review  (T2b flip)

# 3) approve step
curl -sf -H "$AUTH" "$API/workflows/inbox" | jq '.[] | select(.State=="pending") | {ID,ActionRequired}'
curl -sf -X POST -H "$AUTH" -H 'Content-Type: application/json' -d '{"action":"approve","note":""}' "$API/workflow/instances/$INST/act-definition" -o /dev/null -w '%{http_code}\n'   # 204; letter stays in_review

# 4) number task (scheme STD at completion — no scheme was pinned)
TASK=$(curl -sf -H "$AUTH" "$API/workflows/inbox" | jq -r '.[] | select(.State=="pending" and .ActionRequired=="number") | .ID')
curl -sf -X POST -H "$AUTH" -H 'Content-Type: application/json' -d '{"scheme_code":"STD"}' "$API/workflow/tasks/$TASK/number" | jq .   # {"number":"…"}
curl -sf -H "$AUTH" "$API/letters/$LETTER" | jq '{status,number}'   # numbered + number set

# 5) sign task → letter seal ceremony (per the letter-chain memory recipe; internal sig or
#    OTP resume via GET /letters/$LETTER/pending-seal as shipped) → seal_rev 1
curl -sf -X POST -H "$AUTH" -H 'Content-Type: application/json' -d "$SIGN_BODY" "$API/letters/$LETTER/sign" | jq .
curl -sf -H "$AUTH" "$API/letters/$LETTER/seals" | jq '.[0]'        # rev 1; instance now approved

# 6) terminal act stays manual
curl -sf -X POST -H "$AUTH" "$API/letters/$LETTER/sent" -o /dev/null -w '%{http_code}\n'   # 204

# NEGATIVES (each must fail with the exact code):
# a) definition-save order: sign before number → 400 workflow.custom.invalid_steps
curl -s -X POST -H "$AUTH" -H 'Content-Type: application/json' -d "{\"name\":\"bad\",\"visibility\":\"private\",\"steps\":[{\"name\":\"\",\"approver_position_ids\":[\"$POS\"],\"kind\":\"sign\"},{\"name\":\"\",\"approver_position_ids\":[\"$POS\"],\"kind\":\"number\"}]}" "$API/workflow/custom/save" | jq -r .detail
#    → "The Assign-number step must come before any signature, e-Meterai or stamp step"
# b) number chain over the NOW-NUMBERED letter → 409 correspondence.workflow.already_numbered
#    (repeat step 2 against $LETTER … but it is sent now — create a second letter, number it
#     directly via POST /letters/{id}/number, THEN repeat step 2 → 409)
# c) non-assignee completion → 403 workflow.number.not_assignee (second user session, repeat
#    step 4's POST on a fresh chain's number task)
# d) number step over a DOCUMENT subject → 4xx correspondence.workflow.number_letter_only
#    (step 2 body with subject_type document + any doc id)
# e) regression: DOCUMENT approve→sign chain unchanged (start one over a test doc, sign,
#    confirm advance — the document path must be byte-identical)

Expected audit trail on the happy path (GET /workflows/$INST/history): submit →
approve (submitted→submitted) → number "number assigned" (submitted→submitted)
sign "signed" (submitted→approved).


Self-review checklist (done during authoring — re-verify at implementation)

  • Spec coverage: kind ✓, registrar assignee ✓, task-holder authorization (no creator gate,
    public endpoint untouched) ✓, scheme pin OR modal pick ✓, start-time letter-only /
    already-numbered 409 / seals-need-number-or-numbered ✓, save-time order rules (user
    strengthening: approve → ≤1 number → seal) ✓, reject/cancel semantics (number never
    voided; reflection verified with file:line) ✓, builder palette + hint ✓, inbox + banner +
    reused modal ✓, preset in the letter picker via the existing prefill mechanism ✓, MarkSent
    untouched ✓, direct numbering unchanged ✓.
  • Type consistency: NumberTaskContext returns (domain.Instance, domain.StepSpec, error)
    and the handler uses exactly those; reserveNumber flag threaded through BOTH unexported
    cores; FE StepKind/action unions extended in all four places that switch on them
    (workflows/data.ts, approvals/data.ts, api/correspondence.ts, StepListEditor KIND_TAG).
  • Every step-kind switch touched or explicitly cleared: definition.go ValidateSteps /
    StepPlacementsComplete / IsValidStepKind; graph.go stepKindLabel; service.go taskPlacement /
    ActDefinition / fanOutStep(comment) / requireSignLicensed(no) / requireMeteraiAffixable(no);
    guardLetterWorkflowStart; FE nodeClass / KIND_TAG / placementRefs / ContentSwitcher /
    data.ts mappings / approvals mapping / MyTasksTab branches / LetterTask mapping.