Task — engineering-spec@1

"DOC multi-party signing workflow — ordered + parallel + counter-sign with reminder cadence and full audit trail"

draftTASK-DOC-005
module doc · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-DOC-001, TASK-DOC-006 · blocks none

§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.

  1. MUST expose POST /v1/doc/documents/{id}/signing-workflows body { workflow_kind, signers: [{signer_id, region, required_assurance_level, position?}], expires_in_days?, reminder_cadence_override? }.
  1. MUST validate workflow_kind against closed enum per DEC-1751.
  1. MUST validate signature_status against closed enum per DEC-1752.
  1. MUST dispatch per kind:
  1. MUST for each signer per DEC-1754:
  1. MUST route CA per signer region per DEC-1755:
  1. MUST send reminders per DEC-1753 via TASK-MCP-007 cron — at 24h, 72h, 7d after invite; configurable per tenant.
  1. 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; ```

  1. MUST handle decline per DEC-1752 — signer.status=declined → block workflow completion → workflow.status=failed.
  1. MUST emit 6 memory audit kinds per DEC-1756. PII per TASK-MEMORY-111: signer_id (uuid) ok; signature_payload hashed.
  1. MUST thread trace_id through workflow → invite → verify → sign → audit.
  1. MUST NOT apply signature without successful TASK-DOC-006 verification per DEC-1754.
  1. 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

  1. 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

FailureDetectionOutcomeRecovery
Verification failscheck pre-signsigner stays pending; no signatureretry
CA route failsdownstream errsigner status=failed; sev-1retry
Signer email bouncesTASK-EMAIL-009 statussev-2 + reminder via SMSmanual outreach
Workflow expirescron checkstatus=expiredrestart
Ordered position skippedcheck sequenceerror if N+1 invited before Ninherent
Decline mid-orderedblock all subsequentworkflow=failedrestart
Withdraw during signingreject signed signersinherent (signed=immutable)inherent
Multi-CA concurrentper-signer callindependentinherent
Region mismatch (signer.region invalid)validatereject 400use valid
Cross-tenant workflowRLS403inherent

§11 — Implementation notes


End of TASK-DOC-005 spec.