think
16px
820px

Unify Signature Workflows — ONE Engine

For agentic workers: written for INLINE execution by the session controller. Checkbox
(- [ ]) tracking per task; commit per task on main, explicit paths only, no push.
Every task must leave the tree green (go build ./... && go vet ./..., and from T8 on
npx tsc --noEmit && npx vite build).

Goal: Today "Request signature" is a hidden preset that bypasses the workflow fan-out:
RequestSignatures mints a definition + version whose single step is HOLLOW (no positions, no
assignees) and then inserts tasks DIRECTLY per named user, skipping fanOutStep — and with it
the workflow.task_assigned event that produces the in-app notification + email. That is the
reported "signer never gets prompted/nudged" bug.
Unify so the preset emits a real step that
goes through the same fan-out, and so the no-code designer can express everything the preset
could: named individual signers, all-must-sign, a signature tier, several external slots, and a
pre-placed signature box.

Architecture: domain.StepSpec grows five omitempty fields (assignee_user_ids,
completion, contact_slots, placements, plus the SignaturePlacement value type). Because
they are all omitempty, every jsonb step row already in Postgres re-serialises byte-identically
and unmarshals to exactly today's behavior.
fanOutStep becomes the single step-task-creation
path (positions ∪ named users, de-duped, placement-seeded, always notifying).
CompleteSignatureForSubject's def.Kind == signature_request special case is replaced by a
generic effectiveCompletion(step, defKind) that preserves in-flight instances. RequestSignatures
becomes a ~15-line preset that builds a StepSpec and calls fanOutStep.

Tech Stack: Go modular monolith (go/, hexagonal: domain pure → app (ports + service, owns
the kernel.UnitOfWork tx boundary) → adapters (pgx) / httpapi), Postgres + goose migrations
(go/migrations/), React 18 + Carbon + TanStack Query SPA (web/), openapi-typescript client in
packages/api-client generated from api/openapi.yaml.


Global Constraints

  • NEVER run go test — the test DSN points at the LIVE demo Postgres. Verify Go with cd go && go build ./... && go vet ./... only. (A throwaway dind DB is the only exception.)
  • NEVER docker compose down -v — it wiped demo pgdata+miniodata once. Single-service teardown ONLY: docker compose stop <svc> / start <svc>.
  • NEVER git add -Ago/obscura-server is a tracked ELF binary. Add explicit paths only.
  • Web verify: cd web && npx tsc --noEmit && npx vite build. NO new npm deps.
  • After api/openapi.yaml edits regenerate types — npm run gen:api is BROKEN via npm; run openapi-typescript directly in packages/api-client (schema lives there).
  • Deploy ONLY via /home/efran/remote-development/obscura/deploy/update.sh on the host (ssh valbox, ABSOLUTE path; ssh lands in $HOME and the shell is zsh). Never hand-rolled compose build/up.
  • Post-deploy assert /me enabled_modules == [ai, correspondence, esign, semantic, watermarking] (dev-login director@obscura.local at localhost:38080 on valbox).
  • Commit per task on main; do NOT push unless asked. The working tree has a PARALLEL AGENT's uncommitted stego WIP (go/internal/protection/*, stego/*, proto/stego/*) — never commit those; add only your own explicit paths.
  • Back-compat is non-negotiable: in-flight instances pin a definition version; existing jsonb step rows and running signature_request instances must behave EXACTLY as today.
  • i18n: add BOTH web/src/i18n/locales/en.ts and id.ts (nested locales; watch the smart-quote gotcha).

Constraint notes specific to this plan

  • go vet type-checks _test.go files. That is how the "TDD-ish" tasks below are verified
    without running the suite: new pure table-cases are written and vetted, never executed. Do not
    add a test that needs a DB.
  • The parallel stego agent owns go/internal/protection/*, stego/*, proto/stego/*,
    web/src/api/protection.ts, web/src/features/verify/*, web/nginx.conf.
    This plan touches
    none of them. Every commit lists explicit paths.

Reality Check — every anchor was verified against the code; here is where reality differs

Scouted at commit 0649cfe (working tree dirty with the stego agent's WIP). Confirmed exactly as
briefed
unless listed below.

Briefed anchor Verdict
definition.go:12-26 step kinds; StepSpec :34-49; Kind() :59; IsValidStepKind() :68; ValidateSteps :169-216; DefinitionKind* :118-126 exact
service.go:1693-1797 RequestSignatures; hollow step :1741; direct-insert loop :1771-1782; SigningOrder :1762; Recommended[RecommendedSignKindKey] :1752 exact
fanOutStep emits notifications, direct-insert loop does not CONFIRMED — this is the bug. fanOutStep publishes workflow.task_assigned at service.go:1185-1201; wire.go:733-759 relays it to notifySvc.Notify (in-app + email). The :1771-1782 loop publishes nothing.
fanOutExternalSign :1206-1229; AdvanceExternalSignStep :1231-1266; ExternalSignRequester :202-213; SetExternalSignRequester :276 exact (fanOutStep itself spans :1132-1204, not …-1175)
CompleteSignatureForSubject :905-1002, branch :952-993, else :995-996 exact
completeSignOverride :1004-1058 ⚠️ ends at :1064, not :1058 (doc :1004-1011, func :1012-1064)
handlers_esign.go:28RequestSignatures :170; buildEnvelope :178, :205-269; internal-tier skip :222-224 exact
PlaceSignatureModal.tsx confirm() :279-298 exact
useSignVersion at document-detail.ts:488-528 ⚠️ :488-494 is the SignPlacement interface; useSignVersion is :499-528.
SignDocumentVersion handlers_esign.go:397-417 ⚠️ that range is the placement parse block (:397-403 inline struct, :408-417 map to esignapp.Placement), not the whole handler
SignInfo{Placement} esign/app/service.go:852-856; DefaultSigX/Y/W/H :55-62; adapter box [36,36,276,108] pdfsign.go:100-106; clampPlacement pdfsign.go:110,186-236; signerPlacement() :2188-2189 exact (const block is :54-61)
StepListEditor.tsx payload :40-42 omits sign_kind; ContactSlot :118-133 ✅ payload :38-44 omits sign_kind; the contact-slot TextInput is :131-144 (:118 is the kind ContentSwitcher)
MyTasksTab.tsx:28 / :84-97 / sign.mutate :90-96 ✅ (mutate is :92-95)
RequestSignatureModal.tsx payload :110-114 ✅ (signers[] built :109-112, req.mutate :113-114)
Migration 00098 = share_otp taken → next free 00099 confirmed

Where the approved design CONFLICTS with reality — read before starting

  1. 🔴 A zero-assignee sign step is LEGAL and load-bearing. RequestSignatures:1697-1699
    explicitly allows zero internal signers: a Global request whose signers are all external
    opens a 0-task mirror instance that the envelope's roster finalize (FinalizeInstance,
    service.go:1652) advances. The handler passes only internalUserIDs (handlers_esign.go:164-170)
    — externals never reach the workflow at all.
    → Design point 1's rule "a sign step needs ≥1 position OR ≥1 named user OR ≥1 contact slot" is
    correct for the designer but would break this preset. And design point 4's "go through the
    SAME fan-out"
    would hit fanOutStep's len(approvers)==0 → ErrValidation (:1146-1148) and
    break every all-external Global request.
    Resolution (T1/T3): ValidateSteps keeps the strict rule (it is called ONLY from the four
    authoring paths — service.go:559 DefineWorkflow, :600 ReviseWorkflow, :1466
    StartCustomWorkflow, :1563 SaveCustomWorkflow — and never from RequestSignatures, today or
    after). fanOutStep errors only when a step declares assignees but resolves to none; a step
    that declares nobody fans out to zero tasks and returns nil. That case is unreachable from the
    designer precisely because ValidateSteps rejects it.

  2. 🔴 Routing named signers through fanOutStep would silently apply availability substitution.
    RequestSignatures' doc (:1689-1691) states tasks are assigned to the exact users chosen
    "(no availability substitution) so the Global Mekari envelope's signer phones line up with the
    assignees"
    , but fanOutStep:1167 calls s.effectiveAssignee(...) on every assignee. Naively
    unifying would re-route "Alice must sign" to Bob and desynchronise the envelope roster.
    Resolution (T3): substitution applies to position-resolved holders only; individually
    named assignees are used verbatim. A signature is an identity claim, not a fungible duty.

  3. 🔴 Generalising all-must-sign to advanceApprovedInstance is REQUIRED, not cosmetic. The
    current all-must-sign branch (:962-977) jumps straight to StateApproved when no pending tasks
    remain. Correct for a 1-step signature_request; a designer-authored all-must-sign step in a
    3-step workflow would skip steps 2-3.

    Resolution (T4): the last signer calls advanceApprovedInstance (:853), which is
    byte-identical for the 1-step case (same FromState/ToState/Action/Actor/Note) and
    correct for N steps.

  4. 🟡 IsValidStepKind cannot validate the new fields. Its signature is (k string) bool — it
    only ever sees the kind. Design point 1's "extend IsValidStepKind/ValidateSteps" lands
    entirely in ValidateSteps; IsValidStepKind is untouched. A sibling IsValidCompletion(c string)
    is added alongside it (T1).

  5. 🟡 ContactSlot is a string field, not a type. Design point 1's ContactSlots []ContactSlot
    is therefore ContactSlots []string (T1). (domain.ExternalContact is the filled-at-start
    party; the slot is just its label.)

  6. 🟡 sign_kind already exists end-to-end on the backendStepSpec.SignKind (:48),
    stepInput.SignKind (handlers_designer.go:23), and the OpenAPI StepInput/StepSpecOut
    schemas all carry it. Only the frontend drops it: web/src/features/workflows/data.ts:165-170
    (StepInput), :242 (the latest-version parse type) and StepListEditor.tsx:38-44
    (stepsPayload). Design point 6's "expose the tier" is a pure frontend task (T11) — no backend
    change.

  7. 🟡 Multi-slot sign_external is the one genuinely new subsystem. Instance.ExternalContacts
    is keyed by step index only (workflow.go:176, contactForStep:1276-1284), fanOutExternalSign
    mints exactly one envelope, and AdvanceExternalSignStep advances the step when that envelope
    completes. N slots needs per-slot identity end-to-end (workflow → esign envelope → back).
    Resolution (T6): back-compat key format — slot 0 keeps the bare "<step>" key (so every
    existing row and both existing start UIs are untouched), slots ≥1 use "<step>#<slot>"; the
    envelope carries step_slot (mirroring how step_index was added in mig 00095); the step
    advances once every slot is done. A legacy single-slot step has len(Contacts())==1 → done on
    slot 0 → advances immediately, identical to today.

  8. 🟡 Per-position placement refs are dropped (YAGNI + they would change the resolver call
    pattern).
    Keying a placement by position would force ResolveActors to be called once per
    position instead of once per step. Refs are user:<id>, contact:<label>, and * (a
    step-level default) — which is the right UX for a position step anyway (you cannot pre-place a
    box for a holder you cannot name). ResolveActors(step.ApproverPositionIDs, now) stays a single
    bulk call, exactly as today.

  9. 🟡 Pre-existing limitation, NOT fixed here: usePickableUsers (document-detail.ts:963-980)
    reads /api/v1/admin/users, gated on users.admin (server.go:842), and swallows the error to
    []. So the signer picker is already empty for non-admins in RequestSignatureModal. T11
    reuses the same hook for designer named-assignees, inheriting the same limitation: in
    CustomWorkflowModal (gated on workflow.custom, not users.admin) a non-admin sees an empty
    user list and must use positions. Widening that endpoint is out of scope — report it, do not
    fix it.

  10. 🟢 fanOutStep becomes the only step task-creation path — not the only task-creation path.
    Dispose (:1935), Forward (:2015), escalation (:2294) and the built-in legacy Start
    (:443) also create tasks; they are not step fan-outs and are untouched.


File Map

File Task Purpose
go/internal/workflow/domain/definition.go T1 SignaturePlacement, completion consts + IsValidCompletion, 4 new StepSpec fields, Completion()/Contacts()/DeclaresAssignees()/PlacementFor(), placement refs, rewritten ValidateSteps + validatePlacements
go/internal/workflow/domain/definition_test.go T1 pure table cases (written + vetted, never run)
go/internal/workflow/domain/workflow.go T2, T6 Task.Placement; Instance.ExternalSlotsDone
go/migrations/00099_signature_placement.sql (new) T2, T6 workflow_tasks.placement, esign_envelope_signers.placement, workflow_instances.external_slots_done, esign_sign_envelopes.step_slot
go/internal/workflow/adapters/pg.go T2, T6 taskColumns/taskColumnsT consts, placement jsonb in insert/scan; external_slots_done
go/internal/workflow/app/service.go T3, T4, T5, T6 stepAssignees/taskPlacement/rewritten fanOutStep; effectiveCompletion + generic completion; RequestSignatures preset; multi-slot external
go/internal/workflow/app/ports.go (new) T6 MarkExternalSlotDone on RepositoryNOTE: no such file today; ports live in service.go:29-146. Add the method there, do NOT create the file.
go/internal/httpapi/handlers_esign.go T5, T7 parse signers[].placement, pass to RequestSignatures + buildEnvelope
go/internal/httpapi/handlers_designer.go T7 stepInput gains the 4 fields; toStepSpecs maps them
go/internal/esign/domain/envelope.go T6, T7 EnvelopeSigner.Placement; SignEnvelope.StepSlot
go/internal/esign/adapters/envelope_pg.go T6, T7 placement + step_slot columns/scan/insert
go/internal/esign/app/service.go T6, T7 EnvelopeSignerInput.Placement, signerPlacement() real impl, ExternalSignStepInput.SlotIndex, CreateSignEnvelope/RequestExternalSignStep signatures, WorkflowAdvancer port
go/cmd/obscura-server/resolvers.go T6 workflowExternalSignRequester slot passthrough
api/openapi.yaml T7 SignaturePlacement schema; signers[].placement; StepInput/StepSpecOut new fields
packages/api-client/src/schema.ts T7 regenerated
web/src/features/approvals/data.ts T8 InboxTask.PlacementPendingApproval.placement
web/src/features/workflows/MyTasksTab.tsx T8 seed the ceremony from the task's placement
web/src/features/documents/PlaceSignatureModal.tsx T9 initialPlacement + lead props
web/src/features/documents/RequestSignatureModal.tsx T10 per-signer author-mode placement; new version prop
web/src/features/documents/DocumentDetailView.tsx T10 pass version={currentVersion} at :1315
web/src/api/document-detail.ts T10 signers[].placement on the request payload
web/src/styles/app.css T10, T11 .req-sig__roster* + .wf-builder__slot rules
web/src/features/workflows/data.ts T11 StepInput gains the 4 fields; latest-version parse
web/src/features/workflows/StepListEditor.tsx T11 named assignees, completion, tier, N contact slots
web/src/features/workflows/DefinitionEditor.tsx T11 contactSlotcontactSlots prefill + new draft fields
web/src/features/workflows/CustomWorkflowModal.tsx T11 per-slot contact collection + slot keying
web/src/features/workflows/StartInstanceModal.tsx T11 per-slot contact collection + slot keying
web/src/features/workflows/i18n.ts T12 workflows.builder.* en + id
web/src/i18n/locales/en.ts, id.ts T12 docview.requestSignature.* + docview.place.* en + id

Task 1: Domain — placement, completion, named assignees, contact slots, validation

Files: go/internal/workflow/domain/definition.go, go/internal/workflow/domain/definition_test.go.

Everything here is pure (stdlib only) and is the foundation for every later task.

  • [x] In definition.go, change the import block to add strings:
import (
    "encoding/json"
    "strings"
    "time"
)
  • [x] Append after the step-kind const block (i.e. after definition.go:26):
// Completion rules for a step with more than one assignee.
const (
    // CompletionAny: the FIRST assignee to act decides the step. This is the legacy
    // default — an absent/empty `completion` key reads as "any", so every jsonb step
    // written before this field existed keeps behaving exactly as it always has.
    CompletionAny = "any"
    // CompletionAll: EVERY assignee must act before the step completes. This is what the
    // "Request signature" preset always meant (it hard-coded the rule in the service
    // instead of in the step); the designer can now ask for it too.
    CompletionAll = "all"
)

// IsValidCompletion reports whether c is a recognised completion rule. The empty string
// is accepted because it is the legacy/default encoding of "any".
func IsValidCompletion(c string) bool {
    switch c {
    case "", CompletionAny, CompletionAll:
        return true
    }
    return false
}

// SignaturePlacement is a PRE-PLACED visible-signature rectangle in PDF points with origin
// BOTTOM-LEFT (the space the page MediaBox lives in); Page is 1-based. It is exactly the
// geometry the signer's own placement modal already produces — captured earlier, by the
// sender at request time or the author at design time — so the signer's ceremony opens on
// the box instead of a blank page. It is a SUGGESTION: the signer can still move it, and the
// PAdES adapter clamps it to the page and silently falls back to its default box when the
// rect is unusable, so a stale placement can never fail a sign.
type SignaturePlacement struct {
    Page        int     `json:"page"`
    LowerLeftX  float64 `json:"llx"`
    LowerLeftY  float64 `json:"lly"`
    UpperRightX float64 `json:"urx"`
    UpperRightY float64 `json:"ury"`
}

// Placement map keys. A step's Placements map is keyed by an assignee REF so one step can
// pre-place a different box per signer. The prefixes keep the namespaces disjoint: a user id
// can never collide with a free-text contact-slot label.
//
// There is deliberately no position ref: you cannot pre-place a box for a holder you cannot
// name, and keying by position would force ResolveActors to run once per position instead of
// once per step. A position step uses PlacementRefDefault instead.
const (
    placementRefUserPrefix    = "user:"
    placementRefContactPrefix = "contact:"
    // PlacementRefDefault is the step-level fallback box, applied to any assignee with no
    // ref of their own. "*" carries no prefix, so it can never collide with a real ref.
    PlacementRefDefault = "*"
)

// PlacementRefUser is the Placements key for an individually named signer.
func PlacementRefUser(userID string) string { return placementRefUserPrefix + userID }

// PlacementRefContact is the Placements key for an external contact slot, keyed by the
// slot's label (the only stable identity a slot has before the requester fills it in).
func PlacementRefContact(slot string) string { return placementRefContactPrefix + slot }
  • [x] Replace the whole StepSpec struct (definition.go:28-49, doc comment included) with:
// StepSpec is one step in a no-code workflow definition: a human-readable name plus WHO must
// act. Assignees come from ApproverPositionIDs (whose live holders the Resolver expands at
// fan-out) and/or AssigneeUserIDs (individually named users) — unioned and de-duped, one
// pending task each. Kind decides what the task is: an approval ("approve", the default), a
// signature ("sign"), an e-Meterai affix ("meterai") or an external signature
// ("sign_external", which routes to contact slots instead of people).
//
// EVERY field after SignKind is `omitempty` on purpose: a step written before that field
// existed re-serialises byte-identically and unmarshals to the zero value, which the
// accessors below map back to exactly the legacy behavior. That is the whole back-compat
// contract for the jsonb steps column — do not remove an omitempty.
type StepSpec struct {
    Name                string   `json:"name"`
    ApproverPositionIDs []string `json:"approver_position_ids"`
    // StepKind is "approve" (default), "sign", "meterai" or "sign_external". omitempty
    // keeps the jsonb backward-compatible: an approve step serialises WITHOUT a kind key,
    // so old rows that predate this field unmarshal to StepKind=="" and read as approve.
    StepKind string `json:"kind,omitempty"`
    // ContactSlot is the LEGACY single external-signer slot for a sign_external step (e.g.
    // "Client signatory"); the requester fills the actual contact at start. Superseded by
    // ContactSlots but still read (and still written by nothing) — use Contacts(), which
    // normalises both into one slice. Empty for every other kind.
    ContactSlot string `json:"contact_slot,omitempty"`
    // SignKind is the suggested signature tier ("internal"|"global"|"psre") for a sign or
    // sign_external step — a hint carried onto the ceremony, not a hard constraint. Empty
    // = the provider/adapter default.
    SignKind string `json:"sign_kind,omitempty"`

    // AssigneeUserIDs names INDIVIDUAL users who each receive a task when the step
    // activates, expanded ALONGSIDE ApproverPositionIDs (union, de-duped by user id). A
    // step may name positions, users, or both. Unlike a position holder, a named assignee
    // is NEVER routed through the availability/substitute resolver: naming someone is an
    // identity claim ("Alice must sign"), not a fungible duty, and for a Global signature
    // request the envelope's signer phones must line up with the task assignees.
    AssigneeUserIDs []string `json:"assignee_user_ids,omitempty"`
    // StepCompletion is "any" (the first assignee to act decides the step — the legacy
    // default for every step ever written) or "all" (every assignee must act before the
    // step advances). Read it through Completion(), never directly. See also
    // app.effectiveCompletion, which layers the one legacy exception on top.
    StepCompletion string `json:"completion,omitempty"`
    // ContactSlots are the labelled external-signer slots for a sign_external step — one
    // outside party each, one envelope each, all of which must sign before the step
    // advances. Read it through Contacts(), which folds in the legacy single ContactSlot.
    ContactSlots []string `json:"contact_slots,omitempty"`
    // Placements are OPTIONAL pre-placed signature boxes keyed by assignee ref
    // (PlacementRefUser / PlacementRefContact / PlacementRefDefault). Absent = the signer
    // places their own box, which is what every step did before this field existed. Read it
    // through PlacementFor().
    Placements map[string]SignaturePlacement `json:"placements,omitempty"`
}
  • [x] Insert these accessors immediately after the existing IsValidStepKind (i.e. after
    definition.go:74):
// Completion returns the step's completion rule, defaulting an empty/legacy value to "any"
// (first-to-act wins — exactly how every step behaved before this field existed).
func (s StepSpec) Completion() string {
    if s.StepCompletion == CompletionAll {
        return CompletionAll
    }
    return CompletionAny
}

// Contacts normalises a sign_external step's slots into one slice: the legacy single
// ContactSlot first (every such step written before ContactSlots existed), then any
// ContactSlots, trimmed, blanks dropped, de-duped, order stable. A legacy step yields
// exactly one slot, so callers that loop over Contacts() reproduce today's behavior.
func (s StepSpec) Contacts() []string {
    out := make([]string, 0, 1+len(s.ContactSlots))
    seen := map[string]bool{}
    add := func(c string) {
        c = strings.TrimSpace(c)
        if c == "" || seen[c] {
            return
        }
        seen[c] = true
        out = append(out, c)
    }
    add(s.ContactSlot)
    for _, c := range s.ContactSlots {
        add(c)
    }
    return out
}

// DeclaresAssignees reports whether the step names anyone at all to route to: positions,
// individual users, or (for an external signature) contact slots. A step that declares
// nobody has nothing to fan out — that is not an error at fan-out time, because the
// envelope-backed signature request whose signers are ALL external legitimately holds zero
// internal tasks. ValidateSteps rejects such a step in every AUTHORING path, so it is only
// ever reachable from that preset.
func (s StepSpec) DeclaresAssignees() bool {
    return len(s.ApproverPositionIDs) > 0 || len(s.AssigneeUserIDs) > 0 || len(s.Contacts()) > 0
}

// PlacementFor returns the pre-placed box for an assignee ref, falling back to the
// step-level default ("*"), and finally reporting ok=false — meaning "nobody pre-placed one;
// the signer places their own box", the behavior of every step before Placements existed.
func (s StepSpec) PlacementFor(ref string) (SignaturePlacement, bool) {
    if len(s.Placements) == 0 {
        return SignaturePlacement{}, false
    }
    if pl, ok := s.Placements[ref]; ok {
        return pl, true
    }
    if pl, ok := s.Placements[PlacementRefDefault]; ok {
        return pl, true
    }
    return SignaturePlacement{}, false
}
  • [x] Replace ValidateSteps entirely (definition.go:160-216, doc comment included) with:
// ValidateSteps reports whether a step list is a well-formed definition body. Returns the
// bool/string pair (rather than a *kernel.Error) so the domain stays stdlib-only; the app
// layer maps a failure to kernel.ErrValidation.
//
// Every step must name at least one assignee: an approve/sign/meterai step needs at least one
// approver position OR one named user; a sign_external step needs at least one contact slot
// and must NOT name positions or users (it routes outside the org).
//
// e-Meterai ordering rules: a meterai step must come AFTER at least one sign step (the
// provider refuses to stamp an unsigned document — the affix service enforces signed-first,
// so an earlier placement could never complete), and a definition may carry at most ONE
// meterai step (the ledger enforces one stamp per document).
//
// IMPORTANT: this guards the AUTHORING paths only (DefineWorkflow, ReviseWorkflow,
// StartCustomWorkflow, SaveCustomWorkflow). RequestSignatures does NOT call it — and must
// not: an all-external Global request legitimately builds a step with zero internal
// assignees, whose instance is a 0-task mirror the envelope's roster finalize advances.
func ValidateSteps(steps []StepSpec) (ok bool, reason string) {
    if len(steps) == 0 {
        return false, "definition must have at least one step"
    }
    signSeen := false
    meteraiSeen := false
    for _, st := range steps {
        if !IsValidStepKind(st.StepKind) {
            return false, "step has an unknown kind"
        }
        if !IsValidCompletion(st.StepCompletion) {
            return false, "step has an unknown completion rule"
        }
        // An empty id would resolve to nobody and silently break the fan-out.
        for _, pid := range st.ApproverPositionIDs {
            if pid == "" {
                return false, "step has an empty approver position id"
            }
        }
        for _, uid := range st.AssigneeUserIDs {
            if uid == "" {
                return false, "step has an empty assignee user id"
            }
        }
        if pok, preason := validatePlacements(st); !pok {
            return false, preason
        }
        named := len(st.ApproverPositionIDs) > 0 || len(st.AssigneeUserIDs) > 0
        switch st.Kind() {
        case StepKindSignExternal:
            // External-sign steps route to an outside party, not to the org: they name
            // contact SLOTS (filled at start) and must carry neither positions nor users.
            if len(st.ApproverPositionIDs) != 0 {
                return false, "an external signature step cannot name positions"
            }
            if len(st.AssigneeUserIDs) != 0 {
                return false, "an external signature step cannot name internal users"
            }
            if len(st.Contacts()) == 0 {
                return false, "an external signature step needs a contact slot label"
            }
            signSeen = true
        case StepKindSign:
            if !named {
                return false, "a signature step needs at least one position or named signer"
            }
            signSeen = true
        case StepKindMeterai:
            if !named {
                return false, "step has no approver positions"
            }
            if meteraiSeen {
                return false, "definition may have at most one e-Meterai step"
            }
            if !signSeen {
                return false, "an e-Meterai step must come after a signature step"
            }
            meteraiSeen = true
        default: // approve (including the legacy empty kind)
            if !named {
                return false, "step has no approver positions"
            }
        }
    }
    return true, ""
}

// validatePlacements rejects a step whose Placements map references an assignee the step does
// not declare. A typo'd ref would never apply, which reads to the author as "my pre-placed box
// was silently ignored" — an authoring error deserves an authoring-time failure. The
// step-level default ("*") is always allowed, and each rect must be usable.
func validatePlacements(st StepSpec) (ok bool, reason string) {
    if len(st.Placements) == 0 {
        return true, ""
    }
    valid := map[string]bool{PlacementRefDefault: true}
    for _, uid := range st.AssigneeUserIDs {
        valid[PlacementRefUser(uid)] = true
    }
    for _, c := range st.Contacts() {
        valid[PlacementRefContact(c)] = true
    }
    for ref, pl := range st.Placements {
        if !valid[ref] {
            return false, "step has a placement for an assignee it does not name: " + ref
        }
        if pl.Page < 1 {
            return false, "a placement needs a 1-based page number"
        }
        if pl.UpperRightX <= pl.LowerLeftX || pl.UpperRightY <= pl.LowerLeftY {
            return false, "a placement needs a non-empty rectangle"
        }
    }
    return true, ""
}
  • [x] Append to go/internal/workflow/domain/definition_test.go (pure; written and vetted, never
    run
    — see Constraint notes):
func TestCompletionDefaultsToAny(t *testing.T) {
    // A legacy step (no completion key) must read as first-to-act — the behavior of every
    // step ever written before the field existed.
    if got := (StepSpec{}).Completion(); got != CompletionAny {
        t.Fatalf("legacy Completion() = %q, want %q", got, CompletionAny)
    }
    if got := (StepSpec{StepCompletion: CompletionAll}).Completion(); got != CompletionAll {
        t.Fatalf("Completion() = %q, want %q", got, CompletionAll)
    }
    // An unknown value must never read as "all" (fail closed to the legacy rule).
    if got := (StepSpec{StepCompletion: "bogus"}).Completion(); got != CompletionAny {
        t.Fatalf("unknown Completion() = %q, want %q", got, CompletionAny)
    }
}

func TestContactsNormalisesLegacySlot(t *testing.T) {
    cases := []struct {
        name string
        step StepSpec
        want []string
    }{
        {"legacy single", StepSpec{ContactSlot: "Client"}, []string{"Client"}},
        {"new multi", StepSpec{ContactSlots: []string{"Client", "Witness"}}, []string{"Client", "Witness"}},
        {"legacy + new, de-duped", StepSpec{ContactSlot: "Client", ContactSlots: []string{"Client", "Witness"}}, []string{"Client", "Witness"}},
        {"blanks dropped", StepSpec{ContactSlots: []string{" ", "Client", ""}}, []string{"Client"}},
        {"none", StepSpec{}, []string{}},
    }
    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            got := c.step.Contacts()
            if len(got) != len(c.want) {
                t.Fatalf("Contacts() = %v, want %v", got, c.want)
            }
            for i := range got {
                if got[i] != c.want[i] {
                    t.Fatalf("Contacts() = %v, want %v", got, c.want)
                }
            }
        })
    }
}

func TestPlacementForFallsBackToDefault(t *testing.T) {
    mine := SignaturePlacement{Page: 2, LowerLeftX: 10, LowerLeftY: 10, UpperRightX: 100, UpperRightY: 50}
    def := SignaturePlacement{Page: 1, LowerLeftX: 36, LowerLeftY: 36, UpperRightX: 276, UpperRightY: 108}
    st := StepSpec{Placements: map[string]SignaturePlacement{
        PlacementRefUser("u1"): mine,
        PlacementRefDefault:    def,
    }}
    if pl, ok := st.PlacementFor(PlacementRefUser("u1")); !ok || pl != mine {
        t.Fatalf("PlacementFor(u1) = %v/%v, want %v/true", pl, ok, mine)
    }
    if pl, ok := st.PlacementFor(PlacementRefUser("u2")); !ok || pl != def {
        t.Fatalf("PlacementFor(u2) = %v/%v, want the default %v/true", pl, ok, def)
    }
    if _, ok := (StepSpec{}).PlacementFor(PlacementRefUser("u1")); ok {
        t.Fatal("PlacementFor on a step with no placements must report ok=false")
    }
}

func TestValidateStepsNewFields(t *testing.T) {
    pos := []string{"p1"}
    cases := []struct {
        name string
        step StepSpec
        ok   bool
    }{
        {"sign by named user only", StepSpec{Name: "S", StepKind: StepKindSign, AssigneeUserIDs: []string{"u1"}}, true},
        {"sign by position only", StepSpec{Name: "S", StepKind: StepKindSign, ApproverPositionIDs: pos}, true},
        {"sign by both", StepSpec{Name: "S", StepKind: StepKindSign, ApproverPositionIDs: pos, AssigneeUserIDs: []string{"u1"}}, true},
        {"sign by nobody", StepSpec{Name: "S", StepKind: StepKindSign}, false},
        {"approve by named user only", StepSpec{Name: "A", AssigneeUserIDs: []string{"u1"}}, true},
        {"empty assignee id", StepSpec{Name: "A", AssigneeUserIDs: []string{""}}, false},
        {"completion all", StepSpec{Name: "S", StepKind: StepKindSign, AssigneeUserIDs: []string{"u1"}, StepCompletion: CompletionAll}, true},
        {"completion any", StepSpec{Name: "S", StepKind: StepKindSign, AssigneeUserIDs: []string{"u1"}, StepCompletion: CompletionAny}, true},
        {"completion bogus", StepSpec{Name: "S", StepKind: StepKindSign, AssigneeUserIDs: []string{"u1"}, StepCompletion: "sometimes"}, false},
        {"external multi-slot", StepSpec{Name: "E", StepKind: StepKindSignExternal, ContactSlots: []string{"Client", "Witness"}}, true},
        {"external with users", StepSpec{Name: "E", StepKind: StepKindSignExternal, ContactSlots: []string{"Client"}, AssigneeUserIDs: []string{"u1"}}, false},
        {"placement for a named user", StepSpec{Name: "S", StepKind: StepKindSign, AssigneeUserIDs: []string{"u1"},
            Placements: map[string]SignaturePlacement{PlacementRefUser("u1"): {Page: 1, UpperRightX: 10, UpperRightY: 10}}}, true},
        {"placement default ref", StepSpec{Name: "S", StepKind: StepKindSign, ApproverPositionIDs: pos,
            Placements: map[string]SignaturePlacement{PlacementRefDefault: {Page: 1, UpperRightX: 10, UpperRightY: 10}}}, true},
        {"placement for an unknown user", StepSpec{Name: "S", StepKind: StepKindSign, AssigneeUserIDs: []string{"u1"},
            Placements: map[string]SignaturePlacement{PlacementRefUser("nobody"): {Page: 1, UpperRightX: 10, UpperRightY: 10}}}, false},
        {"placement page 0", StepSpec{Name: "S", StepKind: StepKindSign, AssigneeUserIDs: []string{"u1"},
            Placements: map[string]SignaturePlacement{PlacementRefUser("u1"): {Page: 0, UpperRightX: 10, UpperRightY: 10}}}, false},
        {"placement empty rect", StepSpec{Name: "S", StepKind: StepKindSign, AssigneeUserIDs: []string{"u1"},
            Placements: map[string]SignaturePlacement{PlacementRefUser("u1"): {Page: 1}}}, false},
    }
    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            ok, reason := ValidateSteps([]StepSpec{c.step})
            if ok != c.ok {
                t.Fatalf("ValidateSteps ok = %v (reason %q), want %v", ok, reason, c.ok)
            }
            if !ok && reason == "" {
                t.Fatal("ValidateSteps returned !ok with an empty reason")
            }
        })
    }
}

func TestStepSpecJSONBackCompat(t *testing.T) {
    // The whole jsonb contract: a step with no new fields set must serialise WITHOUT any new
    // key, so existing rows round-trip byte-identically.
    b, err := MarshalSteps([]StepSpec{{Name: "Review", ApproverPositionIDs: []string{"p1"}}})
    if err != nil {
        t.Fatal(err)
    }
    const want = `[{"name":"Review","approver_position_ids":["p1"]}]`
    if string(b) != want {
        t.Fatalf("MarshalSteps = %s, want %s", b, want)
    }
    // And a legacy row unmarshals to exactly the legacy behavior.
    steps, err := UnmarshalSteps([]byte(`[{"name":"Signature","kind":"sign"}]`))
    if err != nil {
        t.Fatal(err)
    }
    if len(steps) != 1 || steps[0].Kind() != StepKindSign || steps[0].Completion() != CompletionAny ||
        steps[0].DeclaresAssignees() || len(steps[0].Contacts()) != 0 {
        t.Fatalf("legacy step read as %+v", steps[0])
    }
}
  • [x] Verify cd go && go build ./... && go vet ./... (vet type-checks the new test cases).
  • [x] Commit: git add go/internal/workflow/domain/definition.go go/internal/workflow/domain/definition_test.go && git commit -m "feat(workflow): step spec gains named assignees, completion rule, contact slots + placements"

Task 2: Migration 00099 + Task.Placement + task placement persistence

Files: go/migrations/00099_signature_placement.sql (new), go/internal/workflow/domain/workflow.go,
go/internal/workflow/adapters/pg.go.

  • [x] Create go/migrations/00099_signature_placement.sql. (Both esign columns are added here even
    though T6/T7 consume them — one migration per feature; adding a nullable/defaulted column is
    instant on Postgres.)
-- +goose Up
-- Pre-placed signature boxes + multi-slot external signing, for the unified signature engine.
--
-- workflow_tasks.placement is the box a sender (Request signature) or a workflow author (a
-- sign step in the designer) placed AHEAD of the ceremony. The box itself lives in the step's
-- jsonb spec (no migration needed there — StepSpec.Placements is omitempty), and fan-out seeds
-- it onto each task so the signer's placement modal opens on it. NULL = nobody pre-placed one
-- and the signer places their own, which is exactly what every existing row means.
--
-- Shape: {"page":1,"llx":36,"lly":36,"urx":276,"ury":108} — PDF points, origin BOTTOM-LEFT,
-- page 1-based: the same geometry the signer's modal emits and the PAdES adapter clamps.
ALTER TABLE workflow_tasks ADD COLUMN placement jsonb;

-- The envelope roster's per-signer box: where an external / Global signer's signature lands,
-- captured by the sender at request time. NULL = the adapter's default box, which is what
-- every existing row means (signerPlacement() returned nil for every roster signer).
ALTER TABLE esign_envelope_signers ADD COLUMN placement jsonb;

-- workflow_instances.external_slots_done marks which of a sign_external step's contact SLOTS
-- have had their envelope completed, keyed "<stepIndex>#<slotIndex>" (both 0-based slot,
-- 1-based step). The step advances only once every slot is done. NULL/absent = none done,
-- which is correct for every existing row: a legacy step has exactly one slot, so it advances
-- on that slot's completion exactly as it does today.
ALTER TABLE workflow_instances ADD COLUMN external_slots_done jsonb;

-- esign_sign_envelopes.step_slot is the 0-based contact slot within step_index that this
-- envelope fulfils, so its completion marks exactly that slot. Existing envelopes default to
-- slot 0 — the only slot a legacy single-slot step has. (Mirrors how step_index was added in
-- 00095_workflow_external_sign.sql.)
ALTER TABLE esign_sign_envelopes ADD COLUMN step_slot int NOT NULL DEFAULT 0;

-- +goose Down
ALTER TABLE esign_sign_envelopes DROP COLUMN step_slot;
ALTER TABLE workflow_instances DROP COLUMN external_slots_done;
ALTER TABLE esign_envelope_signers DROP COLUMN placement;
ALTER TABLE workflow_tasks DROP COLUMN placement;
  • [x] In go/internal/workflow/domain/workflow.go, add to the Task struct (after Recommended,
    i.e. after workflow.go:219):
    // Placement is the OPTIONAL pre-placed visible-signature box, seeded from the step at
    // fan-out because the sender/author placed it. nil = the signer places their own box in
    // their ceremony — the behavior of every task created before this field existed. Only
    // ever set on a sign task (an approval or affix has no signature appearance).
    Placement *SignaturePlacement
  • [x] In go/internal/workflow/adapters/pg.go, add the column consts immediately above
    InsertTask (before pg.go:273). They mirror the esign store's envelopeSignerColumns
    pattern and kill the eight copies of the literal list:
// taskColumns is the workflow_tasks column list, in the order scanTask reads them.
// taskColumnsT is the same list qualified for a query that JOINs workflow_instances.
const taskColumns = `id, instance_id, assignee_user_id, action_required, state, instruction, ` +
    `sla_deadline, escalated, created_at, completed_at, recommended, placement`

const taskColumnsT = `t.id, t.instance_id, t.assignee_user_id, t.action_required, t.state, t.instruction, ` +
    `t.sla_deadline, t.escalated, t.created_at, t.completed_at, t.recommended, t.placement`
  • [x] Replace InsertTask (pg.go:273-283) with:
// InsertTask persists a pending inbox task. instruction carries a disposition's routing note
// (empty for ordinary approval/forward tasks); placement carries the pre-placed signature box
// the step seeded (NULL when nobody pre-placed one).
func (s *Store) InsertTask(ctx context.Context, tk domain.Task) error {
    var placement any
    if tk.Placement != nil {
        b, err := json.Marshal(tk.Placement)
        if err != nil {
            return fmt.Errorf("workflow marshal task placement: %w", err)
        }
        placement = b
    }
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `INSERT INTO workflow_tasks (`+taskColumns+`)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
        tk.ID, tk.InstanceID, tk.AssigneeUserID, tk.ActionRequired, tk.State, tk.Instruction,
        tk.SLADeadline, tk.Escalated, tk.CreatedAt, tk.CompletedAt, tk.Recommended, placement); err != nil {
        return fmt.Errorf("workflow insert task: %w", err)
    }
    return nil
}
  • [x] Replace the literal column list with the const at every remaining site. The five
    unqualified SELECTs are at pg.go:288 (ListPendingTasksForInstance), :314
    (ListPendingTasksForUserOnInstance), :327 (GetTask), :375 (ListOverdueTasks), :430
    (InboxForUser) — each becomes `SELECT `+taskColumns+`. The two qualified SELECTs are
    at :445 (FindPendingSignTasksForSubject) and :469 (FindPendingTasksForSubjectAction) —
    each becomes `SELECT `+taskColumnsT+`. For example ListPendingTasksForInstance becomes:
    rows, err := s.db.Exec(ctx).Query(ctx,
        `SELECT `+taskColumns+`
           FROM workflow_tasks WHERE instance_id = $1 AND state = $2 ORDER BY created_at`,
        instanceID, domain.TaskStatePending)

and FindPendingSignTasksForSubject becomes:

    rows, err := s.db.Exec(ctx).Query(ctx,
        `SELECT `+taskColumnsT+`
           FROM workflow_tasks t
           JOIN workflow_instances i ON i.id = t.instance_id
          WHERE t.assignee_user_id = $1
            AND t.state = $2
            AND t.action_required = $3
            AND i.subject_type = $4
            AND i.subject_id = $5
            AND i.definition_version_id IS NOT NULL
            AND i.state = $6
          ORDER BY t.created_at`,
        userID, domain.TaskStatePending, domain.StepKindSign, subjectType, subjectID, string(domain.StateSubmitted))

ListOverdueTasks (:375) builds its query in a query := variable — keep that structure,
only swapping the column list:

    query := `SELECT ` + taskColumns + `
           FROM workflow_tasks
          WHERE state = $1 AND NOT escalated AND sla_deadline IS NOT NULL AND sla_deadline < $2
          ORDER BY sla_deadline`
  • [x] Replace scanTask (pg.go:530-536) with:
func scanTask(row scanner) (domain.Task, error) {
    var tk domain.Task
    var placement []byte
    if err := row.Scan(&tk.ID, &tk.InstanceID, &tk.AssigneeUserID, &tk.ActionRequired, &tk.State,
        &tk.Instruction, &tk.SLADeadline, &tk.Escalated, &tk.CreatedAt, &tk.CompletedAt,
        &tk.Recommended, &placement); err != nil {
        return domain.Task{}, err
    }
    // A corrupt placement is never fatal: the signer just places their own box (the same
    // degradation the PAdES adapter applies to an unusable rect).
    if len(placement) > 0 {
        var pl domain.SignaturePlacement
        if err := json.Unmarshal(placement, &pl); err == nil {
            tk.Placement = &pl
        }
    }
    return tk, nil
}
  • [x] Verify cd go && go build ./... && go vet ./....
  • [x] Commit: git add go/migrations/00099_signature_placement.sql go/internal/workflow/domain/workflow.go go/internal/workflow/adapters/pg.go && git commit -m "feat(workflow): migration 00099 + task-level signature placement persistence"

Task 3: Fan-out unification — positions ∪ named users, placement seeding, always notify

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

This is the task that fixes the reported bug: after T5 routes the preset through here, every
signature request emits workflow.task_assignedwire.go:733 → in-app notification + email.

  • [x] Replace fanOutStep entirely (service.go:1132-1204, doc comment included) with:
// stepAssignee is one resolved actor for a step plus their provenance: a position's live
// holder (substitutable — the duty belongs to the position, not the person) or an
// individually named user (verbatim — the author named THEM).
type stepAssignee struct {
    userID       string
    fromPosition bool
}

// stepAssignees expands a step's assignees: the live holders of its approver positions UNION
// its individually named users, de-duped by user id, positions first then named users (stable
// order). A user who is both a holder and named keeps their position provenance (first entry
// wins), so they route through availability like their peers on that position.
//
// The position set is resolved in ONE bulk ResolveActors call, exactly as before — placements
// are never keyed by position, so there is no reason to resolve them one at a time.
// Read-only; runs inside the caller's UnitOfWork.
func (s *Service) stepAssignees(ctx context.Context, step domain.StepSpec, now time.Time) ([]stepAssignee, error) {
    out := make([]stepAssignee, 0, len(step.ApproverPositionIDs)+len(step.AssigneeUserIDs))
    seen := map[string]bool{}
    if len(step.ApproverPositionIDs) > 0 {
        holders, err := s.resolver.ResolveActors(ctx, step.ApproverPositionIDs, now)
        if err != nil {
            return nil, err
        }
        for _, uid := range holders {
            if uid == "" || seen[uid] {
                continue
            }
            seen[uid] = true
            out = append(out, stepAssignee{userID: uid, fromPosition: true})
        }
    }
    for _, uid := range step.AssigneeUserIDs {
        if uid == "" || seen[uid] {
            continue
        }
        seen[uid] = true
        out = append(out, stepAssignee{userID: uid})
    }
    return out, nil
}

// taskPlacement is the pre-placed signature box a fanned-out assignee's task carries: their
// own ref's box, else the step-level default, else nil (they place their own). Only a SIGN
// task carries one — an approval or an e-Meterai affix has no signature appearance (the
// affix ceremony positions its own square stamp).
func taskPlacement(step domain.StepSpec, a stepAssignee) *domain.SignaturePlacement {
    if step.Kind() != domain.StepKindSign {
        return nil
    }
    pl, ok := step.PlacementFor(domain.PlacementRefUser(a.userID))
    if !ok {
        return nil
    }
    return &pl
}

// fanOutStep expands a step's assignees — the live holders of its approver positions PLUS any
// individually named users, unioned and de-duped — and inserts one pending task each, seeded
// with the step's pre-placed signature box when it has one, emitting workflow.task_assigned so
// every assignee is actually notified.
//
// This is the ONLY path that creates a STEP's tasks: the admin designer, the ad-hoc custom
// builder AND the "Request signature" preset all land here, so they can never diverge and all
// notify identically. (Dispose/Forward/escalation create tasks too, but they are not steps.)
//
// A step that DECLARES assignees (positions and/or users) but resolves to nobody is a
// kernel.ErrValidation — there is nobody to act on it. A step that declares NOBODY fans out to
// zero tasks and is NOT an error: that is the envelope-backed signature request whose signers
// are all external (no Obscura account → no task), whose instance is a 0-task mirror the
// envelope's roster finalize (FinalizeInstance) advances. ValidateSteps rejects a no-assignee
// step in every authoring path, so this case is reachable only from that preset.
//
// Named users are assigned VERBATIM: the availability/substitute resolver runs only on
// position-resolved holders. Naming someone is an identity claim ("Alice must sign"), not a
// fungible duty — and for a Global request the envelope's signer phones must line up with the
// task assignees. Runs inside the caller's UnitOfWork.
func (s *Service) fanOutStep(ctx context.Context, instanceID, subjectType, subjectID string, step domain.StepSpec, stepIndex int, recommendedUserID, initiator string, contacts map[string]domain.ExternalContact, now time.Time) error {
    // A sign_external step has no position to resolve — it mints one envelope per contact
    // slot for the outside parties instead of tasks.
    if step.Kind() == domain.StepKindSignExternal {
        return s.fanOutExternalSign(ctx, instanceID, stepIndex, subjectType, subjectID, step, initiator, contacts)
    }
    assignees, err := s.stepAssignees(ctx, step, now)
    if err != nil {
        return err
    }
    if len(assignees) == 0 {
        if !step.DeclaresAssignees() {
            return nil // envelope-backed mirror: no internal signer holds a task
        }
        return &kernel.Error{Kind: kernel.ErrValidation, Code: "workflow.step.no_approvers", Message: "no approvers resolved for step '" + step.Name + "'"}
    }
    // An e-Meterai step is only completable by a meterai.affix holder, so fail the fan-out
    // LOUDLY when the named position resolved to zero permitted users — the requester picked
    // the wrong position; better an immediate error than a workflow stuck on a task nobody
    // may complete.
    if step.Kind() == domain.StepKindMeterai && s.affixPermChecker != nil {
        permitted := false
        for _, a := range assignees {
            if s.affixPermChecker(ctx, a.userID, step.ApproverPositionIDs) {
                permitted = true
                break
            }
        }
        if !permitted {
            return &kernel.Error{Kind: kernel.ErrValidation, Code: "workflow.step.no_affixers",
                Message: "no holder of the positions on step '" + step.Name + "' is permitted to affix an e-Meterai"}
        }
    }
    for _, a := range assignees {
        assignee := a.userID
        if a.fromPosition {
            // Position-resolved holders route through availability/delegation; a NAMED
            // assignee never does (see the doc comment).
            eff, aerr := s.effectiveAssignee(ctx, a.userID)
            if aerr != nil {
                return aerr
            }
            assignee = eff
        }
        tk := domain.Task{
            ID:             kernel.NewID(),
            InstanceID:     instanceID,
            AssigneeUserID: assignee,
            ActionRequired: step.Kind(), // "approve", "sign" or "meterai"
            State:          domain.TaskStatePending,
            CreatedAt:      now,
            // Flag the recommended holder (compared pre-substitution) so their inbox
            // highlights it; any holder can still act.
            Recommended: recommendedUserID != "" && a.userID == recommendedUserID,
            Placement:   taskPlacement(step, a),
        }
        if err := s.repo.InsertTask(ctx, tk); err != nil {
            return err
        }
        if s.events != nil {
            payload, _ := json.Marshal(map[string]any{
                "user_id":         assignee,
                "instance_id":     instanceID,
                "subject_type":    subjectType,
                "subject_id":      subjectID,
                "action_required": step.Kind(),
            })
            if err := s.events.Publish(ctx, kernel.DomainEvent{
                Aggregate:   "workflow",
                AggregateID: instanceID,
                Type:        "workflow.task_assigned",
                Payload:     payload,
            }); err != nil {
                return err
            }
        }
    }
    return nil
}

Note the signature change: the last-but-one parameter goes from contact *domain.ExternalContact
to contacts map[string]domain.ExternalContact (the whole instance map), because a multi-slot
external step needs to look up one contact per slot (T6). Update the three call sites:

  • [x] service.go:717 (StartFromDefinition): replace
    contactForStep(contacts, 1) with contacts.
  • [x] service.go:890 (advanceApprovedInstance): replace
    contactForStep(inst.ExternalContacts, nextStepIndex) with inst.ExternalContacts.
  • [x] service.go:1537 (StartCustomWorkflow): replace contactForStep(contacts, 1) with contacts.
  • [x] service.go:1435 (the built-in Start, which has no external contacts): replace nil with
    nil — the parameter is now a map, and a nil map is a valid empty map, so this call site is
    unchanged
    . Confirm it still reads s.fanOutStep(ctx, instanceID, subjectType, subjectID, steps[0], 1, recommendedUserID, string(p.UserID), nil, now).

  • [x] Update fanOutExternalSign (service.go:1206-1229) to take the map and keep TODAY's exact
    single-slot behavior (T6 turns it multi-slot; keeping the change minimal here keeps this task
    independently reviewable):

// fanOutExternalSign mints the sign_external step's one-signer envelope via the wired
// ExternalSignRequester (esign-backed). No task is created — the step is "active" via
// current_step, and the envelope's completion advances it (AdvanceExternalSignStep).
// Fails the fan-out LOUDLY when the contact is missing or external signing isn't wired,
// so a misconfigured step can never silently stall.
func (s *Service) fanOutExternalSign(ctx context.Context, instanceID string, stepIndex int, subjectType, subjectID string, step domain.StepSpec, initiator string, contacts map[string]domain.ExternalContact) error {
    contact := contactForStep(contacts, stepIndex)
    if contact == nil || contact.Email == "" {
        return &kernel.Error{Kind: kernel.ErrValidation, Code: "workflow.step.no_contact",
            Message: "external signature step '" + step.Name + "' has no signer contact"}
    }
    if s.externalSignRequester == nil {
        return &kernel.Error{Kind: kernel.ErrConflict, Code: "workflow.external_sign.unsupported",
            Message: "external signing is not configured on this deployment"}
    }
    return s.externalSignRequester.RequestExternalSign(ctx, ExternalSignRequest{
        InstanceID:      instanceID,
        StepIndex:       stepIndex,
        SubjectType:     subjectType,
        SubjectID:       subjectID,
        InitiatorUserID: initiator,
        Contact:         *contact,
        SignKind:        step.SignKind,
    })
}
  • [x] Verify cd go && go build ./... && go vet ./....
  • [x] Self-review as adversary before committing: (a) a position-only step still resolves in ONE
    ResolveActors call and still substitutes → byte-identical to today; (b) a step with no
    assignees at all is unreachable from the designer (ValidateSteps) — grep that
    RequestSignatures still does not call ValidateSteps; (c) Recommended is still compared
    pre-substitution.
  • [x] Commit: git add go/internal/workflow/app/service.go && git commit -m "feat(workflow): unify step fan-out over positions + named users, seed placements, always notify"

Task 4: Generic completion semantics (replaces the signature_request special case)

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

  • [x] Add effectiveCompletion immediately above CompleteSignatureForSubject (before
    service.go:905):
// effectiveCompletion resolves the completion rule that governs a step, carrying the ONE
// legacy exception the jsonb demands.
//
// A step whose spec predates the `completion` field reads as StepCompletion=="" →
// Completion()=="any" (first-to-act wins). That is right for every legacy step EXCEPT those
// inside a signature_request definition: those were minted by the old RequestSignatures
// preset, which hard-coded ALL-must-sign in the SERVICE (a `def.Kind == signature_request`
// branch) instead of in the step, so their stored step carries no completion key at all. An
// unset completion inside a signature_request therefore means "all".
//
// New presets write completion:"all" explicitly and take the same branch, so this fallback is
// needed only for instances already in flight — but they pin their version forever, so it can
// never be removed.
func effectiveCompletion(step domain.StepSpec, defKind string) string {
    if step.StepCompletion == "" && defKind == domain.DefinitionKindSignatureRequest {
        return domain.CompletionAll
    }
    return step.Completion()
}
  • [x] In CompleteSignatureForSubject, replace the block from if def.Kind == domain.DefinitionKindSignatureRequest {
    through the // Normal definition-driven workflow sign step comment and its
    advanceApprovedInstance call (service.go:952-998) with:
            if inst.CurrentStep < 1 || inst.CurrentStep > len(ver.Steps) {
                continue
            }
            step := ver.Steps[inst.CurrentStep-1]
            if effectiveCompletion(step, def.Kind) == domain.CompletionAll {
                // ALL-must-sign: complete ONLY this signer's own task; the step finishes only
                // once every assignee's task is done. (Multi-party signature requests, and any
                // designer step that asks for every named signer.)
                if err := s.repo.CompleteTask(ctx, t.ID, now); err != nil {
                    return err
                }
                remaining, err := s.repo.ListPendingTasksForInstance(ctx, inst.ID)
                if err != nil {
                    return err
                }
                if len(remaining) > 0 {
                    // Partial: record this signature; the step stays open until the rest sign.
                    if err := s.repo.InsertTransition(ctx, domain.Transition{
                        ID:         kernel.NewID(),
                        InstanceID: inst.ID,
                        FromState:  inst.State,
                        ToState:    domain.StateSubmitted,
                        Action:     domain.ActionSign,
                        Actor:      string(p.UserID),
                        Note:       "signed",
                        CreatedAt:  now,
                    }); err != nil {
                        return err
                    }
                    continue
                }
                // Everyone has signed. Advance like any other completed step — to the NEXT
                // step, or to approved on the last one. For a one-step signature request this
                // emits exactly the transition the old hard-coded branch emitted
                // (submitted→approved, action=sign, note="signed"); for a multi-step
                // definition it correctly runs the rest of the workflow instead of jumping to
                // approved.
                if err := s.advanceApprovedInstance(ctx, inst, ver, string(p.UserID), "signed", domain.ActionSign, now); err != nil {
                    return err
                }
                continue
            }
            // Single-completes (the legacy default): the first signer decides the step.
            if err := s.advanceApprovedInstance(ctx, inst, ver, string(p.UserID), "signed", domain.ActionSign, now); err != nil {
                return err
            }

Also update the function's doc comment (service.go:905-912) — replace the sentence
"(single-completes — the first signer wins)" with:

// CompleteSignatureForSubject advances any definition-driven workflow waiting on a SIGN step
// for (subjectType, subjectID), on behalf of p who just signed the subject. How the step
// completes is the STEP's business: an "all" step finishes only once every assignee has
// signed, an "any" step (the legacy default) is decided by the first signer. Either way the
// step then advances exactly like the approve path: to the next step or, on the last step, to
// Approved. It is a no-op (nil) when p holds no pending sign-task on a matching instance — the
// common case for ordinary (non-workflow) signing and for re-signs — so the best-effort esign
// hook never fails a successful sign.
  • [x] In completeSignOverride, replace the early def.Kind skip (service.go:1042-1044):
        if def.Kind == domain.DefinitionKindSignatureRequest {
            continue
        }

…by deleting those three lines and instead inserting, right after the existing
if step.Kind() != domain.StepKindSign { continue } (service.go:1049-1051):

        // An ALL-must-sign step names an exact roster — a superior may NOT sign in their
        // stead; that is precisely what "everyone named must sign" means. (This subsumes the
        // old signature_request exemption: those steps all resolve to "all".) Single-completes
        // steps stay rank-overrideable, exactly as before.
        if effectiveCompletion(step, def.Kind) == domain.CompletionAll {
            continue
        }
        // Note: a step that routes only to NAMED users has no positions to out-rank, so
        // CanOverride is asked about an empty position list and answers false — rank-override
        // simply does not apply to identity-routed steps.

The def lookup above it stays (it now feeds effectiveCompletion instead of the direct
comparison). Update the doc comment's "Signature-request instances are exempt" sentence to
"ALL-must-sign steps are exempt — a named roster must be signed by exactly the named people."

  • [x] Verify cd go && go build ./... && go vet ./....
  • [x] Self-review as adversary — this is the highest-risk task:
    (a) a legacy signature_request (1 step, {"name":"Signature","kind":"sign"}, no completion
    key) → effectiveCompletion = all → same branch as before; its terminal transition via
    advanceApprovedInstance is {FromState: inst.State, ToState: Approved, Action: ActionSign, Actor: p.UserID, Note: "signed"}compare field-by-field against the deleted block at
    :966-977; they must match exactly
    ;
    (b) a legacy non-signature_request sign step → ""anyadvanceApprovedInstance,
    unchanged;
    (c) the new bounds check can only skip when CurrentStep is outside 1..len(steps), which no
    live definition-driven instance is (completeSignOverride:1045 already relies on this).
  • [x] Commit: git add go/internal/workflow/app/service.go && git commit -m "feat(workflow): generic step completion (any|all) replaces the signature_request special case"

Task 5: RequestSignatures becomes a thin preset over the real engine

Files: go/internal/workflow/app/service.go, go/internal/httpapi/handlers_esign.go.

  • [x] Replace RequestSignatures' doc comment + signature + the body from steps := []domain.StepSpec{...}
    through the direct-insert loop (service.go:1686-1693 and :1741-1782) so the whole function
    reads:
// RequestSignatures creates a multi-party signature request. It is now a thin PRESET over the
// ordinary workflow engine: it mints a one-off definition whose single step is a REAL sign step
// naming the chosen users, asking for all-must-sign, carrying the tier and any boxes the sender
// pre-placed — and then fans that step out through the SAME fanOutStep every designer workflow
// uses. That is what makes each signer's task emit workflow.task_assigned, i.e. actually notify
// them (the old direct task insert did not, so nobody was ever prompted or nudged).
//
// signKind is the request's signature tier (internal|global|psre), signingOrder is
// "parallel"|"sequential", deadlineAt is the mandatory by-when, and placements optionally maps
// a signer's USER ID to the box the sender placed for them. Tasks are assigned to the exact
// users chosen (fanOutStep never substitutes a NAMED assignee) so the Global Mekari envelope's
// signer phones line up with the assignees. Sequential ENFORCEMENT (gating later signers) is
// added in a later phase; here all tasks are created pending. Returns the new instance id; the
// caller (HTTP handler) builds the Global envelope and links it.
//
// The definition keeps Kind=signature_request: it marks the definition as a ONE-OFF so it stays
// out of the designer's template list, and effectiveCompletion still reads it to keep instances
// created before the `completion` field existed all-must-sign.
func (s *Service) RequestSignatures(ctx context.Context, p kernel.Principal, subjectType, subjectID string, signerUserIDs []string, signKind, signingOrder string, deadlineAt time.Time, placements map[string]domain.SignaturePlacement) (string, error) {
    if subjectType == "" || subjectID == "" {
        return "", &kernel.Error{Kind: kernel.ErrValidation, Code: "workflow.request_sign.subject_required", Message: "subject type and id are required"}
    }
    // Zero internal signer ids is allowed: an envelope-backed (Global) request may have ONLY external
    // signers (no Obscura account → no workflow task). The instance is then a 0-task mirror that the
    // envelope's roster finalize advances (FinalizeInstance) — fanOutStep tolerates a step that
    // declares nobody for exactly this reason. The HTTP handler enforces ≥1 TOTAL signer.
    switch signKind {
    case "internal", "global", "psre":
    default:
        return "", &kernel.Error{Kind: kernel.ErrValidation, Code: "workflow.request_sign.bad_sign_kind", Message: "signature type must be internal, global or psre"}
    }
    sequential := false
    switch signingOrder {
    case "parallel":
    case "sequential":
        sequential = true
    default:
        return "", &kernel.Error{Kind: kernel.ErrValidation, Code: "workflow.request_sign.bad_order", Message: "signing order must be parallel or sequential"}
    }
    now := s.clock.Now()
    if !deadlineAt.After(now) || deadlineAt.After(now.Add(32*24*time.Hour)) {
        return "", &kernel.Error{Kind: kernel.ErrValidation, Code: "workflow.request_sign.bad_deadline", Message: "deadline must be in the future and within 31 days"}
    }
    // De-dupe signer ids while preserving order.
    seen := map[string]bool{}
    signers := make([]string, 0, len(signerUserIDs))
    for _, u := range signerUserIDs {
        if u == "" || seen[u] {
            continue
        }
        seen[u] = true
        signers = append(signers, u)
    }
    // The preset's step, expressed in exactly the vocabulary the designer uses.
    step := domain.StepSpec{
        Name:            "Signature",
        StepKind:        domain.StepKindSign,
        AssigneeUserIDs: signers,
        StepCompletion:  domain.CompletionAll,
        SignKind:        signKind,
    }
    // Only keep boxes for signers we actually kept (a placement for a dropped duplicate or an
    // unknown id would fail ValidateSteps in the designer; here it would just never apply).
    if len(placements) > 0 {
        pls := make(map[string]domain.SignaturePlacement, len(placements))
        for uid, pl := range placements {
            if !seen[uid] {
                continue
            }
            pls[domain.PlacementRefUser(uid)] = pl
        }
        if len(pls) > 0 {
            step.Placements = pls
        }
    }
    steps := []domain.StepSpec{step}
    definitionID := kernel.NewID()
    versionID := kernel.NewID()
    instanceID := kernel.NewID()
    deadline := deadlineAt
    err := s.uow.Do(ctx, func(ctx context.Context) error {
        if err := s.repo.InsertDefinition(ctx, domain.Definition{
            ID:        definitionID,
            Name:      "Signature request",
            CreatedBy: string(p.UserID),
            Kind:      domain.DefinitionKindSignatureRequest,
            CreatedAt: now,
        }); err != nil {
            return err
        }
        if err := s.repo.InsertDefinitionVersion(ctx, domain.DefinitionVersion{
            ID:           versionID,
            DefinitionID: definitionID,
            Version:      1,
            Steps:        steps,
            CreatedAt:    now,
        }); err != nil {
            return err
        }
        versionRef := versionID
        recommended := map[string]string{domain.RecommendedSignKindKey: signKind}
        if err := s.repo.InsertInstance(ctx, domain.Instance{
            ID:                  instanceID,
            SubjectType:         subjectType,
            SubjectID:           subjectID,
            State:               domain.StateSubmitted,
            Initiator:           string(p.UserID),
            DefinitionVersionID: &versionRef,
            CurrentStep:         1,
            Recommended:         recommended,
            SigningOrder:        sequential,
            DeadlineAt:          &deadline,
            CreatedAt:           now,
            UpdatedAt:           now,
        }); err != nil {
            return err
        }
        // The unification: ONE task per signer through the ordinary fan-out, which seeds each
        // task's pre-placed box and publishes workflow.task_assigned (in-app + email). Zero
        // signers (all-external Global) fans out to zero tasks and is not an error.
        if err := s.fanOutStep(ctx, instanceID, subjectType, subjectID, steps[0], 1, "", string(p.UserID), nil, now); err != nil {
            return err
        }
        return s.repo.InsertTransition(ctx, domain.Transition{
            ID:         kernel.NewID(),
            InstanceID: instanceID,
            FromState:  domain.StateDraft,
            ToState:    domain.StateSubmitted,
            Action:     domain.ActionSubmit,
            Actor:      string(p.UserID),
            CreatedAt:  now,
        })
    })
    if err != nil {
        return "", err
    }
    return instanceID, nil
}

Recommended[RecommendedSignKindKey] is deliberately kept even though the step now carries
SignKind too: handlers_workflow.go:202 projects it as SuggestedSignKind for the document's
Workflow tab. Both are written; the instance map stays the read source.

  • [x] In go/internal/httpapi/handlers_esign.go, add the workflow domain import to the import block
    (handlers_esign.go:17-21), keeping the existing alias style:
    workflowdomain "github.com/Virtue-Digital-Indonesia/obscura/internal/workflow/domain"
  • [x] Extend the RequestSignature body struct's Signers element (handlers_esign.go:32-37) with
    a placement, and widen signerSpec (:196-199):
        Signers []struct {
            UserID    string `json:"user_id"`
            Name      string `json:"name"`
            Email     string `json:"email"`
            Phone     string `json:"phone"`
            Placement *struct {
                Page int     `json:"page"`
                LLX  float64 `json:"llx"`
                LLY  float64 `json:"lly"`
                URX  float64 `json:"urx"`
                URY  float64 `json:"ury"`
            } `json:"placement"`
        } `json:"signers"`
// signerSpec is one requested signer: an internal user (userID set) or an external party (external set,
// with name/email/phone but no account). Request order is the signing order index. placement is the
// box the SENDER pre-placed for them (nil = they place their own / the default box).
type signerSpec struct {
    userID, name, email, phone string
    external                   bool
    placement                  *workflowdomain.SignaturePlacement
}
  • [x] Replace the normalisation block (handlers_esign.go:48-61) with:
    var specs []signerSpec
    if len(body.Signers) > 0 {
        for _, sg := range body.Signers {
            var pl *workflowdomain.SignaturePlacement
            if sg.Placement != nil {
                pl = &workflowdomain.SignaturePlacement{
                    Page:        sg.Placement.Page,
                    LowerLeftX:  sg.Placement.LLX,
                    LowerLeftY:  sg.Placement.LLY,
                    UpperRightX: sg.Placement.URX,
                    UpperRightY: sg.Placement.URY,
                }
            }
            if strings.TrimSpace(sg.UserID) != "" {
                specs = append(specs, signerSpec{userID: sg.UserID, placement: pl})
            } else {
                specs = append(specs, signerSpec{name: sg.Name, email: strings.TrimSpace(sg.Email), phone: strings.TrimSpace(sg.Phone), external: true, placement: pl})
            }
        }
    } else {
        for _, uid := range body.SignerUserIDs {
            specs = append(specs, signerSpec{userID: uid})
        }
    }
  • [x] Replace the internal-id collection + the RequestSignatures call (handlers_esign.go:163-170):
    // Internal user ids drive the workflow tasks (externals have no account → no task); the boxes the
    // sender placed for them ride along and seed each task's ceremony.
    var internalUserIDs []string
    placements := map[string]workflowdomain.SignaturePlacement{}
    for _, sp := range specs {
        if sp.external {
            continue
        }
        internalUserIDs = append(internalUserIDs, sp.userID)
        if sp.placement != nil {
            placements[sp.userID] = *sp.placement
        }
    }
    instanceID, err := s.workflow.RequestSignatures(r.Context(), p, "document", docID, internalUserIDs, signKind, signingOrder, deadlineAt, placements)
  • [x] Verify cd go && go build ./... && go vet ./.... (buildEnvelope is left alone here — T6
    threads the external/Global boxes onto the roster.)
  • [x] Self-review: the direct-insert loop is GONE (grep service.go for ActionRequired: steps[0].Kind()
    → no hits); RequestSignatures still does not call ValidateSteps; the all-external Global
    path still returns an instance id with zero tasks.
  • [x] Commit: git add go/internal/workflow/app/service.go go/internal/httpapi/handlers_esign.go && git commit -m "fix(workflow): request-signature emits a real fanned-out step so signers are notified"

Task 6: Roster/Global placement + multi-slot external signing

KNOWN GAP left open deliberately (found during T6, 2026-07-14). This task's heading promises
it fixes "external/Global signers always land in the default box". It fixes that for the
internal/local tier only. signerPlacement() is consulted solely on the local attestation
path; for the Global (Mekari) tier CreateSignEnvelope builds MultiSigner{Name,Email,Phone}
and never sets Placement, so the sender's box is persisted to esign_envelope_signers.placement
but never transmitted to the provider. The plumbing already exists and is one line
(MultiSigner.PlacementmekariSignatureAnnotation, which handles nil→default AND the
bottom-left→top-left flip).

It is NOT wired on purpose: that flip path is latent + never exercised, and activating an
untested coordinate transform against a live PAID provider is exactly the class of bug that hit
Peruri (a flip mirrors across the page centre, so mid-page looks "slightly off" while
top-of-page lands at the bottom — see [[obscura-peruri-status-2026-07]]). Wire it deliberately,
with funded Mekari credentials and a visual check on a real stamped PDF, not blind.

Files: go/internal/esign/domain/envelope.go, go/internal/esign/adapters/envelope_pg.go,
go/internal/esign/app/service.go, go/internal/httpapi/handlers_esign.go,
go/internal/workflow/domain/workflow.go, go/internal/workflow/adapters/pg.go,
go/internal/workflow/app/service.go, go/cmd/obscura-server/resolvers.go.

Two independent halves that share migration 00099. This is the largest task — if it must be split,
6a (placement) and 6b (multi-slot) are separately revertible.

6a — the roster/global signer's box (fixes "external/Global signers always land in the default box")

  • [x] go/internal/esign/domain/envelope.go — add to EnvelopeSigner (after SignedUA, :50):
    // Placement is the appearance rectangle the SENDER pre-placed for this signer, in PDF
    // points with origin bottom-left, page 1-based. nil = the adapter's default box, which is
    // what every signer got before this field existed.
    Placement *SignerPlacement

and add the value type after the ExternalSignerInput struct (:56):

// SignerPlacement is a roster signer's pre-placed appearance rectangle (PDF points, origin
// bottom-left, page 1-based). It mirrors workflow domain.SignaturePlacement on the esign side
// so neither context imports the other; the JSON tags are identical, so the same jsonb value
// round-trips through both.
type SignerPlacement struct {
    Page        int     `json:"page"`
    LowerLeftX  float64 `json:"llx"`
    LowerLeftY  float64 `json:"lly"`
    UpperRightX float64 `json:"urx"`
    UpperRightY float64 `json:"ury"`
}
  • [x] go/internal/esign/adapters/envelope_pg.go — append placement to envelopeSignerColumns
    (:31-33):
const envelopeSignerColumns = `id, envelope_id, user_id, signer_id, order_index, ` +
    `signature_b64, otp_channel, status, signed_at, is_external, signer_name, signer_email, ` +
    `signer_phone, access_token_hash, invite_channel, invited_at, signed_ip, signed_ua, placement`

Extend scanSigner (:35-49) — declare var placement []byte, append &placement as the final
row.Scan argument, and after the existing tokenHash unwrap add:

    if len(placement) > 0 {
        var pl domain.SignerPlacement
        if err := json.Unmarshal(placement, &pl); err == nil {
            sg.Placement = &pl
        }
    }

(envelope_pg.go must import encoding/json — add it if absent.)

Extend the INSERT INTO esign_envelope_signers (:86-91) to a 19th column/param:

    var placement any
    if sg.Placement != nil {
        b, merr := json.Marshal(sg.Placement)
        if merr != nil {
            return fmt.Errorf("esign marshal signer placement: %w", merr)
        }
        placement = b
    }
    _, err := s.db.Exec(ctx).Exec(ctx,
        `INSERT INTO esign_envelope_signers
         (id, envelope_id, user_id, signer_id, order_index, signature_b64, otp_channel, status, signed_at,
          is_external, signer_name, signer_email, signer_phone, access_token_hash, invite_channel, invited_at, signed_ip, signed_ua, placement)
         VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)`,
        sg.ID, sg.EnvelopeID, nullIfEmpty(sg.UserID), sg.SignerID, sg.OrderIndex, sg.SignatureB64, sg.OTPChannel, status, sg.SignedAt,
        sg.IsExternal, sg.Name, sg.Email, sg.Phone, nullIfEmpty(sg.AccessTokenHash), sg.InviteChannel, sg.InvitedAt, sg.SignedIP, sg.SignedUA, placement)

The two joined selects at :257 and :291 list e.* envelope columns and join signers — check
whether they also project signer columns; if they use envelopeSignerColumns they are already
fixed by the const, otherwise leave them (they do not scan into EnvelopeSigner).

  • [x] go/internal/esign/app/service.go — add to EnvelopeSignerInput (:192-202):
    // Placement is the box the sender pre-placed for this signer (nil = the default box).
    Placement *domain.SignerPlacement

Wire it into the domain.EnvelopeSigner that CreateSignEnvelope (:2439) builds — find the
domain.EnvelopeSigner{...} literal inside it and add Placement: in.Placement, to the field list
(the loop variable is the EnvelopeSignerInput).

Replace signerPlacement (:2188-2189) with:

// signerPlacement is the appearance box for a local external signer: the rectangle the sender
// pre-placed for them at request time, or nil for the adapter's default bottom-left box (what
// every roster signer got before senders could pre-place one). The adapter clamps it and falls
// back to the default box when unusable, so a stale rect can never fail the sign.
func signerPlacement(sg domain.EnvelopeSigner) *Placement {
    if sg.Placement == nil {
        return nil
    }
    return &Placement{
        Page:        sg.Placement.Page,
        LowerLeftX:  sg.Placement.LowerLeftX,
        LowerLeftY:  sg.Placement.LowerLeftY,
        UpperRightX: sg.Placement.UpperRightX,
        UpperRightY: sg.Placement.UpperRightY,
    }
}

(Its only call site, :1643, already passes the signer — no change there.)

  • [x] go/internal/httpapi/handlers_esign.go — in buildEnvelope (:220-262), carry the sender's
    box onto both signer kinds. In the external branch (:226-233) and the internal branch
    (:254-261), add to each esignapp.EnvelopeSignerInput{...} literal:
                Placement: esignSignerPlacement(sp.placement),

and add this converter next to signerSpec (after handlers_esign.go:199):

// esignSignerPlacement re-expresses a sender-placed box as the esign context's own value type
// (the two contexts never import each other; the JSON tags are identical so the same jsonb
// round-trips through both). nil in → nil out → the adapter's default box.
func esignSignerPlacement(pl *workflowdomain.SignaturePlacement) *esigndomain.SignerPlacement {
    if pl == nil {
        return nil
    }
    return &esigndomain.SignerPlacement{
        Page:        pl.Page,
        LowerLeftX:  pl.LowerLeftX,
        LowerLeftY:  pl.LowerLeftY,
        UpperRightX: pl.UpperRightX,
        UpperRightY: pl.UpperRightY,
    }
}

6b — N contact slots per external step

  • [x] go/internal/workflow/domain/workflow.go — add to Instance (after ExternalContacts, :176):
    // ExternalSlotsDone marks which of a sign_external step's contact SLOTS have had their
    // envelope completed, keyed "<stepIndex>#<slotIndex>" (1-based step, 0-based slot). The
    // step advances only once every slot in the spec is done. Empty/nil for every instance
    // with no external steps — and for a legacy single-slot step it simply fills with one
    // entry, which advances the step immediately, exactly as today.
    ExternalSlotsDone map[string]bool

and add the key helper + slot-aware contact lookup next to ExternalContact (:187):

// ExternalSlotKey is the ExternalContacts / ExternalSlotsDone key for a step's contact slot.
// Slot 0 keeps the BARE step key — that is the exact format every existing row and both start
// UIs already write, so a legacy single-slot step needs no migration and no UI change.
func ExternalSlotKey(stepIndex, slotIndex int) string {
    k := strconv.Itoa(stepIndex)
    if slotIndex > 0 {
        k += "#" + strconv.Itoa(slotIndex)
    }
    return k
}

(workflow.go currently imports only time — add strconv.)

  • [x] go/internal/workflow/adapters/pg.go — persist the new instance column. Exactly four sites
    use the plain instance column list, and they are the only ones to touch:
    the INSERT (:52-53)
    and the three SELECTs that feed scanInstance:63 (GetInstance), :81
    (GetInstanceForUpdate) and :114 (GetInstancesByParent). Introduce a const the same way
    T2 did for tasks
    and use it at all four:
// instanceColumns is the workflow_instances column list, in the order scanInstance reads them.
const instanceColumns = `id, subject_type, subject_id, state, initiator, definition_version_id, ` +
    `current_step, parent_instance_id, created_at, updated_at, recommended, signing_order, ` +
    `deadline_at, external_contacts, external_slots_done`

Do NOT touch ListInstances (:165), ListInstancesForParticipant (:205) or
ListInstancesForSubject (:238):
they are joined list projections with their own bespoke
column sets and INLINE rows.Scan calls (they do not call scanInstance, and they do not even
select recommended/external_contacts uniformly). Adding a column to scanInstance leaves them
correct; adding one to them without matching their inline Scan would break at runtime. Verified:
grep -n "scanInstance(" pg.go:62, :80, :123 (callers) + :93 (the definition), and
nothing else.

In InsertInstance (:34-58), marshal it exactly like Recommended:

    var slots any
    if len(i.ExternalSlotsDone) > 0 {
        b, err := json.Marshal(i.ExternalSlotsDone)
        if err != nil {
            return fmt.Errorf("workflow marshal external slots: %w", err)
        }
        slots = b
    }

…appending slots as $15. In scanInstance (:93-109) add var slotsDone []byte, scan it last,
and unmarshal it like the others:

    if len(slotsDone) > 0 {
        _ = json.Unmarshal(slotsDone, &i.ExternalSlotsDone)
    }

Add the marker repository method:

// MarkExternalSlotDone records that one contact slot of a sign_external step has had its
// envelope completed, merging the key into the instance's external_slots_done jsonb. Idempotent:
// re-marking a slot is a no-op. Must run inside the caller's transaction.
func (s *Store) MarkExternalSlotDone(ctx context.Context, instanceID, slotKey string) error {
    if _, err := s.db.Exec(ctx).Exec(ctx,
        `UPDATE workflow_instances
            SET external_slots_done = COALESCE(external_slots_done, '{}'::jsonb) || jsonb_build_object($2::text, true)
          WHERE id = $1`,
        instanceID, slotKey); err != nil {
        return fmt.Errorf("workflow mark external slot done: %w", err)
    }
    return nil
}
  • [x] go/internal/workflow/app/service.go — declare the port on Repository (add next to
    UpdateInstanceStep, service.go:119-121; there is no ports.go — the interfaces live in
    service.go, do not create one
    ):
    // MarkExternalSlotDone merges a "<step>#<slot>" key into an instance's external_slots_done
    // jsonb, recording that one contact slot's envelope completed. Idempotent.
    MarkExternalSlotDone(ctx context.Context, instanceID, slotKey string) error

Add SlotIndex to ExternalSignRequest (:213-222):

    SlotIndex       int // 0-based contact slot within the step (0 for a single-slot/legacy step)
    ContactSlot     string // the slot's label, for the invite copy

Replace fanOutExternalSign (as rewritten in T3) with the multi-slot loop:

// fanOutExternalSign mints ONE envelope per contact slot on the step, via the wired
// ExternalSignRequester (esign-backed). No task is created — the step is "active" via
// current_step, and the step advances once EVERY slot's envelope completes
// (AdvanceExternalSignStep). Fails the fan-out LOUDLY when a contact is missing or external
// signing isn't wired, so a misconfigured step can never silently stall. A legacy single-slot
// step mints exactly one envelope from the bare "<step>" contact key — identical to today.
func (s *Service) fanOutExternalSign(ctx context.Context, instanceID string, stepIndex int, subjectType, subjectID string, step domain.StepSpec, initiator string, contacts map[string]domain.ExternalContact) error {
    slots := step.Contacts()
    if len(slots) == 0 {
        return &kernel.Error{Kind: kernel.ErrValidation, Code: "workflow.step.no_contact",
            Message: "external signature step '" + step.Name + "' has no contact slot"}
    }
    if s.externalSignRequester == nil {
        return &kernel.Error{Kind: kernel.ErrConflict, Code: "workflow.external_sign.unsupported",
            Message: "external signing is not configured on this deployment"}
    }
    for i, slot := range slots {
        c, ok := contacts[domain.ExternalSlotKey(stepIndex, i)]
        if !ok || c.Email == "" {
            return &kernel.Error{Kind: kernel.ErrValidation, Code: "workflow.step.no_contact",
                Message: "external signature step '" + step.Name + "' has no signer contact for '" + slot + "'"}
        }
        if err := s.externalSignRequester.RequestExternalSign(ctx, ExternalSignRequest{
            InstanceID:      instanceID,
            StepIndex:       stepIndex,
            SlotIndex:       i,
            ContactSlot:     slot,
            SubjectType:     subjectType,
            SubjectID:       subjectID,
            InitiatorUserID: initiator,
            Contact:         c,
            SignKind:        step.SignKind,
        }); err != nil {
            return err
        }
    }
    return nil
}

Replace validateExternalContacts (:297-318) so it checks every slot:

// validateExternalContacts checks that every sign_external step has a supplied contact (a name
// and a plausible email) for EACH of its slots, keyed by ExternalSlotKey. Pure; runs at start
// before any envelope is minted so a bad/missing contact is a clean rejection, not a stalled run.
func validateExternalContacts(steps []domain.StepSpec, contacts map[string]domain.ExternalContact) error {
    for i, st := range steps {
        if st.Kind() != domain.StepKindSignExternal {
            continue
        }
        for j, slot := range st.Contacts() {
            c, ok := contacts[domain.ExternalSlotKey(i+1, j)]
            if !ok || strings.TrimSpace(c.Name) == "" {
                return &kernel.Error{Kind: kernel.ErrValidation, Code: "workflow.start_def.contact_required",
                    Message: "external signature step '" + st.Name + "' needs a signer name and email for '" + slot + "'"}
            }
            email := strings.TrimSpace(c.Email)
            at := strings.LastIndex(email, "@")
            if at <= 0 || at == len(email)-1 || !strings.Contains(email[at:], ".") {
                return &kernel.Error{Kind: kernel.ErrValidation, Code: "workflow.start_def.contact_bad_email",
                    Message: "external signature step '" + st.Name + "' needs a valid signer email for '" + slot + "'"}
            }
        }
    }
    return nil
}

Replace AdvanceExternalSignStep (:1231-1265) with the slot-aware version:

// AdvanceExternalSignStep records that ONE contact slot of a sign_external step has been signed
// by its external party, and advances the step once EVERY slot is done. It acts ONLY if
// stepIndex is still the instance's current step (idempotent: a duplicate webhook/poll for an
// already-advanced step is a no-op, and it can never advance a LATER external step
// prematurely). The transition is attributed to the external contact's email. No-op for
// unknown/terminal instances or a mismatched step. Called by esign's envelope-completion path.
//
// A legacy single-slot step marks slot 0 and immediately has every slot done, so it advances on
// the first completion exactly as it always has.
func (s *Service) AdvanceExternalSignStep(ctx context.Context, instanceID string, stepIndex, slotIndex int) error {
    if instanceID == "" || stepIndex < 1 || slotIndex < 0 {
        return nil
    }
    return s.uow.Do(ctx, func(ctx context.Context) error {
        inst, err := s.repo.GetInstanceForUpdate(ctx, instanceID)
        if err != nil {
            if isNotFound(err) {
                return nil
            }
            return err
        }
        if inst.DefinitionVersionID == nil || inst.State != domain.StateSubmitted || inst.CurrentStep != stepIndex {
            return nil
        }
        ver, err := s.repo.GetDefinitionVersion(ctx, *inst.DefinitionVersionID)
        if err != nil {
            return err
        }
        if stepIndex > len(ver.Steps) || ver.Steps[stepIndex-1].Kind() != domain.StepKindSignExternal {
            return nil
        }
        step := ver.Steps[stepIndex-1]
        slotKey := domain.ExternalSlotKey(stepIndex, slotIndex)
        if err := s.repo.MarkExternalSlotDone(ctx, instanceID, slotKey); err != nil {
            return err
        }
        done := map[string]bool{slotKey: true}
        for k, v := range inst.ExternalSlotsDone {
            if v {
                done[k] = true
            }
        }
        for i := range step.Contacts() {
            if !done[domain.ExternalSlotKey(stepIndex, i)] {
                return nil // still waiting on another outside party
            }
        }
        actor := "external"
        if c, ok := inst.ExternalContacts[slotKey]; ok && c.Email != "" {
            actor = c.Email
        }
        return s.advanceApprovedInstance(ctx, inst, ver, actor, "external signature completed", domain.ActionExternalSign, s.clock.Now())
    })
}

contactForStep (:1274-1284) is now unused — delete it (or go vet will not complain, but
the dead helper misleads; grep first: after T3's call-site changes it should have zero callers).

  • [x] go/internal/esign/domain/envelope.go — add to SignEnvelope (after StepIndex, :22):
    // StepSlot is the 0-based contact slot within StepIndex that this envelope fulfils, so its
    // completion marks exactly that slot. 0 for a single-slot step and for every envelope
    // created before multi-slot existed.
    StepSlot int
  • [x] go/internal/esign/adapters/envelope_pg.go — append step_slot to the envelope column const
    (:21), to the INSERT list + params (:69), and to the two joined projections (:257, :291
    e.step_slot); scan it in scanEnvelope. Follow exactly how step_index is threaded today.

  • [x] go/internal/esign/app/service.go — add SlotIndex int and ContactSlot string to
    ExternalSignStepInput; thread SlotIndex onto the domain.SignEnvelope{StepSlot: ...} that
    RequestExternalSignStep creates; add a stepSlot int parameter to CreateSignEnvelope
    (:2439) after stepIndex and set StepSlot on the envelope it builds. Update the
    WorkflowAdvancer port (:314-319):

    // AdvanceExternalSignStep records that one contact SLOT of a sign_external step was signed
    // and advances the step once every slot is done (idempotent per slot). Used instead of
    // FinalizeInstance when env.StepIndex > 0.
    AdvanceExternalSignStep(ctx context.Context, instanceID string, stepIndex, slotIndex int) error

and its call site (search for AdvanceExternalSignStep(ctx, env.InstanceID, env.StepIndex)) →
AdvanceExternalSignStep(ctx, env.InstanceID, env.StepIndex, env.StepSlot).

  • [x] go/internal/httpapi/handlers_esign.go:268CreateSignEnvelope gained a parameter; the
    standalone signature-request path is step 0 / slot 0:
    return s.esign.CreateSignEnvelope(ctx, p, "document", docID, doc.Title, version, pdf, signKind, inputs, signingOrder == "sequential", deadlineAt, instanceID, 0, 0)
  • [x] go/cmd/obscura-server/resolvers.go:112-122 — pass the slot through:
    _, err := r.esign.RequestExternalSignStep(ctx, esignapp.ExternalSignStepInput{
        InitiatorUserID: req.InitiatorUserID,
        SubjectType:     req.SubjectType,
        SubjectID:       req.SubjectID,
        InstanceID:      req.InstanceID,
        StepIndex:       req.StepIndex,
        SlotIndex:       req.SlotIndex,
        ContactSlot:     req.ContactSlot,
        Name:            req.Contact.Name,
        Email:           req.Contact.Email,
        Phone:           req.Contact.Phone,
        SignKind:        req.SignKind,
    })
  • [x] go/internal/workflow/adapters/external_sign_workflow_test.go — the fake
    recordingExternalSigner still satisfies the port (the struct gained fields, not methods), but
    any direct AdvanceExternalSignStep(...) call in that file needs the new slotIndex argument.
    go vet will catch it; fix every call to pass 0.
  • [x] Verify cd go && go build ./... && go vet ./....
  • [x] Commit: git add go/internal/esign go/internal/workflow go/internal/httpapi/handlers_esign.go go/cmd/obscura-server/resolvers.go && git commit -m "feat(esign): per-signer roster placement + N external contact slots per step"

Task 7: HTTP + OpenAPI surface (designer step fields, signer placement) + regen

Files: go/internal/httpapi/handlers_designer.go, api/openapi.yaml,
packages/api-client/src/schema.ts.

  • [x] go/internal/httpapi/handlers_designer.go — replace stepInput + toStepSpecs (:14-38):
// stepInput is the wire shape of a designer step. Kind is "approve" (default), "sign",
// "meterai", or "sign_external" (an empty kind means approve). A sign_external step carries
// ContactSlots labels instead of positions; SignKind is an optional tier hint for
// sign/sign_external steps. AssigneeUserIDs names individual users alongside (or instead of)
// positions; Completion is "any" (default) or "all"; Placements are pre-placed signature boxes
// keyed by assignee ref.
type stepInput struct {
    Name                string   `json:"name"`
    ApproverPositionIDs []string `json:"approver_position_ids"`
    Kind                string   `json:"kind"`
    ContactSlot         string   `json:"contact_slot"`
    SignKind            string   `json:"sign_kind"`
    AssigneeUserIDs     []string `json:"assignee_user_ids"`
    Completion          string   `json:"completion"`
    ContactSlots        []string `json:"contact_slots"`
    Placements          map[string]struct {
        Page int     `json:"page"`
        LLX  float64 `json:"llx"`
        LLY  float64 `json:"lly"`
        URX  float64 `json:"urx"`
        URY  float64 `json:"ury"`
    } `json:"placements"`
}

func toStepSpecs(in []stepInput) []workflowdomain.StepSpec {
    out := make([]workflowdomain.StepSpec, 0, len(in))
    for _, s := range in {
        spec := workflowdomain.StepSpec{
            Name:                s.Name,
            ApproverPositionIDs: s.ApproverPositionIDs,
            StepKind:            s.Kind,
            ContactSlot:         s.ContactSlot,
            SignKind:            s.SignKind,
            AssigneeUserIDs:     s.AssigneeUserIDs,
            StepCompletion:      s.Completion,
            ContactSlots:        s.ContactSlots,
        }
        if len(s.Placements) > 0 {
            spec.Placements = make(map[string]workflowdomain.SignaturePlacement, len(s.Placements))
            for ref, pl := range s.Placements {
                spec.Placements[ref] = workflowdomain.SignaturePlacement{
                    Page:        pl.Page,
                    LowerLeftX:  pl.LLX,
                    LowerLeftY:  pl.LLY,
                    UpperRightX: pl.URX,
                    UpperRightY: pl.URY,
                }
            }
        }
        out = append(out, spec)
    }
    return out
}
  • [x] api/openapi.yaml — add a reusable placement schema immediately before StepInput:
    (:12645). (The two existing INLINE placements — SignDocumentRequest.placement :10302 and
    the meterai one at :2733 — are deliberately left alone: refactoring them is churn with real
    regression risk and zero benefit here.)
    SignaturePlacement:
      type: object
      description: >-
        A signature appearance rectangle in PDF points (origin bottom-left, the same space as
        the page MediaBox); page is 1-based. Clamped to the target page's MediaBox by the
        signer; an unusable rect silently falls back to the default box, never an error.
      properties:
        page:
          type: integer
          minimum: 1
          description: 1-based page number.
        llx:
          type: number
          description: Lower-left X (points).
        lly:
          type: number
          description: Lower-left Y (points).
        urx:
          type: number
          description: Upper-right X (points).
        ury:
          type: number
          description: Upper-right Y (points).
      required: [page, llx, lly, urx, ury]
  • [x] api/openapi.yaml — extend RequestSignatureRequest.signers.items.properties (:10186-10191):
          items:
            type: object
            properties:
              user_id: { type: string }
              name:    { type: string }
              email:   { type: string }
              phone:   { type: string }
              placement:
                allOf:
                  - $ref: '#/components/schemas/SignaturePlacement'
                description: >-
                  Optional box the SENDER pre-placed for this signer. For an internal signer it
                  seeds their workflow task, so their signing ceremony opens on it; for an
                  external/Global signer it becomes their roster appearance rectangle. Omitted =
                  the signer places their own (internal) or the default box (external).
  • [x] api/openapi.yaml — extend StepInput (:12645-12665) and StepSpecOut (:12679-12706)
    with the same four properties (append to each properties: map):
        assignee_user_ids:
          type: array
          items:
            type: string
          description: >-
            Individually named users who each get a task when the step activates, expanded
            ALONGSIDE approver_position_ids (union, de-duped). A named assignee is never routed
            through availability/delegation substitution. A sign/approve/meterai step needs at
            least one position OR one named user.
        completion:
          type: string
          enum: [any, all]
          description: >-
            `any` (default, and the behavior of every step authored before this field) — the
            first assignee to act decides the step. `all` — every assignee must act before it
            advances.
        contact_slots:
          type: array
          items:
            type: string
          description: >-
            Labelled external-signer slots for a sign_external step — one outside party each,
            one envelope each, all of which must sign before the step advances. Supersedes the
            single `contact_slot`, which is still read and folded in first.
        placements:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/SignaturePlacement'
          description: >-
            Optional pre-placed signature boxes keyed by assignee ref — `user:<id>`,
            `contact:<slot label>`, or `*` for a step-level default applied to any assignee
            without one of their own. A ref the step does not name is rejected.
  • [x] Regenerate the client (the npm script is broken — run the generator directly):
cd /home/efran/remote-development/obscura/packages/api-client && npx openapi-typescript ../../api/openapi.yaml -o src/schema.ts
  • [x] Verify cd go && go build ./... && go vet ./... and cd web && npx tsc --noEmit.
  • [x] Commit: git add go/internal/httpapi/handlers_designer.go api/openapi.yaml packages/api-client/src/schema.ts && git commit -m "feat(api): designer step assignees/completion/contact-slots/placements + signer placement"

Task 8: Inbox projection → the signer's ceremony opens on the pre-placed box

Files: web/src/features/approvals/data.ts, web/src/features/workflows/MyTasksTab.tsx.

No backend change: WorkflowInbox (handlers_workflow.go:137-145) does writeJSON(w, 200, tasks)
over domain.Task, which has no json tags → the new field serialises as Placement (PascalCase),
matching the existing InboxTask shape.

  • [x] web/src/features/approvals/data.ts — extend the wire type (:48) and the view model
    (:18-31):
type InboxTask = {
  ID: string
  InstanceID: string
  State: string
  CreatedAt: string
  ActionRequired: string
  Recommended?: boolean
  // The box the sender/author pre-placed for this signer (PDF points, origin bottom-left,
  // 1-based page). Serialized from the Go domain.Task, which has no json tags → PascalCase.
  // null/absent = nobody pre-placed one; the signer places their own.
  Placement?: { page: number; llx: number; lly: number; urx: number; ury: number } | null
}

and in PendingApproval (after submittedLabel, :30):

  // The pre-placed signature box, when the sender/author chose one: the ceremony opens on it
  // (still adjustable). undefined = the signer places their own, as before.
  placement?: SignPlacement

…importing the shared type at the top of the file:

import type { SignPlacement } from '@/api/document-detail'

and mapping it in fetchApprovals' .map(async (t): Promise<PendingApproval> => { return object —
add to the returned literal:

        placement: t.Placement ?? undefined,
  • [x] web/src/features/workflows/MyTasksTab.tsx — pass it into the ceremony (:84-97):
      <PlaceSignatureModal
        open={!!signTask}
        docId={signTask?.subjectId ?? ''}
        version={signTask?.subjectVersion ?? 0}
        signatures={saved}
        initialPlacement={signTask?.placement}
        onClose={() => setSignTask(null)}
        onConfirm={(placement, signatureId) => {
          if (!signTask) return
          sign.mutate(
            { version: signTask.subjectVersion, signatureId, placement },
            { onSuccess: () => { setSignTask(null); qc.invalidateQueries({ queryKey: ['approvals'] }) } },
          )
        }}
      />
  • [x] Verify cd web && npx tsc --noEmit && npx vite build. (initialPlacement does not exist yet →
    tsc will fail. Do T9 first if you prefer strict green-per-task; otherwise fold T8+T9 into
    one commit. Recommended: do T9, then T8, then commit both.)
  • [x] Commit (after T9): git add web/src/features/approvals/data.ts web/src/features/workflows/MyTasksTab.tsx web/src/features/documents/PlaceSignatureModal.tsx && git commit -m "feat(web): signing ceremony opens on the pre-placed box"

Task 9: PlaceSignatureModalinitialPlacement + lead props

Files: web/src/features/documents/PlaceSignatureModal.tsx.

  • [x] Add the two props to the destructure (:26-42) — insert initialPlacement, and lead, after
    variant = 'signature', — and to the inline type (:43-79), after the variant entry:
  // initialPlacement seeds the box: the rectangle the SENDER (Request signature) or the
  // workflow AUTHOR (a designer sign step) pre-placed for this signer. The modal opens on that
  // page with the box already there, still fully draggable/resizable. Absent = the usual
  // default box. It is the exact inverse of what confirm() emits, so a round-trip is lossless.
  initialPlacement?: SignPlacement
  // lead overrides the explanatory paragraph (e.g. author mode, which reuses `external` to hide
  // the signature picker but needs its own copy — the signer is not the person placing it).
  lead?: string
  • [x] Add a seed guard ref next to the other refs (after :101):
  // Seed the initial placement exactly once per open: after that the box is the user's, and
  // paging away and back must not snap it back to the author's suggestion.
  const seeded = useRef(false)
  • [x] In the PDF-load effect (:127-153), open on the pre-placed page and reset the guard — replace
    setPageNum(1) (:131) with:
    seeded.current = false
    setPageNum(initialPlacement?.page ?? 1)

and add initialPlacement?.page to that effect's dep array (:153) → [open, docId, version, initialPlacement?.page].

  • [x] In the render effect, seed the box from the placement before the default-box logic — replace
    the if (meterai) { … } else { … } block (:190-203) with:
      if (initialPlacement && !seeded.current && initialPlacement.page === pageNum) {
        seeded.current = true
        // Invert confirm(): convertToViewportPoint undoes the bottom-left origin AND the page
        // /Rotate the same way convertToPdfPoint applies them, so this round-trips exactly.
        const [ax, ay] = viewport.convertToViewportPoint(initialPlacement.llx, initialPlacement.lly)
        const [bx, by] = viewport.convertToViewportPoint(initialPlacement.urx, initialPlacement.ury)
        const w = Math.abs(bx - ax)
        const h = Math.abs(by - ay)
        // A degenerate rect (a stale placement against a since-replaced page) falls through to
        // the default box rather than seeding an invisible handle.
        if (w >= 8 && h >= 8) {
          aspect.current = w / h
          setBox(clampTo({ x: Math.min(ax, bx), y: Math.min(ay, by), w, h }, viewport.width, viewport.height))
          return
        }
      }
      if (meterai) {
        // Seed the meterai default: a ~100×100 PDF-point square near the bottom-left,
        // mirroring where the provider stamps when no placement is sent. cssScale is the
        // viewport scale, so PDF points × cssScale = CSS px.
        const side = Math.min(100 * cssScale, viewport.width - 16, viewport.height - 16)
        setBox(
          clampTo({ x: 12, y: viewport.height - side - 12, w: side, h: side }, viewport.width, viewport.height),
        )
      } else {
        // Seed a sensible default: ~35% page width in the lower-right, kept on the page.
        const w = Math.min(viewport.width * 0.35, viewport.width - 8)
        const h = w / aspect.current
        setBox(clampTo({ x: viewport.width * 0.6, y: viewport.height * 0.8, w, h }, viewport.width, viewport.height))
      }

and extend that effect's deps (:208) → [status, pageNum, meterai, initialPlacement].

  • [x] Use the lead override in the JSX (:328-336):
      <p className="page__lead muted">
        {lead ??
          (meterai
            ? t('docview.meterai.placeLead')
            : collectPhone
              ? t('docview.place.leadOtp')
              : external
                ? t('docview.place.leadExternal')
                : t('docview.place.lead'))}
      </p>
  • [x] Verify cd web && npx tsc --noEmit && npx vite build.
  • [x] Commit together with T8 (see T8's commit line).

Task 10: RequestSignatureModal — the sender pre-places a box per signer

Files: web/src/features/documents/RequestSignatureModal.tsx, web/src/api/document-detail.ts.

Author mode reuses PlaceSignatureModal with external (which already means "no in-app signature to
pick — just position the box") plus the T9 lead override, so no new modal and no duplicated pdf.js.

  • [x] web/src/api/document-detail.ts — widen useRequestSignature's signer param (:840-843) and
    pass the box through (:845-852):
      signers,
    }: {
      signers: Array<{ user_id?: string; name?: string; email?: string; phone?: string; placement?: SignPlacement }>

(the body: { signers, … } spread at :848 already forwards it verbatim — the field name matches
the OpenAPI signers[].placement added in T7, so no other change is needed there).

  • [x] web/src/features/documents/RequestSignatureModal.tsx — add the imports:
import { PlaceSignatureModal } from '@/features/documents/PlaceSignatureModal'
import type { SignPlacement } from '@/api/document-detail'
  • [x] Add the placement state next to selected/externals (after :58):
  // Boxes the SENDER pre-placed, keyed by signer: an internal user's id, or an external's
  // lower-cased email. Optional per signer — an unplaced signer just places their own.
  const [placements, setPlacements] = useState<Record<string, SignPlacement>>({})
  // Which signer's box is being placed right now (null = the placement popup is closed).
  const [placing, setPlacing] = useState<{ key: string; label: string } | null>(null)
  • [x] Add the roster list with a per-signer Place button, immediately AFTER the internal
    FilterableMultiSelect (i.e. after :153) and before the div.req-sig__external block:
          {(selected.length > 0 || externals.length > 0) && (
            <ul className="req-sig__roster">
              {[
                ...selected.map((u) => ({ key: u.id, label: u.label })),
                ...externals.map((e) => ({ key: e.email.toLowerCase(), label: e.name || e.email })),
              ].map((s) => (
                <li key={s.key} className="req-sig__roster-row">
                  <span className="req-sig__roster-name">{s.label}</span>
                  <Button
                    kind="ghost"
                    size="sm"
                    onClick={() => setPlacing({ key: s.key, label: s.label })}
                  >
                    {placements[s.key] ? t('docview.requestSignature.placed') : t('docview.requestSignature.place')}
                  </Button>
                  {placements[s.key] && (
                    <Button
                      kind="ghost"
                      size="sm"
                      onClick={() => setPlacements((p) => { const { [s.key]: _drop, ...rest } = p; return rest })}
                    >
                      {t('docview.requestSignature.clearPlacement')}
                    </Button>
                  )}
                </li>
              ))}
            </ul>
          )}
  • [x] Render the author-mode placement popup just before the closing </Modal> (after :220):
      {placing && (
        <PlaceSignatureModal
          open
          docId={docId}
          version={0}
          signatures={[]}
          external
          heading={t('docview.requestSignature.placeTitle', { name: placing.label })}
          primaryText={t('docview.requestSignature.placeConfirm')}
          lead={t('docview.requestSignature.placeLead', { name: placing.label })}
          initialPlacement={placements[placing.key]}
          onClose={() => setPlacing(null)}
          onConfirm={(placement) => {
            setPlacements((p) => ({ ...p, [placing.key]: placement }))
            setPlacing(null)
          }}
        />
      )}

version={0} is wrongPlaceSignatureModal fetches fetchPreview(docId, version, true).
The request modal must know the document's current version. It does not today. Add a version
prop to RequestSignatureModal
(:44{ open, docId, version, onClose }), pass
version={version} here, and update its single call site,
web/src/features/documents/DocumentDetailView.tsx:1315, which currently reads:

      <RequestSignatureModal open={requesting} docId={docId} onClose={() => setRequesting(false)} />

→ add version={...} from the document's current version already in scope there (the same value
PlaceSignatureModal is given at DocumentDetailView.tsx:1221). Do not skip this — the popup
renders a blank page otherwise.

  • [x] Attach the boxes to the payload (:109-112):
    const signers = [
      ...selected.map((u) => ({ user_id: u.id, placement: placements[u.id] })),
      ...externals.map((e) => ({
        name: e.name,
        email: e.email || undefined,
        phone: e.phone,
        placement: placements[e.email.toLowerCase()],
      })),
    ]
  • [x] Add the roster styles to web/src/styles/app.css (verified: it is the file that already owns
    .req-sig__external), next to the other .req-sig__ rules:
.req-sig__roster {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  margin-top: 0.5rem;
}
.req-sig__roster-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.req-sig__roster-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
  • [x] Verify cd web && npx tsc --noEmit && npx vite build.
  • [x] Commit: git add web/src/features/documents/RequestSignatureModal.tsx web/src/features/documents/DocumentDetailView.tsx web/src/api/document-detail.ts web/src/styles/app.css && git commit -m "feat(web): sender pre-places a signature box per signer on a request"

Task 11: Designer — named assignees, completion, tier, N contact slots, placement

Files: web/src/features/workflows/data.ts, web/src/features/workflows/StepListEditor.tsx.

  • [x] web/src/features/workflows/data.ts — replace StepInput (:163-170):
// A pre-placed signature box (PDF points, origin bottom-left, 1-based page) — the same shape
// the signing ceremony emits, keyed in `placements` by assignee ref.
export interface StepPlacement {
  page: number
  llx: number
  lly: number
  urx: number
  ury: number
}

// The wire shape of a builder step: a name, the approver/signer/affixer position ids, the kind,
// optionally individually named assignees, a completion rule, a signing-tier hint, external
// contact slots, and pre-placed boxes keyed by assignee ref (`user:<id>` / `contact:<label>` /
// `*` for the step default).
export interface StepInput {
  name: string
  approver_position_ids: string[]
  kind: StepKind
  contact_slot?: string
  sign_kind?: string
  assignee_user_ids?: string[]
  completion?: 'any' | 'all'
  contact_slots?: string[]
  placements?: Record<string, StepPlacement>
}
  • [x] web/src/features/workflows/data.ts — extend the latest-version parse type (:242) and mapper
    (:246-257) so a revision round-trips the new fields instead of silently dropping them:
        Steps: Array<{
          name: string
          approver_position_ids: string[]
          kind?: string
          contact_slot?: string
          sign_kind?: string
          assignee_user_ids?: string[]
          completion?: string
          contact_slots?: string[]
          placements?: Record<string, StepPlacement>
        }>

and in the mapper's returned object add, alongside the existing contact_slot line (:256):

          sign_kind: s.sign_kind ?? '',
          assignee_user_ids: s.assignee_user_ids ?? [],
          completion: s.completion === 'all' ? 'all' : 'any',
          contact_slots: s.contact_slots ?? [],
          placements: s.placements ?? {},
  • [x] web/src/features/workflows/StepListEditor.tsx — replace StepDraft, emptyStep,
    stepsValid and stepsPayload (:13-44):
export interface StepDraft {
  name: string
  positionIds: string[]
  kind: StepKind
  // sign_external steps route to an outside party, not a position: these are the labels for the
  // contacts the requester fills in at start time (positionIds stays empty). One envelope each;
  // all must sign before the step advances.
  contactSlots: string[]
  // Individually named users who each get a task, alongside (or instead of) positionIds. Never
  // substituted by availability/delegation — naming someone is an identity claim.
  assigneeUserIds: string[]
  // 'any' = the first assignee to act decides the step (the default); 'all' = everyone must act.
  completion: 'any' | 'all'
  // The signature tier hint for a sign / sign_external step ('' = the provider default).
  signKind: '' | 'internal' | 'global' | 'psre'
}

// A fresh empty step (approve, no positions, first-to-act).
export const emptyStep = (): StepDraft => ({
  name: '',
  positionIds: [],
  kind: 'approve',
  contactSlots: [''],
  assigneeUserIds: [],
  completion: 'any',
  signKind: '',
})

// A sign_external step is valid with at least one non-blank contact-slot label and NO positions;
// every other kind needs its name plus at least one position OR one named assignee.
export function stepsValid(steps: StepDraft[]): boolean {
  return (
    steps.length > 0 &&
    steps.every((s) =>
      s.name.trim() !== '' &&
      (s.kind === 'sign_external'
        ? s.contactSlots.some((c) => c.trim() !== '')
        : s.positionIds.length > 0 || s.assigneeUserIds.length > 0),
    )
  )
}

// The wire payload for a step list: sign_external steps carry contact_slots and no positions;
// every other kind carries its positions and/or named assignees. Optional fields are omitted
// when unset so the stored jsonb stays byte-identical to what the old builder wrote.
export function stepsPayload(steps: StepDraft[]): StepInput[] {
  return steps.map((s) => {
    const base: StepInput =
      s.kind === 'sign_external'
        ? {
            name: s.name.trim(),
            approver_position_ids: [],
            kind: s.kind,
            contact_slots: s.contactSlots.map((c) => c.trim()).filter((c) => c !== ''),
          }
        : { name: s.name.trim(), approver_position_ids: s.positionIds, kind: s.kind }
    if (s.kind !== 'sign_external' && s.assigneeUserIds.length > 0) base.assignee_user_ids = s.assigneeUserIds
    if (s.completion === 'all') base.completion = 'all'
    if ((s.kind === 'sign' || s.kind === 'sign_external') && s.signKind !== '') base.sign_kind = s.signKind
    return base
  })
}
  • [x] StepListEditor.tsx — add the imports and the user list:
import { usePickableUsers, type PickableUser } from '@/api/document-detail'

and inside the component, after const { t } = useTranslation() (:57):

  // NOTE (known, pre-existing): usePickableUsers reads /api/v1/admin/users, which is gated on
  // `users.admin` and degrades to [] on error — exactly as RequestSignatureModal already does.
  // In the ad-hoc CustomWorkflowModal (gated on workflow.custom) a non-admin therefore sees an
  // empty user list and must route by position. Widening that endpoint is out of scope.
  const { data: people = [] } = usePickableUsers()
  • [x] StepListEditor.tsx — in the non-external branch (:145-184), add the named-assignee picker
    immediately after the existing position FilterableMultiSelect's picked-tags block (i.e. after
    :182, still inside the <>…</>):
                <FilterableMultiSelect
                  id={`wf-step-users-${i}`}
                  className="wf-builder__picker"
                  autoAlign
                  titleText={`${t('workflows.builder.namedAssignees')} (${step.assigneeUserIds.length})`}
                  placeholder={t('workflows.builder.namedAssigneesPlaceholder')}
                  items={people}
                  itemToString={(u: PickableUser | null) => u?.label ?? ''}
                  selectedItems={people.filter((u) => step.assigneeUserIds.includes(u.id))}
                  onChange={({ selectedItems }: { selectedItems: PickableUser[] }) =>
                    patchStep(i, { assigneeUserIds: selectedItems.map((u) => u.id) })
                  }
                  size="sm"
                />
                <p className="wf-builder__kind-hint muted">{t('workflows.builder.namedAssigneesHint')}</p>
                {step.assigneeUserIds.length > 0 && (
                  <div className="wf-builder__picked">
                    {people
                      .filter((u) => step.assigneeUserIds.includes(u.id))
                      .map((u) => (
                        <DismissibleTag
                          key={u.id}
                          type="green"
                          text={u.label}
                          onClose={() =>
                            patchStep(i, { assigneeUserIds: step.assigneeUserIds.filter((id) => id !== u.id) })
                          }
                        />
                      ))}
                  </div>
                )}

and change the "no positions" error (:180-182) so it only fires when NEITHER source is named:

                {step.positionIds.length === 0 && step.assigneeUserIds.length === 0 && attempted ? (
                  <p className="wf-builder__field-error">{t('workflows.builder.assigneesRequired')}</p>
                ) : null}

This means restructuring :167-182: the existing {step.positionIds.length > 0 ? (<div className="wf-builder__picked">…</div>) : attempted ? (<p …>) : null} becomes an unconditional
{step.positionIds.length > 0 && (<div className="wf-builder__picked">…</div>)} followed by the
user picker above, followed by the combined error line.

  • [x] StepListEditor.tsx — replace the sign_external contact-slot TextInput (:131-144) with a
    repeatable list:
            {step.kind === 'sign_external' ? (
              <>
                <p className="wf-builder__kind-hint muted">{t('workflows.builder.externalHint')}</p>
                {step.contactSlots.map((slot, j) => (
                  <div key={j} className="wf-builder__slot">
                    <TextInput
                      id={`wf-step-contact-${i}-${j}`}
                      size="sm"
                      labelText={t('workflows.builder.contactSlotN', { n: j + 1 })}
                      placeholder={t('workflows.builder.contactSlotPlaceholder')}
                      value={slot}
                      onChange={(e) =>
                        patchStep(i, { contactSlots: step.contactSlots.map((c, k) => (k === j ? e.target.value : c)) })
                      }
                      invalid={attempted && slot.trim() === ''}
                      invalidText={t('workflows.builder.contactSlotRequired')}
                    />
                    <IconButton
                      label={t('workflows.builder.removeSlot')}
                      kind="ghost"
                      size="sm"
                      disabled={step.contactSlots.length === 1}
                      onClick={() => patchStep(i, { contactSlots: step.contactSlots.filter((_, k) => k !== j) })}
                    >
                      <TrashCan />
                    </IconButton>
                  </div>
                ))}
                <Button
                  kind="ghost"
                  size="sm"
                  renderIcon={Add}
                  onClick={() => patchStep(i, { contactSlots: [...step.contactSlots, ''] })}
                >
                  {t('workflows.builder.addSlot')}
                </Button>
              </>
            ) : (
  • [x] StepListEditor.tsx — add the completion + tier controls for the relevant kinds, immediately
    after the meterai hint line (:130):
            {(step.kind === 'sign' || step.kind === 'sign_external') && (
              <Dropdown
                id={`wf-step-tier-${i}`}
                className="wf-builder__picker"
                size="sm"
                titleText={t('workflows.builder.signKind')}
                helperText={t('workflows.builder.signKindHint')}
                label={t('workflows.builder.signKindDefault')}
                items={SIGN_KINDS}
                selectedItem={SIGN_KINDS.find((k) => k.id === step.signKind) ?? SIGN_KINDS[0]}
                itemToString={(k: { id: string; labelKey: string } | null) => (k ? t(k.labelKey) : '')}
                onChange={({ selectedItem }: { selectedItem?: { id: string } | null }) =>
                  patchStep(i, { signKind: (selectedItem?.id ?? '') as StepDraft['signKind'] })
                }
              />
            )}
            {step.kind !== 'sign_external' && (
              <>
                <span className="wf-builder__kind-label">{t('workflows.builder.completion')}</span>
                <ContentSwitcher
                  size="sm"
                  className="wf-builder__kind"
                  selectedIndex={step.completion === 'all' ? 1 : 0}
                  onChange={({ index }: { index?: number }) => patchStep(i, { completion: index === 1 ? 'all' : 'any' })}
                >
                  <Switch name="any" text={t('workflows.builder.completionAny')} />
                  <Switch name="all" text={t('workflows.builder.completionAll')} />
                </ContentSwitcher>
                <p className="wf-builder__kind-hint muted">
                  {step.completion === 'all' ? t('workflows.builder.completionAllHint') : t('workflows.builder.completionAnyHint')}
                </p>
              </>
            )}

with this module-level const above the component (after the imports):

// The signature tiers a sign / sign_external step can suggest. '' = the provider default.
const SIGN_KINDS = [
  { id: '', labelKey: 'workflows.builder.signKindDefault' },
  { id: 'internal', labelKey: 'docview.tier.internal.label' },
  { id: 'global', labelKey: 'docview.tier.global.label' },
  { id: 'psre', labelKey: 'docview.tier.psre.label' },
] as const

and add Dropdown to the @carbon/react import (:9).

  • [x] Placement in the designer is intentionally the step-level default only, and is NOT exposed as
    a canvas here.
    The designer has no document to render against (a definition is authored
    before any subject exists), so a WYSIWYG box is meaningless — PlaceSignatureModal needs a
    docId+version. The wire (placements) and the domain (PlacementRefDefault) fully support
    it; the run-time placement UI is the sender's (T10), which is where a document exists.
    Record this in the commit message so it is not mistaken for an omission.

  • [x] Add the slot-row style to web/src/styles/app.css, next to the other .wf-builder__ rules:

.wf-builder__slot {
  display: flex;
  align-items: flex-end;
  gap: 0.5rem;
}
.wf-builder__slot .cds--form-item {
  flex: 1;
}
  • [x] Renaming StepDraft.contactSlotcontactSlots breaks three files; tsc will point at each.
    Verified call sites:

    • web/src/features/workflows/DefinitionEditor.tsx:69 — maps a fetched revision into a
      StepDraft with contactSlot: s.contact_slot ?? ''. Replace with
      contactSlots: s.contact_slots?.length ? s.contact_slots : [s.contact_slot ?? ''] (so a
      legacy single-slot definition still prefills one row), and add
      assigneeUserIds: s.assignee_user_ids ?? [], completion: s.completion === 'all' ? 'all' : 'any',
      signKind: (s.sign_kind ?? '') as StepDraft['signKind'].
    • web/src/features/workflows/CustomWorkflowModal.tsx — its contacts state
      (:48, set at :100) is keyed by step index only: contacts[String(i + 1)] at :113
      (externalContactsValid) and :129 (the ext builder in onSubmit), with the
      external-step list at :161 and the contact rows rendered at :270-290. Make each of those
      iterate the step's slots and key by the backend's ExternalSlotKey format — slot 0 →
      String(i + 1), slot j>0 → `${i + 1}#${j}` — so a single-slot step keeps writing
      exactly the key it writes today.
    • web/src/features/workflows/StartInstanceModal.tsx — the start-from-definition path collects
      the same contacts map; apply the identical slot keying.

    The "<step>"-for-slot-0 format is what makes this safe: a legacy definition has one slot,
    so these UIs keep producing byte-identical payloads.
    - [x] Verify cd web && npx tsc --noEmit && npx vite build (i18n keys are added in T12; tsc will
    flag missing keys only if the locale types are exhaustive — if so, do T12 first).
    - [x] Commit: git add web/src/features/workflows/data.ts web/src/features/workflows/StepListEditor.tsx web/src/features/workflows/CustomWorkflowModal.tsx web/src/features/workflows/DefinitionEditor.tsx web/src/features/workflows/StartInstanceModal.tsx web/src/styles/app.css && git commit -m "feat(web): designer step gains named assignees, completion rule, tier + N contact slots"


Task 12: i18n — en + id

Files: web/src/features/workflows/i18n.ts, web/src/i18n/locales/en.ts, web/src/i18n/locales/id.ts.

Gotcha: id.ts is typed export const id: Translations (locales/id.ts:939) and
features/workflows/i18n.ts is typed export const id: typeof en (:202) — a key added to en
without id is a compile error.
Both files use curly apostrophes (); if Edit mangles them,
rewrite the whole file with Write.

  • [x] web/src/features/workflows/i18n.ts — add to the en builder block (:112-147):
      namedAssignees: 'Named people',
      namedAssigneesPlaceholder: 'Select specific people…',
      namedAssigneesHint: 'Optional. Named people get a task alongside the positions above, and are never substituted by a delegate — name them when the person matters, not the role.',
      assigneesRequired: 'Pick at least one position or named person for this step.',
      completion: 'Who must act',
      completionAny: 'Anyone',
      completionAll: 'Everyone',
      completionAnyHint: 'The first person to act decides this step.',
      completionAllHint: 'Every person on this step must act before it moves on.',
      signKind: 'Signature type',
      signKindHint: 'The tier suggested to the signer. Leave as the default to let the deployment decide.',
      signKindDefault: 'Default',
      contactSlotN: 'Contact slot {{n}}',
      addSlot: 'Add contact slot',
      removeSlot: 'Remove contact slot',
  • [x] web/src/features/workflows/i18n.ts — add the mirrored id keys to its builder block
    (:310-345):
      namedAssignees: 'Orang tertentu',
      namedAssigneesPlaceholder: 'Pilih orang tertentu…',
      namedAssigneesHint: 'Opsional. Orang yang disebut namanya mendapat tugas bersama posisi di atas, dan tidak pernah digantikan delegasi — sebut nama bila orangnya yang penting, bukan perannya.',
      assigneesRequired: 'Pilih minimal satu posisi atau satu orang untuk langkah ini.',
      completion: 'Siapa yang harus bertindak',
      completionAny: 'Siapa saja',
      completionAll: 'Semua',
      completionAnyHint: 'Orang pertama yang bertindak menentukan langkah ini.',
      completionAllHint: 'Semua orang pada langkah ini harus bertindak sebelum lanjut.',
      signKind: 'Jenis tanda tangan',
      signKindHint: 'Tingkat yang disarankan kepada penanda tangan. Biarkan default agar mengikuti pengaturan sistem.',
      signKindDefault: 'Default',
      contactSlotN: 'Slot kontak {{n}}',
      addSlot: 'Tambah slot kontak',
      removeSlot: 'Hapus slot kontak',

contactSlot / contactSlotRequired are still used (the latter by the per-slot TextInput);
contactSlot becomes unused — leave both, removing a key from en forces the same edit in id
for no gain.

  • [x] web/src/i18n/locales/en.ts — add to the docview.requestSignature block (:372-402):
      place: 'Place signature',
      placed: 'Placed ✓',
      clearPlacement: 'Clear',
      placeTitle: 'Where should {{name}} sign?',
      placeLead: 'Drag the box to where {{name}}’s signature should appear. They’ll see it already positioned and can still adjust it. Optional — skip it and they’ll place it themselves.',
      placeConfirm: 'Use this spot',
  • [x] web/src/i18n/locales/id.ts — the mirrored block (same nesting, :372-402):
      place: 'Tempatkan tanda tangan',
      placed: 'Ditempatkan ✓',
      clearPlacement: 'Hapus',
      placeTitle: 'Di mana {{name}} menandatangani?',
      placeLead: 'Geser kotak ke tempat tanda tangan {{name}} seharusnya muncul. Mereka akan melihatnya sudah terposisi dan tetap bisa menyesuaikan. Opsional — lewati dan mereka menempatkannya sendiri.',
      placeConfirm: 'Pakai posisi ini',
  • [x] Verify cd web && npx tsc --noEmit && npx vite build.
  • [x] Commit: git add web/src/features/workflows/i18n.ts web/src/i18n/locales/en.ts web/src/i18n/locales/id.ts && git commit -m "feat(web): i18n for unified signature steps (en + id)"

Task 13: Self-review pass — spec coverage, placeholders, type consistency

Files: none (review only; fix what you find in the owning task's file and amend).

  • [x] Spec coverage. Walk the six design points and tick each: (1) StepSpec extended + all
    omitempty + Completion/Contacts/Placements helpers + validation → T1; (2) fan-out
    unification (positions ∪ users, dedupe, placement seeding, notifications now fire for
    request-signature
    — the reported bug) → T3+T5; (3) generic completion + legacy fallback → T4;
    (4) RequestSignatures is a thin preset, direct-insert loop deleted, signature_request kept
    as the one-off marker → T5; (5) placement captured at send time + threaded into the
    roster/global path → T6a+T10; (6) designer UI + i18n → T11+T12; (7) migration 00099 → T2.
  • [x] Placeholders. grep -rn "TODO\|FIXME\|XXX\|similar to\|as above" go/internal/workflow go/internal/esign go/internal/httpapi web/src/features/workflows web/src/features/documents
    over your diff → must be empty.
  • [x] Type consistency. The placement rect crosses five type boundaries — confirm each pair has
    identical field names AND json tags: workflowdomain.SignaturePlacement
    esigndomain.SignerPlacementesignapp.Placement (no json tags — it is not serialised)
    ↔ the httpapi inline structs ↔ TS SignPlacement/StepPlacement. page is 1-based
    everywhere; the origin is BOTTOM-LEFT everywhere.
    A silent mismatch here mirrors a signature
    across the page — exactly the class of bug the Peruri Y-flip was.
  • [x] Back-compat re-read. Re-read T1's TestStepSpecJSONBackCompat expectation and T4's legacy
    fallback against the deleted code at service.go:952-993 one more time, side by side.
  • [x] Dead code. contactForStep (T6) and StepDraft.contactSlot (T11) should have zero
    references. grep -rn "contactForStep\|\.contactSlot\b" go/ web/src/.
  • [x] Full verify: cd go && go build ./... && go vet ./... then cd web && npx tsc --noEmit && npx vite build.
  • [x] git status --porcelain → confirm NOTHING from go/internal/protection/, stego/,
    proto/stego/, web/src/api/protection.ts, web/src/features/verify/, web/nginx.conf is
    staged, and go/obscura-server is not modified.
  • [x] Amend/commit any fixes into the owning task's commit.

Task 14: OPERATOR-DRIVEN e2e on the live demo

Deploy: ssh valbox then /home/efran/remote-development/obscura/deploy/update.sh (ABSOLUTE
path; the shell is zsh and ssh lands in $HOME). Never hand-rolled compose build/up. The deploy
will be dirty — it co-ships the stego agent's WIP, which is already live; only obscura+web are
rebuilt. Migration 00099 applies on boot.

  • [x] Gate: /meenabled_modules == [ai, correspondence, esign, semantic, watermarking]
    (dev-login director@obscura.local, localhost:38080 on valbox).
  • [x] A — the reported bug is fixed (the headline). As the director, open a document → Request
    signature → add TWO internal signers → place a box for signer 1, leave signer 2 unplaced →
    Send.
    • [x] Each signer receives a notification (in-app bell + the relayed email) — this is what
      never happened before. Confirm the workflow.task_assigned rows reached the outbox:
      SELECT type, payload FROM outbox_events WHERE type='workflow.task_assigned' ORDER BY created_at DESC LIMIT 5;
    • [x] Signer 1 opens their task → the placement modal opens on the sender's page with the box
      already positioned
      . Signer 2's opens on the usual default box.
    • [x] Signer 1 signs → the instance stays submitted (SELECT state, current_step FROM workflow_instances WHERE id='…';), and a submitted→submitted / sign transition is
      recorded.
    • [x] Signer 2 signs → only now does the instance flip to approved. Open the signed PDF:
      signer 1's visible signature is inside the box the sender placed.
  • [x] B — a legacy in-flight instance still all-must-signs. Find a signature_request instance
    created BEFORE this deploy that still has ≥2 pending tasks
    (SELECT i.id FROM workflow_instances i JOIN workflow_definition_versions v ON v.id = i.definition_version_id JOIN workflow_definitions d ON d.id = v.definition_id WHERE d.kind='signature_request' AND i.state='submitted' AND v.steps::text NOT LIKE '%completion%';).
    Have one signer sign → it must NOT approve; the last signer's signature must approve it.
    If no such instance exists, create one BEFORE deploying (this is the single most important
    regression check in the plan — do not skip it).
  • [x] C — a designer-authored named-signer all-must-sign step. Admin → Workflows → new
    definition → step 1 = Sign, named people = two users, Who must act = Everyone, tier =
    Internal; step 2 = Approve by a position. Start it on a document.
    • [x] Both named signers are notified and hold a task.
    • [x] Signer 1 signs → instance stays on step 1.
    • [x] Signer 2 signs → the instance advances to step 2 (current_step = 2) and the
      approver is notified — it must NOT jump to approved (the multi-step generalisation
      from T4 / Reality-Check #3).
  • [x] D — no regressions on the untouched paths.
    • [x] A position-only approve definition still fans out and first-to-act still decides it.
    • [x] An all-external Global request (zero internal signers) still opens, still mints the
      envelope, and the roster finalize still approves the 0-task mirror instance
      (Reality-Check #1).
    • [x] A legacy single-slot sign_external definition still mints exactly one envelope and
      advances on that envelope's completion (Reality-Check #7).
    • [x] An e-Meterai step still refuses to be authored before a sign step.
  • [x] Report: instance ids, the transition rows, and the signed PDF's /Rect vs the box the sender
    placed.

Rollback

Every task is one commit. The riskiest are T4 (completion) and T5 (the preset) — reverting T5 alone
restores the direct-insert loop (and the bug) while leaving the domain intact; reverting T4 restores
the def.Kind special case. Migration 00099's Down drops four additive columns; nothing reads them
after a revert. Instances started under the new code pin their version, so their steps keep the new
fields in jsonb — a revert past T1 would make those steps unmarshal without completion, and an
in-flight new-style all-must-sign request would silently become first-wins.
If you must revert past
T1, drain the in-flight signature_request instances first.