"DOC multi-party signing workflow — ordered + parallel + counter-sign with reminder cadence and full audit trail"
§1 — Description (BCP-14 normative)
The DOC service MUST ship multi-party signing at services/doc/src/signing/ supporting 3 workflow types, TASK-DOC-006 verification per signer, TASK-DOC-002/003/004 CA routing per region, reminder cron, 6 memory audit kinds.
- MUST expose
POST /v1/doc/documents/{id}/signing-workflowsbody{ workflow_kind, signers: [{signer_id, region, required_assurance_level, position?}], expires_in_days?, reminder_cadence_override? }.
- MUST validate
workflow_kindagainst closed enum per DEC-1751.
- MUST validate
signature_statusagainst closed enum per DEC-1752.
- MUST dispatch per kind:
ordered_runner.rs::run(workflow)— invite signer[0], wait for sign, invite signer[1], etc.parallel_runner.rs::run(workflow)— invite all signers immediately.counter_sign_runner.rs::run(workflow)— initiator (position=0) signs first, then invites others in parallel.
- MUST for each signer per DEC-1754:
- Invite via TASK-EMAIL-009 with sign link.
- On click: trigger TASK-DOC-006 verification (method per signer's region + required_assurance_level).
- Only if verified=verified: apply signature via TASK-DOC-002/003/004 (routed per DEC-1755).
- Set status=signed; emit audit.
- MUST route CA per signer region per DEC-1755:
- region=vn → TASK-DOC-004 (VN CA)
- region=eu → TASK-DOC-002 (eIDAS QTSP)
- region=other → TASK-DOC-003 (AATL)
- MUST send reminders per DEC-1753 via TASK-MCP-007 cron — at 24h, 72h, 7d after invite; configurable per tenant.
- MUST define tables at migration
0006: ```sql CREATE TABLE doc_signing_workflows ( workflow_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, document_id UUID NOT NULL, workflow_kind TEXT NOT NULL CHECK (workflow_kind IN ('ordered','parallel','counter_sign')), status TEXT NOT NULL DEFAULT 'in_progress' CHECK (status IN ('in_progress','completed','withdrawn','expired','failed')), expires_at TIMESTAMPTZ NOT NULL, reminder_cadence_hours INT[] NOT NULL DEFAULT '{24,72,168}', created_by UUID NOT NULL, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ ); ALTER TABLE doc_signing_workflows ENABLE ROW LEVEL SECURITY; CREATE POLICY workflows_rls ON doc_signing_workflows USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON doc_signing_workflows FROM cyberos_app; GRANT UPDATE (status, completed_at) ON doc_signing_workflows TO cyberos_app;
CREATE TABLE doc_signers ( signer_row_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, workflow_id UUID NOT NULL REFERENCES doc_signing_workflows(workflow_id), signer_id UUID NOT NULL, region TEXT NOT NULL, required_assurance_level TEXT NOT NULL CHECK (required_assurance_level IN ('low','substantial','high')), position INT, -- NULL for parallel; ordered/counter_sign use 0,1,2... status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','in_progress','signed','declined','expired','withdrawn')), invited_at TIMESTAMPTZ, signed_at TIMESTAMPTZ, verification_id UUID, -- FK to TASK-DOC-006 ca_routing TEXT, signature_payload BYTEA, last_reminder_at TIMESTAMPTZ, reminder_count INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX signers_workflow_idx ON doc_signers(tenant_id, workflow_id, position); ALTER TABLE doc_signers ENABLE ROW LEVEL SECURITY; CREATE POLICY signers_rls ON doc_signers USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON doc_signers FROM cyberos_app; GRANT UPDATE (status, invited_at, signed_at, verification_id, ca_routing, signature_payload, last_reminder_at, reminder_count) ON doc_signers TO cyberos_app; ```
- MUST handle decline per DEC-1752 — signer.status=declined → block workflow completion → workflow.status=failed.
- MUST emit 6 memory audit kinds per DEC-1756. PII per TASK-MEMORY-111: signer_id (uuid) ok; signature_payload hashed.
- MUST thread trace_id through workflow → invite → verify → sign → audit.
- MUST NOT apply signature without successful TASK-DOC-006 verification per DEC-1754.
- MUST NOT route signature to wrong CA per region per DEC-1755.
§2 — Why this design
Why 3 kinds (DEC-1750)? Ordered = legal sign chain (junior → senior), parallel = NDA-style (all sign independently), counter_sign = our signature first then customers.
Why verify-before-sign (DEC-1754)? Signature attached to wrong identity = signing fraud → court-ineffective.
Why per-region CA (DEC-1755)? Regulatory: EU eIDAS, US AATL, VN Decree 130; cross-routing breaks legal validity.
Why declined blocks workflow (DEC-1752)? Without all signers, contract incomplete; workflow.status=failed lets AM restart cleanly.
§3 — API contract
POST /v1/doc/documents/{id}/signing-workflows
GET /v1/doc/signing-workflows/{id} (status + signer list)
POST /v1/doc/signing-workflows/{id}/withdraw (CLO/AM only)
POST /v1/doc/signers/{id}/sign (signer-authenticated callback)
POST /v1/doc/signers/{id}/decline (signer-authenticated)
Sample workflow:
{
"workflow_kind": "ordered",
"signers": [
{"signer_id": "uuid-cto", "region": "vn", "required_assurance_level": "high", "position": 0},
{"signer_id": "uuid-customer", "region": "vn", "required_assurance_level": "substantial", "position": 1}
],
"expires_in_days": 14
}
§4 — Acceptance criteria
- 3-kind workflow enum + cardinality test. 2. 6-status enum + cardinality test. 3. Ordered: signer N+1 invited only after N signs. 4. Parallel: all invited at start. 5. Counter_sign: position-0 signs then others invited. 6. Verification required before sign. 7. CA routed per region (vn/eu/other). 8. Reminder cron 24h/72h/7d. 9. Reminder cadence configurable per tenant. 10. Decline → workflow=failed. 11. All signed → workflow=completed. 12. Expires at expires_at → workflow=expired + remaining signers=expired. 13. 6 memory audit kinds emitted. 14. PII scrubbed (signature payload SHA256). 15. RLS denies cross-tenant. 16. Trace_id preserved. 17. Withdraw by initiator allowed. 18. Append-only via REVOKE UPDATE except status cols. 19. Verification level mismatch → cannot sign. 20. Multi-region workflow: each signer routes to correct CA.
§5 — Verification
#[tokio::test]
async fn ordered_invites_sequentially() {
let ctx = TestContext::ordered_workflow(3_signers).await;
let s1 = ctx.signers()[0];
assert!(s1.invited_at.is_some());
let s2 = ctx.signers()[1];
assert!(s2.invited_at.is_none());
ctx.sign(s1.id).await;
let s2_after = ctx.signers()[1];
assert!(s2_after.invited_at.is_some());
}
#[tokio::test]
async fn parallel_all_invited_at_start() {
let ctx = TestContext::parallel_workflow(3_signers).await;
for s in ctx.signers() {
assert!(s.invited_at.is_some());
}
}
#[tokio::test]
async fn decline_fails_workflow() {
let ctx = TestContext::parallel_workflow(3_signers).await;
ctx.decline(ctx.signers()[0].id).await;
let wf = ctx.fetch_workflow(ctx.workflow_id).await;
assert_eq!(wf.status, "failed");
}
#[tokio::test]
async fn ca_routed_by_region() {
let ctx = TestContext::multi_region_workflow().await;
ctx.sign_all().await;
let signers = ctx.signers();
let vn = signers.iter().find(|s| s.region == "vn").unwrap();
assert!(vn.ca_routing.as_deref().unwrap().contains("vn-ca"));
let eu = signers.iter().find(|s| s.region == "eu").unwrap();
assert!(eu.ca_routing.as_deref().unwrap().contains("qtsp"));
}
// 5.5..5.10
§7 — Dependencies
Upstream: TASK-DOC-001, TASK-DOC-006. Cross-module: TASK-DOC-002/003/004 (CA per region), TASK-EMAIL-009 (invite + reminder), TASK-MCP-007 (reminder cron), TASK-AUTH-101 (initiator role), TASK-MEMORY-111 (PII).
§10 — Failure modes
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Verification fails | check pre-sign | signer stays pending; no signature | retry |
| CA route fails | downstream err | signer status=failed; sev-1 | retry |
| Signer email bounces | TASK-EMAIL-009 status | sev-2 + reminder via SMS | manual outreach |
| Workflow expires | cron check | status=expired | restart |
| Ordered position skipped | check sequence | error if N+1 invited before N | inherent |
| Decline mid-ordered | block all subsequent | workflow=failed | restart |
| Withdraw during signing | reject signed signers | inherent (signed=immutable) | inherent |
| Multi-CA concurrent | per-signer call | independent | inherent |
| Region mismatch (signer.region invalid) | validate | reject 400 | use valid |
| Cross-tenant workflow | RLS | 403 | inherent |
§11 — Implementation notes
- §11.1 Sign link is signed token with workflow_id + signer_row_id; expires per workflow.
- §11.2 Reminder cron: per workflow, computes elapsed since invited_at; sends if matches cadence_hours[i].
- §11.3 Workflow engine state machine: in_progress → completed | failed | expired | withdrawn.
- §11.4 memory audit body: workflow_id, signer_id, kind; signature payload SHA256.
- §11.5 CA router maps region to TASK-DOC-002/003/004 module; future regions extend the table.
End of TASK-DOC-005 spec.