"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) andcd 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 byValidateSteps(every authoring path) and
guardLetterWorkflowStart(every letter start path).StepSpecgains an optional
SchemeCode(jsonbscheme_code,omitempty— the spec's "definition may pin a scheme"). - Advance: new
CompleteNumberForSubjectmirroringCompleteMeteraiForSubject
(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
NumberTaskContextvalidator 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 afromWorkflow bool; new
...ForTasktwins passtrue, which additionally acceptsin_review. Public path
byte-identical. - Endpoint:
POST /workflow/tasks/{taskID}/number {scheme_code, letterhead_id?}in
handlers_correspondence.go (needss.correspondence+s.letterhead+s.workflow, all on
Server), routed withrequireModule("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 EXISTINGCustomWorkflowModal
(initialSteps+fixedSubjectprops 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 initialSteps → draftsFromSteps → 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 domain → app ports/services
owning kernel.UnitOfWork → adapters 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-checkls go/migrations | tailimmediately before writing one (co-agent
number race); only if a CHECK constraint forces it. - Never
git add -A(tracked ELFgo/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
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
}
-
fanOutStepneeds no functional change —ActionRequired: 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
affixPermCheckerblocks :1454–1481): task-holdership is the whole authority — any
resolved assignee can complete. -
ActDefinitionceremony-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.")
- 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
}
- 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),StepPlacementsCompletecallers,
EscalateOverdue(kind-agnostic, action_required carried verbatim :2898), Dispose/Forward
(role tasks, not steps), esignWorkflowAdvancer(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.kinddescription — see header; NO regen).
3a. Correspondence service — engine-scoped status gate, public paths byte-identical
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)"}
}
- 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' useLatestDefinitionVersionkind 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 withcase 'approve':—
case 'number': return [](numbering has no appearance; also keepsprunePlacements
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 }) {
itemsmemo (: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>.WorkflowPickOptiongroup 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 tostartingWf(: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, newweb/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 NumberIconto the@carbon/icons-reactimport (: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.shon valbox — read the deploy memory FIRST; no
migration in this feature, but still re-checkls go/migrations | tailagainst the version
stamp before deploying). Then drive the chain over a FRESH letter.$API= the demo
/api/v1base; 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:
NumberTaskContextreturns(domain.Instance, domain.StepSpec, error)
and the handler uses exactly those;reserveNumberflag threaded through BOTH unexported
cores; FEStepKind/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.