Skip to main content
Status: Accepted Date: 2026-06-07 Decision Makers: Jeremy Bloom (project owner) Blocker: IG-06 (FW completion audit — keystone cross-core seam)
Shipped (2026-06): the seam is live — @/platform/workflow exposes enqueueWorkflowExecution/getThrottleStatus (src/platform/workflow/execution.ts) over the fw-enqueue-execution edge function, with the useWorkflowEnqueue hook for component use.

Context

The FW completion audit flagged IG-06 as the keystone cross-core blocker: there is no sanctioned platform seam for HR/FA/RH to enqueue an FW workflow execution or read FW throttle/KPI status. As a result the three FW integration guides are frozen at 📝 Planned and 0 of 19 cross-core workflow templates exist (6 HR + 5 FA + 6 RH “Planned Workflow Templates” tables in HR-FW-INTEGRATION.md, FA-FW-INTEGRATION.md, RH-FW-INTEGRATION.md).

Current state (file:line evidence)

  • @/platform/workflow is diagram-only. src/platform/workflow/index.ts:1-42 exports SwimLaneCanvas, WorkflowCanvas, diagram version/presence/broadcast hooks and BPMN utils — no execution API. src/platform/workflow/types.ts is entirely swim-lane/canvas types; there is no execution request/result type.
  • FW’s real execution path is pgmq-based. The durable worker supabase/functions/workflow-executor-worker/index.ts reads workflow_execution_queue via pgmq_read (line ~568), processes batches under a per-org semaphore on fw_module_settings (lines ~110-150), and on permanent failure routes to workflow_dlq via pgmq_send. The canonical enqueue primitive is INSERT INTO fw_workflow_executions (… status: 'queued' …) followed by supabase.rpc('pgmq_send', { queue_name: 'workflow_execution_queue', msg: { execution_id, rule_id, trigger_type, organization_id, attempt } }) — see the recovery-workflow path at supabase/functions/workflow-executor-worker/index.ts:490-519 for the exact shape.
  • App-side cores never touch pgmq directly today — they invoke an edge function (e.g. automation-executor, cl-loc-assessment-get) and let the function own the insert+enqueue. The cross-core service style to mirror is src/platform/clinical/loc/getLocAssessmentForUM.ts — a thin async function that supabase.functions.invoke(...)s an org/consent-gated edge function and returns a typed result.
  • FW-53 throttle is live. fw_rate_limit_configs / fw_execution_rate_counters (types: src/cores/fw/types/rate-limiting.ts) back a per-org / per-workflow rate state; the read hook is src/cores/fw/hooks/useExecutionRateSnapshot.ts. The worker rate-limit enforcement (B3) defers/holds over-limit work rather than dropping it (conflict_resolution: 'skip' | 'delay' | 'error' in supabase/functions/_shared/fw-schedule-dispatch.ts:22). A cross-core caller needs to see that hold state before/while enqueuing.
  • FW-58 KPI snapshots live in fw_kpi_snapshots (src/cores/fw/hooks/useKpiSnapshots.ts).

The boundary constraint

Constitution §1 / ADR-010: cores depend on PF only; no direct core-to-core imports. HR/FA/RH must not import from src/cores/fw/*. The only sanctioned channel is the Platform Integration Layer (@/platform/*).

Decision

Add an execution seam to @/platform/workflow — a small, typed, tenant-scoped public API that wraps the FW pgmq enqueue path behind an edge function, plus read surfaces for throttle and KPI state. Cross-core callers (HR/FA/RH) import only from @/platform/workflow; FW owns the edge function and the queue. Under the hood the enqueue does NOT write pgmq from the browser. It calls a new FW edge function (working name fw-enqueue-execution) that, with the service role, runs pf_has_org_access → idempotency lookup → INSERT fw_workflow_executions (status:'queued')pgmq_send('workflow_execution_queue', …) inside one transaction. This keeps RLS, tenant checks, and the queue contract on the FW/PF side and gives the platform layer a boundary-clean wrapper.

Proposed API (@/platform/workflow)

Hook + service split (mirrors @/platform/clinical):
  • enqueueWorkflowExecution / getThrottleStatus ship as plain async services (callable from mutations, schedulers, or React) — the getLocAssessmentForUM pattern.
  • A thin useWorkflowEnqueue() mutation hook and useWorkflowThrottleStatus() / useWorkflowKpiSnapshot() query hooks wrap them for component use (the useExecutionRateSnapshot / useKpiSnapshots pattern), reusing useOrganization() for the default org and sanitizeErrorMessage for errors.

Tenant + auth scoping

  • organizationId is always re-verified server-side in the edge function via pf_has_org_access; client-supplied org is never trusted (security rule + §1).
  • Caller must hold a workflow-execute permission for the org (new fw.workflows.execute_cross_core permission, checked in the edge fn). Read surfaces (getThrottleStatus, subscribeKpiSnapshot) are RLS-scoped reads of fw_execution_rate_counters / fw_kpi_snapshots — no new write capability.
  • No PHI in triggerPayload, workflowKey, or correlationId (security rule); the edge fn rejects payloads exceeding a size cap and logs only IDs.

Idempotency

idempotencyKey (caller-chosen, e.g. hr_employee_hired:{employeeId}) is persisted on fw_workflow_executions under a UNIQUE (organization_id, idempotency_key) partial index. The edge fn does an upsert-style lookup: a second enqueue with the same key returns the existing executionId with status:'deduplicated' and does not re-pgmq_send. This makes retries from HR/FA/RH (and event redelivery) safe.

Failure modes & backpressure

How HR/FA/RH consume it

This unblocks the 19 templates: each integration guide’s “Planned Workflow Templates” entry becomes implementable because the consuming core finally has a sanctioned enqueue verb. The guides flip from 📝 Planned to ✅ Implemented once the seam + per-core template workflowKeys are registered.

Phased implementation plan

  • Phase 0 — this ADR (planning). No code. ~done.
  • Phase 1 — MVP (enqueueWorkflowExecution + getThrottleStatus). New fw-enqueue-execution edge fn (insert+pgmq_send+idempotency+pf_has_org_access); migration adding idempotency_key + unique index + fw.workflows.execute_cross_core permission; the two async services + a useWorkflowEnqueue hook in src/platform/workflow/; RLS + integration tests; wire one template end-to-end (hr-employee-onboarding) as proof. Est. 3–4 days.
  • Phase 2 — KPI subscription + remaining read surface. subscribeKpiSnapshot over Supabase Realtime on fw_kpi_snapshots; query hooks; docs. Est. 1–2 days.
  • Phase 3 — roll out the 19 templates across HR/FA/RH, flipping each integration guide to Implemented. Est. 1–2 days per core (template-config heavy, not seam work).
Total seam effort ≈ 4–6 days; template rollout is incremental and core-owned.

Open questions (for human decision)

  1. workflowKeyrule_id resolution. Per-org registry table (fw_workflow_template_registry) vs. convention on fw_automation_rules.template_key? Affects whether each org must “install” a template before cross-core enqueue works.
  2. Throttled = accepted-or-rejected? Should enqueueWorkflowExecution always persist the row on FW-53 hold (return 'throttled', drain later) or hard-fail so the caller owns retry? MVP assumes accept-and-hold; confirm this is the desired backpressure contract.
  3. Permission model. New fw.workflows.execute_cross_core, or reuse an existing FW-03 automation-trigger permission? Does an HR user enqueueing an FW workflow need an FW grant, or is the cross-core call system-actor on the user’s behalf?
  4. Edge fn vs. PF RPC. Wrap via a Deno edge function (matches getLocAssessmentForUM) or a single SECURITY DEFINER Postgres function (fw_enqueue_execution) the seam calls via supabase.rpc? RPC is one less deploy surface but moves pgmq_send into SQL.
  5. KPI realtime vs. poll. Is fw_kpi_snapshots updated often enough to justify a Realtime channel, or is a polling query hook (like useKpiSnapshots) sufficient for Phase 2?

Consequences

  • @/platform/workflow gains a second responsibility (execution) alongside diagrams; index doc should section it clearly.
  • FW owns one new edge fn + one additive migration; no change to the worker contract.
  • HR/FA/RH stay import-clean (boundary preserved); the 19 templates become buildable.
  • New always-on surface is minimal (small typed API; tree-shakeable).

References