@fabricorg/platform-host 8.2.0 → 8.3.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/dist/index.cjs CHANGED
@@ -36,6 +36,18 @@ var ActionAdmissionError = class extends Error {
36
36
  this.name = "ActionAdmissionError";
37
37
  }
38
38
  };
39
+ /** A definitive authorization-boundary refusal before an invocation is created. */
40
+ var SubmissionRefusalError = class extends Error {
41
+ actionId;
42
+ refusal;
43
+ code = "ACTION_SUBMISSION_REFUSED";
44
+ constructor(actionId, refusal, message) {
45
+ super(message);
46
+ this.actionId = actionId;
47
+ this.refusal = refusal;
48
+ this.name = "SubmissionRefusalError";
49
+ }
50
+ };
39
51
  /**
40
52
  * Why a completion was not applied, typed so a webhook handler can answer
41
53
  * the external system correctly: `retryable` means the invocation is still
@@ -129,7 +141,11 @@ function omitUndefinedOptionalFields(value, keys) {
129
141
  return copy;
130
142
  }
131
143
  function validateFinalization(value, action, privateFields, contracts) {
132
- const normalized = omitUndefinedOptionalFields(value, ["events", "resultPatch"]);
144
+ const normalized = omitUndefinedOptionalFields(value, [
145
+ "events",
146
+ "resultPatch",
147
+ "stagedInvocations"
148
+ ]);
133
149
  if (normalized !== value && normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
134
150
  const record = normalized;
135
151
  if (Array.isArray(record.events)) {
@@ -139,7 +155,7 @@ function validateFinalization(value, action, privateFields, contracts) {
139
155
  }
140
156
  }
141
157
  const result = finalizationJson(normalized);
142
- if (!result || typeof result !== "object" || Array.isArray(result) || Object.keys(result).some((key) => key !== "events" && key !== "resultPatch")) throw new Error("Finalization must return only events and resultPatch.");
158
+ if (!result || typeof result !== "object" || Array.isArray(result) || Object.keys(result).some((key) => key !== "events" && key !== "resultPatch" && key !== "stagedInvocations")) throw new Error("Finalization must return only events, resultPatch and stagedInvocations.");
143
159
  if (result.resultPatch !== void 0) {
144
160
  if (!result.resultPatch || typeof result.resultPatch !== "object" || Array.isArray(result.resultPatch)) throw new Error("Finalization resultPatch must be an object.");
145
161
  if (Object.keys(result.resultPatch).some((key) => privateFields.includes(key))) throw new Error("Finalization resultPatch contains a private result key.");
@@ -1149,6 +1165,120 @@ function createOutboxRequeue(deps) {
1149
1165
  };
1150
1166
  }
1151
1167
  //#endregion
1168
+ //#region src/staged-invocations.ts
1169
+ const MAX_STAGED_INVOCATIONS = 32;
1170
+ const MAX_STAGED_INVOCATION_KEY_LENGTH = 128;
1171
+ const MAX_STAGED_INVOCATION_BYTES = 65536;
1172
+ const MAX_STAGED_INVOCATION_BATCH_BYTES = 262144;
1173
+ const STAGED_INVOCATION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
1174
+ /** Validate and normalize one immutable batch before its parent transaction commits. */
1175
+ function prepareStagedInvocationBatch(input) {
1176
+ if (!Array.isArray(input.requests)) throw new Error("stagedInvocations must be an array.");
1177
+ if (input.requests.length > 32) throw new Error(`stagedInvocations exceeds the 32 request limit.`);
1178
+ const normalized = input.requests.map((candidate, index) => {
1179
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate) || Object.keys(candidate).some((key) => ![
1180
+ "key",
1181
+ "actionId",
1182
+ "parameters"
1183
+ ].includes(key))) throw new Error(`stagedInvocations[${index}] must contain only key, actionId and parameters.`);
1184
+ const request = {
1185
+ key: candidate.key,
1186
+ actionId: candidate.actionId,
1187
+ parameters: candidate.parameters
1188
+ };
1189
+ if (typeof request.key !== "string" || request.key.length === 0 || request.key.length > 128 || !STAGED_INVOCATION_KEY_PATTERN.test(request.key)) throw new Error(`stagedInvocations[${index}] key must match ${STAGED_INVOCATION_KEY_PATTERN} and be at most 128 characters.`);
1190
+ if (!(input.parentAction.stagesInvocations ?? []).includes(request.actionId)) throw new Error(`Action "${input.parentAction.actionId}" may not stage "${String(request.actionId)}".`);
1191
+ const child = input.resolveAction(request.actionId);
1192
+ if (!child) throw new Error(`Staged action "${String(request.actionId)}" is not registered.`);
1193
+ const parameters = finalizationJson(request.parameters);
1194
+ if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) throw new Error(`stagedInvocations[${index}] parameters must be a JSON object.`);
1195
+ if (!child.schema.safeParse(parameters).success) throw new Error(`stagedInvocations[${index}] parameters do not match action "${child.actionId}".`);
1196
+ let redactedParameters = parameters;
1197
+ if (input.redactParameters && !input.redactedKeys?.includes(request.key)) try {
1198
+ redactedParameters = input.redactParameters(child.actionId, structuredClone(parameters));
1199
+ } catch {
1200
+ throw new Error("Staged invocation parameter redaction failed.");
1201
+ }
1202
+ const durableParameters = finalizationJson(redactedParameters);
1203
+ if (!durableParameters || typeof durableParameters !== "object" || Array.isArray(durableParameters) || !child.schema.safeParse(durableParameters).success) throw new Error(`stagedInvocations[${index}] redacted parameters do not match action "${child.actionId}".`);
1204
+ if (byteLength((0, _fabricorg_platform.canonicalJson)({
1205
+ key: request.key,
1206
+ actionId: child.actionId,
1207
+ parameters: durableParameters
1208
+ })) > 65536) throw new Error(`stagedInvocations[${index}] exceeds ${MAX_STAGED_INVOCATION_BYTES} bytes.`);
1209
+ return {
1210
+ key: request.key,
1211
+ actionId: child.actionId,
1212
+ expectedActionVersion: child.version,
1213
+ parameters: durableParameters,
1214
+ parameterDigest: digestParameters(durableParameters)
1215
+ };
1216
+ });
1217
+ for (const [index, request] of normalized.entries()) if (normalized.findIndex((candidate) => candidate.key === request.key) !== index) throw new Error(`stagedInvocations contains duplicate key "${request.key}".`);
1218
+ normalized.sort((left, right) => compareText(left.key, right.key) || compareText(left.actionId, right.actionId));
1219
+ const batchIdentity = normalized.map(({ key, actionId, expectedActionVersion, parameters, parameterDigest }) => ({
1220
+ key,
1221
+ actionId,
1222
+ expectedActionVersion,
1223
+ parameters,
1224
+ parameterDigest
1225
+ }));
1226
+ if (byteLength((0, _fabricorg_platform.canonicalJson)(batchIdentity)) > 262144) throw new Error(`stagedInvocations batch exceeds ${MAX_STAGED_INVOCATION_BATCH_BYTES} bytes.`);
1227
+ const batchDigest = digestParameters({
1228
+ parentInvocationId: input.parent.id,
1229
+ requests: batchIdentity
1230
+ });
1231
+ return normalized.map((request) => ({
1232
+ id: lifecycleId("stg", input.parent.id, request.key),
1233
+ tenantId: input.parent.tenantId,
1234
+ spaceId: input.parent.spaceId,
1235
+ parentInvocationId: input.parent.id,
1236
+ parentActionId: input.parent.actionId,
1237
+ ...request,
1238
+ batchDigest,
1239
+ status: "pending",
1240
+ attemptCount: 0,
1241
+ availableAt: new Date(input.now),
1242
+ leaseToken: 0,
1243
+ createdAt: new Date(input.now),
1244
+ updatedAt: new Date(input.now)
1245
+ }));
1246
+ }
1247
+ function stagedInvocationRequestIdentity(record) {
1248
+ return (0, _fabricorg_platform.canonicalJson)({
1249
+ id: record.id,
1250
+ tenantId: record.tenantId,
1251
+ spaceId: record.spaceId,
1252
+ parentInvocationId: record.parentInvocationId,
1253
+ parentActionId: record.parentActionId,
1254
+ key: record.key,
1255
+ actionId: record.actionId,
1256
+ expectedActionVersion: record.expectedActionVersion,
1257
+ parameters: record.parameters,
1258
+ parameterDigest: record.parameterDigest,
1259
+ batchDigest: record.batchDigest
1260
+ });
1261
+ }
1262
+ function cloneStagedInvocationRecord(record) {
1263
+ return {
1264
+ ...structuredClone(record),
1265
+ availableAt: new Date(record.availableAt),
1266
+ ...record.leaseExpiresAt ? { leaseExpiresAt: new Date(record.leaseExpiresAt) } : {},
1267
+ createdAt: new Date(record.createdAt),
1268
+ updatedAt: new Date(record.updatedAt)
1269
+ };
1270
+ }
1271
+ function asStagedInvocationStore(store) {
1272
+ const candidate = store;
1273
+ return candidate.transactionalStagedInvocations === true && typeof candidate.getActionInvocationByStagedOrigin === "function" && typeof candidate.createActionInvocationForStagedLease === "function" && typeof candidate.claimStagedInvocations === "function" && typeof candidate.markStagedInvocationDispatched === "function" && typeof candidate.markStagedInvocationRejected === "function" && typeof candidate.markStagedInvocationFailed === "function" && typeof candidate.renewStagedInvocationLease === "function" && typeof candidate.rotateStagedInvocationLease === "function" && typeof candidate.cancelStagedInvocationsForFailedParents === "function" && typeof candidate.listStagedInvocations === "function" ? candidate : void 0;
1274
+ }
1275
+ function byteLength(value) {
1276
+ return new TextEncoder().encode(value).byteLength;
1277
+ }
1278
+ function compareText(left, right) {
1279
+ return left < right ? -1 : left > right ? 1 : 0;
1280
+ }
1281
+ //#endregion
1152
1282
  //#region src/host.ts
1153
1283
  const DEFAULT_EXTRACT_EVENTS = (data) => {
1154
1284
  const value = data._events;
@@ -1173,6 +1303,25 @@ const ADMISSION_FAILED = Object.freeze({ kind: "failed" });
1173
1303
  const ADMISSION_TIMED_OUT = Object.freeze({ kind: "timed_out" });
1174
1304
  /** What an admission refusal's code may look like; anything else is `invalid_decision`. */
1175
1305
  const ADMISSION_REFUSAL_CODE = /^[a-z][A-Za-z0-9_.:-]{0,127}$/;
1306
+ var StagedDispatchRejectionError = class extends Error {
1307
+ name = "StagedDispatchRejectionError";
1308
+ };
1309
+ var StagedDispatchLeaseLostError = class extends Error {
1310
+ name = "StagedDispatchLeaseLostError";
1311
+ };
1312
+ var StagedDispatcherHandoffError = class extends Error {
1313
+ name = "StagedDispatcherHandoffError";
1314
+ };
1315
+ var StagedSubmissionTimeoutError = class extends Error {
1316
+ name = "StagedSubmissionTimeoutError";
1317
+ };
1318
+ /** Fixed stage error texts: a stage records no caught error text and no parameters. */
1319
+ const STAGED_ERROR = Object.freeze({
1320
+ dispatch: "Staged invocation dispatch failed.",
1321
+ refused: "Staged invocation submission was definitively refused.",
1322
+ handoff: "Staged child dispatcher handoff was not confirmed.",
1323
+ lookup: "Staged child lookup failed."
1324
+ });
1176
1325
  function createGovernedActionHost(options) {
1177
1326
  const retryableExecutionMaxAttempts = options.retryableExecutionMaxAttempts ?? 5;
1178
1327
  if (!Number.isInteger(retryableExecutionMaxAttempts) || retryableExecutionMaxAttempts <= 0) throw new Error("retryableExecutionMaxAttempts must be a positive integer.");
@@ -1202,6 +1351,9 @@ function createGovernedActionHost(options) {
1202
1351
  const configuredActionResolver = options.resolveAction ?? options.registry?.resolveAction;
1203
1352
  if (!configuredActionResolver) throw new Error("Platform Host requires an explicit module registry or resolveAction function.");
1204
1353
  const actionResolver = configuredActionResolver;
1354
+ const stagedStore = asStagedInvocationStore(options.store);
1355
+ const declaresStaging = options.registry?.orderedModules?.some((module) => (module.actions ?? []).some((action) => (action.stagesInvocations?.length ?? 0) > 0)) ?? false;
1356
+ if (declaresStaging && (!stagedStore || !asAtomicMutationStore(options.store))) throw new Error("Staged descendant invocations require a store with transactionalStagedInvocations and transactionWithEvents capabilities.");
1205
1357
  const stateMachineResolver = options.registry?.resolveStateMachine ?? (() => void 0);
1206
1358
  const eventResultFields = options.eventResultFields ?? ["_events"];
1207
1359
  const admissionTimeoutMs = options.admissionTimeoutMs ?? DEFAULT_ADMISSION_TIMEOUT_MS;
@@ -1210,8 +1362,12 @@ function createGovernedActionHost(options) {
1210
1362
  return error instanceof HostError ? errorMessage(error) : redactErrorText(options.redactErrorText, context, errorMessage(error));
1211
1363
  }
1212
1364
  async function submitAction(input) {
1365
+ return submitActionInternal(input);
1366
+ }
1367
+ async function submitActionInternal(input, internal = {}) {
1213
1368
  const read = snapshotSubmission(input);
1214
- if (read.executionReason && !["initial", "offline_replay"].includes(read.executionReason)) throw new Error(`Unsupported submitted execution reason: ${read.executionReason}`);
1369
+ const submittedExecutionReason = read.executionReason;
1370
+ if (submittedExecutionReason && !["initial", "offline_replay"].includes(submittedExecutionReason) && !(internal.staged && submittedExecutionReason === "staged_dispatch")) throw new Error(`Unsupported submitted execution reason: ${submittedExecutionReason}`);
1215
1371
  const action = actionResolver(read.actionId);
1216
1372
  if (!action) throw new Error(`Unknown action: ${read.actionId}`);
1217
1373
  const declaresAdmission = action.admitSubmission !== void 0;
@@ -1219,15 +1375,15 @@ function createGovernedActionHost(options) {
1219
1375
  if (action.execution?.connectivity === "online-required" && submission.executionReason === "offline_replay") throw new Error(`Action ${submission.actionId} is online-required and cannot be submitted as an offline replay.`);
1220
1376
  if (action.execution?.sensitivity === "restricted" && Object.keys(submission.provenance?.auditAttributes ?? {}).length > 0) throw new Error(`Action ${submission.actionId} declares restricted sensitivity; provenance audit attributes must not be durably recorded.`);
1221
1377
  const authorizationInput = toAuthorizationInput(action, submission);
1222
- if (!await options.authorization.checkEntitlement(authorizationInput)) throw new Error(`Module "${action.namespace}" is not enabled for tenant ${submission.tenantId}`);
1223
- if (!await options.authorization.authorize(authorizationInput)) throw new Error(`Actor ${submission.actorId} is not authorized for action ${submission.actionId}`);
1378
+ if (!await options.authorization.checkEntitlement(authorizationInput)) throw new SubmissionRefusalError(action.actionId, "entitlement", `Module "${action.namespace}" is not enabled for tenant ${submission.tenantId}`);
1379
+ if (!await options.authorization.authorize(authorizationInput)) throw new SubmissionRefusalError(action.actionId, "authorization", `Actor ${submission.actorId} is not authorized for action ${submission.actionId}`);
1224
1380
  if (declaresAdmission && (!submission.parametersArePrivate || !isRecord(submission.parameters))) refuseAdmission(action, submission, "invalid_parameters");
1225
1381
  const actionInvocationId = (0, _fabricorg_platform.createFabricId)("act");
1226
1382
  if (submission.correlationId && submission.provenance?.correlationId && submission.correlationId !== submission.provenance.correlationId) throw new Error("Invocation provenance correlationId does not match submission correlationId.");
1227
1383
  if (submission.causationId && submission.provenance?.causationId && submission.causationId !== submission.provenance.causationId) throw new Error("Invocation provenance causationId does not match submission causationId.");
1228
1384
  const correlationId = submission.correlationId ?? submission.provenance?.correlationId ?? (0, _fabricorg_platform.createFabricId)("corr");
1229
1385
  const prepare = () => {
1230
- const durableParameters = options.redactActionParameters ? options.redactActionParameters(submission.actionId, submission.parameters) : submission.parameters;
1386
+ const durableParameters = !internal.parametersAlreadyRedacted && options.redactActionParameters ? options.redactActionParameters(submission.actionId, submission.parameters) : submission.parameters;
1231
1387
  const parameterDigest = digestParameters(submission.parameters);
1232
1388
  if (submission.authorizationBinding) validateAuthorizationBinding(submission, parameterDigest, action.execution?.authorityMoment, now());
1233
1389
  return {
@@ -1248,7 +1404,7 @@ function createGovernedActionHost(options) {
1248
1404
  (0, _fabricorg_platform.assertGovernanceRuntimeEvidence)(runtimeEvidence);
1249
1405
  const admission = declaresAdmission ? await admitSubmission(action, submission) : void 0;
1250
1406
  const { durableParameters, parameterDigest } = early ?? prepare();
1251
- const durableInvocation = await options.store.createActionInvocation({
1407
+ const invocationInput = {
1252
1408
  id: actionInvocationId,
1253
1409
  tenantId: submission.tenantId,
1254
1410
  spaceId: submission.spaceId,
@@ -1274,7 +1430,24 @@ function createGovernedActionHost(options) {
1274
1430
  ...submission.executionReason ? { executionReason: submission.executionReason } : {},
1275
1431
  ...submission.authorizationBindingId ? { authorizationBindingId: submission.authorizationBindingId } : {},
1276
1432
  ...admission ? { admittedAt: admission.admittedAt } : {}
1277
- });
1433
+ };
1434
+ const staged = internal.staged;
1435
+ const durableInvocation = staged ? await (async () => {
1436
+ if (!stagedStore) throw new Error("Staged invocation store capability disappeared.");
1437
+ const created = await stagedStore.createActionInvocationForStagedLease({
1438
+ stageId: staged.id,
1439
+ workerId: staged.workerId,
1440
+ leaseToken: staged.leaseToken,
1441
+ now: now(),
1442
+ invocation: {
1443
+ ...invocationInput,
1444
+ stagedOrigin: staged.origin
1445
+ }
1446
+ });
1447
+ if (created.kind === "lease_lost") throw new StagedDispatchLeaseLostError("Staged invocation lease lost before child creation.");
1448
+ staged.onChild(created.invocation);
1449
+ return created.invocation;
1450
+ })() : await options.store.createActionInvocation(invocationInput);
1278
1451
  emitPlatformHostTelemetry(options.telemetry, {
1279
1452
  kind: "event",
1280
1453
  name: "invocation.submitted",
@@ -1308,6 +1481,7 @@ function createGovernedActionHost(options) {
1308
1481
  }
1309
1482
  }
1310
1483
  const durableWorkflowId = `action-invocation-${durableInvocation.id}`;
1484
+ if (internal.staged && durableInvocation.id !== actionInvocationId) return replayExistingStagedInvocation(durableInvocation);
1311
1485
  if (durableInvocation.id !== actionInvocationId) {
1312
1486
  if (options.dispatcher && durableInvocation.status === "pending") {
1313
1487
  const dispatched = await options.dispatcher.dispatch({
@@ -1356,6 +1530,7 @@ function createGovernedActionHost(options) {
1356
1530
  ...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
1357
1531
  }, submission.actionId);
1358
1532
  } catch (error) {
1533
+ if (internal.staged) throw new StagedDispatcherHandoffError("Staged child dispatcher handoff failed.");
1359
1534
  throw error;
1360
1535
  }
1361
1536
  return {
@@ -1364,6 +1539,48 @@ function createGovernedActionHost(options) {
1364
1539
  };
1365
1540
  }
1366
1541
  /**
1542
+ * Hand an existing staged child to the dispatcher again under its stable
1543
+ * workflow id, as the public replay branch above does, when the child is
1544
+ * still pending and a dispatcher is configured. Returns undefined when no
1545
+ * handoff is needed. Raw dispatcher errors are replaced by a fixed one.
1546
+ */
1547
+ async function handOffExistingStagedChild(existing) {
1548
+ if (!options.dispatcher || existing.status !== "pending") return void 0;
1549
+ try {
1550
+ return await options.dispatcher.dispatch({
1551
+ actionInvocationId: existing.id,
1552
+ actionId: existing.actionId,
1553
+ tenantId: existing.tenantId,
1554
+ spaceId: existing.spaceId,
1555
+ workflowId: `action-invocation-${existing.id}`
1556
+ });
1557
+ } catch {
1558
+ throw new StagedDispatcherHandoffError("Staged child dispatcher handoff failed.");
1559
+ }
1560
+ }
1561
+ async function replayExistingStagedInvocation(existing) {
1562
+ const workflowId = `action-invocation-${existing.id}`;
1563
+ const dispatched = await handOffExistingStagedChild(existing);
1564
+ if (dispatched) return withConsistency({
1565
+ actionInvocationId: existing.id,
1566
+ status: existing.status,
1567
+ workflowId: dispatched.workflowId,
1568
+ ...dispatched.runId ? { runId: dispatched.runId } : {}
1569
+ }, existing.actionId);
1570
+ return withConsistency({
1571
+ actionInvocationId: existing.id,
1572
+ status: existing.status,
1573
+ workflowId,
1574
+ result: existing.result,
1575
+ ...existing.error ? { error: existing.error } : {},
1576
+ ...existing.hitlRoute ? { hitlRoute: existing.hitlRoute } : {},
1577
+ ...existing.hitlRiskTier ? { hitlRiskTier: existing.hitlRiskTier } : {},
1578
+ ...existing.authorizationReconciliation ? { reconciliation: existing.authorizationReconciliation } : {},
1579
+ ...existing.adapterReconciliation ? { adapterReconciliation: existing.adapterReconciliation } : {},
1580
+ ...existing.pendingCompletion ? { pendingCompletion: existing.pendingCompletion } : {}
1581
+ }, existing.actionId);
1582
+ }
1583
+ /**
1367
1584
  * Run the action's `admitSubmission` check on a copy of the submission
1368
1585
  * snapshot's parameters, with the snapshot's scope and actor, bounded by
1369
1586
  * `admissionTimeoutMs`. Returns when it was admitted; anything but
@@ -1870,6 +2087,13 @@ function createGovernedActionHost(options) {
1870
2087
  try {
1871
2088
  (0, _fabricorg_platform.assertAdapterStepDeadlines)(action);
1872
2089
  (0, _fabricorg_platform.assertActionFinalization)(action);
2090
+ if ((action.stagesInvocations?.length ?? 0) > 0) {
2091
+ const store = asAtomicMutationStore(options.store);
2092
+ if (!stagedStore || !store) throw new Error("Staged descendant invocations require an atomic staging-capable store.");
2093
+ await store.transactionWithEvents(async (transaction) => {
2094
+ if (typeof transaction.stageInvocations !== "function") throw new Error("Staged descendant invocations require transaction-scoped stageInvocations support.");
2095
+ });
2096
+ }
1873
2097
  if (action.finalize) {
1874
2098
  const store = asAtomicMutationStore(options.store);
1875
2099
  if (!store) throw new Error("Action finalization requires atomic mutation transactions.");
@@ -2127,6 +2351,7 @@ function createGovernedActionHost(options) {
2127
2351
  let finalizationInput;
2128
2352
  let domainEvents = [];
2129
2353
  let pendingTransitionEvent;
2354
+ let handlerStagedInvocations = replayCheckpoint?.stagedInvocations ?? [];
2130
2355
  try {
2131
2356
  if (replayCheckpoint) {
2132
2357
  data = structuredClone(replayCheckpoint.result);
@@ -2221,6 +2446,24 @@ function createGovernedActionHost(options) {
2221
2446
  };
2222
2447
  if (!handlerResult.success) throw handlerResult.error != null ? new Error(handlerResult.error) : new HostError("Action handler failed");
2223
2448
  const handlerData = handlerResult.data ?? {};
2449
+ const rawStagedInvocations = handlerResult.stagedInvocations ?? [];
2450
+ const stagedRecords = prepareStagedInvocationBatch({
2451
+ parent: invocation,
2452
+ parentAction: action,
2453
+ requests: rawStagedInvocations,
2454
+ resolveAction: actionResolver,
2455
+ redactParameters: options.redactActionParameters,
2456
+ now: now()
2457
+ });
2458
+ handlerStagedInvocations = stagedRecords.map((record) => ({
2459
+ key: record.key,
2460
+ actionId: record.actionId,
2461
+ parameters: structuredClone(record.parameters)
2462
+ }));
2463
+ if (stagedRecords.length > 0 && action.eventPhase !== "after_adapters") {
2464
+ if (!transaction?.stageInvocations) throw new Error("Staged descendant invocations require an atomic staging transaction.");
2465
+ await transaction.stageInvocations(stagedRecords);
2466
+ }
2224
2467
  if (action.resultSchema && !action.finalize) {
2225
2468
  const publicResult = withoutPrivateHostFields(handlerData, eventResultFields);
2226
2469
  const parsedResult = action.resultSchema.safeParse(publicResult);
@@ -2271,7 +2514,8 @@ function createGovernedActionHost(options) {
2271
2514
  handlerEvents: events,
2272
2515
  result: preparedFinalizationInput.handlerData,
2273
2516
  ...transitionEvent ? { stateTransition: transitionEvent } : {},
2274
- skipped
2517
+ skipped,
2518
+ ...handlerStagedInvocations.length > 0 ? { stagedInvocations: handlerStagedInvocations } : {}
2275
2519
  });
2276
2520
  if (new TextEncoder().encode((0, _fabricorg_platform.canonicalJson)(preparation)).byteLength > 262144) throw new HostError("Finalization checkpoint exceeds 256 KiB.");
2277
2521
  if (executionLease) {
@@ -3070,6 +3314,19 @@ function createGovernedActionHost(options) {
3070
3314
  if (action.resultSchema && !action.resultSchema.safeParse(result).success) throw new Error("Finalization merged result does not match resultSchema.");
3071
3315
  finalPatch.result = result;
3072
3316
  }
3317
+ const stagedRequests = [...handlerStagedInvocations, ...finalized?.stagedInvocations ?? []];
3318
+ if (stagedRequests.length > 0) {
3319
+ if (!transaction.stageInvocations) throw new Error("Staged descendant invocations require an atomic staging transaction.");
3320
+ await transaction.stageInvocations(prepareStagedInvocationBatch({
3321
+ parent: invocation,
3322
+ parentAction: action,
3323
+ requests: stagedRequests,
3324
+ resolveAction: actionResolver,
3325
+ redactParameters: options.redactActionParameters,
3326
+ redactedKeys: handlerStagedInvocations.map((request) => request.key),
3327
+ now: now()
3328
+ }));
3329
+ }
3073
3330
  for (const [index, event] of domainEvents.entries()) await appendEvent(invocation, event, `domain:${index}`, action.version, transaction);
3074
3331
  for (const [index, event] of (finalized?.events ?? []).entries()) await appendEvent(invocation, event, `finalize:${index}`, action.version, transaction);
3075
3332
  if (pendingTransitionEvent) await appendEvent(invocation, pendingTransitionEvent, "state:transition", 1, transaction);
@@ -3104,6 +3361,21 @@ function createGovernedActionHost(options) {
3104
3361
  await hostOperation(() => persistExecutionInvocation(invocation, finalPatch));
3105
3362
  }
3106
3363
  } catch (error) {
3364
+ if ((0, _fabricorg_platform.isReconciliationRequiredError)(error) && error.message.startsWith("Staged invocation batch drift")) {
3365
+ const message = caughtError({
3366
+ actionId: action.actionId,
3367
+ source: "execution"
3368
+ }, error);
3369
+ await hostOperation(() => persistExecutionInvocation(invocation, {
3370
+ status: "reconciliation_required",
3371
+ error: message
3372
+ }));
3373
+ return withConsistency({
3374
+ actionInvocationId,
3375
+ status: "reconciliation_required",
3376
+ error: message
3377
+ }, action.actionId);
3378
+ }
3107
3379
  if (action.finalize) {
3108
3380
  if (error instanceof RecoverableFinalizationError) throw error;
3109
3381
  if (!finalizationCallbackFailure || finalizationCallbackFailure.error !== error) throw new RecoverableFinalizationError(caughtError({
@@ -3400,8 +3672,282 @@ function createGovernedActionHost(options) {
3400
3672
  actionResult,
3401
3673
  withConsistency
3402
3674
  });
3675
+ async function dispatchStagedInvocations(input) {
3676
+ if (!stagedStore) throw new Error("This Platform Host store does not support staged descendant invocations.");
3677
+ const leaseDurationMs = input.leaseDurationMs ?? 3e4;
3678
+ const submissionTimeoutMs = input.submissionTimeoutMs ?? 1e4;
3679
+ const maxAttempts = input.maxAttempts ?? 10;
3680
+ for (const [name, value] of [
3681
+ ["leaseDurationMs", leaseDurationMs],
3682
+ ["submissionTimeoutMs", submissionTimeoutMs],
3683
+ ["maxAttempts", maxAttempts]
3684
+ ]) if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer.`);
3685
+ const cancelled = await stagedStore.cancelStagedInvocationsForFailedParents({
3686
+ ...input.tenantId ? { tenantId: input.tenantId } : {},
3687
+ ...input.spaceId ? { spaceId: input.spaceId } : {},
3688
+ now: now()
3689
+ });
3690
+ const records = await stagedStore.claimStagedInvocations({
3691
+ workerId: input.workerId,
3692
+ leaseDurationMs,
3693
+ limit: input.limit ?? 10,
3694
+ ...input.tenantId ? { tenantId: input.tenantId } : {},
3695
+ ...input.spaceId ? { spaceId: input.spaceId } : {},
3696
+ now: now()
3697
+ });
3698
+ const result = {
3699
+ claimed: records.length,
3700
+ dispatched: 0,
3701
+ rejected: 0,
3702
+ failed: 0,
3703
+ deadLettered: 0,
3704
+ cancelled
3705
+ };
3706
+ const sweep = {
3707
+ store: stagedStore,
3708
+ input,
3709
+ leaseDurationMs,
3710
+ submissionTimeoutMs,
3711
+ maxAttempts
3712
+ };
3713
+ for (const record of records) {
3714
+ let outcome;
3715
+ try {
3716
+ outcome = await dispatchStagedRow(record, sweep);
3717
+ } catch (error) {
3718
+ reportStagedError(input, error);
3719
+ outcome = "unsettled";
3720
+ }
3721
+ if (outcome === "dispatched") result.dispatched += 1;
3722
+ else if (outcome === "rejected") result.rejected += 1;
3723
+ else {
3724
+ result.failed += 1;
3725
+ if (outcome === "dead_lettered") result.deadLettered += 1;
3726
+ }
3727
+ }
3728
+ return result;
3729
+ }
3730
+ /**
3731
+ * Settle one claimed row through a single sequence:
3732
+ *
3733
+ * 1. Renew the claim's lease, then look the child up by staged origin. An
3734
+ * earlier attempt's creation still in flight holds an older lease token
3735
+ * and fails the creation fence, so this lookup is authoritative for
3736
+ * earlier attempts. A child found here goes to step 4: no
3737
+ * pre-submission checks, no governance.
3738
+ * 2. No child: pre-submission checks and the shared submission pipeline.
3739
+ * 3. Unless this attempt's own create returned the child, rotate the lease
3740
+ * token. Only this attempt's own timed-out submission could still create
3741
+ * the child, it holds the old token, and the store orders the rotation
3742
+ * after any create the fence already admitted. The lookup that follows is
3743
+ * authoritative; a child found there goes to step 4, otherwise the
3744
+ * outcome is classified as rejected, retry or dead letter.
3745
+ * 4. A known child that is pending is handed to the configured dispatcher
3746
+ * again under its stable workflow id; the stage settles with its id.
3747
+ *
3748
+ * Each settlement is one compare-and-set on the current owner and token. A
3749
+ * lookup that fails settles the row for retry, never terminally.
3750
+ */
3751
+ async function dispatchStagedRow(record, sweep) {
3752
+ const { store, input, leaseDurationMs, maxAttempts } = sweep;
3753
+ const claimLease = {
3754
+ id: record.id,
3755
+ workerId: input.workerId,
3756
+ leaseToken: record.leaseToken
3757
+ };
3758
+ if (!await store.renewStagedInvocationLease({
3759
+ ...claimLease,
3760
+ leaseDurationMs,
3761
+ now: now()
3762
+ })) return "unsettled";
3763
+ const prior = await lookUpStagedChild(record, sweep);
3764
+ if (prior.kind === "unreadable") return settleStagedRetry(record, claimLease, sweep, {
3765
+ error: STAGED_ERROR.lookup,
3766
+ terminal: false
3767
+ });
3768
+ if (prior.kind === "found") return settleExistingStagedChild(record, claimLease, prior.child, sweep);
3769
+ const known = {};
3770
+ let failure;
3771
+ let submitted = false;
3772
+ const stopHeartbeat = startStagedHeartbeat(store, claimLease, input, leaseDurationMs);
3773
+ try {
3774
+ await withSubmissionTimeout(submitStagedChild(record, claimLease, (child) => {
3775
+ known.child = child;
3776
+ }), sweep.submissionTimeoutMs);
3777
+ submitted = true;
3778
+ } catch (error) {
3779
+ failure = error;
3780
+ } finally {
3781
+ await stopHeartbeat();
3782
+ }
3783
+ const createdChild = known.child;
3784
+ if (createdChild) {
3785
+ if (submitted || !options.dispatcher) return settleStagedDispatched(claimLease, createdChild.id, sweep);
3786
+ return settleStagedRetry(record, claimLease, sweep, {
3787
+ error: STAGED_ERROR.handoff,
3788
+ childInvocationId: createdChild.id,
3789
+ terminal: record.attemptCount >= maxAttempts
3790
+ });
3791
+ }
3792
+ const rotatedToken = await store.rotateStagedInvocationLease({
3793
+ ...claimLease,
3794
+ leaseDurationMs,
3795
+ now: now()
3796
+ });
3797
+ if (rotatedToken === void 0) return "unsettled";
3798
+ const lease = {
3799
+ ...claimLease,
3800
+ leaseToken: rotatedToken
3801
+ };
3802
+ const after = await lookUpStagedChild(record, sweep);
3803
+ if (after.kind === "unreadable") return settleStagedRetry(record, lease, sweep, {
3804
+ error: STAGED_ERROR.lookup,
3805
+ terminal: false
3806
+ });
3807
+ if (after.kind === "found") return settleExistingStagedChild(record, lease, after.child, sweep);
3808
+ if (isDefinitiveStagedRefusal(failure)) return await store.markStagedInvocationRejected({
3809
+ ...lease,
3810
+ error: STAGED_ERROR.refused,
3811
+ now: now()
3812
+ }) ? "rejected" : "unsettled";
3813
+ return settleStagedRetry(record, lease, sweep, {
3814
+ error: STAGED_ERROR.dispatch,
3815
+ terminal: record.attemptCount >= maxAttempts
3816
+ });
3817
+ }
3818
+ /** Pre-submission checks, then the shared submission pipeline under the claim's lease. */
3819
+ async function submitStagedChild(record, lease, onChild) {
3820
+ const parent = await options.store.getActionInvocation(record.parentInvocationId, record.tenantId, record.spaceId);
3821
+ if (!parent || parent.status !== "completed") throw new Error("Staged parent is not completed.");
3822
+ const child = actionResolver(record.actionId);
3823
+ if (!child) throw new StagedDispatchRejectionError("Staged action is no longer registered.");
3824
+ if (child.execution?.authorityMoment === "capture") throw new StagedDispatchRejectionError("Staged action now requires capture-time authority.");
3825
+ if (!child.schema.safeParse(structuredClone(record.parameters)).success) throw new StagedDispatchRejectionError("Staged parameters no longer match the registered action.");
3826
+ return submitActionInternal({
3827
+ actionId: record.actionId,
3828
+ parameters: structuredClone(record.parameters),
3829
+ tenantId: parent.tenantId,
3830
+ spaceId: parent.spaceId,
3831
+ actorId: parent.actorId,
3832
+ actorType: parent.actorType,
3833
+ correlationId: parent.correlationId,
3834
+ causationId: parent.id,
3835
+ provenance: {
3836
+ source: "worker",
3837
+ correlationId: parent.correlationId,
3838
+ causationId: parent.id
3839
+ },
3840
+ executionReason: "staged_dispatch"
3841
+ }, {
3842
+ staged: {
3843
+ id: record.id,
3844
+ origin: {
3845
+ parentInvocationId: parent.id,
3846
+ stageKey: record.key
3847
+ },
3848
+ workerId: lease.workerId,
3849
+ leaseToken: lease.leaseToken,
3850
+ onChild
3851
+ },
3852
+ parametersAlreadyRedacted: true
3853
+ });
3854
+ }
3855
+ async function lookUpStagedChild(record, sweep) {
3856
+ try {
3857
+ const child = await sweep.store.getActionInvocationByStagedOrigin(record.tenantId, record.spaceId, {
3858
+ parentInvocationId: record.parentInvocationId,
3859
+ stageKey: record.key
3860
+ });
3861
+ return child ? {
3862
+ kind: "found",
3863
+ child
3864
+ } : { kind: "absent" };
3865
+ } catch (error) {
3866
+ reportStagedError(sweep.input, error);
3867
+ return { kind: "unreadable" };
3868
+ }
3869
+ }
3870
+ async function settleExistingStagedChild(record, lease, child, sweep) {
3871
+ if (options.dispatcher && child.status === "pending") {
3872
+ const stopHeartbeat = startStagedHeartbeat(sweep.store, lease, sweep.input, sweep.leaseDurationMs);
3873
+ let handedOff = false;
3874
+ try {
3875
+ await withSubmissionTimeout(handOffExistingStagedChild(child), sweep.submissionTimeoutMs);
3876
+ handedOff = true;
3877
+ } catch {} finally {
3878
+ await stopHeartbeat();
3879
+ }
3880
+ if (!handedOff) return settleStagedRetry(record, lease, sweep, {
3881
+ error: STAGED_ERROR.handoff,
3882
+ childInvocationId: child.id,
3883
+ terminal: record.attemptCount >= sweep.maxAttempts
3884
+ });
3885
+ }
3886
+ return settleStagedDispatched(lease, child.id, sweep);
3887
+ }
3888
+ async function settleStagedDispatched(lease, childInvocationId, sweep) {
3889
+ return await sweep.store.markStagedInvocationDispatched({
3890
+ ...lease,
3891
+ childInvocationId,
3892
+ now: now()
3893
+ }) ? "dispatched" : "unsettled";
3894
+ }
3895
+ async function settleStagedRetry(record, lease, sweep, settlement) {
3896
+ if (!await sweep.store.markStagedInvocationFailed({
3897
+ ...lease,
3898
+ error: settlement.error,
3899
+ availableAt: new Date(now().getTime() + (sweep.input.retryDelayMs?.(record.attemptCount) ?? 1e3)),
3900
+ deadLetter: settlement.terminal,
3901
+ ...settlement.childInvocationId ? { childInvocationId: settlement.childInvocationId } : {},
3902
+ now: now()
3903
+ })) return "unsettled";
3904
+ return settlement.terminal ? "dead_lettered" : "retry";
3905
+ }
3906
+ function isDefinitiveStagedRefusal(error) {
3907
+ return error instanceof SubmissionRefusalError || error instanceof StagedDispatchRejectionError || error instanceof ActionAdmissionError && (error.refusal === "refused" || error.refusal === "invalid_parameters");
3908
+ }
3909
+ function reportStagedError(input, error) {
3910
+ try {
3911
+ input.onError?.(error);
3912
+ } catch {}
3913
+ }
3914
+ /** Renew one lease token while a slow step runs; the returned stop awaits a renewal in flight. */
3915
+ function startStagedHeartbeat(store, lease, input, leaseDurationMs) {
3916
+ const intervalMs = input.leaseRenewalIntervalMs ?? Math.max(1, Math.floor(leaseDurationMs / 3));
3917
+ let stopped = false;
3918
+ let timer;
3919
+ let running;
3920
+ const schedule = () => {
3921
+ if (!stopped) timer = setTimeout(tick, intervalMs);
3922
+ };
3923
+ const tick = () => {
3924
+ running = store.renewStagedInvocationLease({
3925
+ ...lease,
3926
+ leaseDurationMs,
3927
+ now: now()
3928
+ }).then(() => void 0).catch(() => void 0).finally(schedule);
3929
+ };
3930
+ schedule();
3931
+ return async () => {
3932
+ stopped = true;
3933
+ if (timer) clearTimeout(timer);
3934
+ await running;
3935
+ };
3936
+ }
3937
+ async function withSubmissionTimeout(submission, timeoutMs) {
3938
+ let timer;
3939
+ try {
3940
+ return await Promise.race([submission, new Promise((_, reject) => {
3941
+ timer = setTimeout(() => reject(new StagedSubmissionTimeoutError("Staged invocation submission timed out.")), timeoutMs);
3942
+ })]);
3943
+ } finally {
3944
+ if (timer) clearTimeout(timer);
3945
+ }
3946
+ }
3403
3947
  return {
3404
3948
  submitAction,
3949
+ hasStagedInvocationActions: declaresStaging,
3950
+ ...stagedStore ? { dispatchStagedInvocations } : {},
3405
3951
  executeInvocation,
3406
3952
  resumeApprovedInvocation,
3407
3953
  recordExecutionAttestation,
@@ -3731,6 +4277,7 @@ function emitRelayTelemetry(telemetry, name, occurredAt, workerId, attributes) {
3731
4277
  var MemoryPlatformHostStore = class {
3732
4278
  db;
3733
4279
  transactionalOutbox;
4280
+ transactionalStagedInvocations;
3734
4281
  adapterCommit;
3735
4282
  transactionTail = Promise.resolve();
3736
4283
  invocations = [];
@@ -3741,6 +4288,7 @@ var MemoryPlatformHostStore = class {
3741
4288
  executionAttestations = [];
3742
4289
  externalReconciliations = [];
3743
4290
  outbox = [];
4291
+ stagedInvocations = [];
3744
4292
  /**
3745
4293
  * Requeue evidence is append-only audit history, so unlike the ledger
3746
4294
  * arrays above, which tests have always inspected and seeded directly, it
@@ -3751,6 +4299,7 @@ var MemoryPlatformHostStore = class {
3751
4299
  constructor(db, transactionProvider) {
3752
4300
  this.db = db;
3753
4301
  this.transactionalOutbox = transactionProvider !== void 0;
4302
+ this.transactionalStagedInvocations = transactionProvider !== void 0;
3754
4303
  if (transactionProvider) this.transactionWithEvents = (run) => this.runTransactionWithEvents(run, transactionProvider);
3755
4304
  }
3756
4305
  async transaction(run) {
@@ -3776,6 +4325,7 @@ var MemoryPlatformHostStore = class {
3776
4325
  const conditionalAdapters = [];
3777
4326
  const leasedUpdates = [];
3778
4327
  const pendingReconciliations = [];
4328
+ const pendingStagedBatches = [];
3779
4329
  const appendPending = async (event, metadata) => {
3780
4330
  if (this.events.some((candidate) => candidate.id === event.id) || pendingEvents.some((candidate) => candidate.event.id === event.id)) return;
3781
4331
  pendingEvents.push({
@@ -3832,6 +4382,9 @@ var MemoryPlatformHostStore = class {
3832
4382
  appendExternalReconciliation: async (record) => {
3833
4383
  pendingReconciliations.push(record);
3834
4384
  },
4385
+ stageInvocations: async (records) => {
4386
+ pendingStagedBatches.push(records.map(cloneStagedInvocationRecord));
4387
+ },
3835
4388
  updateActionInvocation: async (id, tenantId, spaceId, patch) => {
3836
4389
  pendingUpdates.push({
3837
4390
  id,
@@ -3885,6 +4438,15 @@ var MemoryPlatformHostStore = class {
3885
4438
  rememberAppend(this.externalReconciliations, record.id);
3886
4439
  await this.appendExternalReconciliation(record);
3887
4440
  }
4441
+ for (const records of pendingStagedBatches) {
4442
+ const appended = this.applyStagedBatch(records);
4443
+ if (appended.length > 0) undoLedgerWrites.push(() => {
4444
+ for (const id of appended) {
4445
+ const index = this.stagedInvocations.findIndex((record) => record.id === id);
4446
+ if (index !== -1) this.stagedInvocations.splice(index, 1);
4447
+ }
4448
+ });
4449
+ }
3888
4450
  for (const [id, patch, scope] of pendingAdapterUpdates) {
3889
4451
  const record = await this.getAdapterInvocation(id, scope);
3890
4452
  if (!record) throw new Error(`AdapterInvocation not found: ${id}`);
@@ -3935,9 +4497,50 @@ var MemoryPlatformHostStore = class {
3935
4497
  this.invocations.push(record);
3936
4498
  return record;
3937
4499
  }
4500
+ async createActionInvocationForStagedLease(input) {
4501
+ const { invocation, now = /* @__PURE__ */ new Date() } = input;
4502
+ const existing = this.invocations.find((record) => record.tenantId === invocation.tenantId && record.spaceId === invocation.spaceId && record.stagedOrigin?.parentInvocationId === invocation.stagedOrigin.parentInvocationId && record.stagedOrigin.stageKey === invocation.stagedOrigin.stageKey);
4503
+ if (existing) return {
4504
+ kind: "existing",
4505
+ invocation: existing
4506
+ };
4507
+ if (!this.stagedInvocations.find((record) => record.id === input.stageId && record.tenantId === invocation.tenantId && record.spaceId === invocation.spaceId && record.parentInvocationId === invocation.stagedOrigin.parentInvocationId && record.key === invocation.stagedOrigin.stageKey && record.actionId === invocation.actionId && record.status === "pending" && record.leaseOwner === input.workerId && record.leaseToken === input.leaseToken && record.leaseExpiresAt !== void 0 && record.leaseExpiresAt > now)) return { kind: "lease_lost" };
4508
+ const createdAt = /* @__PURE__ */ new Date();
4509
+ const record = {
4510
+ ...invocation,
4511
+ attemptCount: 0,
4512
+ leaseToken: 0,
4513
+ createdAt,
4514
+ updatedAt: createdAt
4515
+ };
4516
+ this.invocations.push(record);
4517
+ return {
4518
+ kind: "created",
4519
+ invocation: record
4520
+ };
4521
+ }
4522
+ applyStagedBatch(records) {
4523
+ if (records.length === 0) return [];
4524
+ const first = records[0];
4525
+ const existing = this.stagedInvocations.filter((record) => record.tenantId === first.tenantId && record.spaceId === first.spaceId && record.parentInvocationId === first.parentInvocationId);
4526
+ if (existing.length > 0) {
4527
+ if (!(existing.length === records.length && records.every((record) => {
4528
+ const stored = existing.find((candidate) => candidate.key === record.key);
4529
+ return stored !== void 0 && stored.batchDigest === record.batchDigest && stagedInvocationRequestIdentity(stored) === stagedInvocationRequestIdentity(record);
4530
+ }))) throw new _fabricorg_platform.ReconciliationRequiredError(`Staged invocation batch drift for parent ${first.parentInvocationId}.`);
4531
+ return [];
4532
+ }
4533
+ const keys = records.map((record) => record.key);
4534
+ if (new Set(keys).size !== keys.length || records.some((record) => record.tenantId !== first.tenantId || record.spaceId !== first.spaceId || record.parentInvocationId !== first.parentInvocationId || record.batchDigest !== first.batchDigest)) throw new Error(`Invalid staged invocation batch for parent ${first.parentInvocationId}.`);
4535
+ for (const record of records) this.stagedInvocations.push(cloneStagedInvocationRecord(record));
4536
+ return records.map((record) => record.id);
4537
+ }
3938
4538
  async getActionInvocation(id, tenantId, spaceId) {
3939
4539
  return this.invocations.find((record) => record.id === id && record.tenantId === tenantId && record.spaceId === spaceId);
3940
4540
  }
4541
+ async getActionInvocationByStagedOrigin(tenantId, spaceId, origin) {
4542
+ return this.invocations.find((record) => record.tenantId === tenantId && record.spaceId === spaceId && record.stagedOrigin?.parentInvocationId === origin.parentInvocationId && record.stagedOrigin.stageKey === origin.stageKey);
4543
+ }
3941
4544
  async updateActionInvocation(id, tenantId, spaceId, patch) {
3942
4545
  const record = this.invocations.find((row) => row.id === id && row.tenantId === tenantId && row.spaceId === spaceId);
3943
4546
  if (!record) throw new Error(`ActionInvocation not found: ${id}`);
@@ -4267,6 +4870,96 @@ var MemoryPlatformHostStore = class {
4267
4870
  record.updatedAt = current;
4268
4871
  return true;
4269
4872
  }
4873
+ async claimStagedInvocations(input) {
4874
+ while (this.adapterCommit) await this.adapterCommit;
4875
+ const current = input.now ?? /* @__PURE__ */ new Date();
4876
+ const eligible = this.stagedInvocations.filter((record) => {
4877
+ return this.invocations.find((candidate) => candidate.id === record.parentInvocationId && candidate.tenantId === record.tenantId && candidate.spaceId === record.spaceId)?.status === "completed" && record.status === "pending" && record.availableAt <= current && (!record.leaseExpiresAt || record.leaseExpiresAt <= current) && (!input.tenantId || record.tenantId === input.tenantId) && (!input.spaceId || record.spaceId === input.spaceId);
4878
+ }).sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime() || left.id.localeCompare(right.id)).slice(0, Math.max(1, Math.min(input.limit ?? 100, 1e3)));
4879
+ for (const record of eligible) {
4880
+ record.leaseOwner = input.workerId;
4881
+ record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
4882
+ record.leaseToken += 1;
4883
+ record.attemptCount += 1;
4884
+ record.updatedAt = current;
4885
+ }
4886
+ return eligible.map(cloneStagedInvocationRecord);
4887
+ }
4888
+ async markStagedInvocationDispatched(input) {
4889
+ return this.settleStagedInvocation(input, "dispatched", input.childInvocationId, void 0);
4890
+ }
4891
+ async markStagedInvocationRejected(input) {
4892
+ return this.settleStagedInvocation(input, "rejected", void 0, input.error);
4893
+ }
4894
+ async markStagedInvocationFailed(input) {
4895
+ const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
4896
+ if (!record) return false;
4897
+ record.status = input.deadLetter ? "dead_lettered" : "pending";
4898
+ record.lastError = input.error;
4899
+ if (input.childInvocationId) record.childInvocationId = input.childInvocationId;
4900
+ record.availableAt = new Date(input.availableAt);
4901
+ record.updatedAt = input.now ?? /* @__PURE__ */ new Date();
4902
+ delete record.leaseOwner;
4903
+ delete record.leaseExpiresAt;
4904
+ return true;
4905
+ }
4906
+ async renewStagedInvocationLease(input) {
4907
+ const current = input.now ?? /* @__PURE__ */ new Date();
4908
+ const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
4909
+ if (!record || !record.leaseExpiresAt || record.leaseExpiresAt <= current) return false;
4910
+ record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
4911
+ record.updatedAt = current;
4912
+ return true;
4913
+ }
4914
+ /** Await-free, like the fenced create above, so a create runs wholly before or after it. */
4915
+ async rotateStagedInvocationLease(input) {
4916
+ const current = input.now ?? /* @__PURE__ */ new Date();
4917
+ const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
4918
+ if (!record || !record.leaseExpiresAt || record.leaseExpiresAt <= current) return void 0;
4919
+ record.leaseToken += 1;
4920
+ record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
4921
+ record.updatedAt = current;
4922
+ return record.leaseToken;
4923
+ }
4924
+ async cancelStagedInvocationsForFailedParents(input = {}) {
4925
+ const current = input.now ?? /* @__PURE__ */ new Date();
4926
+ let cancelled = 0;
4927
+ for (const record of this.stagedInvocations) {
4928
+ if (record.status !== "pending" || input.tenantId && record.tenantId !== input.tenantId || input.spaceId && record.spaceId !== input.spaceId) continue;
4929
+ const parent = this.invocations.find((candidate) => candidate.id === record.parentInvocationId && candidate.tenantId === record.tenantId && candidate.spaceId === record.spaceId);
4930
+ if (!parent || ![
4931
+ "failed",
4932
+ "blocked_by_policy",
4933
+ "validation_failed"
4934
+ ].includes(parent.status)) continue;
4935
+ record.status = "cancelled";
4936
+ record.lastError = `Parent invocation ended with ${parent.status}.`;
4937
+ record.updatedAt = current;
4938
+ delete record.leaseOwner;
4939
+ delete record.leaseExpiresAt;
4940
+ cancelled += 1;
4941
+ }
4942
+ return cancelled;
4943
+ }
4944
+ async listStagedInvocations(input) {
4945
+ return this.stagedInvocations.filter((record) => record.tenantId === input.tenantId && record.spaceId === input.spaceId && (input.parentInvocationId === void 0 || record.parentInvocationId === input.parentInvocationId) && (!input.statuses?.length || input.statuses.includes(record.status))).sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime() || left.id.localeCompare(right.id)).map(cloneStagedInvocationRecord);
4946
+ }
4947
+ leasedStagedInvocation(id, workerId, leaseToken) {
4948
+ return this.stagedInvocations.find((record) => record.id === id && record.status === "pending" && record.leaseOwner === workerId && record.leaseToken === leaseToken);
4949
+ }
4950
+ /** Mirrors PostgreSQL: a missing child id keeps the recorded one; `lastError` is replaced. */
4951
+ settleStagedInvocation(input, status, childInvocationId, lastError) {
4952
+ const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
4953
+ if (!record) return false;
4954
+ record.status = status;
4955
+ if (childInvocationId !== void 0) record.childInvocationId = childInvocationId;
4956
+ if (lastError !== void 0) record.lastError = lastError;
4957
+ else delete record.lastError;
4958
+ record.updatedAt = input.now ?? /* @__PURE__ */ new Date();
4959
+ delete record.leaseOwner;
4960
+ delete record.leaseExpiresAt;
4961
+ return true;
4962
+ }
4270
4963
  async listEvents(tenantId, spaceId) {
4271
4964
  return this.events.filter((event) => event.tenantId === tenantId && event.spaceId === spaceId).sort((left, right) => left.sequence - right.sequence);
4272
4965
  }
@@ -4360,10 +5053,12 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4360
5053
  sql;
4361
5054
  transactionWithEvents;
4362
5055
  transactionalOutbox;
5056
+ transactionalStagedInvocations;
4363
5057
  constructor(db, sql, transactionProvider) {
4364
5058
  this.db = db;
4365
5059
  this.sql = sql;
4366
5060
  this.transactionalOutbox = transactionProvider !== void 0;
5061
+ this.transactionalStagedInvocations = transactionProvider !== void 0;
4367
5062
  if (transactionProvider) this.transactionWithEvents = async (run) => transactionProvider.run(async ({ db, sql }) => {
4368
5063
  const scoped = new PostgresPlatformHostStore(db, sql);
4369
5064
  return run({
@@ -4389,7 +5084,8 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4389
5084
  updateLeasedActionInvocation: (input) => scoped.updateLeasedActionInvocation(input),
4390
5085
  updateAdapterInvocation: (id, patch, scope) => scoped.updateAdapterInvocation(id, patch, scope),
4391
5086
  writeAdapterInvocation: (input) => scoped.writeAdapterInvocation(input),
4392
- appendExternalReconciliation: (record) => scoped.appendExternalReconciliation(record)
5087
+ appendExternalReconciliation: (record) => scoped.appendExternalReconciliation(record),
5088
+ stageInvocations: (records) => scoped.stageInvocations(records)
4393
5089
  });
4394
5090
  });
4395
5091
  }
@@ -4593,6 +5289,38 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4593
5289
  version: 10,
4594
5290
  name: "finalization_preparation_checkpoint",
4595
5291
  sql: `ALTER TABLE fabric_platform.action_invocations ADD COLUMN IF NOT EXISTS finalization_checkpoint jsonb;`
5292
+ },
5293
+ {
5294
+ version: 11,
5295
+ name: "staged_descendant_invocations",
5296
+ sql: `
5297
+ ALTER TABLE fabric_platform.action_invocations
5298
+ ADD COLUMN IF NOT EXISTS staged_parent_invocation_id text,
5299
+ ADD COLUMN IF NOT EXISTS staged_stage_key text;
5300
+ CREATE UNIQUE INDEX IF NOT EXISTS action_invocations_staged_origin_idx
5301
+ ON fabric_platform.action_invocations
5302
+ (tenant_id, space_id, staged_parent_invocation_id, staged_stage_key)
5303
+ WHERE staged_parent_invocation_id IS NOT NULL AND staged_stage_key IS NOT NULL;
5304
+ CREATE TABLE IF NOT EXISTS fabric_platform.staged_invocations (
5305
+ id text PRIMARY KEY,
5306
+ tenant_id text NOT NULL, space_id text NOT NULL,
5307
+ parent_invocation_id text NOT NULL, parent_action_id text NOT NULL,
5308
+ stage_key text NOT NULL, action_id text NOT NULL,
5309
+ expected_action_version integer NOT NULL,
5310
+ parameters jsonb NOT NULL, parameter_digest text NOT NULL, batch_digest text NOT NULL,
5311
+ status text NOT NULL, child_invocation_id text,
5312
+ attempt_count integer NOT NULL DEFAULT 0,
5313
+ available_at timestamptz NOT NULL,
5314
+ lease_owner text, lease_expires_at timestamptz,
5315
+ lease_token bigint NOT NULL DEFAULT 0,
5316
+ last_error text, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
5317
+ UNIQUE (tenant_id, space_id, parent_invocation_id, stage_key)
5318
+ );
5319
+ CREATE INDEX IF NOT EXISTS staged_invocations_claim_idx
5320
+ ON fabric_platform.staged_invocations (status, available_at, lease_expires_at, created_at);
5321
+ CREATE INDEX IF NOT EXISTS staged_invocations_parent_idx
5322
+ ON fabric_platform.staged_invocations (tenant_id, space_id, parent_invocation_id, created_at);
5323
+ `
4596
5324
  }
4597
5325
  ]);
4598
5326
  }
@@ -4608,11 +5336,11 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4608
5336
  try {
4609
5337
  result = await this.sql.query(`INSERT INTO fabric_platform.action_invocations
4610
5338
  (id,tenant_id,space_id,action_id,action_version,actor_id,actor_type,status,
4611
- parameters,result,correlation_id,causation_id,idempotency_key,
4612
- parameter_digest,parameter_digest_algorithm,idempotency_actor_id,
4613
- idempotency_authorization_binding_id,invocation_provenance,authorization_binding,execution_reason,
4614
- authorization_binding_id,error,runtime_evidence,${admittedColumn}created_at,updated_at)
4615
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14,$15,$16,$17,$18::jsonb,$19::jsonb,$20,$21,$22,$23::jsonb,${admittedValue}$24,$24)
5339
+ parameters,result,correlation_id,causation_id,idempotency_key,
5340
+ parameter_digest,parameter_digest_algorithm,idempotency_actor_id,
5341
+ idempotency_authorization_binding_id,invocation_provenance,authorization_binding,execution_reason,
5342
+ authorization_binding_id,error,runtime_evidence,${admittedColumn}created_at,updated_at)
5343
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14,$15,$16,$17,$18::jsonb,$19::jsonb,$20,$21,$22,$23::jsonb,${admittedValue}$24,$24)
4616
5344
  ON CONFLICT (tenant_id,space_id,action_id,idempotency_key)
4617
5345
  WHERE idempotency_key IS NOT NULL DO UPDATE SET id=fabric_platform.action_invocations.id
4618
5346
  RETURNING *`, [
@@ -4648,6 +5376,140 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4648
5376
  }
4649
5377
  return toActionRecord(result.rows[0]);
4650
5378
  }
5379
+ async createActionInvocationForStagedLease(input) {
5380
+ const { invocation } = input;
5381
+ const current = input.now ?? /* @__PURE__ */ new Date();
5382
+ const admitted = invocation.admittedAt !== void 0;
5383
+ const admittedColumn = admitted ? ",admitted_at" : "";
5384
+ const admittedValue = admitted ? ",$30" : "";
5385
+ const values = [
5386
+ invocation.id,
5387
+ invocation.tenantId,
5388
+ invocation.spaceId,
5389
+ invocation.actionId,
5390
+ invocation.actionVersion,
5391
+ invocation.actorId,
5392
+ invocation.actorType,
5393
+ invocation.status,
5394
+ JSON.stringify(invocation.parameters),
5395
+ JSON.stringify(invocation.result),
5396
+ invocation.correlationId,
5397
+ invocation.causationId ?? null,
5398
+ invocation.idempotencyKey ?? null,
5399
+ invocation.parameterDigest ?? null,
5400
+ invocation.parameterDigestAlgorithm ?? null,
5401
+ invocation.idempotencyActorId ?? null,
5402
+ invocation.idempotencyAuthorizationBindingId ?? null,
5403
+ invocation.provenance ? JSON.stringify(invocation.provenance) : null,
5404
+ invocation.authorizationBinding ? JSON.stringify(invocation.authorizationBinding) : null,
5405
+ invocation.executionReason ?? null,
5406
+ invocation.authorizationBindingId ?? null,
5407
+ invocation.error ?? null,
5408
+ invocation.runtimeEvidence ? JSON.stringify(invocation.runtimeEvidence) : null,
5409
+ invocation.stagedOrigin.parentInvocationId,
5410
+ invocation.stagedOrigin.stageKey,
5411
+ input.stageId,
5412
+ input.workerId,
5413
+ input.leaseToken,
5414
+ current,
5415
+ ...admitted ? [invocation.admittedAt] : []
5416
+ ];
5417
+ let result;
5418
+ try {
5419
+ result = await this.sql.query(`WITH fence AS (
5420
+ SELECT stage.id
5421
+ FROM fabric_platform.staged_invocations AS stage
5422
+ WHERE stage.id=$26 AND stage.tenant_id=$2 AND stage.space_id=$3
5423
+ AND stage.parent_invocation_id=$24 AND stage.stage_key=$25 AND stage.action_id=$4
5424
+ AND stage.status='pending' AND stage.lease_owner=$27 AND stage.lease_token=$28
5425
+ AND stage.lease_expires_at IS NOT NULL AND stage.lease_expires_at > $29
5426
+ FOR SHARE
5427
+ )
5428
+ INSERT INTO fabric_platform.action_invocations
5429
+ (id,tenant_id,space_id,action_id,action_version,actor_id,actor_type,status,
5430
+ parameters,result,correlation_id,causation_id,idempotency_key,parameter_digest,
5431
+ parameter_digest_algorithm,idempotency_actor_id,idempotency_authorization_binding_id,
5432
+ invocation_provenance,authorization_binding,execution_reason,authorization_binding_id,error,
5433
+ runtime_evidence,staged_parent_invocation_id,staged_stage_key${admittedColumn},created_at,updated_at)
5434
+ SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14,$15,$16,$17,
5435
+ $18::jsonb,$19::jsonb,$20,$21,$22,$23::jsonb,$24,$25${admittedValue},$29,$29
5436
+ FROM fence
5437
+ ON CONFLICT (tenant_id,space_id,staged_parent_invocation_id,staged_stage_key)
5438
+ WHERE staged_parent_invocation_id IS NOT NULL AND staged_stage_key IS NOT NULL
5439
+ DO UPDATE SET id=fabric_platform.action_invocations.id
5440
+ RETURNING *`, values);
5441
+ } catch (error) {
5442
+ if (error?.code === "42703" || error?.code === "42P01") throw new Error("A staged child needs PostgreSQL migration 11 (staged_descendant_invocations); run ensureSchema() before dispatching staged invocations.", { cause: error });
5443
+ throw error;
5444
+ }
5445
+ if (result.rows[0]) {
5446
+ const record = toActionRecord(result.rows[0]);
5447
+ return {
5448
+ kind: record.id === invocation.id ? "created" : "existing",
5449
+ invocation: record
5450
+ };
5451
+ }
5452
+ const existing = await this.getActionInvocationByStagedOrigin(invocation.tenantId, invocation.spaceId, invocation.stagedOrigin);
5453
+ return existing ? {
5454
+ kind: "existing",
5455
+ invocation: existing
5456
+ } : { kind: "lease_lost" };
5457
+ }
5458
+ async stageInvocations(records) {
5459
+ if (records.length === 0) return;
5460
+ const first = records[0];
5461
+ if (!(await this.sql.query(`SELECT id FROM fabric_platform.action_invocations
5462
+ WHERE id=$1 AND tenant_id=$2 AND space_id=$3 FOR UPDATE`, [
5463
+ first.parentInvocationId,
5464
+ first.tenantId,
5465
+ first.spaceId
5466
+ ])).rows[0]) throw new Error(`ActionInvocation not found: ${first.parentInvocationId}`);
5467
+ const current = await this.sql.query(`SELECT * FROM fabric_platform.staged_invocations
5468
+ WHERE tenant_id=$1 AND space_id=$2 AND parent_invocation_id=$3
5469
+ ORDER BY stage_key`, [
5470
+ first.tenantId,
5471
+ first.spaceId,
5472
+ first.parentInvocationId
5473
+ ]);
5474
+ if (current.rows.length > 0) {
5475
+ const stored = current.rows.map(toStagedInvocationRecord);
5476
+ if (!(stored.length === records.length && records.every((record) => {
5477
+ const candidate = stored.find((item) => item.key === record.key);
5478
+ return candidate !== void 0 && candidate.batchDigest === record.batchDigest && stagedInvocationRequestIdentity(candidate) === stagedInvocationRequestIdentity(record);
5479
+ }))) throw new _fabricorg_platform.ReconciliationRequiredError(`Staged invocation batch drift for parent ${first.parentInvocationId}.`);
5480
+ return;
5481
+ }
5482
+ for (const record of records) {
5483
+ if (record.tenantId !== first.tenantId || record.spaceId !== first.spaceId || record.parentInvocationId !== first.parentInvocationId || record.batchDigest !== first.batchDigest) throw new Error(`Invalid staged invocation batch for parent ${first.parentInvocationId}.`);
5484
+ await this.sql.query(`INSERT INTO fabric_platform.staged_invocations
5485
+ (id,tenant_id,space_id,parent_invocation_id,parent_action_id,stage_key,action_id,
5486
+ expected_action_version,parameters,parameter_digest,batch_digest,status,child_invocation_id,
5487
+ attempt_count,available_at,lease_owner,lease_expires_at,lease_token,last_error,created_at,updated_at)
5488
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)`, [
5489
+ record.id,
5490
+ record.tenantId,
5491
+ record.spaceId,
5492
+ record.parentInvocationId,
5493
+ record.parentActionId,
5494
+ record.key,
5495
+ record.actionId,
5496
+ record.expectedActionVersion,
5497
+ JSON.stringify(record.parameters),
5498
+ record.parameterDigest,
5499
+ record.batchDigest,
5500
+ record.status,
5501
+ record.childInvocationId ?? null,
5502
+ record.attemptCount,
5503
+ record.availableAt,
5504
+ record.leaseOwner ?? null,
5505
+ record.leaseExpiresAt ?? null,
5506
+ record.leaseToken,
5507
+ record.lastError ?? null,
5508
+ record.createdAt,
5509
+ record.updatedAt
5510
+ ]);
5511
+ }
5512
+ }
4651
5513
  async getActionInvocation(id, tenantId, spaceId) {
4652
5514
  const result = await this.sql.query(`SELECT * FROM fabric_platform.action_invocations
4653
5515
  WHERE id=$1 AND tenant_id=$2 AND space_id=$3`, [
@@ -4657,6 +5519,17 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4657
5519
  ]);
4658
5520
  return result.rows[0] ? toActionRecord(result.rows[0]) : void 0;
4659
5521
  }
5522
+ async getActionInvocationByStagedOrigin(tenantId, spaceId, origin) {
5523
+ const result = await this.sql.query(`SELECT * FROM fabric_platform.action_invocations
5524
+ WHERE tenant_id=$1 AND space_id=$2
5525
+ AND staged_parent_invocation_id=$3 AND staged_stage_key=$4`, [
5526
+ tenantId,
5527
+ spaceId,
5528
+ origin.parentInvocationId,
5529
+ origin.stageKey
5530
+ ]);
5531
+ return result.rows[0] ? toActionRecord(result.rows[0]) : void 0;
5532
+ }
4660
5533
  async updateActionInvocation(id, tenantId, spaceId, patch) {
4661
5534
  await this.sql.query(`UPDATE fabric_platform.action_invocations SET
4662
5535
  status=COALESCE($4,status), result=COALESCE($5::jsonb,result),
@@ -5187,6 +6060,139 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
5187
6060
  input.outboxId ?? null
5188
6061
  ])).rows.map((row) => toOutboxRequeueRecord(row, ""));
5189
6062
  }
6063
+ async claimStagedInvocations(input) {
6064
+ const current = input.now ?? /* @__PURE__ */ new Date();
6065
+ const leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
6066
+ const limit = Math.max(1, Math.min(input.limit ?? 100, 1e3));
6067
+ return (await this.sql.query(`WITH claimable AS (
6068
+ SELECT stage.id
6069
+ FROM fabric_platform.staged_invocations AS stage
6070
+ JOIN fabric_platform.action_invocations AS parent
6071
+ ON parent.id=stage.parent_invocation_id
6072
+ AND parent.tenant_id=stage.tenant_id AND parent.space_id=stage.space_id
6073
+ WHERE stage.status='pending' AND parent.status='completed'
6074
+ AND stage.available_at <= $1
6075
+ AND (stage.lease_expires_at IS NULL OR stage.lease_expires_at <= $1)
6076
+ AND ($2::text IS NULL OR stage.tenant_id=$2)
6077
+ AND ($3::text IS NULL OR stage.space_id=$3)
6078
+ ORDER BY stage.created_at,stage.id
6079
+ FOR UPDATE OF stage SKIP LOCKED LIMIT $4
6080
+ )
6081
+ UPDATE fabric_platform.staged_invocations AS stage
6082
+ SET lease_owner=$5, lease_expires_at=$6, lease_token=stage.lease_token+1,
6083
+ attempt_count=stage.attempt_count+1, updated_at=$1
6084
+ FROM claimable WHERE stage.id=claimable.id
6085
+ RETURNING stage.*`, [
6086
+ current,
6087
+ input.tenantId ?? null,
6088
+ input.spaceId ?? null,
6089
+ limit,
6090
+ input.workerId,
6091
+ leaseExpiresAt
6092
+ ])).rows.map(toStagedInvocationRecord);
6093
+ }
6094
+ async markStagedInvocationDispatched(input) {
6095
+ return this.settleStagedInvocation(input.id, input.workerId, input.leaseToken, "dispatched", input.now ?? /* @__PURE__ */ new Date(), input.childInvocationId, null);
6096
+ }
6097
+ async markStagedInvocationRejected(input) {
6098
+ return this.settleStagedInvocation(input.id, input.workerId, input.leaseToken, "rejected", input.now ?? /* @__PURE__ */ new Date(), null, input.error);
6099
+ }
6100
+ async markStagedInvocationFailed(input) {
6101
+ return (await this.sql.query(`UPDATE fabric_platform.staged_invocations
6102
+ SET status=$4, last_error=$5, available_at=$6,
6103
+ child_invocation_id=COALESCE($7,child_invocation_id),
6104
+ lease_owner=NULL, lease_expires_at=NULL, updated_at=$8
6105
+ WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3 RETURNING id`, [
6106
+ input.id,
6107
+ input.workerId,
6108
+ input.leaseToken,
6109
+ input.deadLetter ? "dead_lettered" : "pending",
6110
+ input.error,
6111
+ input.availableAt,
6112
+ input.childInvocationId ?? null,
6113
+ input.now ?? /* @__PURE__ */ new Date()
6114
+ ])).rows.length === 1;
6115
+ }
6116
+ async renewStagedInvocationLease(input) {
6117
+ const current = input.now ?? /* @__PURE__ */ new Date();
6118
+ return (await this.sql.query(`UPDATE fabric_platform.staged_invocations
6119
+ SET lease_expires_at=$4, updated_at=$5
6120
+ WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3
6121
+ AND lease_expires_at IS NOT NULL AND lease_expires_at > $5 RETURNING id`, [
6122
+ input.id,
6123
+ input.workerId,
6124
+ input.leaseToken,
6125
+ new Date(current.getTime() + input.leaseDurationMs),
6126
+ current
6127
+ ])).rows.length === 1;
6128
+ }
6129
+ /**
6130
+ * The UPDATE needs a row lock that conflicts with the `FOR SHARE` lock the
6131
+ * fenced child insert takes on this row, so it waits for an in-flight
6132
+ * insert to commit. A fenced insert under the old token that reaches the
6133
+ * row while this rotation is uncommitted waits for it, rechecks the new row
6134
+ * version and inserts nothing (live tests "orders a token rotation after an
6135
+ * in-flight fenced create ..." and "refuses a fenced create under the old
6136
+ * token once a token rotation is ordered before it").
6137
+ */
6138
+ async rotateStagedInvocationLease(input) {
6139
+ const current = input.now ?? /* @__PURE__ */ new Date();
6140
+ const result = await this.sql.query(`UPDATE fabric_platform.staged_invocations
6141
+ SET lease_token=lease_token+1, lease_expires_at=$4, updated_at=$5
6142
+ WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3
6143
+ AND lease_expires_at IS NOT NULL AND lease_expires_at > $5
6144
+ RETURNING lease_token`, [
6145
+ input.id,
6146
+ input.workerId,
6147
+ input.leaseToken,
6148
+ new Date(current.getTime() + input.leaseDurationMs),
6149
+ current
6150
+ ]);
6151
+ return result.rows[0] ? Number(result.rows[0].lease_token) : void 0;
6152
+ }
6153
+ async cancelStagedInvocationsForFailedParents(input = {}) {
6154
+ return (await this.sql.query(`UPDATE fabric_platform.staged_invocations AS stage
6155
+ SET status='cancelled', last_error='Parent invocation ended with ' || parent.status || '.',
6156
+ lease_owner=NULL, lease_expires_at=NULL, updated_at=$3
6157
+ FROM fabric_platform.action_invocations AS parent
6158
+ WHERE parent.id=stage.parent_invocation_id
6159
+ AND parent.tenant_id=stage.tenant_id AND parent.space_id=stage.space_id
6160
+ AND parent.status IN ('failed','blocked_by_policy','validation_failed')
6161
+ AND stage.status='pending'
6162
+ AND ($1::text IS NULL OR stage.tenant_id=$1)
6163
+ AND ($2::text IS NULL OR stage.space_id=$2)
6164
+ RETURNING stage.id`, [
6165
+ input.tenantId ?? null,
6166
+ input.spaceId ?? null,
6167
+ input.now ?? /* @__PURE__ */ new Date()
6168
+ ])).rows.length;
6169
+ }
6170
+ async listStagedInvocations(input) {
6171
+ return (await this.sql.query(`SELECT * FROM fabric_platform.staged_invocations
6172
+ WHERE tenant_id=$1 AND space_id=$2
6173
+ AND ($3::text IS NULL OR parent_invocation_id=$3)
6174
+ AND ($4::text[] IS NULL OR status=ANY($4::text[]))
6175
+ ORDER BY created_at,id`, [
6176
+ input.tenantId,
6177
+ input.spaceId,
6178
+ input.parentInvocationId ?? null,
6179
+ input.statuses?.length ? [...input.statuses] : null
6180
+ ])).rows.map(toStagedInvocationRecord);
6181
+ }
6182
+ async settleStagedInvocation(id, workerId, leaseToken, status, now, childInvocationId, lastError) {
6183
+ return (await this.sql.query(`UPDATE fabric_platform.staged_invocations
6184
+ SET status=$4, child_invocation_id=COALESCE($5,child_invocation_id), last_error=$6,
6185
+ lease_owner=NULL, lease_expires_at=NULL, updated_at=$7
6186
+ WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3 RETURNING id`, [
6187
+ id,
6188
+ workerId,
6189
+ leaseToken,
6190
+ status,
6191
+ childInvocationId,
6192
+ lastError,
6193
+ now
6194
+ ])).rows.length === 1;
6195
+ }
5190
6196
  async nextEventSequence(tenantId, spaceId) {
5191
6197
  const result = await this.sql.query(`INSERT INTO fabric_platform.event_sequences (tenant_id,space_id,next_sequence)
5192
6198
  VALUES ($1,$2,1) ON CONFLICT (tenant_id,space_id) DO UPDATE
@@ -5441,6 +6447,10 @@ function toActionRecord(row) {
5441
6447
  ...row.parameter_digest_algorithm ? { parameterDigestAlgorithm: String(row.parameter_digest_algorithm) } : {},
5442
6448
  ...row.idempotency_actor_id ? { idempotencyActorId: String(row.idempotency_actor_id) } : {},
5443
6449
  ...row.idempotency_authorization_binding_id ? { idempotencyAuthorizationBindingId: String(row.idempotency_authorization_binding_id) } : {},
6450
+ ...row.staged_parent_invocation_id && row.staged_stage_key ? { stagedOrigin: {
6451
+ parentInvocationId: String(row.staged_parent_invocation_id),
6452
+ stageKey: String(row.staged_stage_key)
6453
+ } } : {},
5444
6454
  ...row.invocation_provenance ? { provenance: row.invocation_provenance } : {},
5445
6455
  ...row.authorization_binding ? { authorizationBinding: row.authorization_binding } : {},
5446
6456
  ...row.execution_reason ? { executionReason: String(row.execution_reason) } : {},
@@ -5470,6 +6480,31 @@ function toActionRecord(row) {
5470
6480
  updatedAt: new Date(row.updated_at)
5471
6481
  };
5472
6482
  }
6483
+ function toStagedInvocationRecord(row) {
6484
+ return {
6485
+ id: String(row.id),
6486
+ tenantId: String(row.tenant_id),
6487
+ spaceId: String(row.space_id),
6488
+ parentInvocationId: String(row.parent_invocation_id),
6489
+ parentActionId: String(row.parent_action_id),
6490
+ key: String(row.stage_key),
6491
+ actionId: String(row.action_id),
6492
+ expectedActionVersion: Number(row.expected_action_version),
6493
+ parameters: row.parameters,
6494
+ parameterDigest: String(row.parameter_digest),
6495
+ batchDigest: String(row.batch_digest),
6496
+ status: String(row.status),
6497
+ ...row.child_invocation_id ? { childInvocationId: String(row.child_invocation_id) } : {},
6498
+ attemptCount: Number(row.attempt_count),
6499
+ availableAt: new Date(row.available_at),
6500
+ ...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
6501
+ ...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
6502
+ leaseToken: Number(row.lease_token ?? 0),
6503
+ ...row.last_error ? { lastError: String(row.last_error) } : {},
6504
+ createdAt: new Date(row.created_at),
6505
+ updatedAt: new Date(row.updated_at)
6506
+ };
6507
+ }
5473
6508
  function toObligationRecord(row) {
5474
6509
  return {
5475
6510
  id: String(row.id),
@@ -5609,6 +6644,23 @@ function createStoreBackedActionDispatcher() {
5609
6644
  async function runPlatformActionWorkerCycle(options) {
5610
6645
  const leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
5611
6646
  emitWorkerTelemetry(options.telemetry, "worker.cycle.started", options.workerId);
6647
+ let staged;
6648
+ if ((options.dispatchStagedInvocations === true || options.dispatchStagedInvocations !== false && options.host.hasStagedInvocationActions === true) && typeof options.host.dispatchStagedInvocations === "function") try {
6649
+ staged = await options.host.dispatchStagedInvocations({
6650
+ workerId: options.workerId,
6651
+ ...options.stagedInvocationBatchSize !== void 0 ? { limit: options.stagedInvocationBatchSize } : {},
6652
+ ...options.stagedInvocationLeaseDurationMs !== void 0 ? { leaseDurationMs: options.stagedInvocationLeaseDurationMs } : {},
6653
+ ...options.stagedInvocationLeaseRenewalIntervalMs !== void 0 ? { leaseRenewalIntervalMs: options.stagedInvocationLeaseRenewalIntervalMs } : {},
6654
+ ...options.stagedInvocationSubmissionTimeoutMs !== void 0 ? { submissionTimeoutMs: options.stagedInvocationSubmissionTimeoutMs } : {},
6655
+ ...options.stagedInvocationMaxAttempts !== void 0 ? { maxAttempts: options.stagedInvocationMaxAttempts } : {},
6656
+ ...options.stagedInvocationRetryDelayMs ? { retryDelayMs: options.stagedInvocationRetryDelayMs } : {},
6657
+ ...options.tenantId ? { tenantId: options.tenantId } : {},
6658
+ ...options.spaceId ? { spaceId: options.spaceId } : {},
6659
+ ...options.onError ? { onError: (error) => options.onError?.(error) } : {}
6660
+ });
6661
+ } catch (error) {
6662
+ options.onError?.(error);
6663
+ }
5612
6664
  if (options.sweepOverdueCompletions !== false && typeof options.host.reconcileOverdueCompletions === "function") try {
5613
6665
  await options.host.reconcileOverdueCompletions({
5614
6666
  ...options.tenantId ? { tenantId: options.tenantId } : {},
@@ -5666,9 +6718,20 @@ async function runPlatformActionWorkerCycle(options) {
5666
6718
  completed,
5667
6719
  failed,
5668
6720
  ...waitingForApproval > 0 ? { waitingForApproval } : {},
5669
- ...deferred > 0 ? { deferred } : {}
6721
+ ...deferred > 0 ? { deferred } : {},
6722
+ ...staged && Object.values(staged).some((value) => value > 0) ? { staged } : {}
5670
6723
  };
5671
- emitWorkerTelemetry(options.telemetry, "worker.cycle.completed", options.workerId, result);
6724
+ emitWorkerTelemetry(options.telemetry, "worker.cycle.completed", options.workerId, {
6725
+ claimed: result.claimed,
6726
+ completed: result.completed,
6727
+ failed: result.failed,
6728
+ ...result.waitingForApproval !== void 0 ? { waitingForApproval: result.waitingForApproval } : {},
6729
+ ...result.deferred !== void 0 ? { deferred: result.deferred } : {},
6730
+ ...result.staged ? {
6731
+ stagedClaimed: result.staged.claimed,
6732
+ stagedDispatched: result.staged.dispatched
6733
+ } : {}
6734
+ });
5672
6735
  return result;
5673
6736
  }
5674
6737
  function startLeaseHeartbeat(options, invocation, leaseDurationMs) {
@@ -5926,15 +6989,16 @@ async function adapterProbe(subject) {
5926
6989
  };
5927
6990
  }
5928
6991
  /**
5929
- * The suite any deployment of the host must pass before it claims isolation.
6992
+ * The suite a deployment of the host must pass before it claims isolation.
5930
6993
  *
5931
- * Eight checks: an invocation cannot be read, executed, or completed under
6994
+ * Nine checks: an invocation cannot be read, executed, or completed under
5932
6995
  * another tenant's scope; an idempotency key is a tenant's own, so the same
5933
6996
  * key in two tenants is two invocations; a tenant's event stream carries no
5934
6997
  * other tenant's events; a worker scoped to one tenant does not claim another's
5935
- * work; an adapter checkpoint cannot be read or written under another tenant,
5936
- * space, or owning invocation; and, when the subject lends a read, a viewer in
5937
- * the other tenant sees nothing of ours.
6998
+ * work; staged listing and claiming retain tenant and space scope; an adapter
6999
+ * checkpoint cannot be read or written under another tenant, space, or owning
7000
+ * invocation; and, when the subject lends a read, a viewer in the other tenant
7001
+ * sees nothing of ours.
5938
7002
  */
5939
7003
  function tenantIsolationChecks() {
5940
7004
  return [
@@ -5978,6 +7042,103 @@ function tenantIsolationChecks() {
5978
7042
  return ["one idempotency key: one invocation within a tenant, two across tenants"];
5979
7043
  }
5980
7044
  },
7045
+ {
7046
+ id: "fabric.tenancy.staged-invocation-scoped.v1",
7047
+ async run(subject) {
7048
+ await subject.reset?.();
7049
+ const store = subject.store;
7050
+ if (store.transactionalStagedInvocations !== true || typeof store.transactionWithEvents !== "function" || typeof store.listStagedInvocations !== "function" || typeof store.claimStagedInvocations !== "function") return ["skipped: store does not expose transactional staged invocations"];
7051
+ const suffix = globalThis.crypto.randomUUID();
7052
+ const probeTenant = `iso-stage-tenant-${suffix}`;
7053
+ const otherProbeTenant = `iso-stage-tenant-other-${suffix}`;
7054
+ const probeSpace = `iso-stage-space-${suffix}`;
7055
+ const otherProbeSpace = `iso-stage-space-other-${suffix}`;
7056
+ const parents = [
7057
+ {
7058
+ id: `iso_stage_parent_ours_${suffix}`,
7059
+ tenantId: probeTenant,
7060
+ spaceId: probeSpace,
7061
+ actorId: subject.ours.actorId,
7062
+ actorType: subject.ours.actorType
7063
+ },
7064
+ {
7065
+ id: `iso_stage_parent_theirs_${suffix}`,
7066
+ tenantId: otherProbeTenant,
7067
+ spaceId: probeSpace,
7068
+ actorId: subject.theirs.actorId,
7069
+ actorType: subject.theirs.actorType
7070
+ },
7071
+ {
7072
+ id: `iso_stage_parent_space_${suffix}`,
7073
+ tenantId: probeTenant,
7074
+ spaceId: otherProbeSpace,
7075
+ actorId: subject.ours.actorId,
7076
+ actorType: subject.ours.actorType
7077
+ }
7078
+ ];
7079
+ for (const parent of parents) await store.createActionInvocation({
7080
+ ...parent,
7081
+ actionId: subject.actionId,
7082
+ actionVersion: 1,
7083
+ status: "completed",
7084
+ parameters: {},
7085
+ result: {},
7086
+ correlationId: suffix
7087
+ });
7088
+ const now = /* @__PURE__ */ new Date();
7089
+ const records = parents.map((parent, index) => ({
7090
+ id: `iso_stage_${index}_${suffix}`,
7091
+ tenantId: parent.tenantId,
7092
+ spaceId: parent.spaceId,
7093
+ parentInvocationId: parent.id,
7094
+ parentActionId: subject.actionId,
7095
+ key: "child",
7096
+ actionId: subject.actionId,
7097
+ expectedActionVersion: 1,
7098
+ parameters: {},
7099
+ parameterDigest: `digest-${index}`,
7100
+ batchDigest: `batch-${index}`,
7101
+ status: "pending",
7102
+ attemptCount: 0,
7103
+ availableAt: now,
7104
+ leaseToken: 0,
7105
+ createdAt: now,
7106
+ updatedAt: now
7107
+ }));
7108
+ try {
7109
+ await store.transactionWithEvents(async (transaction) => {
7110
+ if (!transaction.stageInvocations) fail$1("transactionalStagedInvocations did not supply stageInvocations");
7111
+ for (const record of records) await transaction.stageInvocations([record]);
7112
+ });
7113
+ const ours = await store.listStagedInvocations({
7114
+ tenantId: probeTenant,
7115
+ spaceId: probeSpace
7116
+ });
7117
+ if (!ours.some((record) => record.id === records[0].id)) fail$1("our staged invocation was not listable under our scope, so the check proves nothing");
7118
+ if (ours.some((record) => record.id === records[1].id || record.id === records[2].id)) fail$1("staged invocation listing crossed tenant or space");
7119
+ if ((await store.listStagedInvocations({
7120
+ tenantId: otherProbeTenant,
7121
+ spaceId: probeSpace,
7122
+ parentInvocationId: parents[0].id
7123
+ })).length !== 0) fail$1("a staged invocation was listable under another tenant's scope");
7124
+ const claimed = await store.claimStagedInvocations({
7125
+ workerId: `iso-stage-worker-${suffix}`,
7126
+ tenantId: probeTenant,
7127
+ spaceId: probeSpace,
7128
+ leaseDurationMs: 6e4,
7129
+ now
7130
+ });
7131
+ if (!claimed.some((record) => record.id === records[0].id)) fail$1("our staged invocation was not claimable under our scope, so the check proves nothing");
7132
+ if (claimed.some((record) => record.tenantId !== probeTenant || record.spaceId !== probeSpace)) fail$1("staged invocation claim crossed tenant or space");
7133
+ return ["staged listing and claiming retained both tenant and space scope"];
7134
+ } finally {
7135
+ for (const parent of parents) await store.updateActionInvocation(parent.id, parent.tenantId, parent.spaceId, {
7136
+ status: "failed",
7137
+ error: "tenant isolation probe; retired"
7138
+ });
7139
+ }
7140
+ }
7141
+ },
5981
7142
  {
5982
7143
  id: "fabric.tenancy.events-scoped.v1",
5983
7144
  async run(subject) {
@@ -6775,13 +7936,20 @@ async function runEventAppendChecks(subject) {
6775
7936
  exports.ActionAdmissionError = ActionAdmissionError;
6776
7937
  exports.ExternalCompletionError = ExternalCompletionError;
6777
7938
  exports.IdempotencyConflictError = IdempotencyConflictError;
7939
+ exports.MAX_STAGED_INVOCATIONS = MAX_STAGED_INVOCATIONS;
7940
+ exports.MAX_STAGED_INVOCATION_BATCH_BYTES = MAX_STAGED_INVOCATION_BATCH_BYTES;
7941
+ exports.MAX_STAGED_INVOCATION_BYTES = MAX_STAGED_INVOCATION_BYTES;
7942
+ exports.MAX_STAGED_INVOCATION_KEY_LENGTH = MAX_STAGED_INVOCATION_KEY_LENGTH;
6778
7943
  exports.MemoryPlatformHostStore = MemoryPlatformHostStore;
6779
7944
  exports.PARAMETER_DIGEST_ALGORITHM = PARAMETER_DIGEST_ALGORITHM;
6780
7945
  exports.PLATFORM_HOST_CONTRACT_VERSION = PLATFORM_HOST_CONTRACT_VERSION;
6781
7946
  exports.PLATFORM_HOST_HEALTH_CONTRACT_VERSION = PLATFORM_HOST_HEALTH_CONTRACT_VERSION;
6782
7947
  exports.PLATFORM_HOST_METRIC_NAMES = PLATFORM_HOST_METRIC_NAMES;
6783
7948
  exports.PostgresPlatformHostStore = PostgresPlatformHostStore;
7949
+ exports.STAGED_INVOCATION_KEY_PATTERN = STAGED_INVOCATION_KEY_PATTERN;
7950
+ exports.SubmissionRefusalError = SubmissionRefusalError;
6784
7951
  exports.applyPostgresMigrations = applyPostgresMigrations;
7952
+ exports.asStagedInvocationStore = asStagedInvocationStore;
6785
7953
  Object.defineProperty(exports, "canonicalJson", {
6786
7954
  enumerable: true,
6787
7955
  get: function() {
@@ -6789,6 +7957,7 @@ Object.defineProperty(exports, "canonicalJson", {
6789
7957
  }
6790
7958
  });
6791
7959
  exports.cloneOutboxRecord = cloneOutboxRecord;
7960
+ exports.cloneStagedInvocationRecord = cloneStagedInvocationRecord;
6792
7961
  exports.createDurableSagaParentLifecycle = createDurableSagaParentLifecycle;
6793
7962
  exports.createGovernedActionHost = createGovernedActionHost;
6794
7963
  exports.createStoreBackedActionDispatcher = createStoreBackedActionDispatcher;
@@ -6797,11 +7966,13 @@ exports.emitPlatformHostTelemetry = emitPlatformHostTelemetry;
6797
7966
  exports.eventAppendChecks = eventAppendChecks;
6798
7967
  exports.getPlatformHostHealthSnapshot = getPlatformHostHealthSnapshot;
6799
7968
  exports.outboxActorDisclosure = outboxActorDisclosure;
7969
+ exports.prepareStagedInvocationBatch = prepareStagedInvocationBatch;
6800
7970
  exports.runEventAppendChecks = runEventAppendChecks;
6801
7971
  exports.runOutboxRelayCycle = runOutboxRelayCycle;
6802
7972
  exports.runPlatformActionWorker = runPlatformActionWorker;
6803
7973
  exports.runPlatformActionWorkerCycle = runPlatformActionWorkerCycle;
6804
7974
  exports.runTenantIsolationChecks = runTenantIsolationChecks;
7975
+ exports.stagedInvocationRequestIdentity = stagedInvocationRequestIdentity;
6805
7976
  exports.tenantIsolationChecks = tenantIsolationChecks;
6806
7977
  exports.toEnterpriseEventEnvelope = toEnterpriseEventEnvelope;
6807
7978