@fabricorg/platform-host 0.3.1 → 0.4.0

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,13 @@
1
1
  # @fabricorg/platform-host
2
2
 
3
+ ## 0.4.0 — 2026-07-19
4
+
5
+ - Add an optional agent-only HITL evaluator between schema validation and ordinary policies.
6
+ - Persist HITL route, risk tier, reason, and ruleset version as durable invocation evidence.
7
+ - Add authorized, atomic approval and rejection decisions with governed resume execution.
8
+ - Keep parked invocations out of worker claims and report worker-parked work separately from failures.
9
+ - Preserve existing behavior for hosts that do not configure HITL.
10
+
3
11
  ## 0.3.1 — 2026-07-19
4
12
 
5
13
  - 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.0
7
7
  ```
8
8
 
9
9
  Vertical packages register `FabricModule` definitions. Host applications provide tenant authorization,
@@ -11,7 +11,7 @@ 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
@@ -29,6 +29,51 @@ adapter that already reached `succeeded`, and event, policy, and adapter writes
29
29
  stale action that was not declared idempotent fails terminally for manual reconciliation instead of
30
30
  silently rerunning unknown side effects.
31
31
 
32
+ ## Agent HITL
33
+
34
+ Hosts can inject a vertical-owned `hitlEvaluator`. It runs only for `actorType: "agent"`, after schema
35
+ validation and before ordinary policies. Omitting it preserves the pre-0.4 behavior. The host owns the
36
+ durable lifecycle; the vertical continues to own the rules and risk classification.
37
+
38
+ ```ts
39
+ const host = createGovernedActionHost({
40
+ store,
41
+ authorization,
42
+ hitlPolicyVersion: "gtm-rules.v7",
43
+ hitlEvaluator: async (context) => ({
44
+ route: context.actionId === "gtm.send_message" ? "needs-approval" : "auto-execute",
45
+ riskTier: context.actionId === "gtm.send_message" ? "high" : "low",
46
+ reason: "Vertical-owned prospect-touching rule",
47
+ }),
48
+ });
49
+ ```
50
+
51
+ The evaluator returns `auto-execute`, `needs-approval`, `escalate`, or `rejected`:
52
+
53
+ - `auto-execute` continues through policies, state validation, the handler, events, and adapters.
54
+ - `needs-approval` and `escalate` persist the route and risk evidence, clear any worker lease, and park
55
+ the invocation as `waiting_for_approval`. Polling workers never claim that status.
56
+ - `rejected` terminally fails before policies and mutation code run.
57
+
58
+ An approval workflow calls `resumeApprovedInvocation()` instead of invoking the handler directly. The
59
+ host authorizes the approver (using `authorizeApproval` when provided), then atomically persists the
60
+ decision and changes `waiting_for_approval` to either leased `running` or terminal `failed`. This keeps
61
+ the decision in the mutation ledger and prevents an approval/worker race.
62
+
63
+ ```ts
64
+ await host.resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, {
65
+ approved: true,
66
+ approverId: reviewer.id,
67
+ approverType: "natural_person",
68
+ reason: "Reviewed prospect-facing draft",
69
+ editsReference: "draft-revision-2",
70
+ });
71
+ ```
72
+
73
+ Custom stores remain source-compatible when HITL is unused. To enable HITL they must implement the
74
+ additive `ApprovalPlatformHostStore` capability. Both built-in stores implement it; Postgres users must
75
+ call `ensureSchema()` so the HITL evidence and approval-decision columns are added.
76
+
32
77
  Sensitive values must not be passed as action parameters. Stage them in tenant-bound encrypted storage
33
78
  and pass an opaque identifier instead; action parameters are intentionally durable audit evidence.
34
79
  Hosts should additionally configure `redactActionParameters` as a fail-safe allowlist for actions
package/dist/index.cjs CHANGED
@@ -47,7 +47,9 @@ function createGovernedActionHost(options) {
47
47
  status: durableInvocation.status,
48
48
  workflowId: durableWorkflowId,
49
49
  result: durableInvocation.result,
50
- ...durableInvocation.error ? { error: durableInvocation.error } : {}
50
+ ...durableInvocation.error ? { error: durableInvocation.error } : {},
51
+ ...durableInvocation.hitlRoute ? { hitlRoute: durableInvocation.hitlRoute } : {},
52
+ ...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
51
53
  };
52
54
  }
53
55
  if (options.dispatcher) {
@@ -62,7 +64,9 @@ function createGovernedActionHost(options) {
62
64
  actionInvocationId: durableInvocation.id,
63
65
  status: durableInvocation.status,
64
66
  workflowId: dispatched.workflowId,
65
- ...dispatched.runId ? { runId: dispatched.runId } : {}
67
+ ...dispatched.runId ? { runId: dispatched.runId } : {},
68
+ ...durableInvocation.hitlRoute ? { hitlRoute: durableInvocation.hitlRoute } : {},
69
+ ...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
66
70
  };
67
71
  } catch (error) {
68
72
  const message = errorMessage(error);
@@ -82,19 +86,23 @@ function createGovernedActionHost(options) {
82
86
  );
83
87
  return { ...executed, workflowId: durableWorkflowId };
84
88
  }
85
- async function executeInvocation(actionInvocationId, tenantId, spaceId) {
86
- const invocation = await options.store.getActionInvocation(
89
+ async function executeInvocation(actionInvocationId, tenantId, spaceId, executionOptions = {}) {
90
+ const loadedInvocation = await options.store.getActionInvocation(
87
91
  actionInvocationId,
88
92
  tenantId,
89
93
  spaceId
90
94
  );
91
- if (!invocation) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
92
- if (isTerminal(invocation.status)) {
95
+ if (!loadedInvocation) {
96
+ throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
97
+ }
98
+ let invocation = loadedInvocation;
99
+ if (isTerminal(invocation.status) || invocation.status === "waiting_for_approval") {
100
+ return actionResult(invocation);
101
+ }
102
+ if (invocation.status === "running" && invocation.leaseOwner && executionOptions.leaseOwner !== invocation.leaseOwner) {
93
103
  return {
94
- actionInvocationId,
95
- status: invocation.status,
96
- result: invocation.result,
97
- ...invocation.error ? { error: invocation.error } : {}
104
+ ...actionResult(invocation),
105
+ error: `Invocation is leased by ${invocation.leaseOwner}`
98
106
  };
99
107
  }
100
108
  const action = platform.resolveAction(invocation.actionId);
@@ -108,15 +116,68 @@ function createGovernedActionHost(options) {
108
116
  `Interrupted action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`
109
117
  );
110
118
  }
111
- await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
112
- status: "running"
113
- });
119
+ if (invocation.status !== "running") {
120
+ await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
121
+ status: "running"
122
+ });
123
+ }
114
124
  try {
115
125
  const parsed = action.schema.safeParse(invocation.parameters);
116
126
  if (!parsed.success) {
117
127
  const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
118
128
  return fail(invocation, "validation_failed", message);
119
129
  }
130
+ if (invocation.actorType === "agent" && options.hitlEvaluator) {
131
+ const hitlParameters = isRecord(parsed.data) ? parsed.data : invocation.parameters;
132
+ const approvalStore = asApprovalStore(options.store);
133
+ if (!approvalStore) {
134
+ return fail(
135
+ invocation,
136
+ "failed",
137
+ "The configured HITL evaluator requires a store with approval persistence support."
138
+ );
139
+ }
140
+ if (!invocation.hitlRoute) {
141
+ const decision = await options.hitlEvaluator({
142
+ actionId: action.actionId,
143
+ actorId: invocation.actorId,
144
+ actorType: invocation.actorType,
145
+ tenantId,
146
+ spaceId,
147
+ parameters: hitlParameters,
148
+ ...numberValue(hitlParameters.confidence) !== void 0 ? { confidence: numberValue(hitlParameters.confidence) } : {},
149
+ ...isRiskTier(hitlParameters.riskTier) ? { riskTier: hitlParameters.riskTier } : {},
150
+ ...stringValue(hitlParameters.agentSessionId) ? { agentSessionId: stringValue(hitlParameters.agentSessionId) } : {},
151
+ ...stringValue(hitlParameters.agentRunId) ? { agentRunId: stringValue(hitlParameters.agentRunId) } : {}
152
+ });
153
+ invocation = await approvalStore.recordHitlDecision(
154
+ actionInvocationId,
155
+ tenantId,
156
+ spaceId,
157
+ {
158
+ ...decision,
159
+ ...options.hitlPolicyVersion ? { policyVersion: options.hitlPolicyVersion } : {},
160
+ evaluatedAt: now()
161
+ }
162
+ );
163
+ }
164
+ if (invocation.hitlRoute === "rejected") {
165
+ return fail(
166
+ invocation,
167
+ "failed",
168
+ `HITL rejected: ${invocation.hitlReason ?? "no reason provided"}`
169
+ );
170
+ }
171
+ if ((invocation.hitlRoute === "needs-approval" || invocation.hitlRoute === "escalate") && !invocation.approvalDecision?.approved) {
172
+ await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
173
+ status: "waiting_for_approval"
174
+ });
175
+ return {
176
+ ...actionResult(invocation),
177
+ status: "waiting_for_approval"
178
+ };
179
+ }
180
+ }
120
181
  const authorizationInput = toAuthorizationInput(action, invocation);
121
182
  const definitions = options.resolvePolicies ? await options.resolvePolicies({
122
183
  ...authorizationInput,
@@ -329,11 +390,80 @@ function createGovernedActionHost(options) {
329
390
  status: "completed",
330
391
  result
331
392
  });
332
- return { actionInvocationId, status: "completed", result };
393
+ return {
394
+ actionInvocationId,
395
+ status: "completed",
396
+ result,
397
+ ...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
398
+ ...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
399
+ };
333
400
  } catch (error) {
334
401
  return fail(invocation, "failed", errorMessage(error));
335
402
  }
336
403
  }
404
+ async function resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, decision) {
405
+ if (!decision.approverId.trim()) throw new Error("approverId is required");
406
+ const invocation = await options.store.getActionInvocation(
407
+ actionInvocationId,
408
+ tenantId,
409
+ spaceId
410
+ );
411
+ if (!invocation) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
412
+ if (isTerminal(invocation.status)) return actionResult(invocation);
413
+ if (invocation.status !== "waiting_for_approval") {
414
+ return {
415
+ ...actionResult(invocation),
416
+ error: `Invocation is not waiting for approval (status: ${invocation.status})`
417
+ };
418
+ }
419
+ const action = platform.resolveAction(invocation.actionId);
420
+ if (!action) return fail(invocation, "failed", `Unknown action: ${invocation.actionId}`);
421
+ const approvalAuthorizationInput = toAuthorizationInput(action, {
422
+ tenantId,
423
+ spaceId,
424
+ actorId: decision.approverId,
425
+ actorType: decision.approverType
426
+ });
427
+ const entitled = await options.authorization.checkEntitlement(
428
+ approvalAuthorizationInput
429
+ );
430
+ const authorized = options.authorization.authorizeApproval ? await options.authorization.authorizeApproval({
431
+ ...approvalAuthorizationInput,
432
+ decision
433
+ }) : await options.authorization.authorize(approvalAuthorizationInput);
434
+ if (!entitled || !authorized) {
435
+ return {
436
+ ...actionResult(invocation),
437
+ error: `Actor ${decision.approverId} is not authorized to decide this approval`
438
+ };
439
+ }
440
+ const approvalStore = asApprovalStore(options.store);
441
+ if (!approvalStore) {
442
+ return {
443
+ ...actionResult(invocation),
444
+ error: "Approval resume requires a store with approval persistence support."
445
+ };
446
+ }
447
+ const transitionStartedAt = now();
448
+ const decidedAt = decision.decidedAt ?? transitionStartedAt;
449
+ const leaseOwner = `approval-resume-${platform.createFabricId("lease")}`;
450
+ const transition = await approvalStore.beginApprovalDecision({
451
+ actionInvocationId,
452
+ tenantId,
453
+ spaceId,
454
+ decision: { ...decision, decidedAt },
455
+ leaseOwner,
456
+ leaseDurationMs: options.approvalResumeLeaseDurationMs ?? 5 * 6e4,
457
+ now: transitionStartedAt
458
+ });
459
+ const transitioned = transition.invocation ?? await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
460
+ if (!transition.applied || !transitioned) {
461
+ if (!transitioned) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
462
+ return actionResult(transitioned);
463
+ }
464
+ if (!decision.approved) return actionResult(transitioned);
465
+ return executeInvocation(actionInvocationId, tenantId, spaceId, { leaseOwner });
466
+ }
337
467
  async function appendEvent(invocation, event, deduplicationKey) {
338
468
  const timestamp = now();
339
469
  const envelope = {
@@ -366,9 +496,38 @@ function createGovernedActionHost(options) {
366
496
  invocation.spaceId,
367
497
  { status, error }
368
498
  );
369
- return { actionInvocationId: invocation.id, status, error };
499
+ return {
500
+ actionInvocationId: invocation.id,
501
+ status,
502
+ error,
503
+ ...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
504
+ ...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
505
+ };
370
506
  }
371
- return { submitAction, executeInvocation };
507
+ return { submitAction, executeInvocation, resumeApprovedInvocation };
508
+ }
509
+ function actionResult(invocation) {
510
+ return {
511
+ actionInvocationId: invocation.id,
512
+ status: invocation.status,
513
+ result: invocation.result,
514
+ ...invocation.error ? { error: invocation.error } : {},
515
+ ...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
516
+ ...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
517
+ };
518
+ }
519
+ function asApprovalStore(store) {
520
+ const candidate = store;
521
+ return typeof candidate.recordHitlDecision === "function" && typeof candidate.beginApprovalDecision === "function" ? store : void 0;
522
+ }
523
+ function numberValue(value) {
524
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
525
+ }
526
+ function stringValue(value) {
527
+ return typeof value === "string" && value.length > 0 ? value : void 0;
528
+ }
529
+ function isRiskTier(value) {
530
+ return value === "low" || value === "medium" || value === "high";
372
531
  }
373
532
  function toAuthorizationInput(action, input) {
374
533
  return {
@@ -446,6 +605,45 @@ var MemoryPlatformHostStore = class {
446
605
  const record = await this.getActionInvocation(id, tenantId, spaceId);
447
606
  if (!record) throw new Error(`ActionInvocation not found: ${id}`);
448
607
  Object.assign(record, patch, { updatedAt: /* @__PURE__ */ new Date() });
608
+ if (patch.status === "waiting_for_approval" || patch.status === "completed" || patch.status === "failed" || patch.status === "blocked_by_policy" || patch.status === "validation_failed") {
609
+ delete record.leaseOwner;
610
+ delete record.leaseExpiresAt;
611
+ }
612
+ }
613
+ async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
614
+ const record = await this.getActionInvocation(actionInvocationId, tenantId, spaceId);
615
+ if (!record) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
616
+ record.hitlRoute = decision.route;
617
+ record.hitlRiskTier = decision.riskTier;
618
+ record.hitlReason = decision.reason;
619
+ if (decision.policyVersion) record.hitlPolicyVersion = decision.policyVersion;
620
+ record.updatedAt = decision.evaluatedAt;
621
+ return record;
622
+ }
623
+ async beginApprovalDecision(input) {
624
+ const record = await this.getActionInvocation(
625
+ input.actionInvocationId,
626
+ input.tenantId,
627
+ input.spaceId
628
+ );
629
+ if (!record || record.status !== "waiting_for_approval") {
630
+ return { applied: false, ...record ? { invocation: record } : {} };
631
+ }
632
+ record.approvalDecision = input.decision;
633
+ record.updatedAt = input.now;
634
+ if (!input.decision.approved) {
635
+ record.status = "failed";
636
+ record.error = `Approval rejected: ${input.decision.reason ?? "no reason provided"}`;
637
+ delete record.leaseOwner;
638
+ delete record.leaseExpiresAt;
639
+ return { applied: true, invocation: record };
640
+ }
641
+ record.status = "running";
642
+ record.error = void 0;
643
+ record.leaseOwner = input.leaseOwner;
644
+ record.leaseExpiresAt = new Date(input.now.getTime() + input.leaseDurationMs);
645
+ record.attemptCount = Math.max(record.attemptCount, 1);
646
+ return { applied: true, invocation: record };
449
647
  }
450
648
  async appendPolicyEvaluation(record) {
451
649
  if (this.policyEvaluations.some((candidate) => candidate.id === record.id)) return;
@@ -523,14 +721,20 @@ var PostgresPlatformHostStore = class {
523
721
  parameters jsonb NOT NULL, result jsonb NOT NULL DEFAULT '{}'::jsonb,
524
722
  correlation_id text NOT NULL, causation_id text, idempotency_key text, error text,
525
723
  attempt_count integer NOT NULL DEFAULT 0, lease_owner text,
526
- lease_expires_at timestamptz,
724
+ lease_expires_at timestamptz, hitl_route text, hitl_risk_tier text,
725
+ hitl_reason text, hitl_policy_version text, approval_decision jsonb,
527
726
  created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL
528
727
  );
529
728
  ALTER TABLE fabric_platform.action_invocations
530
729
  ADD COLUMN IF NOT EXISTS idempotency_key text,
531
730
  ADD COLUMN IF NOT EXISTS attempt_count integer NOT NULL DEFAULT 0,
532
731
  ADD COLUMN IF NOT EXISTS lease_owner text,
533
- ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz;
732
+ ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz,
733
+ ADD COLUMN IF NOT EXISTS hitl_route text,
734
+ ADD COLUMN IF NOT EXISTS hitl_risk_tier text,
735
+ ADD COLUMN IF NOT EXISTS hitl_reason text,
736
+ ADD COLUMN IF NOT EXISTS hitl_policy_version text,
737
+ ADD COLUMN IF NOT EXISTS approval_decision jsonb;
534
738
  CREATE UNIQUE INDEX IF NOT EXISTS action_invocations_idempotency_idx
535
739
  ON fabric_platform.action_invocations
536
740
  (tenant_id, space_id, action_id, idempotency_key)
@@ -614,9 +818,9 @@ var PostgresPlatformHostStore = class {
614
818
  `UPDATE fabric_platform.action_invocations SET
615
819
  status=COALESCE($4,status), result=COALESCE($5::jsonb,result),
616
820
  error=CASE WHEN $6::boolean THEN $7 ELSE error END,
617
- lease_owner=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
821
+ lease_owner=CASE WHEN $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','validation_failed')
618
822
  THEN NULL ELSE lease_owner END,
619
- lease_expires_at=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
823
+ lease_expires_at=CASE WHEN $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','validation_failed')
620
824
  THEN NULL ELSE lease_expires_at END,
621
825
  updated_at=now()
622
826
  WHERE id=$1 AND tenant_id=$2 AND space_id=$3`,
@@ -631,6 +835,54 @@ var PostgresPlatformHostStore = class {
631
835
  ]
632
836
  );
633
837
  }
838
+ async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
839
+ const result = await this.sql.query(
840
+ `UPDATE fabric_platform.action_invocations SET
841
+ hitl_route=$4, hitl_risk_tier=$5, hitl_reason=$6,
842
+ hitl_policy_version=$7, updated_at=$8
843
+ WHERE id=$1 AND tenant_id=$2 AND space_id=$3
844
+ RETURNING *`,
845
+ [
846
+ actionInvocationId,
847
+ tenantId,
848
+ spaceId,
849
+ decision.route,
850
+ decision.riskTier,
851
+ decision.reason,
852
+ decision.policyVersion ?? null,
853
+ decision.evaluatedAt
854
+ ]
855
+ );
856
+ const row = result.rows[0];
857
+ if (!row) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
858
+ return toActionRecord(row);
859
+ }
860
+ async beginApprovalDecision(input) {
861
+ const result = await this.sql.query(
862
+ `UPDATE fabric_platform.action_invocations SET
863
+ approval_decision=$4::jsonb,
864
+ status=CASE WHEN $5::boolean THEN 'running' ELSE 'failed' END,
865
+ error=CASE WHEN $5::boolean THEN NULL ELSE $6 END,
866
+ lease_owner=CASE WHEN $5::boolean THEN $7 ELSE NULL END,
867
+ lease_expires_at=CASE WHEN $5::boolean THEN $8 ELSE NULL END,
868
+ attempt_count=CASE WHEN $5::boolean THEN GREATEST(attempt_count,1) ELSE attempt_count END,
869
+ updated_at=$9
870
+ WHERE id=$1 AND tenant_id=$2 AND space_id=$3 AND status='waiting_for_approval'
871
+ RETURNING *`,
872
+ [
873
+ input.actionInvocationId,
874
+ input.tenantId,
875
+ input.spaceId,
876
+ JSON.stringify({ ...input.decision, decidedAt: input.decision.decidedAt.toISOString() }),
877
+ input.decision.approved,
878
+ `Approval rejected: ${input.decision.reason ?? "no reason provided"}`,
879
+ input.leaseOwner,
880
+ new Date(input.now.getTime() + input.leaseDurationMs),
881
+ input.now
882
+ ]
883
+ );
884
+ return result.rows[0] ? { applied: true, invocation: toActionRecord(result.rows[0]) } : { applied: false };
885
+ }
634
886
  async appendPolicyEvaluation(record) {
635
887
  await this.sql.query(
636
888
  `INSERT INTO fabric_platform.policy_evaluations
@@ -838,11 +1090,27 @@ function toActionRecord(row) {
838
1090
  attemptCount: Number(row.attempt_count ?? 0),
839
1091
  ...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
840
1092
  ...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
1093
+ ...row.hitl_route ? { hitlRoute: String(row.hitl_route) } : {},
1094
+ ...row.hitl_risk_tier ? { hitlRiskTier: String(row.hitl_risk_tier) } : {},
1095
+ ...row.hitl_reason ? { hitlReason: String(row.hitl_reason) } : {},
1096
+ ...row.hitl_policy_version ? { hitlPolicyVersion: String(row.hitl_policy_version) } : {},
1097
+ ...row.approval_decision ? { approvalDecision: toApprovalDecision(row.approval_decision) } : {},
841
1098
  ...row.error ? { error: String(row.error) } : {},
842
1099
  createdAt: new Date(row.created_at),
843
1100
  updatedAt: new Date(row.updated_at)
844
1101
  };
845
1102
  }
1103
+ function toApprovalDecision(value) {
1104
+ const decision = value;
1105
+ return {
1106
+ approved: Boolean(decision.approved),
1107
+ approverId: String(decision.approverId),
1108
+ approverType: String(decision.approverType),
1109
+ ...decision.reason ? { reason: String(decision.reason) } : {},
1110
+ ...decision.editsReference ? { editsReference: String(decision.editsReference) } : {},
1111
+ decidedAt: new Date(decision.decidedAt)
1112
+ };
1113
+ }
846
1114
  function toEventRecord(row) {
847
1115
  return {
848
1116
  id: String(row.id),
@@ -883,21 +1151,29 @@ async function runPlatformActionWorkerCycle(options) {
883
1151
  });
884
1152
  let completed = 0;
885
1153
  let failed = 0;
1154
+ let waitingForApproval = 0;
886
1155
  for (const invocation of claimed) {
887
1156
  try {
888
1157
  const result = await options.host.executeInvocation(
889
1158
  invocation.id,
890
1159
  invocation.tenantId,
891
- invocation.spaceId
1160
+ invocation.spaceId,
1161
+ { leaseOwner: options.workerId }
892
1162
  );
893
1163
  if (result.status === "completed") completed += 1;
1164
+ else if (result.status === "waiting_for_approval") waitingForApproval += 1;
894
1165
  else failed += 1;
895
1166
  } catch (error) {
896
1167
  failed += 1;
897
1168
  options.onError?.(error, invocation);
898
1169
  }
899
1170
  }
900
- return { claimed: claimed.length, completed, failed };
1171
+ return {
1172
+ claimed: claimed.length,
1173
+ completed,
1174
+ failed,
1175
+ ...waitingForApproval > 0 ? { waitingForApproval } : {}
1176
+ };
901
1177
  }
902
1178
  async function runPlatformActionWorker(options) {
903
1179
  while (!options.signal?.aborted) {