@fabricorg/platform-host 3.0.0 → 5.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,46 @@
1
1
  # @fabricorg/platform-host
2
2
 
3
+ ## 5.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - c504d0a: Enforce every field of `ActionExecutionContract` or hand it to an explicit owner.
8
+
9
+ - `connectivity`: an action declaring `online-required` is refused when submitted with
10
+ `executionReason: "offline_replay"`. What a client offers offline is a composition decision; what
11
+ the Host accepts is not.
12
+ - `consistency`: an action declaring `provisional-until-reconciled` marks the events it emits and the
13
+ submission result, so bus consumers and reconciliation flows can tell a settled fact from a pending
14
+ one without a lookup.
15
+ - `sensitivity`: an action declaring `restricted` refuses a submission carrying provenance audit
16
+ attributes instead of durably recording them.
17
+ - `completion`: `immediate` combined with `kind: "saga"` is rejected at registry build.
18
+
19
+ `consistency` is persisted by the PostgreSQL store (additive nullable column, applied by
20
+ `ensureSchema()`) and carried through `toEnterpriseEventEnvelope()` to the bus, so a provisional fact
21
+ stays marked as provisional after replay and after publication. Every result path is decorated, so an
22
+ idempotent replay reports the same consistency as the original submission.
23
+
24
+ Applications that previously submitted offline replays of online-required actions, or audit
25
+ attributes on restricted actions, now receive an explicit error where both were silent.
26
+
27
+ At release, raise the `@fabricorg/platform` peer floor to the version carrying
28
+ `AssetEventEnvelope.consistency`.
29
+
30
+ ## 4.0.0
31
+
32
+ ### Major Changes
33
+
34
+ - Make the capability runtime vertical-neutral and instance-scoped. Move experience and display
35
+ semantics into a namespaced companion package, require explicit host registries, replace privacy
36
+ field defaults with caller-owned policy, add PostgreSQL projection adapters, and generate executable
37
+ ProjectionHost read and PlatformHost mutation seams.
38
+
39
+ ### Patch Changes
40
+
41
+ - Updated dependencies
42
+ - @fabricorg/platform@1.0.0
43
+
3
44
  ## 3.0.0
4
45
 
5
46
  ### Major Changes
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.12.0 @fabricorg/platform-host@^3.0.0
6
+ pnpm add @fabricorg/platform@^1.0.0 @fabricorg/platform-host@^4.0.0
7
7
  ```
8
8
 
9
9
  ## AI agent integration boundary
@@ -15,7 +15,7 @@ registration, and submit stable idempotent commands. The gateway derives tenant
15
15
  calls `submitAction()`; an agent must never call handlers, adapters, workflow internals, or database
16
16
  writes directly.
17
17
 
18
- See the [Platform Host 3.x agent integration
18
+ See the [Platform Host 4.x agent integration
19
19
  guide](https://platform.fabric.pro/docs/platform/reference/platform-host) for application wiring,
20
20
  execution-time authorization, the PostgreSQL transaction binder, recovery semantics, and the external
21
21
  gateway contract.
@@ -32,8 +32,8 @@ Actor → ActionInvocation → Schema → Agent HITL → PolicyEvaluation → St
32
32
  worker entry point. With no dispatcher the host executes inline, which is intended for tests and local
33
33
  development only.
34
34
 
35
- Applications that assemble action catalogs per runtime can provide `resolveAction` instead of
36
- mutating the process-wide platform registry. A custom `extractEvents` implementation can pair with
35
+ Applications construct a `createModuleRegistry()` per runtime and pass it as `registry`; a narrow
36
+ `resolveAction` function remains available for adapters. No process-wide platform registry exists. A custom `extractEvents` implementation can pair with
37
37
  `eventResultFields` so its event carrier is removed from the durable invocation result. Domain events
38
38
  without an explicit `eventSchemaVersion` inherit the action version, while host lifecycle events stay
39
39
  at version 1. Emitted domain events must appear in the action's `emitsEvents` declaration. The
@@ -81,6 +81,7 @@ by default; `outbox.includeProvenance` is the explicit classification-aware over
81
81
  ```ts
82
82
  const host = createGovernedActionHost({
83
83
  store,
84
+ registry,
84
85
  authorization: {
85
86
  checkEntitlement,
86
87
  authorize: authorizeSubmission,
package/dist/index.cjs CHANGED
@@ -31,10 +31,10 @@ function digestParameters(parameters) {
31
31
  return crypto.createHash("sha256").update(canonicalJson(parameters)).digest("hex");
32
32
  }
33
33
  var ATTRIBUTE_NAME = /^[a-z][a-z0-9-]*(?:\.[A-Za-z][A-Za-z0-9_-]*)+$/;
34
- var INVOCATION_SOURCES = /* @__PURE__ */ new Set(["sdui", "api", "agent", "worker", "system"]);
34
+ var INVOCATION_SOURCE = /^(?:ui|sdui|api|agent|worker|system|[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+)$/;
35
35
  function normalizeProvenance(input, options) {
36
36
  if (!input) return void 0;
37
- if (!INVOCATION_SOURCES.has(input.source)) throw new Error(`Invalid invocation provenance source: ${input.source}`);
37
+ if (!INVOCATION_SOURCE.test(input.source)) throw new Error(`Invalid invocation provenance source: ${input.source}`);
38
38
  const maxAttributes = options?.maxAttributes ?? 16;
39
39
  const maxValueLength = options?.maxValueLength ?? 256;
40
40
  const allowlist = new Set(options?.auditAttributeAllowlist ?? []);
@@ -83,7 +83,10 @@ function createGovernedActionHost(options) {
83
83
  for (const adapter of options.adapters ?? []) adapters.register(adapter);
84
84
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
85
85
  const extractEvents = options.extractEvents ?? DEFAULT_EXTRACT_EVENTS;
86
- const actionResolver = options.resolveAction ?? platform.resolveAction;
86
+ const configuredActionResolver = options.resolveAction ?? options.registry?.resolveAction;
87
+ if (!configuredActionResolver) throw new Error("Platform Host requires an explicit module registry or resolveAction function.");
88
+ const actionResolver = configuredActionResolver;
89
+ const stateMachineResolver = options.registry?.resolveStateMachine ?? (() => void 0);
87
90
  const eventResultFields = options.eventResultFields ?? ["_events"];
88
91
  async function submitAction(input) {
89
92
  if (input.executionReason && !["initial", "offline_replay"].includes(input.executionReason)) {
@@ -91,6 +94,12 @@ function createGovernedActionHost(options) {
91
94
  }
92
95
  const action = actionResolver(input.actionId);
93
96
  if (!action) throw new Error(`Unknown action: ${input.actionId}`);
97
+ if (action.execution?.connectivity === "online-required" && input.executionReason === "offline_replay") {
98
+ throw new Error(`Action ${input.actionId} is online-required and cannot be submitted as an offline replay.`);
99
+ }
100
+ if (action.execution?.sensitivity === "restricted" && Object.keys(input.provenance?.auditAttributes ?? {}).length > 0) {
101
+ throw new Error(`Action ${input.actionId} declares restricted sensitivity; provenance audit attributes must not be durably recorded.`);
102
+ }
94
103
  const authorizationInput = toAuthorizationInput(action, input);
95
104
  if (!await options.authorization.checkEntitlement(authorizationInput)) {
96
105
  throw new Error(`Module "${action.namespace}" is not enabled for tenant ${input.tenantId}`);
@@ -163,7 +172,7 @@ function createGovernedActionHost(options) {
163
172
  }
164
173
  const durableWorkflowId = `action-invocation-${durableInvocation.id}`;
165
174
  if (durableInvocation.id !== actionInvocationId) {
166
- return {
175
+ return withConsistency({
167
176
  actionInvocationId: durableInvocation.id,
168
177
  status: durableInvocation.status,
169
178
  workflowId: durableWorkflowId,
@@ -172,7 +181,7 @@ function createGovernedActionHost(options) {
172
181
  ...durableInvocation.hitlRoute ? { hitlRoute: durableInvocation.hitlRoute } : {},
173
182
  ...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {},
174
183
  ...durableInvocation.authorizationReconciliation ? { reconciliation: durableInvocation.authorizationReconciliation } : {}
175
- };
184
+ }, input.actionId);
176
185
  }
177
186
  if (options.dispatcher) {
178
187
  try {
@@ -182,14 +191,14 @@ function createGovernedActionHost(options) {
182
191
  spaceId: input.spaceId,
183
192
  workflowId: durableWorkflowId
184
193
  });
185
- return {
194
+ return withConsistency({
186
195
  actionInvocationId: durableInvocation.id,
187
196
  status: durableInvocation.status,
188
197
  workflowId: dispatched.workflowId,
189
198
  ...dispatched.runId ? { runId: dispatched.runId } : {},
190
199
  ...durableInvocation.hitlRoute ? { hitlRoute: durableInvocation.hitlRoute } : {},
191
200
  ...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
192
- };
201
+ }, input.actionId);
193
202
  } catch (error) {
194
203
  const message = errorMessage(error);
195
204
  await options.store.updateActionInvocation(
@@ -206,7 +215,7 @@ function createGovernedActionHost(options) {
206
215
  input.tenantId,
207
216
  input.spaceId
208
217
  );
209
- return { ...executed, workflowId: durableWorkflowId };
218
+ return { ...withConsistency(executed, input.actionId), workflowId: durableWorkflowId };
210
219
  }
211
220
  async function executeInvocation(actionInvocationId, tenantId, spaceId, executionOptions = {}, executionReasonOverride) {
212
221
  const loadedInvocation = await options.store.getActionInvocation(
@@ -220,7 +229,7 @@ function createGovernedActionHost(options) {
220
229
  const resumingRunningInvocation = loadedInvocation.status === "running";
221
230
  let invocation = loadedInvocation;
222
231
  if (isTerminal(invocation.status) || invocation.status === "waiting_for_approval") {
223
- return actionResult(invocation);
232
+ return withConsistency(actionResult(invocation), invocation.actionId);
224
233
  }
225
234
  if (invocation.status === "running" && invocation.leaseOwner && executionOptions.leaseOwner !== invocation.leaseOwner) {
226
235
  return {
@@ -337,7 +346,7 @@ function createGovernedActionHost(options) {
337
346
  message: `Action ${action.actionId} requires durable capture-time authorization evidence`
338
347
  };
339
348
  await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { status: "reconciliation_required", error: reconciliation.message, authorizationReconciliation: reconciliation });
340
- return { actionInvocationId, status: "reconciliation_required", error: reconciliation.message, reconciliation };
349
+ return withConsistency({ actionInvocationId, status: "reconciliation_required", error: reconciliation.message, reconciliation }, action.actionId);
341
350
  }
342
351
  const bindingExpired = authorityMoment !== "capture" && invocation.authorizationBinding?.expiresAt !== void 0 && Date.parse(invocation.authorizationBinding.expiresAt) <= now().getTime();
343
352
  if (bindingExpired) {
@@ -350,7 +359,7 @@ function createGovernedActionHost(options) {
350
359
  message: `Authorization binding for action ${action.actionId} expired before execution`
351
360
  };
352
361
  await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { status: "reconciliation_required", error: reconciliation.message, authorizationReconciliation: reconciliation });
353
- return { actionInvocationId, status: "reconciliation_required", error: reconciliation.message, reconciliation };
362
+ return withConsistency({ actionInvocationId, status: "reconciliation_required", error: reconciliation.message, reconciliation }, action.actionId);
354
363
  }
355
364
  const executionAuthorized = authorityMoment === "capture" ? invocation.authorizationBinding !== void 0 : options.authorization.authorizeExecution ? await options.authorization.authorizeExecution({
356
365
  ...authorizationInput,
@@ -372,7 +381,7 @@ function createGovernedActionHost(options) {
372
381
  message: `Actor ${invocation.actorId} is not authorized to execute action ${action.actionId}`
373
382
  };
374
383
  await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { status: "reconciliation_required", error: reconciliation.message, authorizationReconciliation: reconciliation });
375
- return { actionInvocationId, status: "reconciliation_required", error: reconciliation.message, reconciliation };
384
+ return withConsistency({ actionInvocationId, status: "reconciliation_required", error: reconciliation.message, reconciliation }, action.actionId);
376
385
  }
377
386
  const definitions = options.resolvePolicies ? await options.resolvePolicies({
378
387
  ...authorizationInput,
@@ -388,6 +397,7 @@ function createGovernedActionHost(options) {
388
397
  parameters: parsed.data,
389
398
  db: options.store.db,
390
399
  services: options.services,
400
+ resolveCodePolicy: options.registry?.resolvePolicy,
391
401
  mode: "execute",
392
402
  now: now()
393
403
  });
@@ -440,10 +450,11 @@ function createGovernedActionHost(options) {
440
450
  spaceId,
441
451
  binding.entityType,
442
452
  entityId
443
- ) ?? initialState(binding.entityType) : initialState(binding.entityType);
453
+ ) ?? initialState(stateMachineResolver(binding.entityType)) : initialState(stateMachineResolver(binding.entityType));
444
454
  const targetState = typeof binding.targetState === "function" ? binding.targetState(parsed.data) : binding.targetState;
445
455
  if (targetState !== "") {
446
- const transition = platform.validateTransition(
456
+ const transition = platform.validateStateMachineTransition(
457
+ stateMachineResolver(binding.entityType),
447
458
  binding.entityType,
448
459
  currentState,
449
460
  targetState,
@@ -712,7 +723,7 @@ function createGovernedActionHost(options) {
712
723
  spaceId
713
724
  );
714
725
  if (!invocation) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
715
- if (isTerminal(invocation.status)) return actionResult(invocation);
726
+ if (isTerminal(invocation.status)) return withConsistency(actionResult(invocation), invocation.actionId);
716
727
  if (invocation.status !== "waiting_for_approval") {
717
728
  return {
718
729
  ...actionResult(invocation),
@@ -762,9 +773,9 @@ function createGovernedActionHost(options) {
762
773
  const transitioned = transition.invocation ?? await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
763
774
  if (!transition.applied || !transitioned) {
764
775
  if (!transitioned) throw new Error(`ActionInvocation not found: ${actionInvocationId}`);
765
- return actionResult(transitioned);
776
+ return withConsistency(actionResult(transitioned), transitioned.actionId);
766
777
  }
767
- if (!decision.approved) return actionResult(transitioned);
778
+ if (!decision.approved) return withConsistency(actionResult(transitioned), transitioned.actionId);
768
779
  return executeInvocation(
769
780
  actionInvocationId,
770
781
  tenantId,
@@ -805,6 +816,13 @@ function createGovernedActionHost(options) {
805
816
  spaceId
806
817
  });
807
818
  }
819
+ function declaredConsistency(actionId) {
820
+ return actionResolver(actionId)?.execution?.consistency;
821
+ }
822
+ function withConsistency(result, actionId) {
823
+ const consistency = declaredConsistency(actionId);
824
+ return consistency ? { ...result, consistency } : result;
825
+ }
808
826
  async function appendEvent(invocation, event, deduplicationKey, defaultEventSchemaVersion = 1, transaction) {
809
827
  const timestamp = now();
810
828
  const envelope = {
@@ -827,7 +845,8 @@ function createGovernedActionHost(options) {
827
845
  recordedAt: timestamp,
828
846
  correlationId: invocation.correlationId,
829
847
  ...invocation.causationId ? { causationId: invocation.causationId } : {},
830
- ...invocation.provenance ? { provenance: { source: invocation.provenance.source, ...invocation.provenance.auditAttributes ? { auditAttributes: invocation.provenance.auditAttributes } : {} } } : {}
848
+ ...invocation.provenance ? { provenance: { source: invocation.provenance.source, ...invocation.provenance.auditAttributes ? { auditAttributes: invocation.provenance.auditAttributes } : {} } } : {},
849
+ ...declaredConsistency(invocation.actionId) === "provisional-until-reconciled" ? { consistency: "provisional-until-reconciled" } : {}
831
850
  };
832
851
  if (options.outbox) {
833
852
  const hostLifecycleEvent = (/* @__PURE__ */ new Set(["AdapterInvocationStarted", "AdapterInvocationSucceeded", "AdapterInvocationFailed", "ComplianceBlocked"])).has(envelope.eventType);
@@ -929,8 +948,7 @@ function declaredPolicies(policyIds) {
929
948
  codeEvaluatorPolicyId: policyId
930
949
  }));
931
950
  }
932
- function initialState(entityType) {
933
- const machine = platform.resolveStateMachine(entityType);
951
+ function initialState(machine) {
934
952
  return Object.values(machine?.states ?? {}).find((state) => state.stateClass === "initial")?.id ?? "none";
935
953
  }
936
954
  function isTerminal(status) {
@@ -996,6 +1014,8 @@ function toEnterpriseEventEnvelope(event, metadata) {
996
1014
  payloadClassification: metadata.payloadClassification,
997
1015
  ...metadata.traceContext ? { traceContext: { ...metadata.traceContext } } : {},
998
1016
  ...metadata.includeProvenance && event.provenance ? { provenance: event.provenance } : {},
1017
+ // A provisional fact must stay marked as provisional once it leaves the platform.
1018
+ ...event.consistency ? { consistency: event.consistency } : {},
999
1019
  payload: event.payload
1000
1020
  };
1001
1021
  }
@@ -1427,10 +1447,11 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
1427
1447
  actor_id text NOT NULL, actor_type text NOT NULL, action_invocation_id text,
1428
1448
  payload jsonb NOT NULL, sequence bigint NOT NULL,
1429
1449
  occurred_at timestamptz NOT NULL, recorded_at timestamptz NOT NULL,
1430
- correlation_id text NOT NULL, causation_id text, provenance jsonb,
1450
+ correlation_id text NOT NULL, causation_id text, provenance jsonb, consistency text,
1431
1451
  UNIQUE (tenant_id, space_id, sequence)
1432
1452
  );
1433
1453
  ALTER TABLE fabric_platform.asset_events ADD COLUMN IF NOT EXISTS provenance jsonb;
1454
+ ALTER TABLE fabric_platform.asset_events ADD COLUMN IF NOT EXISTS consistency text;
1434
1455
  CREATE INDEX IF NOT EXISTS asset_events_subject_idx
1435
1456
  ON fabric_platform.asset_events
1436
1457
  (tenant_id, space_id, subject_type, subject_id, sequence);
@@ -1714,8 +1735,8 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
1714
1735
  `INSERT INTO fabric_platform.asset_events
1715
1736
  (id,tenant_id,space_id,event_type,event_schema_version,subject_type,subject_id,
1716
1737
  actor_id,actor_type,action_invocation_id,payload,sequence,occurred_at,recorded_at,
1717
- correlation_id,causation_id,provenance)
1718
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,$16,$17::jsonb)
1738
+ correlation_id,causation_id,provenance,consistency)
1739
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,$16,$17::jsonb,$18)
1719
1740
  ON CONFLICT (id) DO NOTHING`,
1720
1741
  [
1721
1742
  event.id,
@@ -1734,7 +1755,8 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
1734
1755
  event.recordedAt,
1735
1756
  event.correlationId,
1736
1757
  event.causationId ?? null,
1737
- event.provenance ? JSON.stringify(event.provenance) : null
1758
+ event.provenance ? JSON.stringify(event.provenance) : null,
1759
+ event.consistency ?? null
1738
1760
  ]
1739
1761
  );
1740
1762
  }
@@ -1745,8 +1767,8 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
1745
1767
  INSERT INTO fabric_platform.asset_events
1746
1768
  (id,tenant_id,space_id,event_type,event_schema_version,subject_type,subject_id,
1747
1769
  actor_id,actor_type,action_invocation_id,payload,sequence,occurred_at,recorded_at,
1748
- correlation_id,causation_id,provenance)
1749
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,$16,$17::jsonb)
1770
+ correlation_id,causation_id,provenance,consistency)
1771
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,$16,$17::jsonb,$19)
1750
1772
  ON CONFLICT (id) DO NOTHING RETURNING id
1751
1773
  )
1752
1774
  INSERT INTO fabric_platform.event_outbox
@@ -1771,7 +1793,8 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
1771
1793
  event.correlationId,
1772
1794
  event.causationId ?? null,
1773
1795
  event.provenance ? JSON.stringify(event.provenance) : null,
1774
- JSON.stringify(envelope)
1796
+ JSON.stringify(envelope),
1797
+ event.consistency ?? null
1775
1798
  ]
1776
1799
  );
1777
1800
  }
@@ -2013,7 +2036,8 @@ function toEventRecord(row) {
2013
2036
  recordedAt: new Date(row.recorded_at),
2014
2037
  correlationId: String(row.correlation_id),
2015
2038
  ...row.causation_id ? { causationId: String(row.causation_id) } : {},
2016
- ...row.provenance ? { provenance: row.provenance } : {}
2039
+ ...row.provenance ? { provenance: row.provenance } : {},
2040
+ ...row.consistency ? { consistency: row.consistency } : {}
2017
2041
  };
2018
2042
  }
2019
2043
  function toOutboxRecord(row) {