Task — engineering-spec@1

"EMAIL Genie prefix — inbound subject prefix routes message to Genie (Branded AI) for automated drafting + action proposals"

draftTASK-EMAIL-008
module email · class product · priority p1 · created 2026-05-17 · shipped null
depends on TASK-EMAIL-001, TASK-EMAIL-005, TASK-PORTAL-005, TASK-CUO-101 · blocks none

§1 — Description (BCP-14 normative)

The EMAIL service MUST ship Genie-prefix routing at services/email/src/genie/ matching subject prefix, calling TASK-PORTAL-005 Branded Genie, proposing actions, queueing for user approval, 6 memory audit kinds.

  1. MUST hook into services/email/src/inbound_processor.rs after message stored: call genie::route(message).
  1. MUST match prefix per DEC-1590 via prefix_router.rs::matches(subject, tenant):
  1. MUST load context per DEC-1594 — brand pack (TASK-PORTAL-002), CRM contact via TASK-EMAIL-006 link, thread last-10-msgs, tenant tool list from TASK-MCP-006.
  1. MUST call Branded Genie at portal_bridge.rs::propose(message, context) — invokes TASK-PORTAL-005 chat with system prompt: "Read this email, propose 0-N actions. Output JSON [{kind, params, rationale}]".
  1. MUST validate proposed genie_action_kind against closed enum per DEC-1592.
  1. MUST queue actions for user review per DEC-1593 — NEVER auto-execute.
  1. MUST define genie_sessions and genie_actions tables at migration 0010: ```sql CREATE TABLE genie_sessions ( session_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, message_id UUID NOT NULL, thread_id UUID NOT NULL, status TEXT NOT NULL DEFAULT 'proposing' CHECK (status IN ('proposing','awaiting_review','executing','completed','failed','dismissed')), started_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, trace_id CHAR(32) ); ALTER TABLE genie_sessions ENABLE ROW LEVEL SECURITY; CREATE POLICY genie_sessions_rls ON genie_sessions USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON genie_sessions FROM cyberos_app; GRANT UPDATE (status, completed_at) ON genie_sessions TO cyberos_app;

CREATE TABLE genie_actions ( action_id UUID PRIMARY KEY, session_id UUID NOT NULL REFERENCES genie_sessions(session_id), tenant_id UUID NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('draft_reply','create_issue','summarize_thread','fetch_data','escalate_human','no_action')), params JSONB NOT NULL, rationale TEXT, status TEXT NOT NULL DEFAULT 'proposed' CHECK (status IN ('proposed','approved','executed','dismissed','failed')), reviewed_by UUID, reviewed_at TIMESTAMPTZ, executed_at TIMESTAMPTZ, result JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE genie_actions ENABLE ROW LEVEL SECURITY; CREATE POLICY genie_actions_rls ON genie_actions USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON genie_actions FROM cyberos_app; GRANT UPDATE (status, reviewed_by, reviewed_at, executed_at, result) ON genie_actions TO cyberos_app; ```

  1. MUST emit 6 memory audit kinds per DEC-1595. PII per TASK-MEMORY-111: message body + AI output SHA-256 hashed.
  1. MUST thread trace_id from inbound → prefix match → Genie → action queue → user approve → execute.
  1. MUST add tenant config column to extend TASK-EMAIL-001: ``sql ALTER TABLE email_tenant_config ADD COLUMN genie_prefix TEXT DEFAULT 'Genie:'; ALTER TABLE email_tenant_config ADD COLUMN genie_enabled BOOLEAN DEFAULT false; ``
  1. MUST NOT auto-execute action per DEC-1593.
  1. MUST NOT bypass TASK-MCP-006 tool gating when executing fetch_data action — only allowlisted tools per tenant.

§2 — Why this design

Why prefix routing (DEC-1590)? Lightweight opt-in per-message; doesn't intercept regular email flow.

Why action proposals not direct execution (DEC-1593)? AI hallucination risk on customer-facing sends; manual review is the gate.

Why 6 action kinds (DEC-1591)? Covers common Genie use cases — reply drafts, issue creation, thread summary, data lookup, human escalation, no-op (matches existing CDO workflows).

Why brand pack context (DEC-1594)? Branded Genie must speak in tenant voice; brand pack defines tone+language+disclaimers.


§3 — API contract

GET    /v1/email/genie/sessions                  (list user's pending Genie reviews)
GET    /v1/email/genie/sessions/{id}             (detail with proposed actions)
POST   /v1/email/genie/actions/{id}/approve      (execute)
POST   /v1/email/genie/actions/{id}/dismiss      (mark dismissed)
PUT    /v1/email/genie/config                    (tenant prefix + enabled toggle)

Sample session detail:

{
  "session_id": "uuid",
  "message_id": "uuid",
  "status": "awaiting_review",
  "actions": [
    {
      "action_id": "uuid",
      "kind": "draft_reply",
      "params": {"body": "Dear John, thanks for reaching out..."},
      "rationale": "Customer asked about pricing; drafted standard response.",
      "status": "proposed"
    },
    {
      "action_id": "uuid",
      "kind": "create_issue",
      "params": {"project_id": "...", "title": "Schedule pricing demo for Acme"},
      "rationale": "Follow-up needed in 24h.",
      "status": "proposed"
    }
  ]
}

§4 — Acceptance criteria

  1. Subject prefix triggers Genie. 2. Case-insensitive match. 3. Strips Re:/Fwd: before match. 4. Tenant prefix configurable. 5. Genie_enabled toggle respected (off → skip silently). 6. Context loaded (brand + CRM + thread + tools). 7. 6 action kinds enum + cardinality test. 8. Actions queued, never auto-executed. 9. 6 memory audit kinds emitted. 10. PII scrubbed (body/AI output SHA256). 11. RLS denies cross-tenant. 12. Trace_id preserved. 13. fetch_data respects TASK-MCP-006 gating. 14. Approve → execute (calls TASK-EMAIL-009 send / TASK-PROJ-001 create / etc.). 15. Dismiss → status=dismissed (audit). 16. AI failure → status=failed + sev-2. 17. Append-only sessions/actions tables. 18. Multiple actions per session executed in order. 19. Result of execution stored in actions.result. 20. Branded Genie call uses brand pack tone.

§5 — Verification

#[tokio::test]
async fn prefix_triggers_genie() {
    let ctx = TestContext::with_genie_enabled().await;
    let msg = ctx.receive_inbound_with_subject("Genie: Draft a reply").await;
    let session = ctx.fetch_genie_session_for_msg(msg.id).await;
    assert!(session.is_some());
}

#[tokio::test]
async fn never_auto_executes() {
    let ctx = TestContext::with_genie_enabled().await;
    let msg = ctx.receive_inbound_with_subject("Genie: Help me").await;
    let session = ctx.wait_for_session_proposing(msg.id).await;
    let sent_emails = ctx.email_send_count().await;
    let issues = ctx.issue_create_count().await;
    assert_eq!(sent_emails, 0);
    assert_eq!(issues, 0);
    assert_eq!(session.status, "awaiting_review");
}

#[tokio::test]
async fn case_insensitive_prefix() {
    let ctx = TestContext::with_genie_prefix("Genie:").await;
    let cases = ["GENIE: help", "genie: help", "Genie: help", "Re: GENIE: reply", "Fwd: Genie: forward"];
    for subj in cases {
        let msg = ctx.receive_inbound_with_subject(subj).await;
        assert!(ctx.fetch_genie_session_for_msg(msg.id).await.is_some());
    }
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-EMAIL-001, TASK-PORTAL-005, TASK-CUO-101. Cross-module: TASK-PORTAL-002 (brand pack), TASK-CRM-001 (contact), TASK-MCP-006 (tool gating), TASK-AI-003 (LLM), TASK-MEMORY-111 (PII).

§8 — Sample payloads (see §3)

§9 — Open questions

None blocking.

§10 — Failure modes

FailureDetectionOutcomeRecovery
Genie disabled (tenant)flag checkskip silentlyinherent
Branded Genie unreachableHTTP timeoutstatus=failed; sev-2retry
AI returns invalid action kindenum matchfilter + sev-3 auditinherent
User no email send permissionexec timeaction fails, status=failedrequest perm
Tool not in allowlist (fetch_data)TASK-MCP-006 gaterejectedinherent
Prefix not configureduse default "Genie:"inherentinherent
Concurrent prefix match on same msgUNIQUE on session_id+message_idfirst winsinherent
Approve already-executed actionstatus check409inherent
Brand pack missingTASK-PORTAL-002 fallbackuse default toneinherent
Genie context too large (>50k tokens)truncatelast 10 msgs onlyinherent

§11 — Implementation notes


End of TASK-EMAIL-008 spec.