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 withcd 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 -A—go/obscura-serveris 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.yamledits regenerate types —npm run gen:apiis BROKEN via npm; runopenapi-typescriptdirectly inpackages/api-client(schema lives there). - Deploy ONLY via
/home/efran/remote-development/obscura/deploy/update.shon the host (ssh valbox, ABSOLUTE path; ssh lands in $HOME and the shell is zsh). Never hand-rolled compose build/up. - Post-deploy assert
/meenabled_modules == [ai, correspondence, esign, semantic, watermarking](dev-logindirector@obscura.localatlocalhost:38080on 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_requestinstances must behave EXACTLY as today. - i18n: add BOTH
web/src/i18n/locales/en.tsandid.ts(nested locales; watch the smart-quote gotcha).
Constraint notes specific to this plan
go vettype-checks_test.gofiles. 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:28 → RequestSignatures :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
-
🔴 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 onlyinternalUserIDs(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 hitfanOutStep'slen(approvers)==0 → ErrValidation(:1146-1148) and
break every all-external Global request.
Resolution (T1/T3):ValidateStepskeeps the strict rule (it is called ONLY from the four
authoring paths —service.go:559DefineWorkflow,:600ReviseWorkflow,:1466
StartCustomWorkflow,:1563SaveCustomWorkflow — and never fromRequestSignatures, today or
after).fanOutSteperrors 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 becauseValidateStepsrejects it. -
🔴 Routing named signers through
fanOutStepwould 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", butfanOutStep:1167callss.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. -
🔴 Generalising all-must-sign to
advanceApprovedInstanceis REQUIRED, not cosmetic. The
current all-must-sign branch (:962-977) jumps straight toStateApprovedwhen no pending tasks
remain. Correct for a 1-stepsignature_request; a designer-authored all-must-sign step in a
3-step workflow would skip steps 2-3.
Resolution (T4): the last signer callsadvanceApprovedInstance(:853), which is
byte-identical for the 1-step case (sameFromState/ToState/Action/Actor/Note) and
correct for N steps. -
🟡
IsValidStepKindcannot validate the new fields. Its signature is(k string) bool— it
only ever sees the kind. Design point 1's "extendIsValidStepKind/ValidateSteps" lands
entirely inValidateSteps;IsValidStepKindis untouched. A siblingIsValidCompletion(c string)
is added alongside it (T1). -
🟡
ContactSlotis astringfield, not a type. Design point 1'sContactSlots []ContactSlot
is thereforeContactSlots []string(T1). (domain.ExternalContactis the filled-at-start
party; the slot is just its label.) -
🟡
sign_kindalready exists end-to-end on the backend —StepSpec.SignKind(:48),
stepInput.SignKind(handlers_designer.go:23), and the OpenAPIStepInput/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) andStepListEditor.tsx:38-44
(stepsPayload). Design point 6's "expose the tier" is a pure frontend task (T11) — no backend
change. -
🟡 Multi-slot
sign_externalis the one genuinely new subsystem.Instance.ExternalContacts
is keyed by step index only (workflow.go:176,contactForStep:1276-1284),fanOutExternalSign
mints exactly one envelope, andAdvanceExternalSignStepadvances 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 carriesstep_slot(mirroring howstep_indexwas added in mig 00095); the step
advances once every slot is done. A legacy single-slot step haslen(Contacts())==1→ done on
slot 0 → advances immediately, identical to today. -
🟡 Per-position placement refs are dropped (YAGNI + they would change the resolver call
pattern). Keying a placement by position would forceResolveActorsto be called once per
position instead of once per step. Refs areuser:<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. -
🟡 Pre-existing limitation, NOT fixed here:
usePickableUsers(document-detail.ts:963-980)
reads/api/v1/admin/users, gated onusers.admin(server.go:842), and swallows the error to
[]. So the signer picker is already empty for non-admins inRequestSignatureModal. T11
reuses the same hook for designer named-assignees, inheriting the same limitation: in
CustomWorkflowModal(gated onworkflow.custom, notusers.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. -
🟢
fanOutStepbecomes the only step task-creation path — not the only task-creation path.
Dispose (:1935), Forward (:2015), escalation (:2294) and the built-in legacyStart
(: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 Repository — NOTE: 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.Placement → PendingApproval.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 | contactSlot → contactSlots 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 addstrings:
import (
"encoding/json"
"strings"
"time"
)
- [x] Append after the step-kind
constblock (i.e. afterdefinition.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
StepSpecstruct (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
ValidateStepsentirely (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 theTaskstruct (afterRecommended,
i.e. afterworkflow.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(beforepg.go:273). They mirror the esign store'senvelopeSignerColumns
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
unqualifiedSELECTs are atpg.go:288(ListPendingTasksForInstance),:314
(ListPendingTasksForUserOnInstance),:327(GetTask),:375(ListOverdueTasks),:430
(InboxForUser) — each becomes`SELECT `+taskColumns+`. The two qualifiedSELECTs are
at:445(FindPendingSignTasksForSubject) and:469(FindPendingTasksForSubjectAction) —
each becomes`SELECT `+taskColumnsT+`. For exampleListPendingTasksForInstancebecomes:
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_assigned → wire.go:733 → in-app notification + email.
- [x] Replace
fanOutStepentirely (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)withcontacts. - [x]
service.go:890(advanceApprovedInstance): replace
contactForStep(inst.ExternalContacts, nextStepIndex)withinst.ExternalContacts. - [x]
service.go:1537(StartCustomWorkflow): replacecontactForStep(contacts, 1)withcontacts. -
[x]
service.go:1435(the built-inStart, which has no external contacts): replacenilwith
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 readss.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
ResolveActorscall and still substitutes → byte-identical to today; (b) a step with no
assignees at all is unreachable from the designer (ValidateSteps) — grep that
RequestSignaturesstill does not callValidateSteps; (c)Recommendedis 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
effectiveCompletionimmediately aboveCompleteSignatureForSubject(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 fromif def.Kind == domain.DefinitionKindSignatureRequest {
through the// Normal definition-driven workflow sign stepcomment and its
advanceApprovedInstancecall (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 earlydef.Kindskip (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 legacysignature_request(1 step,{"name":"Signature","kind":"sign"}, no completion
key) →effectiveCompletion=all→ same branch as before; its terminal transition via
advanceApprovedInstanceis{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 →""→any→advanceApprovedInstance,
unchanged;
(c) the new bounds check can only skip whenCurrentStepis outside1..len(steps), which no
live definition-driven instance is (completeSignOverride:1045already 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 fromsteps := []domain.StepSpec{...}
through the direct-insert loop (service.go:1686-1693and: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
RequestSignaturebody struct'sSignerselement (handlers_esign.go:32-37) with
a placement, and widensignerSpec(: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
RequestSignaturescall (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 ./.... (buildEnvelopeis left alone here — T6
threads the external/Global boxes onto the roster.) - [x] Self-review: the direct-insert loop is GONE (grep
service.goforActionRequired: steps[0].Kind()
→ no hits);RequestSignaturesstill does not callValidateSteps; 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) tierCreateSignEnvelopebuildsMultiSigner{Name,Email,Phone}
and never setsPlacement, so the sender's box is persisted toesign_envelope_signers.placement
but never transmitted to the provider. The plumbing already exists and is one line
(MultiSigner.Placement→mekariSignatureAnnotation, 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 toEnvelopeSigner(afterSignedUA,: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— appendplacementtoenvelopeSignerColumns
(: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 toEnvelopeSignerInput(: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— inbuildEnvelope(: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 eachesignapp.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 toInstance(afterExternalContacts,: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 feedscanInstance—: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 onRepository(add next to
UpdateInstanceStep,service.go:119-121; there is noports.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 toSignEnvelope(afterStepIndex,: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— appendstep_slotto the envelope column const
(:21), to the INSERT list + params (:69), and to the two joined projections (:257,:291
→e.step_slot); scan it inscanEnvelope. Follow exactly howstep_indexis threaded today. -
[x]
go/internal/esign/app/service.go— addSlotIndex intandContactSlot stringto
ExternalSignStepInput; threadSlotIndexonto thedomain.SignEnvelope{StepSlot: ...}that
RequestExternalSignStepcreates; add astepSlot intparameter toCreateSignEnvelope
(:2439) afterstepIndexand setStepSloton the envelope it builds. Update the
WorkflowAdvancerport (: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:268—CreateSignEnvelopegained 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
recordingExternalSignerstill satisfies the port (the struct gained fields, not methods), but
any directAdvanceExternalSignStep(...)call in that file needs the newslotIndexargument.
go vetwill catch it; fix every call to pass0. - [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— replacestepInput+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 beforeStepInput:
(:12645). (The two existing INLINE placements —SignDocumentRequest.placement:10302and
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— extendRequestSignatureRequest.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— extendStepInput(:12645-12665) andStepSpecOut(:12679-12706)
with the same four properties (append to eachproperties: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 ./...andcd 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. (initialPlacementdoes 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: PlaceSignatureModal — initialPlacement + lead props
Files: web/src/features/documents/PlaceSignatureModal.tsx.
- [x] Add the two props to the destructure (
:26-42) — insertinitialPlacement,andlead,after
variant = 'signature',— and to the inline type (:43-79), after thevariantentry:
// 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
theif (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
leadoverride 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— widenuseRequestSignature'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 thediv.req-sig__externalblock:
{(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 wrong — PlaceSignatureModal 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— replaceStepInput(: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— replaceStepDraft,emptyStep,
stepsValidandstepsPayload(: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 positionFilterableMultiSelect'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-slotTextInput(: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 themeteraihint 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 —PlaceSignatureModalneeds 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.contactSlot→contactSlotsbreaks three files; tsc will point at each.
Verified call sites:web/src/features/workflows/DefinitionEditor.tsx:69— maps a fetched revision into a
StepDraftwithcontactSlot: 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— itscontactsstate
(:48, set at:100) is keyed by step index only:contacts[String(i + 1)]at:113
(externalContactsValid) and:129(theextbuilder inonSubmit), with the
external-step list at:161and the contact rows rendered at:270-290. Make each of those
iterate the step's slots and key by the backend'sExternalSlotKeyformat — 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] Verifycd 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 enbuilderblock (: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 itsbuilderblock
(: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 thedocview.requestSignatureblock (: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/Placementshelpers + 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)RequestSignaturesis a thin preset, direct-insert loop deleted,signature_requestkept
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.SignerPlacement↔esignapp.Placement(no json tags — it is not serialised)
↔ the httpapi inline structs ↔ TSSignPlacement/StepPlacement.pageis 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
TestStepSpecJSONBackCompatexpectation and T4's legacy
fallback against the deleted code atservice.go:952-993one more time, side by side. - [x] Dead code.
contactForStep(T6) andStepDraft.contactSlot(T11) should have zero
references.grep -rn "contactForStep\|\.contactSlot\b" go/ web/src/. - [x] Full verify:
cd go && go build ./... && go vet ./...thencd web && npx tsc --noEmit && npx vite build. - [x]
git status --porcelain→ confirm NOTHING fromgo/internal/protection/,stego/,
proto/stego/,web/src/api/protection.ts,web/src/features/verify/,web/nginx.confis
staged, andgo/obscura-serveris 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:
/me→enabled_modules == [ai, correspondence, esign, semantic, watermarking]
(dev-logindirector@obscura.local,localhost:38080on 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 theworkflow.task_assignedrows 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 asubmitted→submitted / signtransition 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] Each signer receives a notification (in-app bell + the relayed email) — this is what
- [x] B — a legacy in-flight instance still all-must-signs. Find a
signature_requestinstance
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_externaldefinition 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
/Rectvs 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.