> ## Documentation Index
> Fetch the complete documentation index at: https://docs.encoreos.io/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR-024: @/platform/workflow Execution Seam (IG-06)

> Sanctioned Platform Integration Layer seam so HR/FA/RH can enqueue FW workflow executions and read throttle/KPI status without importing FW.

**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`](/architecture/integrations/HR-FW-INTEGRATION),
[`FA-FW-INTEGRATION.md`](/architecture/integrations/FA-FW-INTEGRATION),
[`RH-FW-INTEGRATION.md`](/architecture/integrations/RH-FW-INTEGRATION)).

### 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](/architecture/decisions/ADR-010-core-pf-dependency-boundary): 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`)

```ts theme={null}
// --- enqueue -------------------------------------------------------------
export interface EnqueueWorkflowExecutionInput {
  /** FW workflow template/rule key (e.g. 'hr-leave-request-approval'). Resolved to a rule_id server-side, per org. */
  workflowKey: string;
  /** Tenant scope. Verified server-side via pf_has_org_access; never trusted from the client. */
  organizationId: string;
  /** Trigger discriminator stored on the execution + queue msg. */
  triggerType: 'event' | 'manual' | 'scheduled' | 'api';
  /** Non-PHI trigger payload merged into fw_workflow_executions.trigger_payload. */
  triggerPayload?: Record<string, unknown>;
  /** Dedupe key. Re-enqueues with the same key for the same org return the existing execution (no duplicate row/msg). */
  idempotencyKey: string;
  /** Optional caller correlation id for tracing across cores. */
  correlationId?: string;
}

export interface EnqueueWorkflowExecutionResult {
  executionId: string;
  /** 'queued' on first enqueue; 'deduplicated' when idempotencyKey already mapped; 'throttled' when accepted-but-held by FW-53. */
  status: 'queued' | 'deduplicated' | 'throttled';
  /** Present when status==='throttled': when FW expects to release the hold. */
  retryAfter?: string; // ISO-8601
  correlationId: string;
}

export function enqueueWorkflowExecution(
  input: EnqueueWorkflowExecutionInput,
): Promise<EnqueueWorkflowExecutionResult>;

// --- throttle (FW-53) ----------------------------------------------------
export interface ThrottleStatus {
  organizationId: string;
  workflowKey?: string;
  /** True when a relevant FW-53 limit is currently holding/deferring executions. */
  isThrottled: boolean;
  /** 'ok' | 'held' (deferred, will run later) | 'rejected' (over a hard cap). */
  state: 'ok' | 'held' | 'rejected';
  /** Per-limit detail surfaced from fw_execution_rate_counters / fw_rate_limit_configs. */
  limits: Array<{
    limitType: 'max_concurrent' | 'max_per_minute' | 'max_per_hour' | 'max_cascade_depth';
    current: number;
    limit: number;
    windowResetAt?: string; // ISO-8601
  }>;
}

export function getThrottleStatus(args: {
  organizationId: string;
  workflowKey?: string;
}): Promise<ThrottleStatus>;

// --- KPI (FW-58) ---------------------------------------------------------
export interface KpiSnapshot {
  organizationId: string;
  workflowKey?: string;
  capturedAt: string; // ISO-8601
  metrics: {
    queuedCount: number;
    runningCount: number;
    completedCount: number;
    failedCount: number;
    p50DurationMs?: number;
    p95DurationMs?: number;
  };
}

/** Realtime subscription; returns an unsubscribe handle. Backed by a TanStack-Query/Realtime hook in the consumer. */
export function subscribeKpiSnapshot(
  args: { organizationId: string; workflowKey?: string },
  onSnapshot: (snapshot: KpiSnapshot) => void,
): () => void;
```

**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

| Condition                                                              | Behavior                                                                                                                                                                                                         |
| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **FW-53 hold** (over `max_per_minute`/`max_per_hour`/`max_concurrent`) | Row inserted with the configured `conflict_resolution: 'delay'` semantics; `enqueueWorkflowExecution` returns `status:'throttled'` + `retryAfter`. Caller should **not** spin-retry; the worker drains the hold. |
| **FW-53 hard reject** (`'error'` resolution / `max_cascade_depth`)     | Edge fn returns a typed `rejected` error; `getThrottleStatus().state === 'rejected'`. Caller surfaces a user-facing "workflow temporarily unavailable" toast — no silent drop.                                   |
| **Worker down**                                                        | Enqueue still succeeds (row + pgmq msg persist); execution stays `queued` until pg\_cron revives the worker. The seam is **fire-and-durable**, not synchronous. KPI `queuedCount` rises — observable.            |
| **Org lacks the workflow** (`workflowKey` unresolved for that org)     | Edge fn returns `404`-style typed error before any insert; no row, no msg.                                                                                                                                       |
| **pgmq unavailable**                                                   | Mirror the worker's existing fallback (`fw_claim_queued_executions`): the row is still inserted `queued`; the cron-driven claim path picks it up even if `pgmq_send` failed.                                     |
| **Idempotency race**                                                   | `UNIQUE` constraint makes the second insert a no-op returning the first row.                                                                                                                                     |

## How HR/FA/RH consume it

```ts theme={null}
// HR-03 onboarding (src/cores/hr/...), no FW import:
import { enqueueWorkflowExecution, getThrottleStatus } from '@/platform/workflow';

const { isThrottled } = await getThrottleStatus({ organizationId, workflowKey: 'hr-employee-onboarding' });
const res = await enqueueWorkflowExecution({
  workflowKey: 'hr-employee-onboarding',
  organizationId,
  triggerType: 'event',
  triggerPayload: { employeeId, startDate },          // non-PHI ids only
  idempotencyKey: `hr_employee_hired:${employeeId}`,
});
// res.status: 'queued' | 'deduplicated' | 'throttled'
```

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 `workflowKey`s 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. **`workflowKey` → `rule_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

* [ADR-010 Core–PF Dependency Boundary](/architecture/decisions/ADR-010-core-pf-dependency-boundary)
* [ADR-004 CL↔FW Event Patterns](/architecture/decisions/ADR-004-cl-fw-event-patterns)
* [HR-FW](/architecture/integrations/HR-FW-INTEGRATION) · [FA-FW](/architecture/integrations/FA-FW-INTEGRATION) · [RH-FW](/architecture/integrations/RH-FW-INTEGRATION) integration contracts
* `supabase/functions/workflow-executor-worker/index.ts` (pgmq enqueue/dispatch shape)
* `src/platform/clinical/loc/getLocAssessmentForUM.ts` (cross-core service pattern)
* FW-53 rate limiting (`src/cores/fw/types/rate-limiting.ts`) · FW-58 KPI (`src/cores/fw/hooks/useKpiSnapshots.ts`)
