@fabricorg/platform-host 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @fabricorg/platform-host
2
2
 
3
+ ## 0.4.1 — 2026-07-20
4
+
5
+ - Add runtime-local action resolution without process-wide registry mutation.
6
+ - Pass the injected host clock to policy evaluators.
7
+ - Treat empty state-machine targets as no-op transitions.
8
+ - Default domain events to the action schema version while preserving explicit event versions.
9
+ - Omit custom extracted-event carrier fields from durable invocation results.
10
+
11
+ ## 0.4.0 — 2026-07-19
12
+
13
+ - Add an optional agent-only HITL evaluator between schema validation and ordinary policies.
14
+ - Persist HITL route, risk tier, reason, and ruleset version as durable invocation evidence.
15
+ - Add authorized, atomic approval and rejection decisions with governed resume execution.
16
+ - Keep parked invocations out of worker claims and report worker-parked work separately from failures.
17
+ - Preserve existing behavior for hosts that do not configure HITL.
18
+
3
19
  ## 0.3.1 — 2026-07-19
4
20
 
5
21
  - Make policy, event, and adapter checkpoints deterministic and replay-safe.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  The canonical host for the `@fabricorg/platform` mutation pipeline.
4
4
 
5
5
  ```bash
6
- pnpm add @fabricorg/platform@^0.7.0 @fabricorg/platform-host@^0.2.0
6
+ pnpm add @fabricorg/platform@^0.7.0 @fabricorg/platform-host@^0.4.1
7
7
  ```
8
8
 
9
9
  Vertical packages register `FabricModule` definitions. Host applications provide tenant authorization,
@@ -11,13 +11,19 @@ module entitlements, persistence, projections, and an optional durable dispatche
11
11
  ordering and lifecycle invariant:
12
12
 
13
13
  ```text
14
- Actor → ActionInvocation → PolicyEvaluation → StateMachine → Handler → AssetEvent → AdapterInvocation → Projection
14
+ Actor → ActionInvocation → Schema → Agent HITL → PolicyEvaluation → StateMachine → Handler → AssetEvent → AdapterInvocation → Projection
15
15
  ```
16
16
 
17
17
  `submitAction()` creates the durable `ActionInvocation` before dispatch. `executeInvocation()` is the
18
18
  worker entry point. With no dispatcher the host executes inline, which is intended for tests and local
19
19
  development only.
20
20
 
21
+ Applications that assemble action catalogs per runtime can provide `resolveAction` instead of
22
+ mutating the process-wide platform registry. A custom `extractEvents` implementation can pair with
23
+ `eventResultFields` so its event carrier is removed from the durable invocation result. Domain events
24
+ without an explicit `eventSchemaVersion` inherit the action version, while host lifecycle events stay
25
+ at version 1. The injected `now` clock is also passed to ordinary policy evaluation.
26
+
21
27
  Production polling workers can use `createStoreBackedActionDispatcher()` plus
22
28
  `runPlatformActionWorker()`. The pending invocation row is the durable queue item; workers claim
23
29
  bounded batches with an atomic lease and `FOR UPDATE SKIP LOCKED`, and an expired `running` lease is
@@ -29,6 +35,51 @@ adapter that already reached `succeeded`, and event, policy, and adapter writes
29
35
  stale action that was not declared idempotent fails terminally for manual reconciliation instead of
30
36
  silently rerunning unknown side effects.
31
37
 
38
+ ## Agent HITL
39
+
40
+ Hosts can inject a vertical-owned `hitlEvaluator`. It runs only for `actorType: "agent"`, after schema
41
+ validation and before ordinary policies. Omitting it preserves the pre-0.4 behavior. The host owns the
42
+ durable lifecycle; the vertical continues to own the rules and risk classification.
43
+
44
+ ```ts
45
+ const host = createGovernedActionHost({
46
+ store,
47
+ authorization,
48
+ hitlPolicyVersion: "gtm-rules.v7",
49
+ hitlEvaluator: async (context) => ({
50
+ route: context.actionId === "gtm.send_message" ? "needs-approval" : "auto-execute",
51
+ riskTier: context.actionId === "gtm.send_message" ? "high" : "low",
52
+ reason: "Vertical-owned prospect-touching rule",
53
+ }),
54
+ });
55
+ ```
56
+
57
+ The evaluator returns `auto-execute`, `needs-approval`, `escalate`, or `rejected`:
58
+
59
+ - `auto-execute` continues through policies, state validation, the handler, events, and adapters.
60
+ - `needs-approval` and `escalate` persist the route and risk evidence, clear any worker lease, and park
61
+ the invocation as `waiting_for_approval`. Polling workers never claim that status.
62
+ - `rejected` terminally fails before policies and mutation code run.
63
+
64
+ An approval workflow calls `resumeApprovedInvocation()` instead of invoking the handler directly. The
65
+ host authorizes the approver (using `authorizeApproval` when provided), then atomically persists the
66
+ decision and changes `waiting_for_approval` to either leased `running` or terminal `failed`. This keeps
67
+ the decision in the mutation ledger and prevents an approval/worker race.
68
+
69
+ ```ts
70
+ await host.resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, {
71
+ approved: true,
72
+ approverId: reviewer.id,
73
+ approverType: "natural_person",
74
+ reason: "Reviewed prospect-facing draft",
75
+ editsReference: "draft-revision-2",
76
+ });
77
+ ```
78
+
79
+ Custom stores remain source-compatible when HITL is unused. To enable HITL they must implement the
80
+ additive `ApprovalPlatformHostStore` capability. Both built-in stores implement it; Postgres users must
81
+ call `ensureSchema()` so the HITL evidence and approval-decision columns are added.
82
+
32
83
  Sensitive values must not be passed as action parameters. Stage them in tenant-bound encrypted storage
33
84
  and pass an opaque identifier instead; action parameters are intentionally durable audit evidence.
34
85
  Hosts should additionally configure `redactActionParameters` as a fail-safe allowlist for actions
package/dist/index.cjs CHANGED
@@ -12,8 +12,10 @@ function createGovernedActionHost(options) {
12
12
  for (const adapter of options.adapters ?? []) adapters.register(adapter);
13
13
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
14
14
  const extractEvents = options.extractEvents ?? DEFAULT_EXTRACT_EVENTS;
15
+ const actionResolver = options.resolveAction ?? platform.resolveAction;
16
+ const eventResultFields = options.eventResultFields ?? ["_events"];
15
17
  async function submitAction(input) {
16
- const action = platform.resolveAction(input.actionId);
18
+ const action = actionResolver(input.actionId);
17
19
  if (!action) throw new Error(`Unknown action: ${input.actionId}`);
18
20
  const authorizationInput = toAuthorizationInput(action, input);
19
21
  if (!await options.authorization.checkEntitlement(authorizationInput)) {
@@ -47,7 +49,9 @@ function createGovernedActionHost(options) {
47
49
  status: durableInvocation.status,
48
50
  workflowId: durableWorkflowId,
49
51
  result: durableInvocation.result,
50
- ...durableInvocation.error ? { error: durableInvocation.error } : {}
52
+ ...durableInvocation.error ? { error: durableInvocation.error } : {},
53
+ ...durableInvocation.hitlRoute ? { hitlRoute: durableInvocation.hitlRoute } : {},
54
+ ...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
51
55
  };
52
56
  }
53
57
  if (options.dispatcher) {
@@ -62,7 +66,9 @@ function createGovernedActionHost(options) {
62
66
  actionInvocationId: durableInvocation.id,
63
67
  status: durableInvocation.status,
64
68
  workflowId: dispatched.workflowId,
65
- ...dispatched.runId ? { runId: dispatched.runId } : {}
69
+ ...dispatched.runId ? { runId: dispatched.runId } : {},
70
+ ...durableInvocation.hitlRoute ? { hitlRoute: durableInvocation.hitlRoute } : {},
71
+ ...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
66
72
  };
67
73
  } catch (error) {
68
74
  const message = errorMessage(error);
@@ -82,22 +88,26 @@ function createGovernedActionHost(options) {
82
88
  );
83
89
  return { ...executed, workflowId: durableWorkflowId };
84
90
  }
85
- async function executeInvocation(actionInvocationId, tenantId, spaceId) {
86
- const invocation = await options.store.getActionInvocation(
91
+ async function executeInvocation(actionInvocationId, tenantId, spaceId, executionOptions = {}) {
92
+ const loadedInvocation = await options.store.getActionInvocation(
87
93
  actionInvocationId,
88
94
  tenantId,
89
95
  spaceId
90
96
  );
91
- if (!invocation) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
92
- if (isTerminal(invocation.status)) {
97
+ if (!loadedInvocation) {
98
+ throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
99
+ }
100
+ let invocation = loadedInvocation;
101
+ if (isTerminal(invocation.status) || invocation.status === "waiting_for_approval") {
102
+ return actionResult(invocation);
103
+ }
104
+ if (invocation.status === "running" && invocation.leaseOwner && executionOptions.leaseOwner !== invocation.leaseOwner) {
93
105
  return {
94
- actionInvocationId,
95
- status: invocation.status,
96
- result: invocation.result,
97
- ...invocation.error ? { error: invocation.error } : {}
106
+ ...actionResult(invocation),
107
+ error: `Invocation is leased by ${invocation.leaseOwner}`
98
108
  };
99
109
  }
100
- const action = platform.resolveAction(invocation.actionId);
110
+ const action = actionResolver(invocation.actionId);
101
111
  if (!action) {
102
112
  return fail(invocation, "failed", `Unknown action: ${invocation.actionId}`);
103
113
  }
@@ -108,15 +118,68 @@ function createGovernedActionHost(options) {
108
118
  `Interrupted action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`
109
119
  );
110
120
  }
111
- await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
112
- status: "running"
113
- });
121
+ if (invocation.status !== "running") {
122
+ await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
123
+ status: "running"
124
+ });
125
+ }
114
126
  try {
115
127
  const parsed = action.schema.safeParse(invocation.parameters);
116
128
  if (!parsed.success) {
117
129
  const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
118
130
  return fail(invocation, "validation_failed", message);
119
131
  }
132
+ if (invocation.actorType === "agent" && options.hitlEvaluator) {
133
+ const hitlParameters = isRecord(parsed.data) ? parsed.data : invocation.parameters;
134
+ const approvalStore = asApprovalStore(options.store);
135
+ if (!approvalStore) {
136
+ return fail(
137
+ invocation,
138
+ "failed",
139
+ "The configured HITL evaluator requires a store with approval persistence support."
140
+ );
141
+ }
142
+ if (!invocation.hitlRoute) {
143
+ const decision = await options.hitlEvaluator({
144
+ actionId: action.actionId,
145
+ actorId: invocation.actorId,
146
+ actorType: invocation.actorType,
147
+ tenantId,
148
+ spaceId,
149
+ parameters: hitlParameters,
150
+ ...numberValue(hitlParameters.confidence) !== void 0 ? { confidence: numberValue(hitlParameters.confidence) } : {},
151
+ ...isRiskTier(hitlParameters.riskTier) ? { riskTier: hitlParameters.riskTier } : {},
152
+ ...stringValue(hitlParameters.agentSessionId) ? { agentSessionId: stringValue(hitlParameters.agentSessionId) } : {},
153
+ ...stringValue(hitlParameters.agentRunId) ? { agentRunId: stringValue(hitlParameters.agentRunId) } : {}
154
+ });
155
+ invocation = await approvalStore.recordHitlDecision(
156
+ actionInvocationId,
157
+ tenantId,
158
+ spaceId,
159
+ {
160
+ ...decision,
161
+ ...options.hitlPolicyVersion ? { policyVersion: options.hitlPolicyVersion } : {},
162
+ evaluatedAt: now()
163
+ }
164
+ );
165
+ }
166
+ if (invocation.hitlRoute === "rejected") {
167
+ return fail(
168
+ invocation,
169
+ "failed",
170
+ `HITL rejected: ${invocation.hitlReason ?? "no reason provided"}`
171
+ );
172
+ }
173
+ if ((invocation.hitlRoute === "needs-approval" || invocation.hitlRoute === "escalate") && !invocation.approvalDecision?.approved) {
174
+ await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
175
+ status: "waiting_for_approval"
176
+ });
177
+ return {
178
+ ...actionResult(invocation),
179
+ status: "waiting_for_approval"
180
+ };
181
+ }
182
+ }
120
183
  const authorizationInput = toAuthorizationInput(action, invocation);
121
184
  const definitions = options.resolvePolicies ? await options.resolvePolicies({
122
185
  ...authorizationInput,
@@ -132,7 +195,8 @@ function createGovernedActionHost(options) {
132
195
  parameters: parsed.data,
133
196
  db: options.store.db,
134
197
  services: options.services,
135
- mode: "execute"
198
+ mode: "execute",
199
+ now: now()
136
200
  });
137
201
  for (const outcome of outcomes) {
138
202
  await options.store.appendPolicyEvaluation({
@@ -165,15 +229,17 @@ function createGovernedActionHost(options) {
165
229
  entityId
166
230
  ) ?? initialState(binding.entityType) : initialState(binding.entityType);
167
231
  const targetState = typeof binding.targetState === "function" ? binding.targetState(parsed.data) : binding.targetState;
168
- const transition = platform.validateTransition(
169
- binding.entityType,
170
- currentState,
171
- targetState,
172
- action.actionId
173
- );
174
- const replayingAppliedTransition = action.idempotent && currentState === targetState;
175
- if (!transition.valid && !replayingAppliedTransition) {
176
- return fail(invocation, "failed", transition.error ?? "Invalid state transition");
232
+ if (targetState !== "") {
233
+ const transition = platform.validateTransition(
234
+ binding.entityType,
235
+ currentState,
236
+ targetState,
237
+ action.actionId
238
+ );
239
+ const replayingAppliedTransition = action.idempotent && currentState === targetState;
240
+ if (!transition.valid && !replayingAppliedTransition) {
241
+ return fail(invocation, "failed", transition.error ?? "Invalid state transition");
242
+ }
177
243
  }
178
244
  }
179
245
  let data;
@@ -202,7 +268,7 @@ function createGovernedActionHost(options) {
202
268
  domainEvents = extractEvents(data);
203
269
  if (action.eventPhase !== "after_adapters") {
204
270
  for (const [index, event] of domainEvents.entries()) {
205
- await appendEvent(invocation, event, `domain:${index}`);
271
+ await appendEvent(invocation, event, `domain:${index}`, action.version);
206
272
  }
207
273
  }
208
274
  } catch (error) {
@@ -321,27 +387,96 @@ function createGovernedActionHost(options) {
321
387
  }
322
388
  if (action.eventPhase === "after_adapters") {
323
389
  for (const [index, event] of domainEvents.entries()) {
324
- await appendEvent(invocation, event, `domain:${index}`);
390
+ await appendEvent(invocation, event, `domain:${index}`, action.version);
325
391
  }
326
392
  }
327
- const result = withoutPrivateHostFields(data);
393
+ const result = withoutPrivateHostFields(data, eventResultFields);
328
394
  await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
329
395
  status: "completed",
330
396
  result
331
397
  });
332
- return { actionInvocationId, status: "completed", result };
398
+ return {
399
+ actionInvocationId,
400
+ status: "completed",
401
+ result,
402
+ ...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
403
+ ...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
404
+ };
333
405
  } catch (error) {
334
406
  return fail(invocation, "failed", errorMessage(error));
335
407
  }
336
408
  }
337
- async function appendEvent(invocation, event, deduplicationKey) {
409
+ async function resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, decision) {
410
+ if (!decision.approverId.trim()) throw new Error("approverId is required");
411
+ const invocation = await options.store.getActionInvocation(
412
+ actionInvocationId,
413
+ tenantId,
414
+ spaceId
415
+ );
416
+ if (!invocation) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
417
+ if (isTerminal(invocation.status)) return actionResult(invocation);
418
+ if (invocation.status !== "waiting_for_approval") {
419
+ return {
420
+ ...actionResult(invocation),
421
+ error: `Invocation is not waiting for approval (status: ${invocation.status})`
422
+ };
423
+ }
424
+ const action = actionResolver(invocation.actionId);
425
+ if (!action) return fail(invocation, "failed", `Unknown action: ${invocation.actionId}`);
426
+ const approvalAuthorizationInput = toAuthorizationInput(action, {
427
+ tenantId,
428
+ spaceId,
429
+ actorId: decision.approverId,
430
+ actorType: decision.approverType
431
+ });
432
+ const entitled = await options.authorization.checkEntitlement(
433
+ approvalAuthorizationInput
434
+ );
435
+ const authorized = options.authorization.authorizeApproval ? await options.authorization.authorizeApproval({
436
+ ...approvalAuthorizationInput,
437
+ decision
438
+ }) : await options.authorization.authorize(approvalAuthorizationInput);
439
+ if (!entitled || !authorized) {
440
+ return {
441
+ ...actionResult(invocation),
442
+ error: `Actor ${decision.approverId} is not authorized to decide this approval`
443
+ };
444
+ }
445
+ const approvalStore = asApprovalStore(options.store);
446
+ if (!approvalStore) {
447
+ return {
448
+ ...actionResult(invocation),
449
+ error: "Approval resume requires a store with approval persistence support."
450
+ };
451
+ }
452
+ const transitionStartedAt = now();
453
+ const decidedAt = decision.decidedAt ?? transitionStartedAt;
454
+ const leaseOwner = `approval-resume-${platform.createFabricId("lease")}`;
455
+ const transition = await approvalStore.beginApprovalDecision({
456
+ actionInvocationId,
457
+ tenantId,
458
+ spaceId,
459
+ decision: { ...decision, decidedAt },
460
+ leaseOwner,
461
+ leaseDurationMs: options.approvalResumeLeaseDurationMs ?? 5 * 6e4,
462
+ now: transitionStartedAt
463
+ });
464
+ const transitioned = transition.invocation ?? await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
465
+ if (!transition.applied || !transitioned) {
466
+ if (!transitioned) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
467
+ return actionResult(transitioned);
468
+ }
469
+ if (!decision.approved) return actionResult(transitioned);
470
+ return executeInvocation(actionInvocationId, tenantId, spaceId, { leaseOwner });
471
+ }
472
+ async function appendEvent(invocation, event, deduplicationKey, defaultEventSchemaVersion = 1) {
338
473
  const timestamp = now();
339
474
  const envelope = {
340
475
  id: lifecycleId("evt", invocation.id, deduplicationKey),
341
476
  tenantId: invocation.tenantId,
342
477
  spaceId: invocation.spaceId,
343
478
  eventType: event.eventType,
344
- eventSchemaVersion: event.eventSchemaVersion ?? 1,
479
+ eventSchemaVersion: event.eventSchemaVersion ?? defaultEventSchemaVersion,
345
480
  subjectType: event.subjectType,
346
481
  subjectId: event.subjectId,
347
482
  actorId: invocation.actorId,
@@ -366,9 +501,38 @@ function createGovernedActionHost(options) {
366
501
  invocation.spaceId,
367
502
  { status, error }
368
503
  );
369
- return { actionInvocationId: invocation.id, status, error };
504
+ return {
505
+ actionInvocationId: invocation.id,
506
+ status,
507
+ error,
508
+ ...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
509
+ ...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
510
+ };
370
511
  }
371
- return { submitAction, executeInvocation };
512
+ return { submitAction, executeInvocation, resumeApprovedInvocation };
513
+ }
514
+ function actionResult(invocation) {
515
+ return {
516
+ actionInvocationId: invocation.id,
517
+ status: invocation.status,
518
+ result: invocation.result,
519
+ ...invocation.error ? { error: invocation.error } : {},
520
+ ...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
521
+ ...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
522
+ };
523
+ }
524
+ function asApprovalStore(store) {
525
+ const candidate = store;
526
+ return typeof candidate.recordHitlDecision === "function" && typeof candidate.beginApprovalDecision === "function" ? store : void 0;
527
+ }
528
+ function numberValue(value) {
529
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
530
+ }
531
+ function stringValue(value) {
532
+ return typeof value === "string" && value.length > 0 ? value : void 0;
533
+ }
534
+ function isRiskTier(value) {
535
+ return value === "low" || value === "medium" || value === "high";
372
536
  }
373
537
  function toAuthorizationInput(action, input) {
374
538
  return {
@@ -397,9 +561,10 @@ function initialState(entityType) {
397
561
  function isTerminal(status) {
398
562
  return ["completed", "failed", "blocked_by_policy", "validation_failed"].includes(status);
399
563
  }
400
- function withoutPrivateHostFields(data) {
401
- const { _events: _ignored, ...result } = data;
402
- return result;
564
+ function withoutPrivateHostFields(data, eventResultFields) {
565
+ return Object.fromEntries(
566
+ Object.entries(data).filter(([key]) => !eventResultFields.includes(key))
567
+ );
403
568
  }
404
569
  function errorMessage(error) {
405
570
  return error instanceof Error ? error.message : String(error);
@@ -446,6 +611,45 @@ var MemoryPlatformHostStore = class {
446
611
  const record = await this.getActionInvocation(id, tenantId, spaceId);
447
612
  if (!record) throw new Error(`ActionInvocation not found: ${id}`);
448
613
  Object.assign(record, patch, { updatedAt: /* @__PURE__ */ new Date() });
614
+ if (patch.status === "waiting_for_approval" || patch.status === "completed" || patch.status === "failed" || patch.status === "blocked_by_policy" || patch.status === "validation_failed") {
615
+ delete record.leaseOwner;
616
+ delete record.leaseExpiresAt;
617
+ }
618
+ }
619
+ async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
620
+ const record = await this.getActionInvocation(actionInvocationId, tenantId, spaceId);
621
+ if (!record) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
622
+ record.hitlRoute = decision.route;
623
+ record.hitlRiskTier = decision.riskTier;
624
+ record.hitlReason = decision.reason;
625
+ if (decision.policyVersion) record.hitlPolicyVersion = decision.policyVersion;
626
+ record.updatedAt = decision.evaluatedAt;
627
+ return record;
628
+ }
629
+ async beginApprovalDecision(input) {
630
+ const record = await this.getActionInvocation(
631
+ input.actionInvocationId,
632
+ input.tenantId,
633
+ input.spaceId
634
+ );
635
+ if (!record || record.status !== "waiting_for_approval") {
636
+ return { applied: false, ...record ? { invocation: record } : {} };
637
+ }
638
+ record.approvalDecision = input.decision;
639
+ record.updatedAt = input.now;
640
+ if (!input.decision.approved) {
641
+ record.status = "failed";
642
+ record.error = `Approval rejected: ${input.decision.reason ?? "no reason provided"}`;
643
+ delete record.leaseOwner;
644
+ delete record.leaseExpiresAt;
645
+ return { applied: true, invocation: record };
646
+ }
647
+ record.status = "running";
648
+ record.error = void 0;
649
+ record.leaseOwner = input.leaseOwner;
650
+ record.leaseExpiresAt = new Date(input.now.getTime() + input.leaseDurationMs);
651
+ record.attemptCount = Math.max(record.attemptCount, 1);
652
+ return { applied: true, invocation: record };
449
653
  }
450
654
  async appendPolicyEvaluation(record) {
451
655
  if (this.policyEvaluations.some((candidate) => candidate.id === record.id)) return;
@@ -523,14 +727,20 @@ var PostgresPlatformHostStore = class {
523
727
  parameters jsonb NOT NULL, result jsonb NOT NULL DEFAULT '{}'::jsonb,
524
728
  correlation_id text NOT NULL, causation_id text, idempotency_key text, error text,
525
729
  attempt_count integer NOT NULL DEFAULT 0, lease_owner text,
526
- lease_expires_at timestamptz,
730
+ lease_expires_at timestamptz, hitl_route text, hitl_risk_tier text,
731
+ hitl_reason text, hitl_policy_version text, approval_decision jsonb,
527
732
  created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL
528
733
  );
529
734
  ALTER TABLE fabric_platform.action_invocations
530
735
  ADD COLUMN IF NOT EXISTS idempotency_key text,
531
736
  ADD COLUMN IF NOT EXISTS attempt_count integer NOT NULL DEFAULT 0,
532
737
  ADD COLUMN IF NOT EXISTS lease_owner text,
533
- ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz;
738
+ ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz,
739
+ ADD COLUMN IF NOT EXISTS hitl_route text,
740
+ ADD COLUMN IF NOT EXISTS hitl_risk_tier text,
741
+ ADD COLUMN IF NOT EXISTS hitl_reason text,
742
+ ADD COLUMN IF NOT EXISTS hitl_policy_version text,
743
+ ADD COLUMN IF NOT EXISTS approval_decision jsonb;
534
744
  CREATE UNIQUE INDEX IF NOT EXISTS action_invocations_idempotency_idx
535
745
  ON fabric_platform.action_invocations
536
746
  (tenant_id, space_id, action_id, idempotency_key)
@@ -614,9 +824,9 @@ var PostgresPlatformHostStore = class {
614
824
  `UPDATE fabric_platform.action_invocations SET
615
825
  status=COALESCE($4,status), result=COALESCE($5::jsonb,result),
616
826
  error=CASE WHEN $6::boolean THEN $7 ELSE error END,
617
- lease_owner=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
827
+ lease_owner=CASE WHEN $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','validation_failed')
618
828
  THEN NULL ELSE lease_owner END,
619
- lease_expires_at=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
829
+ lease_expires_at=CASE WHEN $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','validation_failed')
620
830
  THEN NULL ELSE lease_expires_at END,
621
831
  updated_at=now()
622
832
  WHERE id=$1 AND tenant_id=$2 AND space_id=$3`,
@@ -631,6 +841,54 @@ var PostgresPlatformHostStore = class {
631
841
  ]
632
842
  );
633
843
  }
844
+ async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
845
+ const result = await this.sql.query(
846
+ `UPDATE fabric_platform.action_invocations SET
847
+ hitl_route=$4, hitl_risk_tier=$5, hitl_reason=$6,
848
+ hitl_policy_version=$7, updated_at=$8
849
+ WHERE id=$1 AND tenant_id=$2 AND space_id=$3
850
+ RETURNING *`,
851
+ [
852
+ actionInvocationId,
853
+ tenantId,
854
+ spaceId,
855
+ decision.route,
856
+ decision.riskTier,
857
+ decision.reason,
858
+ decision.policyVersion ?? null,
859
+ decision.evaluatedAt
860
+ ]
861
+ );
862
+ const row = result.rows[0];
863
+ if (!row) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
864
+ return toActionRecord(row);
865
+ }
866
+ async beginApprovalDecision(input) {
867
+ const result = await this.sql.query(
868
+ `UPDATE fabric_platform.action_invocations SET
869
+ approval_decision=$4::jsonb,
870
+ status=CASE WHEN $5::boolean THEN 'running' ELSE 'failed' END,
871
+ error=CASE WHEN $5::boolean THEN NULL ELSE $6 END,
872
+ lease_owner=CASE WHEN $5::boolean THEN $7 ELSE NULL END,
873
+ lease_expires_at=CASE WHEN $5::boolean THEN $8 ELSE NULL END,
874
+ attempt_count=CASE WHEN $5::boolean THEN GREATEST(attempt_count,1) ELSE attempt_count END,
875
+ updated_at=$9
876
+ WHERE id=$1 AND tenant_id=$2 AND space_id=$3 AND status='waiting_for_approval'
877
+ RETURNING *`,
878
+ [
879
+ input.actionInvocationId,
880
+ input.tenantId,
881
+ input.spaceId,
882
+ JSON.stringify({ ...input.decision, decidedAt: input.decision.decidedAt.toISOString() }),
883
+ input.decision.approved,
884
+ `Approval rejected: ${input.decision.reason ?? "no reason provided"}`,
885
+ input.leaseOwner,
886
+ new Date(input.now.getTime() + input.leaseDurationMs),
887
+ input.now
888
+ ]
889
+ );
890
+ return result.rows[0] ? { applied: true, invocation: toActionRecord(result.rows[0]) } : { applied: false };
891
+ }
634
892
  async appendPolicyEvaluation(record) {
635
893
  await this.sql.query(
636
894
  `INSERT INTO fabric_platform.policy_evaluations
@@ -838,11 +1096,27 @@ function toActionRecord(row) {
838
1096
  attemptCount: Number(row.attempt_count ?? 0),
839
1097
  ...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
840
1098
  ...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
1099
+ ...row.hitl_route ? { hitlRoute: String(row.hitl_route) } : {},
1100
+ ...row.hitl_risk_tier ? { hitlRiskTier: String(row.hitl_risk_tier) } : {},
1101
+ ...row.hitl_reason ? { hitlReason: String(row.hitl_reason) } : {},
1102
+ ...row.hitl_policy_version ? { hitlPolicyVersion: String(row.hitl_policy_version) } : {},
1103
+ ...row.approval_decision ? { approvalDecision: toApprovalDecision(row.approval_decision) } : {},
841
1104
  ...row.error ? { error: String(row.error) } : {},
842
1105
  createdAt: new Date(row.created_at),
843
1106
  updatedAt: new Date(row.updated_at)
844
1107
  };
845
1108
  }
1109
+ function toApprovalDecision(value) {
1110
+ const decision = value;
1111
+ return {
1112
+ approved: Boolean(decision.approved),
1113
+ approverId: String(decision.approverId),
1114
+ approverType: String(decision.approverType),
1115
+ ...decision.reason ? { reason: String(decision.reason) } : {},
1116
+ ...decision.editsReference ? { editsReference: String(decision.editsReference) } : {},
1117
+ decidedAt: new Date(decision.decidedAt)
1118
+ };
1119
+ }
846
1120
  function toEventRecord(row) {
847
1121
  return {
848
1122
  id: String(row.id),
@@ -883,21 +1157,29 @@ async function runPlatformActionWorkerCycle(options) {
883
1157
  });
884
1158
  let completed = 0;
885
1159
  let failed = 0;
1160
+ let waitingForApproval = 0;
886
1161
  for (const invocation of claimed) {
887
1162
  try {
888
1163
  const result = await options.host.executeInvocation(
889
1164
  invocation.id,
890
1165
  invocation.tenantId,
891
- invocation.spaceId
1166
+ invocation.spaceId,
1167
+ { leaseOwner: options.workerId }
892
1168
  );
893
1169
  if (result.status === "completed") completed += 1;
1170
+ else if (result.status === "waiting_for_approval") waitingForApproval += 1;
894
1171
  else failed += 1;
895
1172
  } catch (error) {
896
1173
  failed += 1;
897
1174
  options.onError?.(error, invocation);
898
1175
  }
899
1176
  }
900
- return { claimed: claimed.length, completed, failed };
1177
+ return {
1178
+ claimed: claimed.length,
1179
+ completed,
1180
+ failed,
1181
+ ...waitingForApproval > 0 ? { waitingForApproval } : {}
1182
+ };
901
1183
  }
902
1184
  async function runPlatformActionWorker(options) {
903
1185
  while (!options.signal?.aborted) {