@fabricorg/platform-host 0.6.0 → 1.0.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,38 @@
1
1
  # @fabricorg/platform-host
2
2
 
3
+ ## 1.0.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fdd3e69: Add provider-neutral external mutation footprints, delegated principals, enforceable obligations,
8
+ execution attestations, and reconciliation evidence with durable host-store support.
9
+ - 714ad06: Add enforceable Fabric-family compatibility controls and durable runtime contract-version evidence.
10
+ - 82aa9c8: Add parameter-aware execution-time authorization, opaque durable admission bindings, and an
11
+ application-bound PostgreSQL unit of work for atomic domain/event persistence and recoverable
12
+ after-adapter finalization.
13
+
14
+ ### Patch Changes
15
+
16
+ - Updated dependencies [bc7d3ab]
17
+ - Updated dependencies [fdd3e69]
18
+ - Updated dependencies [714ad06]
19
+ - Updated dependencies [43c5770]
20
+ - Updated dependencies [986636b]
21
+ - @fabricorg/platform@0.10.0
22
+
23
+ ## 0.7.0 — 2026-07-27
24
+
25
+ - Add parameter-aware execution authorization immediately before policies and mutation execution,
26
+ including approval resume and interrupted-work recovery.
27
+ - Persist an opaque authorization binding with each invocation so applications can revalidate the
28
+ exact admission without retaining credentials or caller proofs.
29
+ - Add an optional transaction-scoped mutation unit of work that commits domain writes and
30
+ before-adapter events atomically.
31
+ - Atomically append `after_adapters` completion events with invocation completion when the store
32
+ provides the unit-of-work capability; finalization failures remain recoverable and do not repeat
33
+ succeeded adapter checkpoints.
34
+ - Advance the durable Host lifecycle contract to version 2.
35
+
3
36
  ## 0.6.0
4
37
 
5
38
  - Persist governance, Host, provider-bridge, package, and policy-ruleset generations with every new invocation.
package/README.md CHANGED
@@ -3,9 +3,23 @@
3
3
  The canonical host for the `@fabricorg/platform` mutation pipeline.
4
4
 
5
5
  ```bash
6
- pnpm add @fabricorg/platform@^0.9.0 @fabricorg/platform-host@^0.6.0
6
+ pnpm add @fabricorg/platform@^0.9.0 @fabricorg/platform-host@^0.7.0
7
7
  ```
8
8
 
9
+ ## AI agent integration boundary
10
+
11
+ Application and vertical owners install this package. Hermes, Fabric Harness, and other external agent
12
+ runtimes do not import Platform Host merely to call a deployed application. They connect to the
13
+ application's authenticated REST or MCP gateway, discover only the actions allowed by their
14
+ registration, and submit stable idempotent commands. The gateway derives tenant and actor identity and
15
+ calls `submitAction()`; an agent must never call handlers, adapters, workflow internals, or database
16
+ writes directly.
17
+
18
+ See the [Platform Host 0.7 agent integration
19
+ guide](https://platform.fabric.pro/docs/platform/reference/platform-host) for application wiring,
20
+ execution-time authorization, the PostgreSQL transaction binder, recovery semantics, and the external
21
+ gateway contract.
22
+
9
23
  Vertical packages register `FabricModule` definitions. Host applications provide tenant authorization,
10
24
  module entitlements, persistence, projections, and an optional durable dispatcher. The package owns the
11
25
  ordering and lifecycle invariant:
@@ -36,6 +50,56 @@ adapter that already reached `succeeded`, and event, policy, and adapter writes
36
50
  stale action that was not declared idempotent fails terminally for manual reconciliation instead of
37
51
  silently rerunning unknown side effects.
38
52
 
53
+ ## Execution authorization
54
+
55
+ Submission authorization proves that a caller may create an invocation. Applications that delegate
56
+ resource-scoped authority should also configure `authorizeExecution`. The Host calls it with the
57
+ schema-parsed durable parameters, canonical invocation, opaque `authorizationBindingId`, and an
58
+ `executionReason` of `initial`, `approval_resume`, or `recovery` immediately before policies and
59
+ mutation code run.
60
+
61
+ ```ts
62
+ const host = createGovernedActionHost({
63
+ store,
64
+ authorization: {
65
+ checkEntitlement,
66
+ authorize: authorizeSubmission,
67
+ authorizeExecution: async ({ invocation, parameters, executionReason }) =>
68
+ admissionStore.authorize({
69
+ admissionId: invocation.authorizationBindingId,
70
+ parameters,
71
+ executionReason,
72
+ }),
73
+ },
74
+ });
75
+ ```
76
+
77
+ When `authorizeExecution` is absent, the Host reuses `authorize` at the execution boundary. A
78
+ completed idempotent replay returns its existing result without creating or executing a new
79
+ mutation.
80
+
81
+ External gateways should persist a compact admission record bound to the tenant, registration, actor,
82
+ action, canonical parameter hash, resource scope, and expiry, then pass its opaque non-secret identifier
83
+ as `authorizationBindingId` to `submitAction()`. Credentials and signed principal proofs do not belong
84
+ in action parameters or workflow history.
85
+
86
+ ## Atomic mutation unit of work
87
+
88
+ Production stores can implement `AtomicMutationPlatformHostStore.transactionWithEvents`. The
89
+ transaction-scoped `db`, event sequence, event append, event listing, and invocation update methods
90
+ must all use the same database transaction.
91
+
92
+ `PostgresPlatformHostStore` enables this capability when constructed with a
93
+ `PostgresPlatformHostTransactionProvider`. The application owns that narrow binder because only the
94
+ application knows how its `TDb` is rebound to the transaction's SQL client.
95
+
96
+ - `before_adapters`: handler domain writes and declared domain events commit or roll back together.
97
+ - `after_adapters`: completion events and invocation completion commit together. A finalization
98
+ failure leaves the invocation recoverable; succeeded adapter checkpoints are not repeated.
99
+
100
+ Stores without this additive capability retain the legacy boundary for compatibility and must not
101
+ claim atomic domain-write/event persistence.
102
+
39
103
  ## Agent HITL
40
104
 
41
105
  Hosts can inject a vertical-owned `hitlEvaluator`. It runs only for `actorType: "agent"`, after schema
@@ -107,7 +171,7 @@ explain which contract and provider adapter governed a mutation after dependenci
107
171
  const host = createGovernedActionHost({
108
172
  // ...
109
173
  runtimeEvidence: {
110
- hostPackageVersion: "0.6.0",
174
+ hostPackageVersion: "0.7.0",
111
175
  policyRulesetVersion: "gtm-rules.v8",
112
176
  providerBridge: { name: "@fabric-harness/databricks", version: "1" },
113
177
  },
package/dist/index.cjs CHANGED
@@ -5,13 +5,16 @@ var platform = require('@fabricorg/platform');
5
5
  // src/host.ts
6
6
 
7
7
  // src/types.ts
8
- var PLATFORM_HOST_CONTRACT_VERSION = 1;
8
+ var PLATFORM_HOST_CONTRACT_VERSION = 2;
9
9
 
10
10
  // src/host.ts
11
11
  var DEFAULT_EXTRACT_EVENTS = (data) => {
12
12
  const value = data._events;
13
13
  return Array.isArray(value) ? value : [];
14
14
  };
15
+ var RecoverableFinalizationError = class extends Error {
16
+ name = "RecoverableFinalizationError";
17
+ };
15
18
  function createGovernedActionHost(options) {
16
19
  const adapters = new platform.AdapterRegistry();
17
20
  for (const adapter of options.adapters ?? []) adapters.register(adapter);
@@ -52,7 +55,8 @@ function createGovernedActionHost(options) {
52
55
  runtimeEvidence,
53
56
  correlationId,
54
57
  ...input.causationId ? { causationId: input.causationId } : {},
55
- ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
58
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {},
59
+ ...input.authorizationBindingId ? { authorizationBindingId: input.authorizationBindingId } : {}
56
60
  });
57
61
  const durableWorkflowId = `action-invocation-${durableInvocation.id}`;
58
62
  if (durableInvocation.id !== actionInvocationId && isTerminal(durableInvocation.status)) {
@@ -100,7 +104,7 @@ function createGovernedActionHost(options) {
100
104
  );
101
105
  return { ...executed, workflowId: durableWorkflowId };
102
106
  }
103
- async function executeInvocation(actionInvocationId, tenantId, spaceId, executionOptions = {}) {
107
+ async function executeInvocation(actionInvocationId, tenantId, spaceId, executionOptions = {}, executionReasonOverride) {
104
108
  const loadedInvocation = await options.store.getActionInvocation(
105
109
  actionInvocationId,
106
110
  tenantId,
@@ -109,6 +113,7 @@ function createGovernedActionHost(options) {
109
113
  if (!loadedInvocation) {
110
114
  throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
111
115
  }
116
+ const resumingRunningInvocation = loadedInvocation.status === "running";
112
117
  let invocation = loadedInvocation;
113
118
  if (isTerminal(invocation.status) || invocation.status === "waiting_for_approval") {
114
119
  return actionResult(invocation);
@@ -210,6 +215,28 @@ function createGovernedActionHost(options) {
210
215
  }
211
216
  }
212
217
  const authorizationInput = toAuthorizationInput(action, invocation);
218
+ const executionReason = executionReasonOverride ?? (invocation.attemptCount > 1 || resumingRunningInvocation && invocation.attemptCount === 0 ? "recovery" : "initial");
219
+ if (!await options.authorization.checkEntitlement(authorizationInput)) {
220
+ return fail(
221
+ invocation,
222
+ "failed",
223
+ `Module "${action.namespace}" is no longer enabled for tenant ${tenantId}`
224
+ );
225
+ }
226
+ const executionAuthorized = options.authorization.authorizeExecution ? await options.authorization.authorizeExecution({
227
+ ...authorizationInput,
228
+ actionInvocationId,
229
+ parameters: parsed.data,
230
+ invocation,
231
+ executionReason
232
+ }) : await options.authorization.authorize(authorizationInput);
233
+ if (!executionAuthorized) {
234
+ return fail(
235
+ invocation,
236
+ "failed",
237
+ `Actor ${invocation.actorId} is not authorized to execute action ${action.actionId}`
238
+ );
239
+ }
213
240
  const definitions = options.resolvePolicies ? await options.resolvePolicies({
214
241
  ...authorizationInput,
215
242
  declaredPolicyIds: action.policies ?? []
@@ -294,8 +321,8 @@ function createGovernedActionHost(options) {
294
321
  let data;
295
322
  let domainEvents = [];
296
323
  try {
297
- const handlerResult = await options.store.transaction(
298
- (db) => action.handler ? action.handler(
324
+ const runHandler = async (db, transaction) => {
325
+ const handlerResult = action.handler ? await action.handler(
299
326
  {
300
327
  actionInvocationId,
301
328
  tenantId,
@@ -308,28 +335,39 @@ function createGovernedActionHost(options) {
308
335
  services: options.services
309
336
  },
310
337
  parsed.data
311
- ) : Promise.resolve({ success: true, data: {} })
312
- );
313
- if (!handlerResult.success) {
314
- return fail(invocation, "failed", handlerResult.error ?? "Action handler failed");
315
- }
316
- data = handlerResult.data ?? {};
317
- domainEvents = extractEvents(data);
318
- const undeclaredEvent = domainEvents.find(
319
- (event) => !action.emitsEvents.includes(event.eventType)
320
- );
321
- if (undeclaredEvent) {
322
- return fail(
323
- invocation,
324
- "failed",
325
- `${action.actionId} emitted undeclared event type ${undeclaredEvent.eventType}`
338
+ ) : { success: true, data: {} };
339
+ if (!handlerResult.success) {
340
+ throw new Error(handlerResult.error ?? "Action handler failed");
341
+ }
342
+ const handlerData = handlerResult.data ?? {};
343
+ const events = extractEvents(handlerData);
344
+ const undeclaredEvent = events.find(
345
+ (event) => !action.emitsEvents.includes(event.eventType)
326
346
  );
327
- }
328
- if (action.eventPhase !== "after_adapters") {
329
- for (const [index, event] of domainEvents.entries()) {
330
- await appendEvent(invocation, event, `domain:${index}`, action.version);
347
+ if (undeclaredEvent) {
348
+ throw new Error(
349
+ `${action.actionId} emitted undeclared event type ${undeclaredEvent.eventType}`
350
+ );
331
351
  }
332
- }
352
+ if (action.eventPhase !== "after_adapters") {
353
+ for (const [index, event] of events.entries()) {
354
+ await appendEvent(
355
+ invocation,
356
+ event,
357
+ `domain:${index}`,
358
+ action.version,
359
+ transaction
360
+ );
361
+ }
362
+ }
363
+ return { data: handlerData, domainEvents: events };
364
+ };
365
+ const atomicStore = asAtomicMutationStore(options.store);
366
+ const executed = atomicStore ? await atomicStore.transactionWithEvents(
367
+ (transaction) => runHandler(transaction.db, transaction)
368
+ ) : await options.store.transaction((db) => runHandler(db));
369
+ data = executed.data;
370
+ domainEvents = executed.domainEvents;
333
371
  } catch (error) {
334
372
  return fail(invocation, "failed", errorMessage(error));
335
373
  }
@@ -460,11 +498,6 @@ function createGovernedActionHost(options) {
460
498
  return fail(invocation, "failed", message);
461
499
  }
462
500
  }
463
- if (action.eventPhase === "after_adapters") {
464
- for (const [index, event] of domainEvents.entries()) {
465
- await appendEvent(invocation, event, `domain:${index}`, action.version);
466
- }
467
- }
468
501
  const governanceStore = asGovernanceStore(options.store);
469
502
  if (governanceStore && options.resolvePolicyObligations) {
470
503
  const obligations = await governanceStore.listPolicyObligations(actionInvocationId, tenantId, spaceId);
@@ -474,10 +507,43 @@ function createGovernedActionHost(options) {
474
507
  }
475
508
  }
476
509
  const result = withoutPrivateHostFields(data, eventResultFields);
477
- await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
478
- status: "completed",
479
- result
480
- });
510
+ try {
511
+ const atomicStore = asAtomicMutationStore(options.store);
512
+ if (action.eventPhase === "after_adapters" && atomicStore) {
513
+ await atomicStore.transactionWithEvents(async (transaction) => {
514
+ for (const [index, event] of domainEvents.entries()) {
515
+ await appendEvent(
516
+ invocation,
517
+ event,
518
+ `domain:${index}`,
519
+ action.version,
520
+ transaction
521
+ );
522
+ }
523
+ await transaction.updateActionInvocation(
524
+ actionInvocationId,
525
+ tenantId,
526
+ spaceId,
527
+ { status: "completed", result }
528
+ );
529
+ });
530
+ } else {
531
+ if (action.eventPhase === "after_adapters") {
532
+ for (const [index, event] of domainEvents.entries()) {
533
+ await appendEvent(invocation, event, `domain:${index}`, action.version);
534
+ }
535
+ }
536
+ await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
537
+ status: "completed",
538
+ result
539
+ });
540
+ }
541
+ } catch (error) {
542
+ if (action.eventPhase === "after_adapters") {
543
+ throw new RecoverableFinalizationError(errorMessage(error));
544
+ }
545
+ throw error;
546
+ }
481
547
  return {
482
548
  actionInvocationId,
483
549
  status: "completed",
@@ -486,6 +552,7 @@ function createGovernedActionHost(options) {
486
552
  ...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
487
553
  };
488
554
  } catch (error) {
555
+ if (error instanceof RecoverableFinalizationError) throw error;
489
556
  return fail(invocation, "failed", errorMessage(error));
490
557
  }
491
558
  }
@@ -550,7 +617,13 @@ function createGovernedActionHost(options) {
550
617
  return actionResult(transitioned);
551
618
  }
552
619
  if (!decision.approved) return actionResult(transitioned);
553
- return executeInvocation(actionInvocationId, tenantId, spaceId, { leaseOwner });
620
+ return executeInvocation(
621
+ actionInvocationId,
622
+ tenantId,
623
+ spaceId,
624
+ { leaseOwner },
625
+ "approval_resume"
626
+ );
554
627
  }
555
628
  async function recordExecutionAttestation(actionInvocationId, tenantId, spaceId, attestation, adapterInvocationId) {
556
629
  const governanceStore = asGovernanceStore(options.store);
@@ -584,7 +657,7 @@ function createGovernedActionHost(options) {
584
657
  spaceId
585
658
  });
586
659
  }
587
- async function appendEvent(invocation, event, deduplicationKey, defaultEventSchemaVersion = 1) {
660
+ async function appendEvent(invocation, event, deduplicationKey, defaultEventSchemaVersion = 1, transaction) {
588
661
  const timestamp = now();
589
662
  const envelope = {
590
663
  id: lifecycleId("evt", invocation.id, deduplicationKey),
@@ -598,7 +671,7 @@ function createGovernedActionHost(options) {
598
671
  actorType: invocation.actorType,
599
672
  actionInvocationId: invocation.id,
600
673
  payload: event.payload,
601
- sequence: await options.store.nextEventSequence(
674
+ sequence: await (transaction ?? options.store).nextEventSequence(
602
675
  invocation.tenantId,
603
676
  invocation.spaceId
604
677
  ),
@@ -607,7 +680,7 @@ function createGovernedActionHost(options) {
607
680
  correlationId: invocation.correlationId,
608
681
  ...invocation.causationId ? { causationId: invocation.causationId } : {}
609
682
  };
610
- await options.store.appendEvent(envelope);
683
+ await (transaction ?? options.store).appendEvent(envelope);
611
684
  }
612
685
  async function fail(invocation, status, error) {
613
686
  await options.store.updateActionInvocation(
@@ -640,6 +713,10 @@ function asApprovalStore(store) {
640
713
  const candidate = store;
641
714
  return typeof candidate.recordHitlDecision === "function" && typeof candidate.beginApprovalDecision === "function" ? store : void 0;
642
715
  }
716
+ function asAtomicMutationStore(store) {
717
+ const candidate = store;
718
+ return typeof candidate.transactionWithEvents === "function" ? store : void 0;
719
+ }
643
720
  function asGovernanceStore(store) {
644
721
  const candidate = store;
645
722
  return typeof candidate.recordMutationGovernance === "function" && typeof candidate.appendPolicyObligations === "function" && typeof candidate.appendExecutionAttestation === "function" ? store : void 0;
@@ -866,13 +943,26 @@ var MemoryPlatformHostStore = class {
866
943
  };
867
944
 
868
945
  // src/postgres-store.ts
869
- var PostgresPlatformHostStore = class {
870
- constructor(db, sql) {
946
+ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
947
+ constructor(db, sql, transactionProvider) {
871
948
  this.db = db;
872
949
  this.sql = sql;
950
+ if (transactionProvider) {
951
+ this.transactionWithEvents = async (run) => transactionProvider.run(async ({ db: db2, sql: sql2 }) => {
952
+ const scoped = new _PostgresPlatformHostStore(db2, sql2);
953
+ return run({
954
+ db: db2,
955
+ appendEvent: (event) => scoped.appendEvent(event),
956
+ nextEventSequence: (tenantId, spaceId) => scoped.nextEventSequence(tenantId, spaceId),
957
+ listEvents: (tenantId, spaceId) => scoped.listEvents(tenantId, spaceId),
958
+ updateActionInvocation: (id, tenantId, spaceId, patch) => scoped.updateActionInvocation(id, tenantId, spaceId, patch)
959
+ });
960
+ });
961
+ }
873
962
  }
874
963
  db;
875
964
  sql;
965
+ transactionWithEvents;
876
966
  async ensureSchema() {
877
967
  await this.sql.query(`
878
968
  CREATE SCHEMA IF NOT EXISTS fabric_platform;
@@ -881,7 +971,8 @@ var PostgresPlatformHostStore = class {
881
971
  action_id text NOT NULL, action_version integer NOT NULL,
882
972
  actor_id text NOT NULL, actor_type text NOT NULL, status text NOT NULL,
883
973
  parameters jsonb NOT NULL, result jsonb NOT NULL DEFAULT '{}'::jsonb,
884
- correlation_id text NOT NULL, causation_id text, idempotency_key text, error text,
974
+ correlation_id text NOT NULL, causation_id text, idempotency_key text,
975
+ authorization_binding_id text, error text,
885
976
  attempt_count integer NOT NULL DEFAULT 0, lease_owner text,
886
977
  lease_expires_at timestamptz, hitl_route text, hitl_risk_tier text,
887
978
  hitl_reason text, hitl_policy_version text, approval_decision jsonb,
@@ -890,6 +981,7 @@ var PostgresPlatformHostStore = class {
890
981
  );
891
982
  ALTER TABLE fabric_platform.action_invocations
892
983
  ADD COLUMN IF NOT EXISTS idempotency_key text,
984
+ ADD COLUMN IF NOT EXISTS authorization_binding_id text,
893
985
  ADD COLUMN IF NOT EXISTS attempt_count integer NOT NULL DEFAULT 0,
894
986
  ADD COLUMN IF NOT EXISTS lease_owner text,
895
987
  ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz,
@@ -964,8 +1056,9 @@ var PostgresPlatformHostStore = class {
964
1056
  const result = await this.sql.query(
965
1057
  `INSERT INTO fabric_platform.action_invocations
966
1058
  (id,tenant_id,space_id,action_id,action_version,actor_id,actor_type,status,
967
- parameters,result,correlation_id,causation_id,idempotency_key,error,runtime_evidence,created_at,updated_at)
968
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14,$15::jsonb,$16,$16)
1059
+ parameters,result,correlation_id,causation_id,idempotency_key,
1060
+ authorization_binding_id,error,runtime_evidence,created_at,updated_at)
1061
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14,$15,$16::jsonb,$17,$17)
969
1062
  ON CONFLICT (tenant_id,space_id,action_id,idempotency_key)
970
1063
  WHERE idempotency_key IS NOT NULL DO UPDATE SET id=fabric_platform.action_invocations.id
971
1064
  RETURNING *`,
@@ -983,6 +1076,7 @@ var PostgresPlatformHostStore = class {
983
1076
  input.correlationId,
984
1077
  input.causationId ?? null,
985
1078
  input.idempotencyKey ?? null,
1079
+ input.authorizationBindingId ?? null,
986
1080
  input.error ?? null,
987
1081
  input.runtimeEvidence ? JSON.stringify(input.runtimeEvidence) : null,
988
1082
  now
@@ -1346,6 +1440,7 @@ function toActionRecord(row) {
1346
1440
  correlationId: String(row.correlation_id),
1347
1441
  ...row.causation_id ? { causationId: String(row.causation_id) } : {},
1348
1442
  ...row.idempotency_key ? { idempotencyKey: String(row.idempotency_key) } : {},
1443
+ ...row.authorization_binding_id ? { authorizationBindingId: String(row.authorization_binding_id) } : {},
1349
1444
  attemptCount: Number(row.attempt_count ?? 0),
1350
1445
  ...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
1351
1446
  ...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},