Task — engineering-spec@1

"PORTAL client-initiated workflows — new project request / billing inquiry / support ticket → CHAT thread with SLA + auto-routing + status tracking"

draftTASK-PORTAL-006
module portal · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-CHAT-005 · blocks none

§1 — Description (BCP-14 normative)

The PORTAL service MUST ship client-initiated workflows at services/portal/src/workflows/ with 5 closed-enum workflow kinds, auto-routing to assignees via per-tenant rules, CHAT-thread bridging, SLA monitoring, auto-prioritisation on security keywords, file attachments via TASK-DOC-001, internal/external message separation, and 6 memory audit kinds.

  1. MUST define closed client_workflow_kind enum: ('new_project_request','billing_inquiry','support_ticket','task','general_question') per DEC-1240. Cardinality test asserts 5.
  1. MUST define closed workflow_status enum: ('submitted','acknowledged','in_progress','awaiting_client','resolved','closed','escalated') per DEC-1243. Cardinality test asserts 7.
  1. MUST define portal_workflow_submissions table at migration 0018: (workflow_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, engagement_id UUID NOT NULL, submitter_subject_id UUID NOT NULL, workflow_kind client_workflow_kind NOT NULL, status workflow_status NOT NULL DEFAULT 'submitted', title TEXT NOT NULL, body TEXT NOT NULL, attachments JSONB NOT NULL DEFAULT '[]'::jsonb, assignee_subject_id UUID, chat_thread_id UUID, sla_acknowledged_by TIMESTAMPTZ, sla_resolved_by TIMESTAMPTZ, acknowledged_at TIMESTAMPTZ, resolved_at TIMESTAMPTZ, escalated_at TIMESTAMPTZ, escalation_reason TEXT, submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), trace_id CHAR(32)). RLS scoped to tenant_id AND (submitter_subject_id = auth.subject_id OR assignee_subject_id = auth.subject_id OR has_role('engagement_admin')).
  1. MUST define portal_workflow_routing_rules at migration 0019: (id BIGSERIAL PRIMARY KEY, tenant_id UUID NOT NULL, workflow_kind client_workflow_kind NOT NULL, engagement_id UUID, assignee_subject_id UUID NOT NULL, priority INT NOT NULL DEFAULT 100, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by_subject_id UUID NOT NULL). Per-tenant rules; lower priority number wins; engagement_id NULL = tenant-wide rule.
  1. MUST expose POST /v1/portal/workflows/submit body { engagement_id, workflow_kind, title, body, attachment_s3_keys?: [...] }. Handler:
  1. MUST auto-route per DEC-1242. The router.rs::route(tenant_id, workflow_kind, engagement_id):
  1. MUST create CHAT thread per DEC-1241 via chat_bridge.rs::create_thread(workflow_id):
  1. MUST separate internal vs external messages per DEC-1245. CHAT messages in the thread:
  1. MUST monitor SLA per DEC-1244 via daily job:
  1. MUST support file attachments per DEC-1246. Handler:
  1. MUST auto-prioritise on security keywords per DEC-1247. The auto_priority.rs::check(body, workflow_kind):
  1. MUST expose GET /v1/portal/workflows?engagement_id=...&status=... for submitter list view. Returns own workflows + status mirror.
  1. MUST expose GET /v1/portal/workflows/{id} for detail — shows status + client-visible CHAT messages + SLA timestamps; never internal messages.
  1. MUST support reopen per DEC-1251. POST /v1/portal/workflows/{id}/reopen:
  1. MUST send email on status change per DEC-1252 via TASK-EMAIL-001. Per-tenant template overrides apply.
  1. MUST emit 6 memory audit kinds per DEC-1248: submitted (sev-2), routed (sev-3), status_changed (sev-2), resolved (sev-2), sla_breach (sev-1), client_reply (sev-3).
  1. MUST PII-scrub: title + body SHA256 only in chain; raw in DB.
  1. MUST rate-limit per DEC-1249 — 10 submissions per workflow_kind per hour per caller.
  1. MUST thread trace_id across submit → route → CHAT-create → audit.
  1. MUST be RLS-scoped: submitter sees own; assignee sees assigned; engagement_admin sees Engagement-wide.

§2 — Why this design (rationale for humans)

Why CHAT-thread per workflow (§1 #7, DEC-1241)? Conversation continuity. Workflow has chat history; engagement team chats internally; client sees client-visible portion. Re-implementing chat in PORTAL = waste; reusing CHAT-005 = audit + retention + search free.

Why auto-prioritise security keywords (§1 #11, DEC-1247)? Security incidents need response in minutes, not days. Keyword-based detection is crude but high-recall + zero-cost. False positives (e.g. "the system was down for a minute") get manual de-escalation; missed-positives (no keyword used) get standard SLA — acceptable.

Why internal/external message separation (§1 #8, DEC-1245)? Engagement team needs candid internal discussion ("client is wrong, but let's gently educate"). Surfacing those to client = brand damage. Default-internal + opt-in-external mirrors real consulting workflow.

Why 30-day reopen window (§1 #14, DEC-1251)? Resolved issues sometimes recur; convenient to reuse the original thread vs starting fresh. 30 days = "still recent in context" without indefinite resurrection.

Why per-tenant routing rules (§1 #6, DEC-1242)? Different tenants have different team structures. Hardcoded routing = wrong for everyone except CyberSkill. Rules + fallback to engagement_admin covers all cases.


§3 — API contract

-- 0018_portal_workflow_submissions.sql
CREATE TYPE client_workflow_kind AS ENUM ('new_project_request','billing_inquiry','support_ticket','task','general_question');
CREATE TYPE workflow_status AS ENUM ('submitted','acknowledged','in_progress','awaiting_client','resolved','closed','escalated');

CREATE TABLE portal_workflow_submissions (
  workflow_id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL,
  engagement_id UUID NOT NULL,
  submitter_subject_id UUID NOT NULL,
  workflow_kind client_workflow_kind NOT NULL,
  status workflow_status NOT NULL DEFAULT 'submitted',
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  attachments JSONB NOT NULL DEFAULT '[]'::jsonb,
  assignee_subject_id UUID,
  chat_thread_id UUID,
  sla_acknowledged_by TIMESTAMPTZ,
  sla_resolved_by TIMESTAMPTZ,
  acknowledged_at TIMESTAMPTZ,
  resolved_at TIMESTAMPTZ,
  escalated_at TIMESTAMPTZ,
  escalation_reason TEXT,
  submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  trace_id CHAR(32)
);
CREATE INDEX idx_workflow_submitter ON portal_workflow_submissions(submitter_subject_id, submitted_at DESC);
CREATE INDEX idx_workflow_assignee ON portal_workflow_submissions(assignee_subject_id, status) WHERE status != 'closed';
CREATE INDEX idx_workflow_sla_check ON portal_workflow_submissions(sla_acknowledged_by, sla_resolved_by) WHERE status IN ('submitted','acknowledged','in_progress');
ALTER TABLE portal_workflow_submissions ENABLE ROW LEVEL SECURITY;
CREATE POLICY portal_workflow_submissions_rls ON portal_workflow_submissions
  USING (
    tenant_id = current_setting('auth.tenant_id')::uuid
    AND (
      submitter_subject_id = current_setting('auth.subject_id')::uuid
      OR assignee_subject_id = current_setting('auth.subject_id')::uuid
      OR EXISTS (SELECT 1 FROM subject_roles
                 WHERE subject_id = current_setting('auth.subject_id')::uuid
                   AND role = 'engagement_admin'
                   AND scope_engagement_id = portal_workflow_submissions.engagement_id)
    )
  )
  WITH CHECK (
    tenant_id = current_setting('auth.tenant_id')::uuid
    AND submitter_subject_id = current_setting('auth.subject_id')::uuid
  );
REVOKE DELETE ON portal_workflow_submissions FROM cyberos_app;
GRANT UPDATE (status, assignee_subject_id, chat_thread_id, sla_acknowledged_by, sla_resolved_by,
              acknowledged_at, resolved_at, escalated_at, escalation_reason, updated_at)
  ON portal_workflow_submissions TO cyberos_app;

-- 0019_portal_workflow_routing_rules.sql
CREATE TABLE portal_workflow_routing_rules (
  id BIGSERIAL PRIMARY KEY,
  tenant_id UUID NOT NULL,
  workflow_kind client_workflow_kind NOT NULL,
  engagement_id UUID,
  assignee_subject_id UUID NOT NULL,
  priority INT NOT NULL DEFAULT 100,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  created_by_subject_id UUID NOT NULL
);
CREATE INDEX idx_routing_lookup ON portal_workflow_routing_rules(tenant_id, workflow_kind, engagement_id, priority);
ALTER TABLE portal_workflow_routing_rules ENABLE ROW LEVEL SECURITY;
CREATE POLICY portal_workflow_routing_rules_rls ON portal_workflow_routing_rules
  USING (tenant_id = current_setting('auth.tenant_id')::uuid)
  WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid);
REVOKE UPDATE, DELETE ON portal_workflow_routing_rules FROM cyberos_app;

Endpoints:

POST   /v1/portal/workflows/submit                (submitter)
GET    /v1/portal/workflows?engagement_id=...     (own list)
GET    /v1/portal/workflows/{id}                   (detail)
POST   /v1/portal/workflows/{id}/reply             (submitter reply)
POST   /v1/portal/workflows/{id}/reopen            (submitter; 30d window)
POST   /v1/admin/tenants/{tid}/workflow-routes     (tenant_admin: configure rules)

§4 — Acceptance criteria

  1. client_workflow_kind cardinality — 5 values.
  2. workflow_status cardinality — 7 values.
  3. Submit creates CHAT threadPOST /submit → workflow row + chat thread created in proper channel.
  4. Auto-routing per rule — rule for tenant + workflow_kind → assignee_subject_id populated.
  5. Fallback to engagement_admin — no rule → engagement_admin assigned.
  6. Auto-priority on security keyword — body containing "breach" → status='escalated' + sev-1 alert.
  7. SLA breach detected — daily job marks workflow sla_acknowledged_by < now() → emits sev-1 breach audit.
  8. File attachment ≤ 25 MiB — uploaded file 26 MiB → 413.
  9. Max 5 attachments — 6th attachment → 400.
  10. Internal messages hidden from submitter — CHAT message with client_visible: false NOT in submitter detail view.
  11. Client reply creates client_visible message — POST /reply creates CHAT message with type='client_reply' + visibility=true.
  12. Reopen within 30 days — closed workflow + 29 days later → reopen succeeds.
  13. Reopen beyond 30 days — 31 days later → 400.
  14. Email on status change — status transition triggers TASK-EMAIL-001 send.
  15. Rate limit 10/hr/kind/caller — 11th submission of same kind → 429.
  16. 6 memory audit kinds emitted — full lifecycle covers all 6.
  17. PII scrubbed — title/body SHA256 only in chain.
  18. Trace_id threaded — submit → route → chat-create → audit all share trace_id.
  19. RLS — engagement_admin sees engagement-wide — engagement_admin lists shows all workflows in their Engagement.
  20. RLS — submitter sees own only — submitter list excludes other submitters' rows.

§5 — Verification

// 5.1 submit creates CHAT thread
#[tokio::test]
async fn submit_creates_workflow_and_chat_thread() {
    let ctx = TestContext::with_engagement_subject().await;
    ctx.seed_chat_channel("billing").await;
    let r = ctx.post_workflow("billing_inquiry", "Q on invoice 42", "...").await;
    assert_eq!(r.status(), 201);
    let body: serde_json::Value = r.json().await.unwrap();
    assert!(body["chat_thread_id"].is_string());

    let thread_exists: bool = sqlx::query_scalar(
        "SELECT EXISTS(SELECT 1 FROM chat_threads WHERE id=$1)"
    ).bind(body["chat_thread_id"].as_str().unwrap().parse::<Uuid>().unwrap()).fetch_one(&ctx.pool).await.unwrap();
    assert!(thread_exists);
}

// 5.2 auto-priority on breach keyword
#[tokio::test]
async fn breach_keyword_escalates() {
    let ctx = TestContext::with_engagement_subject().await;
    let r = ctx.post_workflow("support_ticket", "URGENT: data breach detected", "...").await;
    let body: serde_json::Value = r.json().await.unwrap();
    assert_eq!(body["status"], "escalated");

    let audit = ctx.memory_rows().await;
    assert!(audit.iter().any(|r| r.kind == "portal.workflow_status_changed"
        && r.payload["new_status"] == "escalated"));
}

// 5.3 SLA breach detection
#[tokio::test]
async fn sla_breach_emits_sev1() {
    let ctx = TestContext::new().await;
    let wid = ctx.create_workflow_with_past_sla().await;
    ctx.run_sla_monitor_job().await;
    let audit = ctx.memory_rows().await;
    assert!(audit.iter().any(|r| r.kind == "portal.workflow_sla_breach" && r.severity == 1));
}

// 5.4 internal hidden, external visible
#[tokio::test]
async fn submitter_sees_only_client_visible_messages() {
    let ctx = TestContext::with_engagement_subject().await;
    let wid = ctx.create_workflow().await;
    ctx.as_engagement_team().post_chat_message(wid, "internal note", false).await;
    ctx.as_engagement_team().post_chat_message(wid, "client-visible reply", true).await;

    let r = ctx.as_submitter().get_workflow_detail(wid).await;
    let messages: Vec<&str> = ctx.extract_messages(r).await;
    assert!(messages.contains(&"client-visible reply"));
    assert!(!messages.contains(&"internal note"));
}

// 5.5 reopen 30d window
#[tokio::test]
async fn reopen_within_window() {
    let ctx = TestContext::new().await;
    let wid = ctx.create_and_close_workflow().await;
    ctx.travel_clock_forward(Duration::from_days(29)).await;
    let r = ctx.reopen_workflow(wid).await;
    assert_eq!(r.status(), 200);
    let status: String = sqlx::query_scalar("SELECT status::text FROM portal_workflow_submissions WHERE workflow_id=$1")
        .bind(wid).fetch_one(&ctx.pool).await.unwrap();
    assert_eq!(status, "awaiting_client");
}

// 5.6..5.12: enum cardinality, rate limit, routing rule, attachment cap, email, audit

§7 — Dependencies

Upstream: TASK-CHAT-005 (thread + message primitive). Cross-module: TASK-PORTAL-001 (workflow shown in PORTAL list), TASK-PORTAL-003 (IdP subject), TASK-PORTAL-005 (Genie may surface workflow state), TASK-PROJ-001 (new_project_request creates PROJ entity), TASK-INV-001 (billing_inquiry may reference invoices), TASK-AUTH-101 (engagement_admin role), TASK-EMAIL-001 (notification + template overrides), TASK-AI-003 (audit), TASK-MEMORY-111 (PII scrub), TASK-OBS-007 (sev-1 escalation routing). Downstream: None.


§8 — Example payload

portal.workflow_submitted:

{
  "kind": "portal.workflow_submitted",
  "severity": 2,
  "tenant_id": "8a2f...",
  "actor_id": "user.submitter.456",
  "trace_id": "...",
  "occurred_at": "2026-05-17T09:14:32.847Z",
  "payload": {
    "workflow_id": "0190...",
    "engagement_id": "0190...",
    "workflow_kind": "billing_inquiry",
    "title_sha256": "9c4e...",
    "chat_thread_id": "0190...",
    "sla_acknowledged_by": "2026-05-17T13:14:32Z",
    "sla_resolved_by": "2026-05-22T09:14:32Z"
  }
}

§9 — Open questions

Deferred:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Unknown workflow_kindenum check400Caller fixes
Engagement not in membershipscope check403Caller's engagements only
CHAT thread creation failsTASK-CHAT-005 errorWorkflow row rolled back; 500Operator investigates CHAT
Routing rule missing + no engagement_adminfallback missSev-1 alert; workflow remains 'submitted' unassignedTenant_admin assigns rule
Attachment upload failsS3 error503; caller retriesInherent
Attachment > 25 MiBsize check413Caller compresses
> 5 attachmentscount check400Caller reduces
Auto-priority false-positivemanual reviewEngagement_admin de-escalates via status_changeInherent UX
SLA breach not detected (job failure)daily monitor + watchdogSev-2 alertOperator runs monitor manually
Reopen beyond 30dwindow check400New submission
Rate limit hitcounter429Caller waits
Submitter tries to see internal CHAT messagesclient_visible filterInternal hiddenInherent
Cross-tenant submissionRLS rejects403Inherent
Email send failsTASK-EMAIL-001 errorStatus change still committed; email retried by TASK-EMAIL-001 jobInherent retry
Submitter deprovisioned mid-workflow (TASK-PORTAL-004)session revokedSubsequent actions 401; workflow row remainsEngagement team continues internally
Engagement_admin role lost mid-workflowRLS at next readEngagement_admin loses workflow visibilityTenant_admin re-grants role
Status state machine invalid transition (e.g. resolved → submitted)state guard400 + invalid_status_transitionCaller fixes flow
Reopen of escalated workflowspecial handlingStatus → awaiting_client; escalated_at clearedInherent
Concurrent status updatesoptimistic lock via updated_atLast writer wins; loser sees 409Caller refetches + retries
Workflow with no chat_thread_idbridge failure during creationRow exists in degraded state; sev-2 alertBackground job retries CHAT create
Attachment SHA256 mismatchserver verificationAttachment rejectedCaller re-uploads

§11 — Implementation notes

§11.1 Routing rule lookup uses index on (tenant_id, workflow_kind, engagement_id, priority); typical query < 1ms.

§11.2 Security keyword list maintained in auto_priority.rs::SECURITY_KEYWORDS const; review quarterly.

§11.3 Status state machine: enforces valid transitions; e.g., submitted → acknowledged → in_progress → resolved → closed; escalated valid from any non-terminal.

§11.4 SLA business-hours calculator uses tenant timezone + business calendar; defaults UTC weekdays.

§11.5 CHAT thread channel mapping: per-tenant config; default #workflow-<kind>.

§11.6 Client reply via portal UI: server creates CHAT message via TASK-CHAT-005 with type='client_reply' + author=submitter_subject_id.

§11.7 Email templates per TASK-PORTAL-002 brand pack: per-tenant override fallback chain.

§11.8 Rate limit Redis sliding-window per (caller, workflow_kind).

§11.9 Attachments JSONB shape: [{s3_key, sha256, size_bytes, filename, mime_type}].

§11.10 Reopen audit reuses the same workflow_id (vs new row); preserves history continuity.


End of TASK-PORTAL-006 spec.