@fabricorg/platform-host 0.4.2 → 0.5.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,15 @@
1
1
  # @fabricorg/platform-host
2
2
 
3
+ ## 0.5.0 — 2026-07-20
4
+
5
+ - Persist resolved mutation footprints and execution-principal delegation.
6
+ - Persist and enforce policy obligations through external execution attestations.
7
+ - Add append-safe external reconciliation evidence to memory and PostgreSQL stores.
8
+
9
+ ## 0.4.3 — 2026-07-20
10
+
11
+ - Cast approval-resume leases to `timestamptz` for PostgreSQL and Lakebase SQL adapters.
12
+
3
13
  ## 0.4.2 — 2026-07-20
4
14
 
5
15
  - Reject handler-emitted domain events absent from the action's `emitsEvents` declaration.
package/README.md CHANGED
@@ -86,6 +86,18 @@ and pass an opaque identifier instead; action parameters are intentionally durab
86
86
  Hosts should additionally configure `redactActionParameters` as a fail-safe allowlist for actions
87
87
  whose ingress is adjacent to secret material; redaction runs before the invocation row is created.
88
88
 
89
+ ## External mutation governance
90
+
91
+ Hosts can configure `resolveMutationGovernance` to persist an approved, provider-neutral
92
+ `MutationFootprint` and `ExecutionPrincipal` after schema validation. `resolvePolicyObligations`
93
+ makes required controls durable. `extractExecutionAttestation` converts successful adapter output
94
+ into audit-safe external evidence and can satisfy named obligations. Required obligations that are
95
+ still pending or failed prevent completion.
96
+
97
+ The built-in stores persist footprints, delegation, obligations, attestations, and reconciliation
98
+ observations. Provider-specific authentication and clients do not belong here. For Databricks, use
99
+ the optional Platform integration exported by `@fabric-harness/databricks`.
100
+
89
101
  `MemoryPlatformHostStore` is for tests and local demos. Production control planes use
90
102
  `PostgresPlatformHostStore` with Databricks Lakebase (or standard Postgres), call
91
103
  `ensureSchema()` at startup, and hydrate projections from `listEvents()`.
package/dist/index.cjs CHANGED
@@ -129,6 +129,23 @@ function createGovernedActionHost(options) {
129
129
  const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
130
130
  return fail(invocation, "validation_failed", message);
131
131
  }
132
+ if (options.resolveMutationGovernance && !invocation.mutationFootprint) {
133
+ const governanceStore2 = asGovernanceStore(options.store);
134
+ if (!governanceStore2) {
135
+ return fail(invocation, "failed", "The configured mutation-governance resolver requires a governance-capable store.");
136
+ }
137
+ const context = await options.resolveMutationGovernance({
138
+ actionInvocationId,
139
+ actionId: action.actionId,
140
+ tenantId,
141
+ spaceId,
142
+ actorId: invocation.actorId,
143
+ actorType: invocation.actorType,
144
+ parameters: parsed.data
145
+ });
146
+ platform.assertMutationGovernanceContext(context);
147
+ invocation = await governanceStore2.recordMutationGovernance(actionInvocationId, tenantId, spaceId, context);
148
+ }
132
149
  if (invocation.actorType === "agent" && options.hitlEvaluator) {
133
150
  const hitlParameters = isRecord(parsed.data) ? parsed.data : invocation.parameters;
134
151
  const approvalStore = asApprovalStore(options.store);
@@ -219,6 +236,26 @@ function createGovernedActionHost(options) {
219
236
  }, `compliance:${aggregate.policyId}`);
220
237
  return fail(invocation, "blocked_by_policy", reason);
221
238
  }
239
+ if (options.resolvePolicyObligations) {
240
+ const governanceStore2 = asGovernanceStore(options.store);
241
+ if (!governanceStore2) {
242
+ return fail(invocation, "failed", "Policy obligations require a governance-capable store.");
243
+ }
244
+ const existing = await governanceStore2.listPolicyObligations(actionInvocationId, tenantId, spaceId);
245
+ if (existing.length === 0) {
246
+ const createdAt = now();
247
+ const obligations = await options.resolvePolicyObligations(outcomes);
248
+ await governanceStore2.appendPolicyObligations(obligations.map((obligation) => ({
249
+ ...obligation,
250
+ status: obligation.status ?? "pending",
251
+ actionInvocationId,
252
+ tenantId,
253
+ spaceId,
254
+ createdAt,
255
+ updatedAt: createdAt
256
+ })));
257
+ }
258
+ }
222
259
  const binding = action.stateMachine;
223
260
  if (binding) {
224
261
  const entityId = binding.getEntityId(parsed.data);
@@ -374,6 +411,21 @@ function createGovernedActionHost(options) {
374
411
  output,
375
412
  updatedAt: now()
376
413
  });
414
+ if (options.extractExecutionAttestation) {
415
+ const attestation = await options.extractExecutionAttestation({
416
+ actionInvocationId,
417
+ adapterInvocationId,
418
+ adapterType: step.adapterType,
419
+ operation: step.operation,
420
+ vendor: adapter.vendor,
421
+ startedAt,
422
+ completedAt: now(),
423
+ output
424
+ });
425
+ if (attestation) {
426
+ await recordExecutionAttestation(actionInvocationId, tenantId, spaceId, attestation, adapterInvocationId);
427
+ }
428
+ }
377
429
  await appendEvent(invocation, {
378
430
  eventType: "AdapterInvocationSucceeded",
379
431
  subjectType: adapterEventSubject.subjectType,
@@ -400,6 +452,14 @@ function createGovernedActionHost(options) {
400
452
  await appendEvent(invocation, event, `domain:${index}`, action.version);
401
453
  }
402
454
  }
455
+ const governanceStore = asGovernanceStore(options.store);
456
+ if (governanceStore && options.resolvePolicyObligations) {
457
+ const obligations = await governanceStore.listPolicyObligations(actionInvocationId, tenantId, spaceId);
458
+ const unsatisfied = obligations.filter((obligation) => (obligation.required ?? true) && obligation.status !== "satisfied" && obligation.status !== "waived");
459
+ if (unsatisfied.length > 0) {
460
+ return fail(invocation, "failed", `Unsatisfied policy obligations: ${unsatisfied.map((item) => item.id).join(", ")}`);
461
+ }
462
+ }
403
463
  const result = withoutPrivateHostFields(data, eventResultFields);
404
464
  await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
405
465
  status: "completed",
@@ -479,6 +539,38 @@ function createGovernedActionHost(options) {
479
539
  if (!decision.approved) return actionResult(transitioned);
480
540
  return executeInvocation(actionInvocationId, tenantId, spaceId, { leaseOwner });
481
541
  }
542
+ async function recordExecutionAttestation(actionInvocationId, tenantId, spaceId, attestation, adapterInvocationId) {
543
+ const governanceStore = asGovernanceStore(options.store);
544
+ if (!governanceStore) throw new Error("Execution attestations require a governance-capable store.");
545
+ const invocation = await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
546
+ if (!invocation) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
547
+ const id = lifecycleId("att", actionInvocationId, adapterInvocationId ?? `${attestation.provider}:${attestation.operation}:${attestation.externalOperationId ?? "external"}`);
548
+ await governanceStore.appendExecutionAttestation({
549
+ ...attestation,
550
+ id,
551
+ actionInvocationId,
552
+ tenantId,
553
+ spaceId,
554
+ ...adapterInvocationId ? { adapterInvocationId } : {},
555
+ recordedAt: now()
556
+ });
557
+ for (const obligationId of attestation.satisfiesObligations ?? []) {
558
+ await governanceStore.updatePolicyObligation(actionInvocationId, obligationId, "satisfied", attestation.evidenceReferences);
559
+ }
560
+ }
561
+ async function recordExternalReconciliation(actionInvocationId, tenantId, spaceId, reconciliation) {
562
+ const governanceStore = asGovernanceStore(options.store);
563
+ if (!governanceStore) throw new Error("External reconciliation requires a governance-capable store.");
564
+ const invocation = await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
565
+ if (!invocation) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
566
+ await governanceStore.appendExternalReconciliation({
567
+ ...reconciliation,
568
+ id: lifecycleId("rec", actionInvocationId, `${reconciliation.provider}:${reconciliation.attempt}`),
569
+ actionInvocationId,
570
+ tenantId,
571
+ spaceId
572
+ });
573
+ }
482
574
  async function appendEvent(invocation, event, deduplicationKey, defaultEventSchemaVersion = 1) {
483
575
  const timestamp = now();
484
576
  const envelope = {
@@ -519,7 +611,7 @@ function createGovernedActionHost(options) {
519
611
  ...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
520
612
  };
521
613
  }
522
- return { submitAction, executeInvocation, resumeApprovedInvocation };
614
+ return { submitAction, executeInvocation, resumeApprovedInvocation, recordExecutionAttestation, recordExternalReconciliation };
523
615
  }
524
616
  function actionResult(invocation) {
525
617
  return {
@@ -535,6 +627,10 @@ function asApprovalStore(store) {
535
627
  const candidate = store;
536
628
  return typeof candidate.recordHitlDecision === "function" && typeof candidate.beginApprovalDecision === "function" ? store : void 0;
537
629
  }
630
+ function asGovernanceStore(store) {
631
+ const candidate = store;
632
+ return typeof candidate.recordMutationGovernance === "function" && typeof candidate.appendPolicyObligations === "function" && typeof candidate.appendExecutionAttestation === "function" ? store : void 0;
633
+ }
538
634
  function numberValue(value) {
539
635
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
540
636
  }
@@ -597,6 +693,9 @@ var MemoryPlatformHostStore = class {
597
693
  policyEvaluations = [];
598
694
  adapterInvocations = [];
599
695
  events = [];
696
+ policyObligations = [];
697
+ executionAttestations = [];
698
+ externalReconciliations = [];
600
699
  async transaction(run) {
601
700
  return run(this.db);
602
701
  }
@@ -665,6 +764,40 @@ var MemoryPlatformHostStore = class {
665
764
  if (this.policyEvaluations.some((candidate) => candidate.id === record.id)) return;
666
765
  this.policyEvaluations.push(record);
667
766
  }
767
+ async recordMutationGovernance(actionInvocationId, tenantId, spaceId, context) {
768
+ const record = await this.getActionInvocation(actionInvocationId, tenantId, spaceId);
769
+ if (!record) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
770
+ record.mutationFootprint = context.footprint;
771
+ record.executionPrincipal = context.executionPrincipal;
772
+ record.updatedAt = /* @__PURE__ */ new Date();
773
+ return record;
774
+ }
775
+ async appendPolicyObligations(records) {
776
+ for (const record of records) {
777
+ if (!this.policyObligations.some((candidate) => candidate.actionInvocationId === record.actionInvocationId && candidate.id === record.id)) {
778
+ this.policyObligations.push(record);
779
+ }
780
+ }
781
+ }
782
+ async listPolicyObligations(actionInvocationId, tenantId, spaceId) {
783
+ return this.policyObligations.filter((record) => record.actionInvocationId === actionInvocationId && record.tenantId === tenantId && record.spaceId === spaceId);
784
+ }
785
+ async updatePolicyObligation(actionInvocationId, obligationId, status, evidenceReferences) {
786
+ const record = this.policyObligations.find((candidate) => candidate.actionInvocationId === actionInvocationId && candidate.id === obligationId);
787
+ if (!record) throw new Error(`Policy obligation not found: ${obligationId}`);
788
+ record.status = status;
789
+ if (evidenceReferences) record.evidenceReferences = evidenceReferences;
790
+ record.updatedAt = /* @__PURE__ */ new Date();
791
+ }
792
+ async appendExecutionAttestation(record) {
793
+ if (!this.executionAttestations.some((candidate) => candidate.id === record.id)) this.executionAttestations.push(record);
794
+ }
795
+ async listExecutionAttestations(actionInvocationId, tenantId, spaceId) {
796
+ return this.executionAttestations.filter((record) => record.actionInvocationId === actionInvocationId && record.tenantId === tenantId && record.spaceId === spaceId);
797
+ }
798
+ async appendExternalReconciliation(record) {
799
+ if (!this.externalReconciliations.some((candidate) => candidate.id === record.id)) this.externalReconciliations.push(record);
800
+ }
668
801
  async createAdapterInvocation(record) {
669
802
  if (this.adapterInvocations.some((candidate) => candidate.id === record.id)) return;
670
803
  this.adapterInvocations.push(record);
@@ -739,6 +872,7 @@ var PostgresPlatformHostStore = class {
739
872
  attempt_count integer NOT NULL DEFAULT 0, lease_owner text,
740
873
  lease_expires_at timestamptz, hitl_route text, hitl_risk_tier text,
741
874
  hitl_reason text, hitl_policy_version text, approval_decision jsonb,
875
+ mutation_footprint jsonb, execution_principal jsonb,
742
876
  created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL
743
877
  );
744
878
  ALTER TABLE fabric_platform.action_invocations
@@ -750,7 +884,9 @@ var PostgresPlatformHostStore = class {
750
884
  ADD COLUMN IF NOT EXISTS hitl_risk_tier text,
751
885
  ADD COLUMN IF NOT EXISTS hitl_reason text,
752
886
  ADD COLUMN IF NOT EXISTS hitl_policy_version text,
753
- ADD COLUMN IF NOT EXISTS approval_decision jsonb;
887
+ ADD COLUMN IF NOT EXISTS approval_decision jsonb,
888
+ ADD COLUMN IF NOT EXISTS mutation_footprint jsonb,
889
+ ADD COLUMN IF NOT EXISTS execution_principal jsonb;
754
890
  CREATE UNIQUE INDEX IF NOT EXISTS action_invocations_idempotency_idx
755
891
  ON fabric_platform.action_invocations
756
892
  (tenant_id, space_id, action_id, idempotency_key)
@@ -769,6 +905,24 @@ var PostgresPlatformHostStore = class {
769
905
  input jsonb NOT NULL, output jsonb, error text, attempt integer NOT NULL,
770
906
  created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL
771
907
  );
908
+ CREATE TABLE IF NOT EXISTS fabric_platform.policy_obligations (
909
+ action_invocation_id text NOT NULL, id text NOT NULL,
910
+ tenant_id text NOT NULL, space_id text NOT NULL, type text NOT NULL,
911
+ status text NOT NULL, required boolean NOT NULL DEFAULT true,
912
+ description text, evidence_references jsonb, metadata jsonb,
913
+ created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
914
+ PRIMARY KEY (action_invocation_id,id)
915
+ );
916
+ CREATE TABLE IF NOT EXISTS fabric_platform.execution_attestations (
917
+ id text PRIMARY KEY, action_invocation_id text NOT NULL,
918
+ tenant_id text NOT NULL, space_id text NOT NULL, adapter_invocation_id text,
919
+ attestation jsonb NOT NULL, recorded_at timestamptz NOT NULL
920
+ );
921
+ CREATE TABLE IF NOT EXISTS fabric_platform.external_reconciliations (
922
+ id text PRIMARY KEY, action_invocation_id text NOT NULL,
923
+ tenant_id text NOT NULL, space_id text NOT NULL,
924
+ reconciliation jsonb NOT NULL
925
+ );
772
926
  CREATE TABLE IF NOT EXISTS fabric_platform.event_sequences (
773
927
  tenant_id text NOT NULL, space_id text NOT NULL, next_sequence bigint NOT NULL,
774
928
  PRIMARY KEY (tenant_id, space_id)
@@ -880,7 +1034,7 @@ var PostgresPlatformHostStore = class {
880
1034
  status=CASE WHEN $5::boolean THEN 'running' ELSE 'failed' END,
881
1035
  error=CASE WHEN $5::boolean THEN NULL ELSE $6 END,
882
1036
  lease_owner=CASE WHEN $5::boolean THEN $7 ELSE NULL END,
883
- lease_expires_at=CASE WHEN $5::boolean THEN $8 ELSE NULL END,
1037
+ lease_expires_at=CASE WHEN $5::boolean THEN $8::timestamptz ELSE NULL END,
884
1038
  attempt_count=CASE WHEN $5::boolean THEN GREATEST(attempt_count,1) ELSE attempt_count END,
885
1039
  updated_at=$9
886
1040
  WHERE id=$1 AND tenant_id=$2 AND space_id=$3 AND status='waiting_for_approval'
@@ -914,6 +1068,80 @@ var PostgresPlatformHostStore = class {
914
1068
  ]
915
1069
  );
916
1070
  }
1071
+ async recordMutationGovernance(actionInvocationId, tenantId, spaceId, context) {
1072
+ const result = await this.sql.query(
1073
+ `UPDATE fabric_platform.action_invocations SET mutation_footprint=$4::jsonb,
1074
+ execution_principal=$5::jsonb, updated_at=now()
1075
+ WHERE id=$1 AND tenant_id=$2 AND space_id=$3 RETURNING *`,
1076
+ [actionInvocationId, tenantId, spaceId, JSON.stringify(context.footprint), JSON.stringify(context.executionPrincipal)]
1077
+ );
1078
+ if (!result.rows[0]) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
1079
+ return toActionRecord(result.rows[0]);
1080
+ }
1081
+ async appendPolicyObligations(records) {
1082
+ for (const record of records) {
1083
+ await this.sql.query(
1084
+ `INSERT INTO fabric_platform.policy_obligations
1085
+ (action_invocation_id,id,tenant_id,space_id,type,status,required,description,evidence_references,metadata,created_at,updated_at)
1086
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12)
1087
+ ON CONFLICT (action_invocation_id,id) DO NOTHING`,
1088
+ [
1089
+ record.actionInvocationId,
1090
+ record.id,
1091
+ record.tenantId,
1092
+ record.spaceId,
1093
+ record.type,
1094
+ record.status,
1095
+ record.required ?? true,
1096
+ record.description ?? null,
1097
+ record.evidenceReferences ? JSON.stringify(record.evidenceReferences) : null,
1098
+ record.metadata ? JSON.stringify(record.metadata) : null,
1099
+ record.createdAt,
1100
+ record.updatedAt
1101
+ ]
1102
+ );
1103
+ }
1104
+ }
1105
+ async listPolicyObligations(actionInvocationId, tenantId, spaceId) {
1106
+ const result = await this.sql.query(
1107
+ `SELECT * FROM fabric_platform.policy_obligations WHERE action_invocation_id=$1 AND tenant_id=$2 AND space_id=$3 ORDER BY created_at,id`,
1108
+ [actionInvocationId, tenantId, spaceId]
1109
+ );
1110
+ return result.rows.map(toObligationRecord);
1111
+ }
1112
+ async updatePolicyObligation(actionInvocationId, obligationId, status, evidenceReferences) {
1113
+ await this.sql.query(
1114
+ `UPDATE fabric_platform.policy_obligations SET status=$3,
1115
+ evidence_references=COALESCE($4::jsonb,evidence_references), updated_at=now()
1116
+ WHERE action_invocation_id=$1 AND id=$2`,
1117
+ [actionInvocationId, obligationId, status, evidenceReferences ? JSON.stringify(evidenceReferences) : null]
1118
+ );
1119
+ }
1120
+ async appendExecutionAttestation(record) {
1121
+ const { id, actionInvocationId, tenantId, spaceId, adapterInvocationId, recordedAt, ...attestation } = record;
1122
+ await this.sql.query(
1123
+ `INSERT INTO fabric_platform.execution_attestations
1124
+ (id,action_invocation_id,tenant_id,space_id,adapter_invocation_id,attestation,recorded_at)
1125
+ VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7) ON CONFLICT (id) DO NOTHING`,
1126
+ [id, actionInvocationId, tenantId, spaceId, adapterInvocationId ?? null, JSON.stringify(attestation), recordedAt]
1127
+ );
1128
+ }
1129
+ async listExecutionAttestations(actionInvocationId, tenantId, spaceId) {
1130
+ const result = await this.sql.query(
1131
+ `SELECT * FROM fabric_platform.execution_attestations WHERE action_invocation_id=$1 AND tenant_id=$2 AND space_id=$3 ORDER BY recorded_at,id`,
1132
+ [actionInvocationId, tenantId, spaceId]
1133
+ );
1134
+ return result.rows.map(toAttestationRecord);
1135
+ }
1136
+ async appendExternalReconciliation(record) {
1137
+ const { id, actionInvocationId, tenantId, spaceId, ...reconciliation } = record;
1138
+ await this.sql.query(
1139
+ `INSERT INTO fabric_platform.external_reconciliations
1140
+ (id,action_invocation_id,tenant_id,space_id,reconciliation)
1141
+ VALUES ($1,$2,$3,$4,$5::jsonb) ON CONFLICT (id) DO NOTHING`,
1142
+ [id, actionInvocationId, tenantId, spaceId, JSON.stringify(reconciliation)]
1143
+ );
1144
+ }
917
1145
  async createAdapterInvocation(record) {
918
1146
  await this.sql.query(
919
1147
  `INSERT INTO fabric_platform.adapter_invocations
@@ -1111,11 +1339,41 @@ function toActionRecord(row) {
1111
1339
  ...row.hitl_reason ? { hitlReason: String(row.hitl_reason) } : {},
1112
1340
  ...row.hitl_policy_version ? { hitlPolicyVersion: String(row.hitl_policy_version) } : {},
1113
1341
  ...row.approval_decision ? { approvalDecision: toApprovalDecision(row.approval_decision) } : {},
1342
+ ...row.mutation_footprint ? { mutationFootprint: row.mutation_footprint } : {},
1343
+ ...row.execution_principal ? { executionPrincipal: row.execution_principal } : {},
1114
1344
  ...row.error ? { error: String(row.error) } : {},
1115
1345
  createdAt: new Date(row.created_at),
1116
1346
  updatedAt: new Date(row.updated_at)
1117
1347
  };
1118
1348
  }
1349
+ function toObligationRecord(row) {
1350
+ return {
1351
+ id: String(row.id),
1352
+ actionInvocationId: String(row.action_invocation_id),
1353
+ tenantId: String(row.tenant_id),
1354
+ spaceId: String(row.space_id),
1355
+ type: String(row.type),
1356
+ status: String(row.status),
1357
+ required: Boolean(row.required),
1358
+ ...row.description ? { description: String(row.description) } : {},
1359
+ ...row.evidence_references ? { evidenceReferences: row.evidence_references } : {},
1360
+ ...row.metadata ? { metadata: row.metadata } : {},
1361
+ createdAt: new Date(row.created_at),
1362
+ updatedAt: new Date(row.updated_at)
1363
+ };
1364
+ }
1365
+ function toAttestationRecord(row) {
1366
+ const attestation = row.attestation;
1367
+ return {
1368
+ ...attestation,
1369
+ id: String(row.id),
1370
+ actionInvocationId: String(row.action_invocation_id),
1371
+ tenantId: String(row.tenant_id),
1372
+ spaceId: String(row.space_id),
1373
+ ...row.adapter_invocation_id ? { adapterInvocationId: String(row.adapter_invocation_id) } : {},
1374
+ recordedAt: new Date(row.recorded_at)
1375
+ };
1376
+ }
1119
1377
  function toApprovalDecision(value) {
1120
1378
  const decision = value;
1121
1379
  return {