@fabricorg/platform-host 8.1.2 → 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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AdapterCircuitBreakerRegistry, AdapterRegistry, FABRIC_GOVERNANCE_CONTRACT_VERSION, aggregatePolicyOutcomes, assertAdapterCircuitBreakerConfig, assertAdapterOutcomeConsistency, assertGovernanceRuntimeEvidence, assertMutationGovernanceContext, canonicalJson, createFabricId, evaluatePolicyDefinitions, evaluateStateMachineTransition, executeWithAdapterRetry, isAdapterCircuitOpenError, isReconciliationRequiredError, isRetryableExecutionError } from "@fabricorg/platform";
1
+ import { AdapterCircuitBreakerRegistry, AdapterRegistry, FABRIC_GOVERNANCE_CONTRACT_VERSION, ReconciliationRequiredError, aggregatePolicyOutcomes, assertActionFinalization, assertAdapterCircuitBreakerConfig, assertAdapterOutcomeConsistency, assertAdapterStepDeadlines, assertGovernanceRuntimeEvidence, assertMutationGovernanceContext, canonicalJson, createFabricId, evaluatePolicyDefinitions, evaluateStateMachineTransition, executeWithAdapterRetry, isAdapterCircuitOpenError, isReconciliationRequiredError, isRetryableExecutionError } from "@fabricorg/platform";
2
2
  import { performance } from "perf_hooks";
3
3
  import { assertAssemblyRuntimeCompatible } from "@fabricorg/assembly";
4
4
  import { createHash } from "crypto";
@@ -35,6 +35,18 @@ var ActionAdmissionError = class extends Error {
35
35
  this.name = "ActionAdmissionError";
36
36
  }
37
37
  };
38
+ /** A definitive authorization-boundary refusal before an invocation is created. */
39
+ var SubmissionRefusalError = class extends Error {
40
+ actionId;
41
+ refusal;
42
+ code = "ACTION_SUBMISSION_REFUSED";
43
+ constructor(actionId, refusal, message) {
44
+ super(message);
45
+ this.actionId = actionId;
46
+ this.refusal = refusal;
47
+ this.name = "SubmissionRefusalError";
48
+ }
49
+ };
38
50
  /**
39
51
  * Why a completion was not applied, typed so a webhook handler can answer
40
52
  * the external system correctly: `retryable` means the invocation is still
@@ -95,6 +107,82 @@ function normalizeProvenance(input, options) {
95
107
  };
96
108
  }
97
109
  //#endregion
110
+ //#region src/action-finalization.ts
111
+ /** Strict JSON evidence: reject lossy serialization rather than silently dropping values. */
112
+ function finalizationJson(value) {
113
+ const ancestors = /* @__PURE__ */ new Set();
114
+ function check(item) {
115
+ if (item === null || typeof item === "string" || typeof item === "boolean") return;
116
+ if (typeof item === "number" && Number.isFinite(item)) return;
117
+ if (typeof item !== "object" || !item || ancestors.has(item)) throw new Error("Finalization requires JSON-compatible data.");
118
+ if (!Array.isArray(item) && Object.getPrototypeOf(item) !== Object.prototype && Object.getPrototypeOf(item) !== null) throw new Error("Finalization requires plain JSON objects.");
119
+ if (Object.getOwnPropertySymbols(item).length > 0) throw new Error("Finalization requires JSON string keys.");
120
+ if (Array.isArray(item)) {
121
+ const keys = Object.keys(item);
122
+ if (keys.length !== item.length || keys.some((key, index) => key !== String(index))) throw new Error("Finalization requires dense JSON arrays.");
123
+ }
124
+ ancestors.add(item);
125
+ for (const child of Object.values(item)) check(child);
126
+ ancestors.delete(item);
127
+ }
128
+ check(value);
129
+ return JSON.parse(JSON.stringify(value));
130
+ }
131
+ /** Match the persisted JSON representation before validating finalization execution input. */
132
+ function normalizeFinalizationJson(value) {
133
+ return finalizationJson(JSON.parse(JSON.stringify(value)));
134
+ }
135
+ /** Omit only public optional fields; payloads and patch values retain strict JSON validation. */
136
+ function omitUndefinedOptionalFields(value, keys) {
137
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return value;
138
+ const copy = { ...value };
139
+ for (const key of keys) if (Object.hasOwn(copy, key) && copy[key] === void 0) delete copy[key];
140
+ return copy;
141
+ }
142
+ function validateFinalization(value, action, privateFields, contracts) {
143
+ const normalized = omitUndefinedOptionalFields(value, [
144
+ "events",
145
+ "resultPatch",
146
+ "stagedInvocations"
147
+ ]);
148
+ if (normalized !== value && normalized && typeof normalized === "object" && !Array.isArray(normalized)) {
149
+ const record = normalized;
150
+ if (Array.isArray(record.events)) {
151
+ const events = Object.assign(record.events.slice(), record.events);
152
+ for (let index = 0; index < events.length; index++) if (Object.hasOwn(events, index)) events[index] = omitUndefinedOptionalFields(events[index], ["eventSchemaVersion"]);
153
+ record.events = events;
154
+ }
155
+ }
156
+ const result = finalizationJson(normalized);
157
+ 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.");
158
+ if (result.resultPatch !== void 0) {
159
+ if (!result.resultPatch || typeof result.resultPatch !== "object" || Array.isArray(result.resultPatch)) throw new Error("Finalization resultPatch must be an object.");
160
+ if (Object.keys(result.resultPatch).some((key) => privateFields.includes(key))) throw new Error("Finalization resultPatch contains a private result key.");
161
+ }
162
+ if (result.events !== void 0) {
163
+ if (!Array.isArray(result.events)) throw new Error("Finalization events must be an array.");
164
+ for (const event of result.events) {
165
+ if (!event || typeof event !== "object" || Array.isArray(event) || Object.keys(event).some((key) => ![
166
+ "eventType",
167
+ "subjectType",
168
+ "subjectId",
169
+ "payload",
170
+ "eventSchemaVersion"
171
+ ].includes(key))) throw new Error("Invalid finalization event fields.");
172
+ for (const key of [
173
+ "eventType",
174
+ "subjectType",
175
+ "subjectId"
176
+ ]) if (typeof event[key] !== "string" || !event[key].trim()) throw new Error("Finalization event identifiers must be non-empty.");
177
+ if (!Object.hasOwn(event, "payload")) throw new Error("Finalization event requires a JSON payload.");
178
+ if (event.eventSchemaVersion !== void 0 && (!Number.isInteger(event.eventSchemaVersion) || event.eventSchemaVersion <= 0)) throw new Error("Finalization eventSchemaVersion must be a positive integer.");
179
+ if (!action.emitsEvents.includes(event.eventType)) throw new Error("Finalization emitted undeclared event type.");
180
+ if (contracts && (!contracts.hasEventType(event.eventType) || !contracts.hasSubjectType(event.subjectType))) throw new Error("Finalization emitted an unregistered event or subject type.");
181
+ }
182
+ }
183
+ return result;
184
+ }
185
+ //#endregion
98
186
  //#region src/observability.ts
99
187
  /** Version of the vendor-neutral health/readiness contract. */
100
188
  const PLATFORM_HOST_HEALTH_CONTRACT_VERSION = 1;
@@ -1076,6 +1164,120 @@ function createOutboxRequeue(deps) {
1076
1164
  };
1077
1165
  }
1078
1166
  //#endregion
1167
+ //#region src/staged-invocations.ts
1168
+ const MAX_STAGED_INVOCATIONS = 32;
1169
+ const MAX_STAGED_INVOCATION_KEY_LENGTH = 128;
1170
+ const MAX_STAGED_INVOCATION_BYTES = 65536;
1171
+ const MAX_STAGED_INVOCATION_BATCH_BYTES = 262144;
1172
+ const STAGED_INVOCATION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
1173
+ /** Validate and normalize one immutable batch before its parent transaction commits. */
1174
+ function prepareStagedInvocationBatch(input) {
1175
+ if (!Array.isArray(input.requests)) throw new Error("stagedInvocations must be an array.");
1176
+ if (input.requests.length > 32) throw new Error(`stagedInvocations exceeds the 32 request limit.`);
1177
+ const normalized = input.requests.map((candidate, index) => {
1178
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate) || Object.keys(candidate).some((key) => ![
1179
+ "key",
1180
+ "actionId",
1181
+ "parameters"
1182
+ ].includes(key))) throw new Error(`stagedInvocations[${index}] must contain only key, actionId and parameters.`);
1183
+ const request = {
1184
+ key: candidate.key,
1185
+ actionId: candidate.actionId,
1186
+ parameters: candidate.parameters
1187
+ };
1188
+ 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.`);
1189
+ if (!(input.parentAction.stagesInvocations ?? []).includes(request.actionId)) throw new Error(`Action "${input.parentAction.actionId}" may not stage "${String(request.actionId)}".`);
1190
+ const child = input.resolveAction(request.actionId);
1191
+ if (!child) throw new Error(`Staged action "${String(request.actionId)}" is not registered.`);
1192
+ const parameters = finalizationJson(request.parameters);
1193
+ if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) throw new Error(`stagedInvocations[${index}] parameters must be a JSON object.`);
1194
+ if (!child.schema.safeParse(parameters).success) throw new Error(`stagedInvocations[${index}] parameters do not match action "${child.actionId}".`);
1195
+ let redactedParameters = parameters;
1196
+ if (input.redactParameters && !input.redactedKeys?.includes(request.key)) try {
1197
+ redactedParameters = input.redactParameters(child.actionId, structuredClone(parameters));
1198
+ } catch {
1199
+ throw new Error("Staged invocation parameter redaction failed.");
1200
+ }
1201
+ const durableParameters = finalizationJson(redactedParameters);
1202
+ 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}".`);
1203
+ if (byteLength(canonicalJson({
1204
+ key: request.key,
1205
+ actionId: child.actionId,
1206
+ parameters: durableParameters
1207
+ })) > 65536) throw new Error(`stagedInvocations[${index}] exceeds ${MAX_STAGED_INVOCATION_BYTES} bytes.`);
1208
+ return {
1209
+ key: request.key,
1210
+ actionId: child.actionId,
1211
+ expectedActionVersion: child.version,
1212
+ parameters: durableParameters,
1213
+ parameterDigest: digestParameters(durableParameters)
1214
+ };
1215
+ });
1216
+ 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}".`);
1217
+ normalized.sort((left, right) => compareText(left.key, right.key) || compareText(left.actionId, right.actionId));
1218
+ const batchIdentity = normalized.map(({ key, actionId, expectedActionVersion, parameters, parameterDigest }) => ({
1219
+ key,
1220
+ actionId,
1221
+ expectedActionVersion,
1222
+ parameters,
1223
+ parameterDigest
1224
+ }));
1225
+ if (byteLength(canonicalJson(batchIdentity)) > 262144) throw new Error(`stagedInvocations batch exceeds ${MAX_STAGED_INVOCATION_BATCH_BYTES} bytes.`);
1226
+ const batchDigest = digestParameters({
1227
+ parentInvocationId: input.parent.id,
1228
+ requests: batchIdentity
1229
+ });
1230
+ return normalized.map((request) => ({
1231
+ id: lifecycleId("stg", input.parent.id, request.key),
1232
+ tenantId: input.parent.tenantId,
1233
+ spaceId: input.parent.spaceId,
1234
+ parentInvocationId: input.parent.id,
1235
+ parentActionId: input.parent.actionId,
1236
+ ...request,
1237
+ batchDigest,
1238
+ status: "pending",
1239
+ attemptCount: 0,
1240
+ availableAt: new Date(input.now),
1241
+ leaseToken: 0,
1242
+ createdAt: new Date(input.now),
1243
+ updatedAt: new Date(input.now)
1244
+ }));
1245
+ }
1246
+ function stagedInvocationRequestIdentity(record) {
1247
+ return canonicalJson({
1248
+ id: record.id,
1249
+ tenantId: record.tenantId,
1250
+ spaceId: record.spaceId,
1251
+ parentInvocationId: record.parentInvocationId,
1252
+ parentActionId: record.parentActionId,
1253
+ key: record.key,
1254
+ actionId: record.actionId,
1255
+ expectedActionVersion: record.expectedActionVersion,
1256
+ parameters: record.parameters,
1257
+ parameterDigest: record.parameterDigest,
1258
+ batchDigest: record.batchDigest
1259
+ });
1260
+ }
1261
+ function cloneStagedInvocationRecord(record) {
1262
+ return {
1263
+ ...structuredClone(record),
1264
+ availableAt: new Date(record.availableAt),
1265
+ ...record.leaseExpiresAt ? { leaseExpiresAt: new Date(record.leaseExpiresAt) } : {},
1266
+ createdAt: new Date(record.createdAt),
1267
+ updatedAt: new Date(record.updatedAt)
1268
+ };
1269
+ }
1270
+ function asStagedInvocationStore(store) {
1271
+ const candidate = store;
1272
+ 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;
1273
+ }
1274
+ function byteLength(value) {
1275
+ return new TextEncoder().encode(value).byteLength;
1276
+ }
1277
+ function compareText(left, right) {
1278
+ return left < right ? -1 : left > right ? 1 : 0;
1279
+ }
1280
+ //#endregion
1079
1281
  //#region src/host.ts
1080
1282
  const DEFAULT_EXTRACT_EVENTS = (data) => {
1081
1283
  const value = data._events;
@@ -1100,6 +1302,25 @@ const ADMISSION_FAILED = Object.freeze({ kind: "failed" });
1100
1302
  const ADMISSION_TIMED_OUT = Object.freeze({ kind: "timed_out" });
1101
1303
  /** What an admission refusal's code may look like; anything else is `invalid_decision`. */
1102
1304
  const ADMISSION_REFUSAL_CODE = /^[a-z][A-Za-z0-9_.:-]{0,127}$/;
1305
+ var StagedDispatchRejectionError = class extends Error {
1306
+ name = "StagedDispatchRejectionError";
1307
+ };
1308
+ var StagedDispatchLeaseLostError = class extends Error {
1309
+ name = "StagedDispatchLeaseLostError";
1310
+ };
1311
+ var StagedDispatcherHandoffError = class extends Error {
1312
+ name = "StagedDispatcherHandoffError";
1313
+ };
1314
+ var StagedSubmissionTimeoutError = class extends Error {
1315
+ name = "StagedSubmissionTimeoutError";
1316
+ };
1317
+ /** Fixed stage error texts: a stage records no caught error text and no parameters. */
1318
+ const STAGED_ERROR = Object.freeze({
1319
+ dispatch: "Staged invocation dispatch failed.",
1320
+ refused: "Staged invocation submission was definitively refused.",
1321
+ handoff: "Staged child dispatcher handoff was not confirmed.",
1322
+ lookup: "Staged child lookup failed."
1323
+ });
1103
1324
  function createGovernedActionHost(options) {
1104
1325
  const retryableExecutionMaxAttempts = options.retryableExecutionMaxAttempts ?? 5;
1105
1326
  if (!Number.isInteger(retryableExecutionMaxAttempts) || retryableExecutionMaxAttempts <= 0) throw new Error("retryableExecutionMaxAttempts must be a positive integer.");
@@ -1129,6 +1350,9 @@ function createGovernedActionHost(options) {
1129
1350
  const configuredActionResolver = options.resolveAction ?? options.registry?.resolveAction;
1130
1351
  if (!configuredActionResolver) throw new Error("Platform Host requires an explicit module registry or resolveAction function.");
1131
1352
  const actionResolver = configuredActionResolver;
1353
+ const stagedStore = asStagedInvocationStore(options.store);
1354
+ const declaresStaging = options.registry?.orderedModules?.some((module) => (module.actions ?? []).some((action) => (action.stagesInvocations?.length ?? 0) > 0)) ?? false;
1355
+ if (declaresStaging && (!stagedStore || !asAtomicMutationStore(options.store))) throw new Error("Staged descendant invocations require a store with transactionalStagedInvocations and transactionWithEvents capabilities.");
1132
1356
  const stateMachineResolver = options.registry?.resolveStateMachine ?? (() => void 0);
1133
1357
  const eventResultFields = options.eventResultFields ?? ["_events"];
1134
1358
  const admissionTimeoutMs = options.admissionTimeoutMs ?? DEFAULT_ADMISSION_TIMEOUT_MS;
@@ -1137,8 +1361,12 @@ function createGovernedActionHost(options) {
1137
1361
  return error instanceof HostError ? errorMessage(error) : redactErrorText(options.redactErrorText, context, errorMessage(error));
1138
1362
  }
1139
1363
  async function submitAction(input) {
1364
+ return submitActionInternal(input);
1365
+ }
1366
+ async function submitActionInternal(input, internal = {}) {
1140
1367
  const read = snapshotSubmission(input);
1141
- if (read.executionReason && !["initial", "offline_replay"].includes(read.executionReason)) throw new Error(`Unsupported submitted execution reason: ${read.executionReason}`);
1368
+ const submittedExecutionReason = read.executionReason;
1369
+ if (submittedExecutionReason && !["initial", "offline_replay"].includes(submittedExecutionReason) && !(internal.staged && submittedExecutionReason === "staged_dispatch")) throw new Error(`Unsupported submitted execution reason: ${submittedExecutionReason}`);
1142
1370
  const action = actionResolver(read.actionId);
1143
1371
  if (!action) throw new Error(`Unknown action: ${read.actionId}`);
1144
1372
  const declaresAdmission = action.admitSubmission !== void 0;
@@ -1146,15 +1374,15 @@ function createGovernedActionHost(options) {
1146
1374
  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.`);
1147
1375
  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.`);
1148
1376
  const authorizationInput = toAuthorizationInput(action, submission);
1149
- if (!await options.authorization.checkEntitlement(authorizationInput)) throw new Error(`Module "${action.namespace}" is not enabled for tenant ${submission.tenantId}`);
1150
- if (!await options.authorization.authorize(authorizationInput)) throw new Error(`Actor ${submission.actorId} is not authorized for action ${submission.actionId}`);
1377
+ if (!await options.authorization.checkEntitlement(authorizationInput)) throw new SubmissionRefusalError(action.actionId, "entitlement", `Module "${action.namespace}" is not enabled for tenant ${submission.tenantId}`);
1378
+ if (!await options.authorization.authorize(authorizationInput)) throw new SubmissionRefusalError(action.actionId, "authorization", `Actor ${submission.actorId} is not authorized for action ${submission.actionId}`);
1151
1379
  if (declaresAdmission && (!submission.parametersArePrivate || !isRecord(submission.parameters))) refuseAdmission(action, submission, "invalid_parameters");
1152
1380
  const actionInvocationId = createFabricId("act");
1153
1381
  if (submission.correlationId && submission.provenance?.correlationId && submission.correlationId !== submission.provenance.correlationId) throw new Error("Invocation provenance correlationId does not match submission correlationId.");
1154
1382
  if (submission.causationId && submission.provenance?.causationId && submission.causationId !== submission.provenance.causationId) throw new Error("Invocation provenance causationId does not match submission causationId.");
1155
1383
  const correlationId = submission.correlationId ?? submission.provenance?.correlationId ?? createFabricId("corr");
1156
1384
  const prepare = () => {
1157
- const durableParameters = options.redactActionParameters ? options.redactActionParameters(submission.actionId, submission.parameters) : submission.parameters;
1385
+ const durableParameters = !internal.parametersAlreadyRedacted && options.redactActionParameters ? options.redactActionParameters(submission.actionId, submission.parameters) : submission.parameters;
1158
1386
  const parameterDigest = digestParameters(submission.parameters);
1159
1387
  if (submission.authorizationBinding) validateAuthorizationBinding(submission, parameterDigest, action.execution?.authorityMoment, now());
1160
1388
  return {
@@ -1175,7 +1403,7 @@ function createGovernedActionHost(options) {
1175
1403
  assertGovernanceRuntimeEvidence(runtimeEvidence);
1176
1404
  const admission = declaresAdmission ? await admitSubmission(action, submission) : void 0;
1177
1405
  const { durableParameters, parameterDigest } = early ?? prepare();
1178
- const durableInvocation = await options.store.createActionInvocation({
1406
+ const invocationInput = {
1179
1407
  id: actionInvocationId,
1180
1408
  tenantId: submission.tenantId,
1181
1409
  spaceId: submission.spaceId,
@@ -1201,7 +1429,24 @@ function createGovernedActionHost(options) {
1201
1429
  ...submission.executionReason ? { executionReason: submission.executionReason } : {},
1202
1430
  ...submission.authorizationBindingId ? { authorizationBindingId: submission.authorizationBindingId } : {},
1203
1431
  ...admission ? { admittedAt: admission.admittedAt } : {}
1204
- });
1432
+ };
1433
+ const staged = internal.staged;
1434
+ const durableInvocation = staged ? await (async () => {
1435
+ if (!stagedStore) throw new Error("Staged invocation store capability disappeared.");
1436
+ const created = await stagedStore.createActionInvocationForStagedLease({
1437
+ stageId: staged.id,
1438
+ workerId: staged.workerId,
1439
+ leaseToken: staged.leaseToken,
1440
+ now: now(),
1441
+ invocation: {
1442
+ ...invocationInput,
1443
+ stagedOrigin: staged.origin
1444
+ }
1445
+ });
1446
+ if (created.kind === "lease_lost") throw new StagedDispatchLeaseLostError("Staged invocation lease lost before child creation.");
1447
+ staged.onChild(created.invocation);
1448
+ return created.invocation;
1449
+ })() : await options.store.createActionInvocation(invocationInput);
1205
1450
  emitPlatformHostTelemetry(options.telemetry, {
1206
1451
  kind: "event",
1207
1452
  name: "invocation.submitted",
@@ -1235,6 +1480,7 @@ function createGovernedActionHost(options) {
1235
1480
  }
1236
1481
  }
1237
1482
  const durableWorkflowId = `action-invocation-${durableInvocation.id}`;
1483
+ if (internal.staged && durableInvocation.id !== actionInvocationId) return replayExistingStagedInvocation(durableInvocation);
1238
1484
  if (durableInvocation.id !== actionInvocationId) {
1239
1485
  if (options.dispatcher && durableInvocation.status === "pending") {
1240
1486
  const dispatched = await options.dispatcher.dispatch({
@@ -1283,6 +1529,7 @@ function createGovernedActionHost(options) {
1283
1529
  ...durableInvocation.hitlRiskTier ? { hitlRiskTier: durableInvocation.hitlRiskTier } : {}
1284
1530
  }, submission.actionId);
1285
1531
  } catch (error) {
1532
+ if (internal.staged) throw new StagedDispatcherHandoffError("Staged child dispatcher handoff failed.");
1286
1533
  throw error;
1287
1534
  }
1288
1535
  return {
@@ -1291,6 +1538,48 @@ function createGovernedActionHost(options) {
1291
1538
  };
1292
1539
  }
1293
1540
  /**
1541
+ * Hand an existing staged child to the dispatcher again under its stable
1542
+ * workflow id, as the public replay branch above does, when the child is
1543
+ * still pending and a dispatcher is configured. Returns undefined when no
1544
+ * handoff is needed. Raw dispatcher errors are replaced by a fixed one.
1545
+ */
1546
+ async function handOffExistingStagedChild(existing) {
1547
+ if (!options.dispatcher || existing.status !== "pending") return void 0;
1548
+ try {
1549
+ return await options.dispatcher.dispatch({
1550
+ actionInvocationId: existing.id,
1551
+ actionId: existing.actionId,
1552
+ tenantId: existing.tenantId,
1553
+ spaceId: existing.spaceId,
1554
+ workflowId: `action-invocation-${existing.id}`
1555
+ });
1556
+ } catch {
1557
+ throw new StagedDispatcherHandoffError("Staged child dispatcher handoff failed.");
1558
+ }
1559
+ }
1560
+ async function replayExistingStagedInvocation(existing) {
1561
+ const workflowId = `action-invocation-${existing.id}`;
1562
+ const dispatched = await handOffExistingStagedChild(existing);
1563
+ if (dispatched) return withConsistency({
1564
+ actionInvocationId: existing.id,
1565
+ status: existing.status,
1566
+ workflowId: dispatched.workflowId,
1567
+ ...dispatched.runId ? { runId: dispatched.runId } : {}
1568
+ }, existing.actionId);
1569
+ return withConsistency({
1570
+ actionInvocationId: existing.id,
1571
+ status: existing.status,
1572
+ workflowId,
1573
+ result: existing.result,
1574
+ ...existing.error ? { error: existing.error } : {},
1575
+ ...existing.hitlRoute ? { hitlRoute: existing.hitlRoute } : {},
1576
+ ...existing.hitlRiskTier ? { hitlRiskTier: existing.hitlRiskTier } : {},
1577
+ ...existing.authorizationReconciliation ? { reconciliation: existing.authorizationReconciliation } : {},
1578
+ ...existing.adapterReconciliation ? { adapterReconciliation: existing.adapterReconciliation } : {},
1579
+ ...existing.pendingCompletion ? { pendingCompletion: existing.pendingCompletion } : {}
1580
+ }, existing.actionId);
1581
+ }
1582
+ /**
1294
1583
  * Run the action's `admitSubmission` check on a copy of the submission
1295
1584
  * snapshot's parameters, with the snapshot's scope and actor, bounded by
1296
1585
  * `admissionTimeoutMs`. Returns when it was admitted; anything but
@@ -1462,6 +1751,7 @@ function createGovernedActionHost(options) {
1462
1751
  await attemptEvidenceWrites(evidenceWrites);
1463
1752
  }
1464
1753
  async function failExecution(record, status, error) {
1754
+ if (record.finalizationCheckpoint && hasDurableProviderEffect) return settleAppliedEffect(error);
1465
1755
  await persistExecutionInvocation(record, {
1466
1756
  status,
1467
1757
  error
@@ -1754,14 +2044,34 @@ function createGovernedActionHost(options) {
1754
2044
  };
1755
2045
  }
1756
2046
  const action = actionResolver(invocation.actionId);
1757
- if (action && (resumingRunningInvocation || invocation.attemptCount > 1)) try {
1758
- for (const [stepIndex] of (action.adapterSteps ?? []).entries()) {
2047
+ let hasNonSucceededAdapterRow = false;
2048
+ if ((action || invocation.finalizationCheckpoint) && (resumingRunningInvocation || invocation.attemptCount > 1)) try {
2049
+ let runningCheckpoint;
2050
+ for (const stepIndex of Array.from({ length: Math.max((action?.adapterSteps ?? []).length, invocation.finalizationCheckpoint?.steps.length ?? 0) }, (_, index) => index)) {
1759
2051
  const record = await options.store.getAdapterInvocation(lifecycleId("adp", actionInvocationId, String(stepIndex)), adapterInvocationScope);
1760
- if (record) adapterStatuses.set(record.id, record.status);
2052
+ if (record) {
2053
+ adapterStatuses.set(record.id, record.status);
2054
+ hasNonSucceededAdapterRow ||= record.status !== "succeeded";
2055
+ }
1761
2056
  if (record?.status === "succeeded") hasDurableProviderEffect = true;
2057
+ if (invocation.finalizationCheckpoint && record?.status === "running") runningCheckpoint ??= {
2058
+ stepIndex,
2059
+ record
2060
+ };
1762
2061
  if (record?.status !== "ambiguous") continue;
1763
2062
  return await settleUnknownOutcome(stepIndex, recordedAdapterReconciliation(record), record.reconciliation?.adapterEventSubject);
1764
2063
  }
2064
+ if (runningCheckpoint) {
2065
+ const { stepIndex, record } = runningCheckpoint;
2066
+ return await settleUnknownOutcome(stepIndex, {
2067
+ kind: "adapter_outcome_ambiguous",
2068
+ adapterInvocationId: record.id,
2069
+ adapterType: record.adapterType,
2070
+ operation: record.operation,
2071
+ vendor: record.vendor,
2072
+ message: "Recorded adapter outcome is unknown."
2073
+ });
2074
+ }
1765
2075
  } catch (error) {
1766
2076
  if (error instanceof RecoverableFinalizationError) throw error;
1767
2077
  throw new RecoverableFinalizationError(caughtError({
@@ -1771,6 +2081,37 @@ function createGovernedActionHost(options) {
1771
2081
  }
1772
2082
  if (invocation.status === "running" && invocation.pendingCompletion && !invocation.leaseOwner) return withConsistency(actionResult(invocation), invocation.actionId);
1773
2083
  if (!action) return hostOperation(() => failExecution(invocation, "failed", `Unknown action: ${invocation.actionId}`));
2084
+ if (invocation.finalizationCheckpoint && !action.finalize) return interruptedNonIdempotent("Action no longer declares its checkpointed finalizer.");
2085
+ let hasFencedAdapterWrites = false;
2086
+ try {
2087
+ assertAdapterStepDeadlines(action);
2088
+ assertActionFinalization(action);
2089
+ if ((action.stagesInvocations?.length ?? 0) > 0) {
2090
+ const store = asAtomicMutationStore(options.store);
2091
+ if (!stagedStore || !store) throw new Error("Staged descendant invocations require an atomic staging-capable store.");
2092
+ await store.transactionWithEvents(async (transaction) => {
2093
+ if (typeof transaction.stageInvocations !== "function") throw new Error("Staged descendant invocations require transaction-scoped stageInvocations support.");
2094
+ });
2095
+ }
2096
+ if (action.finalize) {
2097
+ const store = asAtomicMutationStore(options.store);
2098
+ if (!store) throw new Error("Action finalization requires atomic mutation transactions.");
2099
+ await store.transactionWithEvents(async (transaction) => {
2100
+ if (typeof transaction.getActionInvocationForUpdate !== "function" || typeof transaction.updateLeasedActionInvocation !== "function" || options.outbox && typeof transaction.appendEventWithOutbox !== "function") throw new Error("Action finalization requires atomic invocation locking and transaction-scoped lease fencing.");
2101
+ hasFencedAdapterWrites = typeof options.store.writeAdapterInvocation === "function" && typeof transaction.writeAdapterInvocation === "function";
2102
+ });
2103
+ }
2104
+ } catch (error) {
2105
+ return interruptedNonIdempotent(caughtError({
2106
+ actionId: action.actionId,
2107
+ source: "execution"
2108
+ }, error));
2109
+ }
2110
+ const replayCheckpoint = action.finalize && (resumingRunningInvocation || invocation.attemptCount > 1) ? invocation.finalizationCheckpoint : void 0;
2111
+ if (replayCheckpoint) {
2112
+ if (replayCheckpoint.protocol !== "atomic-after-adapters-v1" || replayCheckpoint.actionVersion !== action.version || replayCheckpoint.steps.length !== (action.adapterSteps ?? []).length || replayCheckpoint.steps.some((step, index) => step.adapterType !== action.adapterSteps[index]?.adapterType || step.operation !== action.adapterSteps[index]?.operation) || invocation.pendingCompletion) return interruptedNonIdempotent("Finalization checkpoint protocol, action version, step identity or handoff is incompatible with replay.");
2113
+ }
2114
+ if (action.finalize && !replayCheckpoint && (resumingRunningInvocation || invocation.attemptCount > 1) && hasDurableProviderEffect) return interruptedNonIdempotent("Finalizing action recovery requires a preparation checkpoint; reconciliation is required.");
1774
2115
  if (action.admitSubmission !== void 0 && !isAdmissionTime(invocation.admittedAt)) return hostOperation(() => failExecution(invocation, "failed", `Invocation of ${action.actionId} was not admitted at submission; an action that declares admitSubmission runs only on invocations its check admitted.`));
1775
2116
  if (action.kind === "saga") return hostOperation(() => failExecution(invocation, "failed", `Saga action ${invocation.actionId} requires a durable PlatformActionDispatcher and cannot execute as an atomic Host action.`));
1776
2117
  async function interruptedNonIdempotent(message) {
@@ -1800,8 +2141,9 @@ function createGovernedActionHost(options) {
1800
2141
  }
1801
2142
  return hostOperation(() => failExecution(invocation, "failed", message));
1802
2143
  }
1803
- if (resumingRunningInvocation && invocation.attemptCount === 0 && !invocation.leaseOwner && !action.idempotent) return interruptedNonIdempotent(`Interrupted inline action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`);
1804
- if (invocation.attemptCount > 1 && !action.idempotent) return interruptedNonIdempotent(`Interrupted action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`);
2144
+ const canReplayNonIdempotent = !!replayCheckpoint && !hasNonSucceededAdapterRow && (!executionLease || hasFencedAdapterWrites);
2145
+ if (resumingRunningInvocation && invocation.attemptCount === 0 && !invocation.leaseOwner && !action.idempotent && !canReplayNonIdempotent) return interruptedNonIdempotent(`Interrupted inline action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`);
2146
+ if (invocation.attemptCount > 1 && !action.idempotent && !canReplayNonIdempotent && invocation.deferredBeforeEffectAttempt !== invocation.attemptCount - 1) return interruptedNonIdempotent(`Interrupted action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`);
1805
2147
  if (invocation.status !== "running") await hostOperation(() => options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { status: "running" }));
1806
2148
  emitPlatformHostTelemetry(options.telemetry, {
1807
2149
  kind: "event",
@@ -2002,144 +2344,229 @@ function createGovernedActionHost(options) {
2002
2344
  }))));
2003
2345
  }
2004
2346
  }
2347
+ const preparedInputs = /* @__PURE__ */ new Map();
2348
+ let preparation = replayCheckpoint;
2005
2349
  let data;
2350
+ let finalizationInput;
2006
2351
  let domainEvents = [];
2007
2352
  let pendingTransitionEvent;
2353
+ let handlerStagedInvocations = replayCheckpoint?.stagedInvocations ?? [];
2008
2354
  try {
2009
- const runHandler = async (db, transaction) => {
2010
- if (options.outbox && !transaction?.appendEventWithOutbox) throw new HostError("Outbox egress requires transactionWithEvents to provide appendEventWithOutbox before handler execution.");
2011
- const binding = action.stateMachine;
2012
- let replayedTransition;
2013
- let pendingStateTransition;
2014
- let validatedTransition;
2015
- if (binding) {
2016
- const machine = stateMachineResolver(binding.entityType);
2017
- const entityId = binding.getEntityId(parsed.data);
2018
- const getEntityState = transaction?.getEntityState ? transaction.getEntityState.bind(transaction) : options.store.getEntityState.bind(options.store);
2019
- const currentState = entityId ? await hostOperation(() => getEntityState(tenantId, spaceId, binding.entityType, entityId)) ?? initialState(machine) : initialState(machine);
2020
- const targetState = typeof binding.targetState === "function" ? binding.targetState(parsed.data) : binding.targetState;
2021
- if (targetState !== "") {
2022
- const transition = await evaluateStateMachineTransition(machine, binding.entityType, currentState, targetState, action.actionId, {
2023
- entity: {
2024
- ...entityId ? { id: entityId } : {},
2025
- state: currentState
2026
- },
2027
- actionInvocationId,
2028
- actorId: invocation.actorId,
2029
- parameters: parsed.data,
2030
- db
2031
- });
2032
- validatedTransition = {
2033
- entityId,
2034
- currentState,
2035
- targetState,
2036
- valid: transition.valid
2037
- };
2038
- let replayingAppliedTransition = false;
2039
- if (action.idempotent && currentState === targetState && !transition.valid && entityId) {
2040
- let source;
2041
- if (transaction?.getEntityStateSource) source = await hostOperation(() => transaction.getEntityStateSource(tenantId, spaceId, binding.entityType, entityId));
2042
- else if (options.store.getEntityStateSource) source = await hostOperation(() => options.store.getEntityStateSource(tenantId, spaceId, binding.entityType, entityId));
2043
- else {
2044
- const eventStore = options.store;
2045
- const listEvents = transaction?.listEvents?.bind(transaction) ?? eventStore.listEvents?.bind(eventStore);
2046
- const events = listEvents ? await hostOperation(() => listEvents(tenantId, spaceId)) : [];
2047
- let latest;
2048
- for (const event of events) if (event.tenantId === tenantId && event.spaceId === spaceId && event.subjectType === binding.entityType && event.subjectId === entityId && typeof event.payload?.toState === "string" && (!latest || event.sequence > latest.sequence)) latest = event;
2049
- if (latest && typeof latest.actionInvocationId === "string") source = {
2050
- state: latest.payload.toState,
2051
- actionInvocationId: latest.actionInvocationId
2355
+ if (replayCheckpoint) {
2356
+ data = structuredClone(replayCheckpoint.result);
2357
+ finalizationInput = structuredClone({
2358
+ params: replayCheckpoint.params,
2359
+ handlerData: replayCheckpoint.handlerData
2360
+ });
2361
+ domainEvents = structuredClone(replayCheckpoint.handlerEvents);
2362
+ pendingTransitionEvent = structuredClone(replayCheckpoint.stateTransition);
2363
+ } else {
2364
+ const runHandler = async (db, transaction) => {
2365
+ if (options.outbox && !transaction?.appendEventWithOutbox) throw new HostError("Outbox egress requires transactionWithEvents to provide appendEventWithOutbox before handler execution.");
2366
+ if (action.finalize) {
2367
+ const current = await transaction.getActionInvocationForUpdate(actionInvocationId, tenantId, spaceId);
2368
+ if (!current || current.status !== "running" || current.finalizationCheckpoint || (executionLease ? current.leaseOwner !== executionLease.leaseOwner || current.leaseToken !== executionLease.leaseToken : !!current.leaseOwner)) throw new RecoverableFinalizationError(`Invocation preparation ownership changed: ${actionInvocationId}`);
2369
+ }
2370
+ const binding = action.stateMachine;
2371
+ let replayedTransition;
2372
+ let pendingStateTransition;
2373
+ let validatedTransition;
2374
+ if (binding) {
2375
+ const machine = stateMachineResolver(binding.entityType);
2376
+ const entityId = binding.getEntityId(parsed.data);
2377
+ const getEntityState = transaction?.getEntityState ? transaction.getEntityState.bind(transaction) : options.store.getEntityState.bind(options.store);
2378
+ const currentState = entityId ? await hostOperation(() => getEntityState(tenantId, spaceId, binding.entityType, entityId)) ?? initialState(machine) : initialState(machine);
2379
+ const targetState = typeof binding.targetState === "function" ? binding.targetState(parsed.data) : binding.targetState;
2380
+ if (targetState !== "") {
2381
+ const transition = await evaluateStateMachineTransition(machine, binding.entityType, currentState, targetState, action.actionId, {
2382
+ entity: {
2383
+ ...entityId ? { id: entityId } : {},
2384
+ state: currentState
2385
+ },
2386
+ actionInvocationId,
2387
+ actorId: invocation.actorId,
2388
+ parameters: parsed.data,
2389
+ db
2390
+ });
2391
+ validatedTransition = {
2392
+ entityId,
2393
+ currentState,
2394
+ targetState,
2395
+ valid: transition.valid
2396
+ };
2397
+ let replayingAppliedTransition = false;
2398
+ if (action.idempotent && currentState === targetState && !transition.valid && entityId) {
2399
+ let source;
2400
+ if (transaction?.getEntityStateSource) source = await hostOperation(() => transaction.getEntityStateSource(tenantId, spaceId, binding.entityType, entityId));
2401
+ else if (options.store.getEntityStateSource) source = await hostOperation(() => options.store.getEntityStateSource(tenantId, spaceId, binding.entityType, entityId));
2402
+ else {
2403
+ const eventStore = options.store;
2404
+ const listEvents = transaction?.listEvents?.bind(transaction) ?? eventStore.listEvents?.bind(eventStore);
2405
+ const events = listEvents ? await hostOperation(() => listEvents(tenantId, spaceId)) : [];
2406
+ let latest;
2407
+ for (const event of events) if (event.tenantId === tenantId && event.spaceId === spaceId && event.subjectType === binding.entityType && event.subjectId === entityId && typeof event.payload?.toState === "string" && (!latest || event.sequence > latest.sequence)) latest = event;
2408
+ if (latest && typeof latest.actionInvocationId === "string") source = {
2409
+ state: latest.payload.toState,
2410
+ actionInvocationId: latest.actionInvocationId
2411
+ };
2412
+ }
2413
+ replayingAppliedTransition = source?.actionInvocationId === actionInvocationId && source.state === currentState;
2414
+ }
2415
+ const pending = invocation.pendingStateTransition;
2416
+ if (replayingAppliedTransition && pending && pending.entityType === binding.entityType && pending.entityId === entityId && pending.toState === currentState) replayedTransition = pending;
2417
+ if (action.idempotent && transition.valid && entityId && action.eventPhase === "after_adapters") {
2418
+ pendingStateTransition = {
2419
+ entityType: binding.entityType,
2420
+ entityId,
2421
+ fromState: currentState,
2422
+ toState: targetState
2052
2423
  };
2424
+ if (!transaction) await hostOperation(() => options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { pendingStateTransition }));
2053
2425
  }
2054
- replayingAppliedTransition = source?.actionInvocationId === actionInvocationId && source.state === currentState;
2426
+ if (!transition.valid && !replayingAppliedTransition) throw transition.error != null ? new Error(transition.error) : new HostError("Invalid state transition");
2055
2427
  }
2056
- const pending = invocation.pendingStateTransition;
2057
- if (replayingAppliedTransition && pending && pending.entityType === binding.entityType && pending.entityId === entityId && pending.toState === currentState) replayedTransition = pending;
2058
- if (action.idempotent && transition.valid && entityId && action.eventPhase === "after_adapters") {
2059
- pendingStateTransition = {
2428
+ }
2429
+ const handlerResult = action.handler ? await action.handler({
2430
+ actionInvocationId,
2431
+ tenantId,
2432
+ spaceId,
2433
+ actorId: invocation.actorId,
2434
+ actorType: invocation.actorType,
2435
+ correlationId: invocation.correlationId,
2436
+ ...invocation.causationId ? { causationId: invocation.causationId } : {},
2437
+ ...invocation.provenance ? { provenance: invocation.provenance } : {},
2438
+ ...isAdmissionTime(invocation.admittedAt) ? { admittedAt: invocation.admittedAt } : {},
2439
+ db,
2440
+ services: options.services,
2441
+ ...action.finalize ? { finalizationProtocol: "atomic-after-adapters-v1" } : {}
2442
+ }, parsed.data) : {
2443
+ success: true,
2444
+ data: {}
2445
+ };
2446
+ if (!handlerResult.success) throw handlerResult.error != null ? new Error(handlerResult.error) : new HostError("Action handler failed");
2447
+ const handlerData = handlerResult.data ?? {};
2448
+ const rawStagedInvocations = handlerResult.stagedInvocations ?? [];
2449
+ const stagedRecords = prepareStagedInvocationBatch({
2450
+ parent: invocation,
2451
+ parentAction: action,
2452
+ requests: rawStagedInvocations,
2453
+ resolveAction: actionResolver,
2454
+ redactParameters: options.redactActionParameters,
2455
+ now: now()
2456
+ });
2457
+ handlerStagedInvocations = stagedRecords.map((record) => ({
2458
+ key: record.key,
2459
+ actionId: record.actionId,
2460
+ parameters: structuredClone(record.parameters)
2461
+ }));
2462
+ if (stagedRecords.length > 0 && action.eventPhase !== "after_adapters") {
2463
+ if (!transaction?.stageInvocations) throw new Error("Staged descendant invocations require an atomic staging transaction.");
2464
+ await transaction.stageInvocations(stagedRecords);
2465
+ }
2466
+ if (action.resultSchema && !action.finalize) {
2467
+ const publicResult = withoutPrivateHostFields(handlerData, eventResultFields);
2468
+ const parsedResult = action.resultSchema.safeParse(publicResult);
2469
+ if (!parsedResult.success) throw new HostError(`Action result validation failed at ${parsedResult.error.issues.map((issue) => issue.path.map(String).join(".") || "result").join(", ")}`);
2470
+ }
2471
+ const preparedFinalizationInput = action.finalize ? {
2472
+ params: normalizeFinalizationJson(parsed.data),
2473
+ handlerData: normalizeFinalizationJson(withoutPrivateHostFields(handlerData, eventResultFields))
2474
+ } : void 0;
2475
+ const extractedEvents = extractEvents(handlerData);
2476
+ const events = action.finalize ? normalizeFinalizationJson(extractedEvents) : extractedEvents;
2477
+ const undeclaredEvent = events.find((event) => !action.emitsEvents.includes(event.eventType));
2478
+ if (undeclaredEvent) throw new HostError(`${action.actionId} emitted undeclared event type ${undeclaredEvent.eventType}`);
2479
+ if (action.eventPhase !== "after_adapters") for (const [index, event] of events.entries()) await hostOperation(() => appendEvent(invocation, event, `domain:${index}`, action.version, transaction));
2480
+ let transitionEvent;
2481
+ if (binding && validatedTransition?.entityId && (validatedTransition.valid || replayedTransition)) {
2482
+ const { entityId, currentState, targetState } = validatedTransition;
2483
+ if (!events.some((event) => event.eventType === "StateTransitioned" && event.subjectType === binding.entityType && event.subjectId === entityId)) transitionEvent = {
2484
+ eventType: "StateTransitioned",
2485
+ subjectType: binding.entityType,
2486
+ subjectId: entityId,
2487
+ payload: {
2060
2488
  entityType: binding.entityType,
2061
2489
  entityId,
2062
- fromState: currentState,
2063
- toState: targetState
2064
- };
2065
- if (!transaction) await hostOperation(() => options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { pendingStateTransition }));
2066
- }
2067
- if (!transition.valid && !replayingAppliedTransition) throw transition.error != null ? new Error(transition.error) : new HostError("Invalid state transition");
2490
+ fromState: replayedTransition?.fromState ?? currentState,
2491
+ toState: targetState,
2492
+ causedByAction: action.actionId
2493
+ }
2494
+ };
2068
2495
  }
2069
- }
2070
- const handlerResult = action.handler ? await action.handler({
2071
- actionInvocationId,
2072
- tenantId,
2073
- spaceId,
2074
- actorId: invocation.actorId,
2075
- actorType: invocation.actorType,
2076
- correlationId: invocation.correlationId,
2077
- ...invocation.causationId ? { causationId: invocation.causationId } : {},
2078
- ...invocation.provenance ? { provenance: invocation.provenance } : {},
2079
- ...isAdmissionTime(invocation.admittedAt) ? { admittedAt: invocation.admittedAt } : {},
2080
- db,
2081
- services: options.services
2082
- }, parsed.data) : {
2083
- success: true,
2084
- data: {}
2085
- };
2086
- if (!handlerResult.success) throw handlerResult.error != null ? new Error(handlerResult.error) : new HostError("Action handler failed");
2087
- const handlerData = handlerResult.data ?? {};
2088
- if (action.resultSchema) {
2089
- const publicResult = withoutPrivateHostFields(handlerData, eventResultFields);
2090
- const parsedResult = action.resultSchema.safeParse(publicResult);
2091
- if (!parsedResult.success) throw new HostError(`Action result validation failed at ${parsedResult.error.issues.map((issue) => issue.path.map(String).join(".") || "result").join(", ")}`);
2092
- }
2093
- const events = extractEvents(handlerData);
2094
- const undeclaredEvent = events.find((event) => !action.emitsEvents.includes(event.eventType));
2095
- if (undeclaredEvent) throw new HostError(`${action.actionId} emitted undeclared event type ${undeclaredEvent.eventType}`);
2096
- if (action.eventPhase !== "after_adapters") for (const [index, event] of events.entries()) await hostOperation(() => appendEvent(invocation, event, `domain:${index}`, action.version, transaction));
2097
- let transitionEvent;
2098
- if (binding && validatedTransition?.entityId && (validatedTransition.valid || replayedTransition)) {
2099
- const { entityId, currentState, targetState } = validatedTransition;
2100
- if (!events.some((event) => event.eventType === "StateTransitioned" && event.subjectType === binding.entityType && event.subjectId === entityId)) transitionEvent = {
2101
- eventType: "StateTransitioned",
2102
- subjectType: binding.entityType,
2103
- subjectId: entityId,
2104
- payload: {
2105
- entityType: binding.entityType,
2106
- entityId,
2107
- fromState: replayedTransition?.fromState ?? currentState,
2108
- toState: targetState,
2109
- causedByAction: action.actionId
2496
+ if (transitionEvent && action.eventPhase !== "after_adapters") await hostOperation(() => appendEvent(invocation, transitionEvent, "state:transition", 1, transaction));
2497
+ if (transaction && pendingStateTransition) await hostOperation(() => transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, { pendingStateTransition }));
2498
+ if (preparedFinalizationInput) {
2499
+ const skipped = [];
2500
+ for (const [index, step] of (action.adapterSteps ?? []).entries()) {
2501
+ const input = step.getInput(structuredClone(preparedFinalizationInput.params), structuredClone(preparedFinalizationInput.handlerData));
2502
+ preparedInputs.set(index, input);
2503
+ if (!input) skipped.push(index);
2110
2504
  }
2505
+ preparation = normalizeFinalizationJson({
2506
+ protocol: "atomic-after-adapters-v1",
2507
+ actionVersion: action.version,
2508
+ steps: (action.adapterSteps ?? []).map(({ adapterType, operation }) => ({
2509
+ adapterType,
2510
+ operation
2511
+ })),
2512
+ ...preparedFinalizationInput,
2513
+ handlerEvents: events,
2514
+ result: preparedFinalizationInput.handlerData,
2515
+ ...transitionEvent ? { stateTransition: transitionEvent } : {},
2516
+ skipped,
2517
+ ...handlerStagedInvocations.length > 0 ? { stagedInvocations: handlerStagedInvocations } : {}
2518
+ });
2519
+ if (new TextEncoder().encode(canonicalJson(preparation)).byteLength > 262144) throw new HostError("Finalization checkpoint exceeds 256 KiB.");
2520
+ if (executionLease) {
2521
+ if (!await transaction.updateLeasedActionInvocation({
2522
+ id: actionInvocationId,
2523
+ tenantId,
2524
+ spaceId,
2525
+ workerId: executionLease.leaseOwner,
2526
+ leaseToken: executionLease.leaseToken,
2527
+ patch: { finalizationCheckpoint: preparation }
2528
+ })) throw new RecoverableFinalizationError(`Invocation lease lost: ${actionInvocationId}`);
2529
+ } else await transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, { finalizationCheckpoint: preparation });
2530
+ }
2531
+ return {
2532
+ data: handlerData,
2533
+ domainEvents: events,
2534
+ pendingTransitionEvent: transitionEvent,
2535
+ finalizationInput: preparedFinalizationInput
2111
2536
  };
2112
- }
2113
- if (transitionEvent && action.eventPhase !== "after_adapters") await hostOperation(() => appendEvent(invocation, transitionEvent, "state:transition", 1, transaction));
2114
- if (transaction && pendingStateTransition) await hostOperation(() => transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, { pendingStateTransition }));
2115
- return {
2116
- data: handlerData,
2117
- domainEvents: events,
2118
- pendingTransitionEvent: transitionEvent
2119
2537
  };
2120
- };
2121
- const atomicStore = asAtomicMutationStore(options.store);
2122
- let callbackFailure;
2123
- const run = async (db, transaction) => {
2124
- callbackFailure = void 0;
2125
- try {
2126
- return await runHandler(db, transaction);
2127
- } catch (error) {
2128
- callbackFailure = { error };
2129
- throw error;
2130
- }
2131
- };
2132
- const executed = await (async () => {
2133
- try {
2134
- return atomicStore ? await atomicStore.transactionWithEvents((transaction) => run(transaction.db, transaction)) : await options.store.transaction((db) => run(db));
2135
- } catch (error) {
2136
- if (callbackFailure) throw callbackFailure.error;
2137
- return hostOperation(() => Promise.reject(error));
2138
- }
2139
- })();
2140
- data = executed.data;
2141
- domainEvents = executed.domainEvents;
2142
- pendingTransitionEvent = executed.pendingTransitionEvent;
2538
+ const atomicStore = asAtomicMutationStore(options.store);
2539
+ let callbackFailure;
2540
+ const run = async (db, transaction) => {
2541
+ callbackFailure = void 0;
2542
+ try {
2543
+ return await runHandler(db, transaction);
2544
+ } catch (error) {
2545
+ callbackFailure = { error };
2546
+ throw error;
2547
+ }
2548
+ };
2549
+ const executed = await (async () => {
2550
+ try {
2551
+ return atomicStore ? await atomicStore.transactionWithEvents((transaction) => run(transaction.db, transaction)) : await options.store.transaction((db) => run(db));
2552
+ } catch (error) {
2553
+ if (callbackFailure) throw callbackFailure.error;
2554
+ if (action.finalize) throw new RecoverableFinalizationError(caughtError({
2555
+ actionId: action.actionId,
2556
+ source: "execution"
2557
+ }, error));
2558
+ return hostOperation(() => Promise.reject(error));
2559
+ }
2560
+ })();
2561
+ data = executed.data;
2562
+ finalizationInput = executed.finalizationInput;
2563
+ domainEvents = executed.domainEvents;
2564
+ pendingTransitionEvent = executed.pendingTransitionEvent;
2565
+ if (preparation) invocation = {
2566
+ ...invocation,
2567
+ finalizationCheckpoint: preparation
2568
+ };
2569
+ }
2143
2570
  } catch (error) {
2144
2571
  if (error instanceof RecoverableFinalizationError) throw error;
2145
2572
  if (isReconciliationRequiredError(error)) {
@@ -2164,6 +2591,7 @@ function createGovernedActionHost(options) {
2164
2591
  source: "handler"
2165
2592
  }, error)));
2166
2593
  }
2594
+ const finalizationSteps = [];
2167
2595
  let pendingHandoff = invocation.pendingCompletion;
2168
2596
  for (const [stepIndex, step] of (action.adapterSteps ?? []).entries()) {
2169
2597
  const adapterInvocationId = lifecycleId("adp", actionInvocationId, String(stepIndex));
@@ -2233,8 +2661,16 @@ function createGovernedActionHost(options) {
2233
2661
  }, error));
2234
2662
  }
2235
2663
  if (previousAdapterInvocation?.status === "ambiguous") return await settleUnknownOutcome(stepIndex, recordedAdapterReconciliation(previousAdapterInvocation), previousAdapterInvocation.reconciliation?.adapterEventSubject);
2236
- const input = step.getInput(parsed.data, data);
2237
- if (!input) continue;
2664
+ const input = action.finalize ? preparation.skipped.includes(stepIndex) ? void 0 : replayCheckpoint ? step.getInput(structuredClone(replayCheckpoint.params), structuredClone(replayCheckpoint.handlerData)) : preparedInputs.get(stepIndex) : step.getInput(parsed.data, data);
2665
+ if (!input) {
2666
+ if (action.finalize) finalizationSteps.push({
2667
+ stepIndex,
2668
+ adapterType: step.adapterType,
2669
+ operation: step.operation,
2670
+ status: "skipped"
2671
+ });
2672
+ continue;
2673
+ }
2238
2674
  const adapter = adapters.require(step.adapterType, step.operation);
2239
2675
  const adapterEventSubject = options.adapterEventSubject?.(action.actionId, parsed.data, data) ?? {
2240
2676
  subjectType: "AdapterInvocation",
@@ -2390,8 +2826,9 @@ function createGovernedActionHost(options) {
2390
2826
  let thrownOutcome;
2391
2827
  let adapterDeadlineTimer;
2392
2828
  try {
2393
- const adapterController = options.adapterDeadlineMs ? new AbortController() : void 0;
2394
- const deadlineMs = options.adapterDeadlineMs ? now().getTime() + options.adapterDeadlineMs : void 0;
2829
+ const effectiveDeadlineMs = step.deadlineMs ?? options.adapterDeadlineMs;
2830
+ const adapterController = effectiveDeadlineMs ? new AbortController() : void 0;
2831
+ const deadlineMs = effectiveDeadlineMs ? now().getTime() + effectiveDeadlineMs : void 0;
2395
2832
  if (adapterController && deadlineMs) {
2396
2833
  const remaining = deadlineMs - now().getTime();
2397
2834
  if (remaining <= 0) throw new HostError(`Adapter deadline already expired before execution: ${step.adapterType}:${step.operation}`);
@@ -2576,6 +3013,7 @@ function createGovernedActionHost(options) {
2576
3013
  ...pendingCompletion ? { pendingCompletion } : {}
2577
3014
  }, action.actionId);
2578
3015
  }
3016
+ if (action.finalize) output = normalizeFinalizationJson(output);
2579
3017
  successOutput = output;
2580
3018
  if (handoff) {
2581
3019
  const reference = typeof handoff.externalReference === "string" ? handoff.externalReference.trim() : "";
@@ -2809,7 +3247,8 @@ function createGovernedActionHost(options) {
2809
3247
  return hostOperation(() => failExecution(invocation, "failed", message));
2810
3248
  }
2811
3249
  }
2812
- const result = withoutPrivateHostFields(data, eventResultFields);
3250
+ if (action.finalize && pendingHandoff) return settleAppliedEffect("Finalizing actions cannot finalize accepted external operations.", pendingHandoff);
3251
+ let result = action.finalize ? { ...replayCheckpoint?.result ?? finalizationInput.handlerData } : withoutPrivateHostFields(data, eventResultFields);
2813
3252
  const parked = pendingHandoff ? {
2814
3253
  ...pendingHandoff,
2815
3254
  parkedAt: now()
@@ -2823,25 +3262,96 @@ function createGovernedActionHost(options) {
2823
3262
  result
2824
3263
  };
2825
3264
  let verdictWritten = true;
3265
+ let finalizationCallbackFailure;
2826
3266
  try {
2827
3267
  const atomicStore = asAtomicMutationStore(options.store);
2828
3268
  if (action.eventPhase === "after_adapters" && atomicStore) {
2829
- await hostOperation(() => atomicStore.transactionWithEvents(async (transaction) => {
2830
- for (const [index, event] of domainEvents.entries()) await appendEvent(invocation, event, `domain:${index}`, action.version, transaction);
2831
- if (pendingTransitionEvent) await appendEvent(invocation, pendingTransitionEvent, "state:transition", 1, transaction);
2832
- if (executionLease) {
2833
- if (!transaction.updateLeasedActionInvocation) throw new HostError("Leased atomic finalization requires transaction-scoped fencing support.");
2834
- if (!await transaction.updateLeasedActionInvocation({
2835
- id: actionInvocationId,
2836
- tenantId,
2837
- spaceId,
2838
- workerId: executionLease.leaseOwner,
2839
- leaseToken: executionLease.leaseToken,
2840
- patch: finalPatch
2841
- })) throw new RecoverableFinalizationError(`Invocation lease lost: ${actionInvocationId}`);
2842
- } else verdictWritten = await writeInlineInvocation(invocation, finalPatch, transaction, onPreservedReconciliation);
2843
- }));
2844
- if (!pendingHandoff && verdictWritten) emitInvocationStatusTelemetry(invocation, "completed", options.telemetry, now());
3269
+ if (action.finalize) for (const [stepIndex, step] of (action.adapterSteps ?? []).entries()) {
3270
+ if (finalizationSteps.some((entry) => entry.stepIndex === stepIndex)) continue;
3271
+ const row = await options.store.getAdapterInvocation(lifecycleId("adp", actionInvocationId, String(stepIndex)), adapterInvocationScope);
3272
+ if (row?.status !== "succeeded" || row.output === void 0) {
3273
+ const error = /* @__PURE__ */ new Error("Finalization requires successful stored adapter output.");
3274
+ finalizationCallbackFailure = { error };
3275
+ throw error;
3276
+ }
3277
+ finalizationSteps.push({
3278
+ stepIndex,
3279
+ adapterType: step.adapterType,
3280
+ operation: step.operation,
3281
+ status: "succeeded",
3282
+ adapterInvocationId: row.id,
3283
+ output: finalizationJson(row.output)
3284
+ });
3285
+ }
3286
+ const finalizeSuccess = () => atomicStore.transactionWithEvents(async (transaction) => {
3287
+ try {
3288
+ let finalized;
3289
+ async function assertFinalizationOwner() {
3290
+ const current = await transaction.getActionInvocationForUpdate(actionInvocationId, tenantId, spaceId);
3291
+ if (!current || current.status !== "running" || (executionLease ? current.leaseOwner !== executionLease.leaseOwner || current.leaseToken !== executionLease.leaseToken : !!current.leaseOwner)) throw new RecoverableFinalizationError(`Invocation lease lost or no longer running: ${actionInvocationId}`);
3292
+ }
3293
+ if (action.finalize) {
3294
+ await assertFinalizationOwner();
3295
+ finalized = validateFinalization(await action.finalize({
3296
+ actionInvocationId,
3297
+ tenantId,
3298
+ spaceId,
3299
+ actorId: invocation.actorId,
3300
+ actorType: invocation.actorType,
3301
+ correlationId: invocation.correlationId,
3302
+ causationId: invocation.causationId,
3303
+ provenance: invocation.provenance ? structuredClone(invocation.provenance) : void 0,
3304
+ db: transaction.db
3305
+ }, {
3306
+ ...structuredClone(finalizationInput),
3307
+ adapterOutputs: structuredClone(finalizationSteps.sort((a, b) => a.stepIndex - b.stepIndex))
3308
+ }), action, eventResultFields, options.eventContracts);
3309
+ result = withoutPrivateHostFields({
3310
+ ...result,
3311
+ ...finalized.resultPatch
3312
+ }, eventResultFields);
3313
+ if (action.resultSchema && !action.resultSchema.safeParse(result).success) throw new Error("Finalization merged result does not match resultSchema.");
3314
+ finalPatch.result = result;
3315
+ }
3316
+ const stagedRequests = [...handlerStagedInvocations, ...finalized?.stagedInvocations ?? []];
3317
+ if (stagedRequests.length > 0) {
3318
+ if (!transaction.stageInvocations) throw new Error("Staged descendant invocations require an atomic staging transaction.");
3319
+ await transaction.stageInvocations(prepareStagedInvocationBatch({
3320
+ parent: invocation,
3321
+ parentAction: action,
3322
+ requests: stagedRequests,
3323
+ resolveAction: actionResolver,
3324
+ redactParameters: options.redactActionParameters,
3325
+ redactedKeys: handlerStagedInvocations.map((request) => request.key),
3326
+ now: now()
3327
+ }));
3328
+ }
3329
+ for (const [index, event] of domainEvents.entries()) await appendEvent(invocation, event, `domain:${index}`, action.version, transaction);
3330
+ for (const [index, event] of (finalized?.events ?? []).entries()) await appendEvent(invocation, event, `finalize:${index}`, action.version, transaction);
3331
+ if (pendingTransitionEvent) await appendEvent(invocation, pendingTransitionEvent, "state:transition", 1, transaction);
3332
+ if (action.finalize) await assertFinalizationOwner();
3333
+ if (executionLease) {
3334
+ if (!transaction.updateLeasedActionInvocation) throw new HostError("Leased atomic finalization requires transaction-scoped fencing support.");
3335
+ if (!await transaction.updateLeasedActionInvocation({
3336
+ id: actionInvocationId,
3337
+ tenantId,
3338
+ spaceId,
3339
+ workerId: executionLease.leaseOwner,
3340
+ leaseToken: executionLease.leaseToken,
3341
+ patch: finalPatch
3342
+ })) throw new RecoverableFinalizationError(`Invocation lease lost: ${actionInvocationId}`);
3343
+ } else verdictWritten = await writeInlineInvocation(invocation, finalPatch, transaction, onPreservedReconciliation);
3344
+ } catch (error) {
3345
+ finalizationCallbackFailure = { error };
3346
+ throw error;
3347
+ }
3348
+ });
3349
+ if (action.finalize) await finalizeSuccess();
3350
+ else await hostOperation(finalizeSuccess);
3351
+ if (!pendingHandoff && verdictWritten) {
3352
+ if (action.finalize) rememberVerdict(finalPatch);
3353
+ emitInvocationStatusTelemetry(invocation, "completed", options.telemetry, now());
3354
+ }
2845
3355
  } else {
2846
3356
  if (action.eventPhase === "after_adapters") {
2847
3357
  for (const [index, event] of domainEvents.entries()) await hostOperation(() => appendEvent(invocation, event, `domain:${index}`, action.version));
@@ -2850,6 +3360,33 @@ function createGovernedActionHost(options) {
2850
3360
  await hostOperation(() => persistExecutionInvocation(invocation, finalPatch));
2851
3361
  }
2852
3362
  } catch (error) {
3363
+ if (isReconciliationRequiredError(error) && error.message.startsWith("Staged invocation batch drift")) {
3364
+ const message = caughtError({
3365
+ actionId: action.actionId,
3366
+ source: "execution"
3367
+ }, error);
3368
+ await hostOperation(() => persistExecutionInvocation(invocation, {
3369
+ status: "reconciliation_required",
3370
+ error: message
3371
+ }));
3372
+ return withConsistency({
3373
+ actionInvocationId,
3374
+ status: "reconciliation_required",
3375
+ error: message
3376
+ }, action.actionId);
3377
+ }
3378
+ if (action.finalize) {
3379
+ if (error instanceof RecoverableFinalizationError) throw error;
3380
+ if (!finalizationCallbackFailure || finalizationCallbackFailure.error !== error) throw new RecoverableFinalizationError(caughtError({
3381
+ actionId: action.actionId,
3382
+ source: "execution"
3383
+ }, error));
3384
+ const message = caughtError({
3385
+ actionId: action.actionId,
3386
+ source: "execution"
3387
+ }, error);
3388
+ return hasDurableProviderEffect ? settleAppliedEffect(message) : hostOperation(() => failExecution(invocation, "failed", message));
3389
+ }
2853
3390
  if (action.eventPhase === "after_adapters") throw new RecoverableFinalizationError(errorMessage(error));
2854
3391
  throw error;
2855
3392
  }
@@ -2864,7 +3401,13 @@ function createGovernedActionHost(options) {
2864
3401
  } catch (error) {
2865
3402
  if (error instanceof RecoverableFinalizationError) throw error;
2866
3403
  if (retryableGovernance && isRetryableExecutionError(error)) {
2867
- const reason = executionReasonOverride === "approval_resume" ? "approval resume" : !invocation.leaseOwner || invocation.leaseToken === void 0 ? "not leased" : !action.idempotent ? "action not idempotent" : invocation.attemptCount >= retryableExecutionMaxAttempts ? `attempts exhausted (${invocation.attemptCount}/${retryableExecutionMaxAttempts})` : void 0;
3404
+ let reason = executionReasonOverride === "approval_resume" ? "approval resume" : !invocation.leaseOwner || invocation.leaseToken === void 0 ? "not leased" : invocation.attemptCount >= retryableExecutionMaxAttempts ? `attempts exhausted (${invocation.attemptCount}/${retryableExecutionMaxAttempts})` : void 0;
3405
+ if (!reason && !action.idempotent) try {
3406
+ await persistExecutionInvocation(invocation, { deferredBeforeEffectAttempt: invocation.attemptCount });
3407
+ } catch (markerError) {
3408
+ if (markerError instanceof RecoverableFinalizationError) throw markerError;
3409
+ reason = "pre-effect marker could not be recorded";
3410
+ }
2868
3411
  if (!reason) throw error;
2869
3412
  return hostOperation(() => failExecution(invocation, "failed", caughtError({
2870
3413
  actionId: invocation.actionId,
@@ -3128,8 +3671,282 @@ function createGovernedActionHost(options) {
3128
3671
  actionResult,
3129
3672
  withConsistency
3130
3673
  });
3674
+ async function dispatchStagedInvocations(input) {
3675
+ if (!stagedStore) throw new Error("This Platform Host store does not support staged descendant invocations.");
3676
+ const leaseDurationMs = input.leaseDurationMs ?? 3e4;
3677
+ const submissionTimeoutMs = input.submissionTimeoutMs ?? 1e4;
3678
+ const maxAttempts = input.maxAttempts ?? 10;
3679
+ for (const [name, value] of [
3680
+ ["leaseDurationMs", leaseDurationMs],
3681
+ ["submissionTimeoutMs", submissionTimeoutMs],
3682
+ ["maxAttempts", maxAttempts]
3683
+ ]) if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer.`);
3684
+ const cancelled = await stagedStore.cancelStagedInvocationsForFailedParents({
3685
+ ...input.tenantId ? { tenantId: input.tenantId } : {},
3686
+ ...input.spaceId ? { spaceId: input.spaceId } : {},
3687
+ now: now()
3688
+ });
3689
+ const records = await stagedStore.claimStagedInvocations({
3690
+ workerId: input.workerId,
3691
+ leaseDurationMs,
3692
+ limit: input.limit ?? 10,
3693
+ ...input.tenantId ? { tenantId: input.tenantId } : {},
3694
+ ...input.spaceId ? { spaceId: input.spaceId } : {},
3695
+ now: now()
3696
+ });
3697
+ const result = {
3698
+ claimed: records.length,
3699
+ dispatched: 0,
3700
+ rejected: 0,
3701
+ failed: 0,
3702
+ deadLettered: 0,
3703
+ cancelled
3704
+ };
3705
+ const sweep = {
3706
+ store: stagedStore,
3707
+ input,
3708
+ leaseDurationMs,
3709
+ submissionTimeoutMs,
3710
+ maxAttempts
3711
+ };
3712
+ for (const record of records) {
3713
+ let outcome;
3714
+ try {
3715
+ outcome = await dispatchStagedRow(record, sweep);
3716
+ } catch (error) {
3717
+ reportStagedError(input, error);
3718
+ outcome = "unsettled";
3719
+ }
3720
+ if (outcome === "dispatched") result.dispatched += 1;
3721
+ else if (outcome === "rejected") result.rejected += 1;
3722
+ else {
3723
+ result.failed += 1;
3724
+ if (outcome === "dead_lettered") result.deadLettered += 1;
3725
+ }
3726
+ }
3727
+ return result;
3728
+ }
3729
+ /**
3730
+ * Settle one claimed row through a single sequence:
3731
+ *
3732
+ * 1. Renew the claim's lease, then look the child up by staged origin. An
3733
+ * earlier attempt's creation still in flight holds an older lease token
3734
+ * and fails the creation fence, so this lookup is authoritative for
3735
+ * earlier attempts. A child found here goes to step 4: no
3736
+ * pre-submission checks, no governance.
3737
+ * 2. No child: pre-submission checks and the shared submission pipeline.
3738
+ * 3. Unless this attempt's own create returned the child, rotate the lease
3739
+ * token. Only this attempt's own timed-out submission could still create
3740
+ * the child, it holds the old token, and the store orders the rotation
3741
+ * after any create the fence already admitted. The lookup that follows is
3742
+ * authoritative; a child found there goes to step 4, otherwise the
3743
+ * outcome is classified as rejected, retry or dead letter.
3744
+ * 4. A known child that is pending is handed to the configured dispatcher
3745
+ * again under its stable workflow id; the stage settles with its id.
3746
+ *
3747
+ * Each settlement is one compare-and-set on the current owner and token. A
3748
+ * lookup that fails settles the row for retry, never terminally.
3749
+ */
3750
+ async function dispatchStagedRow(record, sweep) {
3751
+ const { store, input, leaseDurationMs, maxAttempts } = sweep;
3752
+ const claimLease = {
3753
+ id: record.id,
3754
+ workerId: input.workerId,
3755
+ leaseToken: record.leaseToken
3756
+ };
3757
+ if (!await store.renewStagedInvocationLease({
3758
+ ...claimLease,
3759
+ leaseDurationMs,
3760
+ now: now()
3761
+ })) return "unsettled";
3762
+ const prior = await lookUpStagedChild(record, sweep);
3763
+ if (prior.kind === "unreadable") return settleStagedRetry(record, claimLease, sweep, {
3764
+ error: STAGED_ERROR.lookup,
3765
+ terminal: false
3766
+ });
3767
+ if (prior.kind === "found") return settleExistingStagedChild(record, claimLease, prior.child, sweep);
3768
+ const known = {};
3769
+ let failure;
3770
+ let submitted = false;
3771
+ const stopHeartbeat = startStagedHeartbeat(store, claimLease, input, leaseDurationMs);
3772
+ try {
3773
+ await withSubmissionTimeout(submitStagedChild(record, claimLease, (child) => {
3774
+ known.child = child;
3775
+ }), sweep.submissionTimeoutMs);
3776
+ submitted = true;
3777
+ } catch (error) {
3778
+ failure = error;
3779
+ } finally {
3780
+ await stopHeartbeat();
3781
+ }
3782
+ const createdChild = known.child;
3783
+ if (createdChild) {
3784
+ if (submitted || !options.dispatcher) return settleStagedDispatched(claimLease, createdChild.id, sweep);
3785
+ return settleStagedRetry(record, claimLease, sweep, {
3786
+ error: STAGED_ERROR.handoff,
3787
+ childInvocationId: createdChild.id,
3788
+ terminal: record.attemptCount >= maxAttempts
3789
+ });
3790
+ }
3791
+ const rotatedToken = await store.rotateStagedInvocationLease({
3792
+ ...claimLease,
3793
+ leaseDurationMs,
3794
+ now: now()
3795
+ });
3796
+ if (rotatedToken === void 0) return "unsettled";
3797
+ const lease = {
3798
+ ...claimLease,
3799
+ leaseToken: rotatedToken
3800
+ };
3801
+ const after = await lookUpStagedChild(record, sweep);
3802
+ if (after.kind === "unreadable") return settleStagedRetry(record, lease, sweep, {
3803
+ error: STAGED_ERROR.lookup,
3804
+ terminal: false
3805
+ });
3806
+ if (after.kind === "found") return settleExistingStagedChild(record, lease, after.child, sweep);
3807
+ if (isDefinitiveStagedRefusal(failure)) return await store.markStagedInvocationRejected({
3808
+ ...lease,
3809
+ error: STAGED_ERROR.refused,
3810
+ now: now()
3811
+ }) ? "rejected" : "unsettled";
3812
+ return settleStagedRetry(record, lease, sweep, {
3813
+ error: STAGED_ERROR.dispatch,
3814
+ terminal: record.attemptCount >= maxAttempts
3815
+ });
3816
+ }
3817
+ /** Pre-submission checks, then the shared submission pipeline under the claim's lease. */
3818
+ async function submitStagedChild(record, lease, onChild) {
3819
+ const parent = await options.store.getActionInvocation(record.parentInvocationId, record.tenantId, record.spaceId);
3820
+ if (!parent || parent.status !== "completed") throw new Error("Staged parent is not completed.");
3821
+ const child = actionResolver(record.actionId);
3822
+ if (!child) throw new StagedDispatchRejectionError("Staged action is no longer registered.");
3823
+ if (child.execution?.authorityMoment === "capture") throw new StagedDispatchRejectionError("Staged action now requires capture-time authority.");
3824
+ if (!child.schema.safeParse(structuredClone(record.parameters)).success) throw new StagedDispatchRejectionError("Staged parameters no longer match the registered action.");
3825
+ return submitActionInternal({
3826
+ actionId: record.actionId,
3827
+ parameters: structuredClone(record.parameters),
3828
+ tenantId: parent.tenantId,
3829
+ spaceId: parent.spaceId,
3830
+ actorId: parent.actorId,
3831
+ actorType: parent.actorType,
3832
+ correlationId: parent.correlationId,
3833
+ causationId: parent.id,
3834
+ provenance: {
3835
+ source: "worker",
3836
+ correlationId: parent.correlationId,
3837
+ causationId: parent.id
3838
+ },
3839
+ executionReason: "staged_dispatch"
3840
+ }, {
3841
+ staged: {
3842
+ id: record.id,
3843
+ origin: {
3844
+ parentInvocationId: parent.id,
3845
+ stageKey: record.key
3846
+ },
3847
+ workerId: lease.workerId,
3848
+ leaseToken: lease.leaseToken,
3849
+ onChild
3850
+ },
3851
+ parametersAlreadyRedacted: true
3852
+ });
3853
+ }
3854
+ async function lookUpStagedChild(record, sweep) {
3855
+ try {
3856
+ const child = await sweep.store.getActionInvocationByStagedOrigin(record.tenantId, record.spaceId, {
3857
+ parentInvocationId: record.parentInvocationId,
3858
+ stageKey: record.key
3859
+ });
3860
+ return child ? {
3861
+ kind: "found",
3862
+ child
3863
+ } : { kind: "absent" };
3864
+ } catch (error) {
3865
+ reportStagedError(sweep.input, error);
3866
+ return { kind: "unreadable" };
3867
+ }
3868
+ }
3869
+ async function settleExistingStagedChild(record, lease, child, sweep) {
3870
+ if (options.dispatcher && child.status === "pending") {
3871
+ const stopHeartbeat = startStagedHeartbeat(sweep.store, lease, sweep.input, sweep.leaseDurationMs);
3872
+ let handedOff = false;
3873
+ try {
3874
+ await withSubmissionTimeout(handOffExistingStagedChild(child), sweep.submissionTimeoutMs);
3875
+ handedOff = true;
3876
+ } catch {} finally {
3877
+ await stopHeartbeat();
3878
+ }
3879
+ if (!handedOff) return settleStagedRetry(record, lease, sweep, {
3880
+ error: STAGED_ERROR.handoff,
3881
+ childInvocationId: child.id,
3882
+ terminal: record.attemptCount >= sweep.maxAttempts
3883
+ });
3884
+ }
3885
+ return settleStagedDispatched(lease, child.id, sweep);
3886
+ }
3887
+ async function settleStagedDispatched(lease, childInvocationId, sweep) {
3888
+ return await sweep.store.markStagedInvocationDispatched({
3889
+ ...lease,
3890
+ childInvocationId,
3891
+ now: now()
3892
+ }) ? "dispatched" : "unsettled";
3893
+ }
3894
+ async function settleStagedRetry(record, lease, sweep, settlement) {
3895
+ if (!await sweep.store.markStagedInvocationFailed({
3896
+ ...lease,
3897
+ error: settlement.error,
3898
+ availableAt: new Date(now().getTime() + (sweep.input.retryDelayMs?.(record.attemptCount) ?? 1e3)),
3899
+ deadLetter: settlement.terminal,
3900
+ ...settlement.childInvocationId ? { childInvocationId: settlement.childInvocationId } : {},
3901
+ now: now()
3902
+ })) return "unsettled";
3903
+ return settlement.terminal ? "dead_lettered" : "retry";
3904
+ }
3905
+ function isDefinitiveStagedRefusal(error) {
3906
+ return error instanceof SubmissionRefusalError || error instanceof StagedDispatchRejectionError || error instanceof ActionAdmissionError && (error.refusal === "refused" || error.refusal === "invalid_parameters");
3907
+ }
3908
+ function reportStagedError(input, error) {
3909
+ try {
3910
+ input.onError?.(error);
3911
+ } catch {}
3912
+ }
3913
+ /** Renew one lease token while a slow step runs; the returned stop awaits a renewal in flight. */
3914
+ function startStagedHeartbeat(store, lease, input, leaseDurationMs) {
3915
+ const intervalMs = input.leaseRenewalIntervalMs ?? Math.max(1, Math.floor(leaseDurationMs / 3));
3916
+ let stopped = false;
3917
+ let timer;
3918
+ let running;
3919
+ const schedule = () => {
3920
+ if (!stopped) timer = setTimeout(tick, intervalMs);
3921
+ };
3922
+ const tick = () => {
3923
+ running = store.renewStagedInvocationLease({
3924
+ ...lease,
3925
+ leaseDurationMs,
3926
+ now: now()
3927
+ }).then(() => void 0).catch(() => void 0).finally(schedule);
3928
+ };
3929
+ schedule();
3930
+ return async () => {
3931
+ stopped = true;
3932
+ if (timer) clearTimeout(timer);
3933
+ await running;
3934
+ };
3935
+ }
3936
+ async function withSubmissionTimeout(submission, timeoutMs) {
3937
+ let timer;
3938
+ try {
3939
+ return await Promise.race([submission, new Promise((_, reject) => {
3940
+ timer = setTimeout(() => reject(new StagedSubmissionTimeoutError("Staged invocation submission timed out.")), timeoutMs);
3941
+ })]);
3942
+ } finally {
3943
+ if (timer) clearTimeout(timer);
3944
+ }
3945
+ }
3131
3946
  return {
3132
3947
  submitAction,
3948
+ hasStagedInvocationActions: declaresStaging,
3949
+ ...stagedStore ? { dispatchStagedInvocations } : {},
3133
3950
  executeInvocation,
3134
3951
  resumeApprovedInvocation,
3135
3952
  recordExecutionAttestation,
@@ -3459,6 +4276,7 @@ function emitRelayTelemetry(telemetry, name, occurredAt, workerId, attributes) {
3459
4276
  var MemoryPlatformHostStore = class {
3460
4277
  db;
3461
4278
  transactionalOutbox;
4279
+ transactionalStagedInvocations;
3462
4280
  adapterCommit;
3463
4281
  transactionTail = Promise.resolve();
3464
4282
  invocations = [];
@@ -3469,6 +4287,7 @@ var MemoryPlatformHostStore = class {
3469
4287
  executionAttestations = [];
3470
4288
  externalReconciliations = [];
3471
4289
  outbox = [];
4290
+ stagedInvocations = [];
3472
4291
  /**
3473
4292
  * Requeue evidence is append-only audit history, so unlike the ledger
3474
4293
  * arrays above, which tests have always inspected and seeded directly, it
@@ -3479,6 +4298,7 @@ var MemoryPlatformHostStore = class {
3479
4298
  constructor(db, transactionProvider) {
3480
4299
  this.db = db;
3481
4300
  this.transactionalOutbox = transactionProvider !== void 0;
4301
+ this.transactionalStagedInvocations = transactionProvider !== void 0;
3482
4302
  if (transactionProvider) this.transactionWithEvents = (run) => this.runTransactionWithEvents(run, transactionProvider);
3483
4303
  }
3484
4304
  async transaction(run) {
@@ -3504,6 +4324,7 @@ var MemoryPlatformHostStore = class {
3504
4324
  const conditionalAdapters = [];
3505
4325
  const leasedUpdates = [];
3506
4326
  const pendingReconciliations = [];
4327
+ const pendingStagedBatches = [];
3507
4328
  const appendPending = async (event, metadata) => {
3508
4329
  if (this.events.some((candidate) => candidate.id === event.id) || pendingEvents.some((candidate) => candidate.event.id === event.id)) return;
3509
4330
  pendingEvents.push({
@@ -3560,6 +4381,9 @@ var MemoryPlatformHostStore = class {
3560
4381
  appendExternalReconciliation: async (record) => {
3561
4382
  pendingReconciliations.push(record);
3562
4383
  },
4384
+ stageInvocations: async (records) => {
4385
+ pendingStagedBatches.push(records.map(cloneStagedInvocationRecord));
4386
+ },
3563
4387
  updateActionInvocation: async (id, tenantId, spaceId, patch) => {
3564
4388
  pendingUpdates.push({
3565
4389
  id,
@@ -3613,6 +4437,15 @@ var MemoryPlatformHostStore = class {
3613
4437
  rememberAppend(this.externalReconciliations, record.id);
3614
4438
  await this.appendExternalReconciliation(record);
3615
4439
  }
4440
+ for (const records of pendingStagedBatches) {
4441
+ const appended = this.applyStagedBatch(records);
4442
+ if (appended.length > 0) undoLedgerWrites.push(() => {
4443
+ for (const id of appended) {
4444
+ const index = this.stagedInvocations.findIndex((record) => record.id === id);
4445
+ if (index !== -1) this.stagedInvocations.splice(index, 1);
4446
+ }
4447
+ });
4448
+ }
3616
4449
  for (const [id, patch, scope] of pendingAdapterUpdates) {
3617
4450
  const record = await this.getAdapterInvocation(id, scope);
3618
4451
  if (!record) throw new Error(`AdapterInvocation not found: ${id}`);
@@ -3663,13 +4496,59 @@ var MemoryPlatformHostStore = class {
3663
4496
  this.invocations.push(record);
3664
4497
  return record;
3665
4498
  }
4499
+ async createActionInvocationForStagedLease(input) {
4500
+ const { invocation, now = /* @__PURE__ */ new Date() } = input;
4501
+ 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);
4502
+ if (existing) return {
4503
+ kind: "existing",
4504
+ invocation: existing
4505
+ };
4506
+ 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" };
4507
+ const createdAt = /* @__PURE__ */ new Date();
4508
+ const record = {
4509
+ ...invocation,
4510
+ attemptCount: 0,
4511
+ leaseToken: 0,
4512
+ createdAt,
4513
+ updatedAt: createdAt
4514
+ };
4515
+ this.invocations.push(record);
4516
+ return {
4517
+ kind: "created",
4518
+ invocation: record
4519
+ };
4520
+ }
4521
+ applyStagedBatch(records) {
4522
+ if (records.length === 0) return [];
4523
+ const first = records[0];
4524
+ const existing = this.stagedInvocations.filter((record) => record.tenantId === first.tenantId && record.spaceId === first.spaceId && record.parentInvocationId === first.parentInvocationId);
4525
+ if (existing.length > 0) {
4526
+ if (!(existing.length === records.length && records.every((record) => {
4527
+ const stored = existing.find((candidate) => candidate.key === record.key);
4528
+ return stored !== void 0 && stored.batchDigest === record.batchDigest && stagedInvocationRequestIdentity(stored) === stagedInvocationRequestIdentity(record);
4529
+ }))) throw new ReconciliationRequiredError(`Staged invocation batch drift for parent ${first.parentInvocationId}.`);
4530
+ return [];
4531
+ }
4532
+ const keys = records.map((record) => record.key);
4533
+ 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}.`);
4534
+ for (const record of records) this.stagedInvocations.push(cloneStagedInvocationRecord(record));
4535
+ return records.map((record) => record.id);
4536
+ }
3666
4537
  async getActionInvocation(id, tenantId, spaceId) {
3667
4538
  return this.invocations.find((record) => record.id === id && record.tenantId === tenantId && record.spaceId === spaceId);
3668
4539
  }
4540
+ async getActionInvocationByStagedOrigin(tenantId, spaceId, origin) {
4541
+ return this.invocations.find((record) => record.tenantId === tenantId && record.spaceId === spaceId && record.stagedOrigin?.parentInvocationId === origin.parentInvocationId && record.stagedOrigin.stageKey === origin.stageKey);
4542
+ }
3669
4543
  async updateActionInvocation(id, tenantId, spaceId, patch) {
3670
4544
  const record = this.invocations.find((row) => row.id === id && row.tenantId === tenantId && row.spaceId === spaceId);
3671
4545
  if (!record) throw new Error(`ActionInvocation not found: ${id}`);
3672
4546
  Object.assign(record, patch, { updatedAt: /* @__PURE__ */ new Date() });
4547
+ if (Object.hasOwn(patch, "finalizationCheckpoint")) {
4548
+ if (patch.finalizationCheckpoint) record.finalizationCheckpoint = structuredClone(patch.finalizationCheckpoint);
4549
+ else delete record.finalizationCheckpoint;
4550
+ }
4551
+ if (Object.hasOwn(patch, "deferredBeforeEffectAttempt") && patch.deferredBeforeEffectAttempt === void 0) delete record.deferredBeforeEffectAttempt;
3673
4552
  if (Object.hasOwn(patch, "pendingStateTransition")) {
3674
4553
  if (patch.pendingStateTransition) record.pendingStateTransition = { ...patch.pendingStateTransition };
3675
4554
  else delete record.pendingStateTransition;
@@ -3990,6 +4869,96 @@ var MemoryPlatformHostStore = class {
3990
4869
  record.updatedAt = current;
3991
4870
  return true;
3992
4871
  }
4872
+ async claimStagedInvocations(input) {
4873
+ while (this.adapterCommit) await this.adapterCommit;
4874
+ const current = input.now ?? /* @__PURE__ */ new Date();
4875
+ const eligible = this.stagedInvocations.filter((record) => {
4876
+ 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);
4877
+ }).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)));
4878
+ for (const record of eligible) {
4879
+ record.leaseOwner = input.workerId;
4880
+ record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
4881
+ record.leaseToken += 1;
4882
+ record.attemptCount += 1;
4883
+ record.updatedAt = current;
4884
+ }
4885
+ return eligible.map(cloneStagedInvocationRecord);
4886
+ }
4887
+ async markStagedInvocationDispatched(input) {
4888
+ return this.settleStagedInvocation(input, "dispatched", input.childInvocationId, void 0);
4889
+ }
4890
+ async markStagedInvocationRejected(input) {
4891
+ return this.settleStagedInvocation(input, "rejected", void 0, input.error);
4892
+ }
4893
+ async markStagedInvocationFailed(input) {
4894
+ const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
4895
+ if (!record) return false;
4896
+ record.status = input.deadLetter ? "dead_lettered" : "pending";
4897
+ record.lastError = input.error;
4898
+ if (input.childInvocationId) record.childInvocationId = input.childInvocationId;
4899
+ record.availableAt = new Date(input.availableAt);
4900
+ record.updatedAt = input.now ?? /* @__PURE__ */ new Date();
4901
+ delete record.leaseOwner;
4902
+ delete record.leaseExpiresAt;
4903
+ return true;
4904
+ }
4905
+ async renewStagedInvocationLease(input) {
4906
+ const current = input.now ?? /* @__PURE__ */ new Date();
4907
+ const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
4908
+ if (!record || !record.leaseExpiresAt || record.leaseExpiresAt <= current) return false;
4909
+ record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
4910
+ record.updatedAt = current;
4911
+ return true;
4912
+ }
4913
+ /** Await-free, like the fenced create above, so a create runs wholly before or after it. */
4914
+ async rotateStagedInvocationLease(input) {
4915
+ const current = input.now ?? /* @__PURE__ */ new Date();
4916
+ const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
4917
+ if (!record || !record.leaseExpiresAt || record.leaseExpiresAt <= current) return void 0;
4918
+ record.leaseToken += 1;
4919
+ record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
4920
+ record.updatedAt = current;
4921
+ return record.leaseToken;
4922
+ }
4923
+ async cancelStagedInvocationsForFailedParents(input = {}) {
4924
+ const current = input.now ?? /* @__PURE__ */ new Date();
4925
+ let cancelled = 0;
4926
+ for (const record of this.stagedInvocations) {
4927
+ if (record.status !== "pending" || input.tenantId && record.tenantId !== input.tenantId || input.spaceId && record.spaceId !== input.spaceId) continue;
4928
+ const parent = this.invocations.find((candidate) => candidate.id === record.parentInvocationId && candidate.tenantId === record.tenantId && candidate.spaceId === record.spaceId);
4929
+ if (!parent || ![
4930
+ "failed",
4931
+ "blocked_by_policy",
4932
+ "validation_failed"
4933
+ ].includes(parent.status)) continue;
4934
+ record.status = "cancelled";
4935
+ record.lastError = `Parent invocation ended with ${parent.status}.`;
4936
+ record.updatedAt = current;
4937
+ delete record.leaseOwner;
4938
+ delete record.leaseExpiresAt;
4939
+ cancelled += 1;
4940
+ }
4941
+ return cancelled;
4942
+ }
4943
+ async listStagedInvocations(input) {
4944
+ 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);
4945
+ }
4946
+ leasedStagedInvocation(id, workerId, leaseToken) {
4947
+ return this.stagedInvocations.find((record) => record.id === id && record.status === "pending" && record.leaseOwner === workerId && record.leaseToken === leaseToken);
4948
+ }
4949
+ /** Mirrors PostgreSQL: a missing child id keeps the recorded one; `lastError` is replaced. */
4950
+ settleStagedInvocation(input, status, childInvocationId, lastError) {
4951
+ const record = this.leasedStagedInvocation(input.id, input.workerId, input.leaseToken);
4952
+ if (!record) return false;
4953
+ record.status = status;
4954
+ if (childInvocationId !== void 0) record.childInvocationId = childInvocationId;
4955
+ if (lastError !== void 0) record.lastError = lastError;
4956
+ else delete record.lastError;
4957
+ record.updatedAt = input.now ?? /* @__PURE__ */ new Date();
4958
+ delete record.leaseOwner;
4959
+ delete record.leaseExpiresAt;
4960
+ return true;
4961
+ }
3993
4962
  async listEvents(tenantId, spaceId) {
3994
4963
  return this.events.filter((event) => event.tenantId === tenantId && event.spaceId === spaceId).sort((left, right) => left.sequence - right.sequence);
3995
4964
  }
@@ -4083,10 +5052,12 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4083
5052
  sql;
4084
5053
  transactionWithEvents;
4085
5054
  transactionalOutbox;
5055
+ transactionalStagedInvocations;
4086
5056
  constructor(db, sql, transactionProvider) {
4087
5057
  this.db = db;
4088
5058
  this.sql = sql;
4089
5059
  this.transactionalOutbox = transactionProvider !== void 0;
5060
+ this.transactionalStagedInvocations = transactionProvider !== void 0;
4090
5061
  if (transactionProvider) this.transactionWithEvents = async (run) => transactionProvider.run(async ({ db, sql }) => {
4091
5062
  const scoped = new PostgresPlatformHostStore(db, sql);
4092
5063
  return run({
@@ -4112,7 +5083,8 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4112
5083
  updateLeasedActionInvocation: (input) => scoped.updateLeasedActionInvocation(input),
4113
5084
  updateAdapterInvocation: (id, patch, scope) => scoped.updateAdapterInvocation(id, patch, scope),
4114
5085
  writeAdapterInvocation: (input) => scoped.writeAdapterInvocation(input),
4115
- appendExternalReconciliation: (record) => scoped.appendExternalReconciliation(record)
5086
+ appendExternalReconciliation: (record) => scoped.appendExternalReconciliation(record),
5087
+ stageInvocations: (records) => scoped.stageInvocations(records)
4116
5088
  });
4117
5089
  });
4118
5090
  }
@@ -4306,6 +5278,48 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4306
5278
  sql: `
4307
5279
  ALTER TABLE fabric_platform.adapter_invocations ADD COLUMN IF NOT EXISTS reconciliation jsonb;
4308
5280
  `
5281
+ },
5282
+ {
5283
+ version: 9,
5284
+ name: "pre_effect_governance_deferral",
5285
+ sql: `ALTER TABLE fabric_platform.action_invocations ADD COLUMN IF NOT EXISTS deferred_before_effect_attempt integer;`
5286
+ },
5287
+ {
5288
+ version: 10,
5289
+ name: "finalization_preparation_checkpoint",
5290
+ sql: `ALTER TABLE fabric_platform.action_invocations ADD COLUMN IF NOT EXISTS finalization_checkpoint jsonb;`
5291
+ },
5292
+ {
5293
+ version: 11,
5294
+ name: "staged_descendant_invocations",
5295
+ sql: `
5296
+ ALTER TABLE fabric_platform.action_invocations
5297
+ ADD COLUMN IF NOT EXISTS staged_parent_invocation_id text,
5298
+ ADD COLUMN IF NOT EXISTS staged_stage_key text;
5299
+ CREATE UNIQUE INDEX IF NOT EXISTS action_invocations_staged_origin_idx
5300
+ ON fabric_platform.action_invocations
5301
+ (tenant_id, space_id, staged_parent_invocation_id, staged_stage_key)
5302
+ WHERE staged_parent_invocation_id IS NOT NULL AND staged_stage_key IS NOT NULL;
5303
+ CREATE TABLE IF NOT EXISTS fabric_platform.staged_invocations (
5304
+ id text PRIMARY KEY,
5305
+ tenant_id text NOT NULL, space_id text NOT NULL,
5306
+ parent_invocation_id text NOT NULL, parent_action_id text NOT NULL,
5307
+ stage_key text NOT NULL, action_id text NOT NULL,
5308
+ expected_action_version integer NOT NULL,
5309
+ parameters jsonb NOT NULL, parameter_digest text NOT NULL, batch_digest text NOT NULL,
5310
+ status text NOT NULL, child_invocation_id text,
5311
+ attempt_count integer NOT NULL DEFAULT 0,
5312
+ available_at timestamptz NOT NULL,
5313
+ lease_owner text, lease_expires_at timestamptz,
5314
+ lease_token bigint NOT NULL DEFAULT 0,
5315
+ last_error text, created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL,
5316
+ UNIQUE (tenant_id, space_id, parent_invocation_id, stage_key)
5317
+ );
5318
+ CREATE INDEX IF NOT EXISTS staged_invocations_claim_idx
5319
+ ON fabric_platform.staged_invocations (status, available_at, lease_expires_at, created_at);
5320
+ CREATE INDEX IF NOT EXISTS staged_invocations_parent_idx
5321
+ ON fabric_platform.staged_invocations (tenant_id, space_id, parent_invocation_id, created_at);
5322
+ `
4309
5323
  }
4310
5324
  ]);
4311
5325
  }
@@ -4321,11 +5335,11 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4321
5335
  try {
4322
5336
  result = await this.sql.query(`INSERT INTO fabric_platform.action_invocations
4323
5337
  (id,tenant_id,space_id,action_id,action_version,actor_id,actor_type,status,
4324
- parameters,result,correlation_id,causation_id,idempotency_key,
4325
- parameter_digest,parameter_digest_algorithm,idempotency_actor_id,
4326
- idempotency_authorization_binding_id,invocation_provenance,authorization_binding,execution_reason,
4327
- authorization_binding_id,error,runtime_evidence,${admittedColumn}created_at,updated_at)
4328
- 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)
5338
+ parameters,result,correlation_id,causation_id,idempotency_key,
5339
+ parameter_digest,parameter_digest_algorithm,idempotency_actor_id,
5340
+ idempotency_authorization_binding_id,invocation_provenance,authorization_binding,execution_reason,
5341
+ authorization_binding_id,error,runtime_evidence,${admittedColumn}created_at,updated_at)
5342
+ 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)
4329
5343
  ON CONFLICT (tenant_id,space_id,action_id,idempotency_key)
4330
5344
  WHERE idempotency_key IS NOT NULL DO UPDATE SET id=fabric_platform.action_invocations.id
4331
5345
  RETURNING *`, [
@@ -4361,6 +5375,140 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4361
5375
  }
4362
5376
  return toActionRecord(result.rows[0]);
4363
5377
  }
5378
+ async createActionInvocationForStagedLease(input) {
5379
+ const { invocation } = input;
5380
+ const current = input.now ?? /* @__PURE__ */ new Date();
5381
+ const admitted = invocation.admittedAt !== void 0;
5382
+ const admittedColumn = admitted ? ",admitted_at" : "";
5383
+ const admittedValue = admitted ? ",$30" : "";
5384
+ const values = [
5385
+ invocation.id,
5386
+ invocation.tenantId,
5387
+ invocation.spaceId,
5388
+ invocation.actionId,
5389
+ invocation.actionVersion,
5390
+ invocation.actorId,
5391
+ invocation.actorType,
5392
+ invocation.status,
5393
+ JSON.stringify(invocation.parameters),
5394
+ JSON.stringify(invocation.result),
5395
+ invocation.correlationId,
5396
+ invocation.causationId ?? null,
5397
+ invocation.idempotencyKey ?? null,
5398
+ invocation.parameterDigest ?? null,
5399
+ invocation.parameterDigestAlgorithm ?? null,
5400
+ invocation.idempotencyActorId ?? null,
5401
+ invocation.idempotencyAuthorizationBindingId ?? null,
5402
+ invocation.provenance ? JSON.stringify(invocation.provenance) : null,
5403
+ invocation.authorizationBinding ? JSON.stringify(invocation.authorizationBinding) : null,
5404
+ invocation.executionReason ?? null,
5405
+ invocation.authorizationBindingId ?? null,
5406
+ invocation.error ?? null,
5407
+ invocation.runtimeEvidence ? JSON.stringify(invocation.runtimeEvidence) : null,
5408
+ invocation.stagedOrigin.parentInvocationId,
5409
+ invocation.stagedOrigin.stageKey,
5410
+ input.stageId,
5411
+ input.workerId,
5412
+ input.leaseToken,
5413
+ current,
5414
+ ...admitted ? [invocation.admittedAt] : []
5415
+ ];
5416
+ let result;
5417
+ try {
5418
+ result = await this.sql.query(`WITH fence AS (
5419
+ SELECT stage.id
5420
+ FROM fabric_platform.staged_invocations AS stage
5421
+ WHERE stage.id=$26 AND stage.tenant_id=$2 AND stage.space_id=$3
5422
+ AND stage.parent_invocation_id=$24 AND stage.stage_key=$25 AND stage.action_id=$4
5423
+ AND stage.status='pending' AND stage.lease_owner=$27 AND stage.lease_token=$28
5424
+ AND stage.lease_expires_at IS NOT NULL AND stage.lease_expires_at > $29
5425
+ FOR SHARE
5426
+ )
5427
+ INSERT INTO fabric_platform.action_invocations
5428
+ (id,tenant_id,space_id,action_id,action_version,actor_id,actor_type,status,
5429
+ parameters,result,correlation_id,causation_id,idempotency_key,parameter_digest,
5430
+ parameter_digest_algorithm,idempotency_actor_id,idempotency_authorization_binding_id,
5431
+ invocation_provenance,authorization_binding,execution_reason,authorization_binding_id,error,
5432
+ runtime_evidence,staged_parent_invocation_id,staged_stage_key${admittedColumn},created_at,updated_at)
5433
+ SELECT $1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14,$15,$16,$17,
5434
+ $18::jsonb,$19::jsonb,$20,$21,$22,$23::jsonb,$24,$25${admittedValue},$29,$29
5435
+ FROM fence
5436
+ ON CONFLICT (tenant_id,space_id,staged_parent_invocation_id,staged_stage_key)
5437
+ WHERE staged_parent_invocation_id IS NOT NULL AND staged_stage_key IS NOT NULL
5438
+ DO UPDATE SET id=fabric_platform.action_invocations.id
5439
+ RETURNING *`, values);
5440
+ } catch (error) {
5441
+ 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 });
5442
+ throw error;
5443
+ }
5444
+ if (result.rows[0]) {
5445
+ const record = toActionRecord(result.rows[0]);
5446
+ return {
5447
+ kind: record.id === invocation.id ? "created" : "existing",
5448
+ invocation: record
5449
+ };
5450
+ }
5451
+ const existing = await this.getActionInvocationByStagedOrigin(invocation.tenantId, invocation.spaceId, invocation.stagedOrigin);
5452
+ return existing ? {
5453
+ kind: "existing",
5454
+ invocation: existing
5455
+ } : { kind: "lease_lost" };
5456
+ }
5457
+ async stageInvocations(records) {
5458
+ if (records.length === 0) return;
5459
+ const first = records[0];
5460
+ if (!(await this.sql.query(`SELECT id FROM fabric_platform.action_invocations
5461
+ WHERE id=$1 AND tenant_id=$2 AND space_id=$3 FOR UPDATE`, [
5462
+ first.parentInvocationId,
5463
+ first.tenantId,
5464
+ first.spaceId
5465
+ ])).rows[0]) throw new Error(`ActionInvocation not found: ${first.parentInvocationId}`);
5466
+ const current = await this.sql.query(`SELECT * FROM fabric_platform.staged_invocations
5467
+ WHERE tenant_id=$1 AND space_id=$2 AND parent_invocation_id=$3
5468
+ ORDER BY stage_key`, [
5469
+ first.tenantId,
5470
+ first.spaceId,
5471
+ first.parentInvocationId
5472
+ ]);
5473
+ if (current.rows.length > 0) {
5474
+ const stored = current.rows.map(toStagedInvocationRecord);
5475
+ if (!(stored.length === records.length && records.every((record) => {
5476
+ const candidate = stored.find((item) => item.key === record.key);
5477
+ return candidate !== void 0 && candidate.batchDigest === record.batchDigest && stagedInvocationRequestIdentity(candidate) === stagedInvocationRequestIdentity(record);
5478
+ }))) throw new ReconciliationRequiredError(`Staged invocation batch drift for parent ${first.parentInvocationId}.`);
5479
+ return;
5480
+ }
5481
+ for (const record of records) {
5482
+ 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}.`);
5483
+ await this.sql.query(`INSERT INTO fabric_platform.staged_invocations
5484
+ (id,tenant_id,space_id,parent_invocation_id,parent_action_id,stage_key,action_id,
5485
+ expected_action_version,parameters,parameter_digest,batch_digest,status,child_invocation_id,
5486
+ attempt_count,available_at,lease_owner,lease_expires_at,lease_token,last_error,created_at,updated_at)
5487
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)`, [
5488
+ record.id,
5489
+ record.tenantId,
5490
+ record.spaceId,
5491
+ record.parentInvocationId,
5492
+ record.parentActionId,
5493
+ record.key,
5494
+ record.actionId,
5495
+ record.expectedActionVersion,
5496
+ JSON.stringify(record.parameters),
5497
+ record.parameterDigest,
5498
+ record.batchDigest,
5499
+ record.status,
5500
+ record.childInvocationId ?? null,
5501
+ record.attemptCount,
5502
+ record.availableAt,
5503
+ record.leaseOwner ?? null,
5504
+ record.leaseExpiresAt ?? null,
5505
+ record.leaseToken,
5506
+ record.lastError ?? null,
5507
+ record.createdAt,
5508
+ record.updatedAt
5509
+ ]);
5510
+ }
5511
+ }
4364
5512
  async getActionInvocation(id, tenantId, spaceId) {
4365
5513
  const result = await this.sql.query(`SELECT * FROM fabric_platform.action_invocations
4366
5514
  WHERE id=$1 AND tenant_id=$2 AND space_id=$3`, [
@@ -4370,6 +5518,17 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4370
5518
  ]);
4371
5519
  return result.rows[0] ? toActionRecord(result.rows[0]) : void 0;
4372
5520
  }
5521
+ async getActionInvocationByStagedOrigin(tenantId, spaceId, origin) {
5522
+ const result = await this.sql.query(`SELECT * FROM fabric_platform.action_invocations
5523
+ WHERE tenant_id=$1 AND space_id=$2
5524
+ AND staged_parent_invocation_id=$3 AND staged_stage_key=$4`, [
5525
+ tenantId,
5526
+ spaceId,
5527
+ origin.parentInvocationId,
5528
+ origin.stageKey
5529
+ ]);
5530
+ return result.rows[0] ? toActionRecord(result.rows[0]) : void 0;
5531
+ }
4373
5532
  async updateActionInvocation(id, tenantId, spaceId, patch) {
4374
5533
  await this.sql.query(`UPDATE fabric_platform.action_invocations SET
4375
5534
  status=COALESCE($4,status), result=COALESCE($5::jsonb,result),
@@ -4379,6 +5538,8 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4379
5538
  pending_completion=CASE WHEN $12::boolean THEN $13::jsonb ELSE pending_completion END,
4380
5539
  completion_due_at=CASE WHEN $12::boolean THEN ($13::jsonb->>'dueAt')::timestamptz ELSE completion_due_at END,
4381
5540
  external_completion=CASE WHEN $14::boolean THEN $15::jsonb ELSE external_completion END,
5541
+ ${Object.hasOwn(patch, "finalizationCheckpoint") ? `finalization_checkpoint=$${18 + (Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? 2 : 0)}::jsonb,` : ""}
5542
+ ${Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? "deferred_before_effect_attempt=CASE WHEN $18::boolean THEN $19::integer ELSE deferred_before_effect_attempt END," : ""}
4382
5543
  pending_state_transition=CASE WHEN $16::boolean THEN $17::jsonb ELSE pending_state_transition END,
4383
5544
  lease_owner=CASE WHEN ($13::jsonb IS NOT NULL AND $4='running') OR $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
4384
5545
  THEN NULL ELSE lease_owner END,
@@ -4402,7 +5563,9 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4402
5563
  Object.hasOwn(patch, "externalCompletion"),
4403
5564
  patch.externalCompletion ? JSON.stringify(patch.externalCompletion) : null,
4404
5565
  Object.hasOwn(patch, "pendingStateTransition"),
4405
- patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null
5566
+ patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null,
5567
+ ...Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? [true, patch.deferredBeforeEffectAttempt ?? null] : [],
5568
+ ...Object.hasOwn(patch, "finalizationCheckpoint") ? [patch.finalizationCheckpoint ? JSON.stringify(patch.finalizationCheckpoint) : null] : []
4406
5569
  ]);
4407
5570
  }
4408
5571
  async recordHitlDecision(actionInvocationId, tenantId, spaceId, decision) {
@@ -4896,6 +6059,139 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
4896
6059
  input.outboxId ?? null
4897
6060
  ])).rows.map((row) => toOutboxRequeueRecord(row, ""));
4898
6061
  }
6062
+ async claimStagedInvocations(input) {
6063
+ const current = input.now ?? /* @__PURE__ */ new Date();
6064
+ const leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
6065
+ const limit = Math.max(1, Math.min(input.limit ?? 100, 1e3));
6066
+ return (await this.sql.query(`WITH claimable AS (
6067
+ SELECT stage.id
6068
+ FROM fabric_platform.staged_invocations AS stage
6069
+ JOIN fabric_platform.action_invocations AS parent
6070
+ ON parent.id=stage.parent_invocation_id
6071
+ AND parent.tenant_id=stage.tenant_id AND parent.space_id=stage.space_id
6072
+ WHERE stage.status='pending' AND parent.status='completed'
6073
+ AND stage.available_at <= $1
6074
+ AND (stage.lease_expires_at IS NULL OR stage.lease_expires_at <= $1)
6075
+ AND ($2::text IS NULL OR stage.tenant_id=$2)
6076
+ AND ($3::text IS NULL OR stage.space_id=$3)
6077
+ ORDER BY stage.created_at,stage.id
6078
+ FOR UPDATE OF stage SKIP LOCKED LIMIT $4
6079
+ )
6080
+ UPDATE fabric_platform.staged_invocations AS stage
6081
+ SET lease_owner=$5, lease_expires_at=$6, lease_token=stage.lease_token+1,
6082
+ attempt_count=stage.attempt_count+1, updated_at=$1
6083
+ FROM claimable WHERE stage.id=claimable.id
6084
+ RETURNING stage.*`, [
6085
+ current,
6086
+ input.tenantId ?? null,
6087
+ input.spaceId ?? null,
6088
+ limit,
6089
+ input.workerId,
6090
+ leaseExpiresAt
6091
+ ])).rows.map(toStagedInvocationRecord);
6092
+ }
6093
+ async markStagedInvocationDispatched(input) {
6094
+ return this.settleStagedInvocation(input.id, input.workerId, input.leaseToken, "dispatched", input.now ?? /* @__PURE__ */ new Date(), input.childInvocationId, null);
6095
+ }
6096
+ async markStagedInvocationRejected(input) {
6097
+ return this.settleStagedInvocation(input.id, input.workerId, input.leaseToken, "rejected", input.now ?? /* @__PURE__ */ new Date(), null, input.error);
6098
+ }
6099
+ async markStagedInvocationFailed(input) {
6100
+ return (await this.sql.query(`UPDATE fabric_platform.staged_invocations
6101
+ SET status=$4, last_error=$5, available_at=$6,
6102
+ child_invocation_id=COALESCE($7,child_invocation_id),
6103
+ lease_owner=NULL, lease_expires_at=NULL, updated_at=$8
6104
+ WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3 RETURNING id`, [
6105
+ input.id,
6106
+ input.workerId,
6107
+ input.leaseToken,
6108
+ input.deadLetter ? "dead_lettered" : "pending",
6109
+ input.error,
6110
+ input.availableAt,
6111
+ input.childInvocationId ?? null,
6112
+ input.now ?? /* @__PURE__ */ new Date()
6113
+ ])).rows.length === 1;
6114
+ }
6115
+ async renewStagedInvocationLease(input) {
6116
+ const current = input.now ?? /* @__PURE__ */ new Date();
6117
+ return (await this.sql.query(`UPDATE fabric_platform.staged_invocations
6118
+ SET lease_expires_at=$4, updated_at=$5
6119
+ WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3
6120
+ AND lease_expires_at IS NOT NULL AND lease_expires_at > $5 RETURNING id`, [
6121
+ input.id,
6122
+ input.workerId,
6123
+ input.leaseToken,
6124
+ new Date(current.getTime() + input.leaseDurationMs),
6125
+ current
6126
+ ])).rows.length === 1;
6127
+ }
6128
+ /**
6129
+ * The UPDATE needs a row lock that conflicts with the `FOR SHARE` lock the
6130
+ * fenced child insert takes on this row, so it waits for an in-flight
6131
+ * insert to commit. A fenced insert under the old token that reaches the
6132
+ * row while this rotation is uncommitted waits for it, rechecks the new row
6133
+ * version and inserts nothing (live tests "orders a token rotation after an
6134
+ * in-flight fenced create ..." and "refuses a fenced create under the old
6135
+ * token once a token rotation is ordered before it").
6136
+ */
6137
+ async rotateStagedInvocationLease(input) {
6138
+ const current = input.now ?? /* @__PURE__ */ new Date();
6139
+ const result = await this.sql.query(`UPDATE fabric_platform.staged_invocations
6140
+ SET lease_token=lease_token+1, lease_expires_at=$4, updated_at=$5
6141
+ WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3
6142
+ AND lease_expires_at IS NOT NULL AND lease_expires_at > $5
6143
+ RETURNING lease_token`, [
6144
+ input.id,
6145
+ input.workerId,
6146
+ input.leaseToken,
6147
+ new Date(current.getTime() + input.leaseDurationMs),
6148
+ current
6149
+ ]);
6150
+ return result.rows[0] ? Number(result.rows[0].lease_token) : void 0;
6151
+ }
6152
+ async cancelStagedInvocationsForFailedParents(input = {}) {
6153
+ return (await this.sql.query(`UPDATE fabric_platform.staged_invocations AS stage
6154
+ SET status='cancelled', last_error='Parent invocation ended with ' || parent.status || '.',
6155
+ lease_owner=NULL, lease_expires_at=NULL, updated_at=$3
6156
+ FROM fabric_platform.action_invocations AS parent
6157
+ WHERE parent.id=stage.parent_invocation_id
6158
+ AND parent.tenant_id=stage.tenant_id AND parent.space_id=stage.space_id
6159
+ AND parent.status IN ('failed','blocked_by_policy','validation_failed')
6160
+ AND stage.status='pending'
6161
+ AND ($1::text IS NULL OR stage.tenant_id=$1)
6162
+ AND ($2::text IS NULL OR stage.space_id=$2)
6163
+ RETURNING stage.id`, [
6164
+ input.tenantId ?? null,
6165
+ input.spaceId ?? null,
6166
+ input.now ?? /* @__PURE__ */ new Date()
6167
+ ])).rows.length;
6168
+ }
6169
+ async listStagedInvocations(input) {
6170
+ return (await this.sql.query(`SELECT * FROM fabric_platform.staged_invocations
6171
+ WHERE tenant_id=$1 AND space_id=$2
6172
+ AND ($3::text IS NULL OR parent_invocation_id=$3)
6173
+ AND ($4::text[] IS NULL OR status=ANY($4::text[]))
6174
+ ORDER BY created_at,id`, [
6175
+ input.tenantId,
6176
+ input.spaceId,
6177
+ input.parentInvocationId ?? null,
6178
+ input.statuses?.length ? [...input.statuses] : null
6179
+ ])).rows.map(toStagedInvocationRecord);
6180
+ }
6181
+ async settleStagedInvocation(id, workerId, leaseToken, status, now, childInvocationId, lastError) {
6182
+ return (await this.sql.query(`UPDATE fabric_platform.staged_invocations
6183
+ SET status=$4, child_invocation_id=COALESCE($5,child_invocation_id), last_error=$6,
6184
+ lease_owner=NULL, lease_expires_at=NULL, updated_at=$7
6185
+ WHERE id=$1 AND status='pending' AND lease_owner=$2 AND lease_token=$3 RETURNING id`, [
6186
+ id,
6187
+ workerId,
6188
+ leaseToken,
6189
+ status,
6190
+ childInvocationId,
6191
+ lastError,
6192
+ now
6193
+ ])).rows.length === 1;
6194
+ }
4899
6195
  async nextEventSequence(tenantId, spaceId) {
4900
6196
  const result = await this.sql.query(`INSERT INTO fabric_platform.event_sequences (tenant_id,space_id,next_sequence)
4901
6197
  VALUES ($1,$2,1) ON CONFLICT (tenant_id,space_id) DO UPDATE
@@ -5032,6 +6328,8 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
5032
6328
  pending_completion=CASE WHEN $14::boolean THEN $15::jsonb ELSE pending_completion END,
5033
6329
  completion_due_at=CASE WHEN $14::boolean THEN ($15::jsonb->>'dueAt')::timestamptz ELSE completion_due_at END,
5034
6330
  external_completion=CASE WHEN $16::boolean THEN $17::jsonb ELSE external_completion END,
6331
+ ${Object.hasOwn(patch, "finalizationCheckpoint") ? `finalization_checkpoint=$${20 + (Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? 2 : 0)}::jsonb,` : ""}
6332
+ ${Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? "deferred_before_effect_attempt=CASE WHEN $20::boolean THEN $21::integer ELSE deferred_before_effect_attempt END," : ""}
5035
6333
  pending_state_transition=CASE WHEN $18::boolean THEN $19::jsonb ELSE pending_state_transition END,
5036
6334
  lease_owner=CASE WHEN ($15::jsonb IS NOT NULL AND $6='running') OR $6 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
5037
6335
  THEN NULL ELSE lease_owner END,
@@ -5059,7 +6357,9 @@ var PostgresPlatformHostStore = class PostgresPlatformHostStore {
5059
6357
  Object.hasOwn(patch, "externalCompletion"),
5060
6358
  patch.externalCompletion ? JSON.stringify(patch.externalCompletion) : null,
5061
6359
  Object.hasOwn(patch, "pendingStateTransition"),
5062
- patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null
6360
+ patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null,
6361
+ ...Object.hasOwn(patch, "deferredBeforeEffectAttempt") ? [true, patch.deferredBeforeEffectAttempt ?? null] : [],
6362
+ ...Object.hasOwn(patch, "finalizationCheckpoint") ? [patch.finalizationCheckpoint ? JSON.stringify(patch.finalizationCheckpoint) : null] : []
5063
6363
  ])).rows.length === 1;
5064
6364
  }
5065
6365
  async renewActionInvocationLease(input) {
@@ -5146,16 +6446,22 @@ function toActionRecord(row) {
5146
6446
  ...row.parameter_digest_algorithm ? { parameterDigestAlgorithm: String(row.parameter_digest_algorithm) } : {},
5147
6447
  ...row.idempotency_actor_id ? { idempotencyActorId: String(row.idempotency_actor_id) } : {},
5148
6448
  ...row.idempotency_authorization_binding_id ? { idempotencyAuthorizationBindingId: String(row.idempotency_authorization_binding_id) } : {},
6449
+ ...row.staged_parent_invocation_id && row.staged_stage_key ? { stagedOrigin: {
6450
+ parentInvocationId: String(row.staged_parent_invocation_id),
6451
+ stageKey: String(row.staged_stage_key)
6452
+ } } : {},
5149
6453
  ...row.invocation_provenance ? { provenance: row.invocation_provenance } : {},
5150
6454
  ...row.authorization_binding ? { authorizationBinding: row.authorization_binding } : {},
5151
6455
  ...row.execution_reason ? { executionReason: String(row.execution_reason) } : {},
5152
6456
  ...row.authorization_reconciliation ? { authorizationReconciliation: row.authorization_reconciliation } : {},
5153
6457
  ...row.adapter_reconciliation ? { adapterReconciliation: row.adapter_reconciliation } : {},
6458
+ ...row.finalization_checkpoint ? { finalizationCheckpoint: row.finalization_checkpoint } : {},
5154
6459
  ...row.pending_state_transition ? { pendingStateTransition: row.pending_state_transition } : {},
5155
6460
  ...row.pending_completion ? { pendingCompletion: toPendingCompletion(row.pending_completion) } : {},
5156
6461
  ...row.external_completion ? { externalCompletion: toExternalCompletion(row.external_completion) } : {},
5157
6462
  ...row.authorization_binding_id ? { authorizationBindingId: String(row.authorization_binding_id) } : {},
5158
6463
  attemptCount: Number(row.attempt_count ?? 0),
6464
+ ...row.deferred_before_effect_attempt == null ? {} : { deferredBeforeEffectAttempt: Number(row.deferred_before_effect_attempt) },
5159
6465
  leaseToken: Number(row.lease_token ?? 0),
5160
6466
  ...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
5161
6467
  ...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
@@ -5173,6 +6479,31 @@ function toActionRecord(row) {
5173
6479
  updatedAt: new Date(row.updated_at)
5174
6480
  };
5175
6481
  }
6482
+ function toStagedInvocationRecord(row) {
6483
+ return {
6484
+ id: String(row.id),
6485
+ tenantId: String(row.tenant_id),
6486
+ spaceId: String(row.space_id),
6487
+ parentInvocationId: String(row.parent_invocation_id),
6488
+ parentActionId: String(row.parent_action_id),
6489
+ key: String(row.stage_key),
6490
+ actionId: String(row.action_id),
6491
+ expectedActionVersion: Number(row.expected_action_version),
6492
+ parameters: row.parameters,
6493
+ parameterDigest: String(row.parameter_digest),
6494
+ batchDigest: String(row.batch_digest),
6495
+ status: String(row.status),
6496
+ ...row.child_invocation_id ? { childInvocationId: String(row.child_invocation_id) } : {},
6497
+ attemptCount: Number(row.attempt_count),
6498
+ availableAt: new Date(row.available_at),
6499
+ ...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
6500
+ ...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
6501
+ leaseToken: Number(row.lease_token ?? 0),
6502
+ ...row.last_error ? { lastError: String(row.last_error) } : {},
6503
+ createdAt: new Date(row.created_at),
6504
+ updatedAt: new Date(row.updated_at)
6505
+ };
6506
+ }
5176
6507
  function toObligationRecord(row) {
5177
6508
  return {
5178
6509
  id: String(row.id),
@@ -5312,6 +6643,23 @@ function createStoreBackedActionDispatcher() {
5312
6643
  async function runPlatformActionWorkerCycle(options) {
5313
6644
  const leaseDurationMs = options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
5314
6645
  emitWorkerTelemetry(options.telemetry, "worker.cycle.started", options.workerId);
6646
+ let staged;
6647
+ if ((options.dispatchStagedInvocations === true || options.dispatchStagedInvocations !== false && options.host.hasStagedInvocationActions === true) && typeof options.host.dispatchStagedInvocations === "function") try {
6648
+ staged = await options.host.dispatchStagedInvocations({
6649
+ workerId: options.workerId,
6650
+ ...options.stagedInvocationBatchSize !== void 0 ? { limit: options.stagedInvocationBatchSize } : {},
6651
+ ...options.stagedInvocationLeaseDurationMs !== void 0 ? { leaseDurationMs: options.stagedInvocationLeaseDurationMs } : {},
6652
+ ...options.stagedInvocationLeaseRenewalIntervalMs !== void 0 ? { leaseRenewalIntervalMs: options.stagedInvocationLeaseRenewalIntervalMs } : {},
6653
+ ...options.stagedInvocationSubmissionTimeoutMs !== void 0 ? { submissionTimeoutMs: options.stagedInvocationSubmissionTimeoutMs } : {},
6654
+ ...options.stagedInvocationMaxAttempts !== void 0 ? { maxAttempts: options.stagedInvocationMaxAttempts } : {},
6655
+ ...options.stagedInvocationRetryDelayMs ? { retryDelayMs: options.stagedInvocationRetryDelayMs } : {},
6656
+ ...options.tenantId ? { tenantId: options.tenantId } : {},
6657
+ ...options.spaceId ? { spaceId: options.spaceId } : {},
6658
+ ...options.onError ? { onError: (error) => options.onError?.(error) } : {}
6659
+ });
6660
+ } catch (error) {
6661
+ options.onError?.(error);
6662
+ }
5315
6663
  if (options.sweepOverdueCompletions !== false && typeof options.host.reconcileOverdueCompletions === "function") try {
5316
6664
  await options.host.reconcileOverdueCompletions({
5317
6665
  ...options.tenantId ? { tenantId: options.tenantId } : {},
@@ -5369,9 +6717,20 @@ async function runPlatformActionWorkerCycle(options) {
5369
6717
  completed,
5370
6718
  failed,
5371
6719
  ...waitingForApproval > 0 ? { waitingForApproval } : {},
5372
- ...deferred > 0 ? { deferred } : {}
6720
+ ...deferred > 0 ? { deferred } : {},
6721
+ ...staged && Object.values(staged).some((value) => value > 0) ? { staged } : {}
5373
6722
  };
5374
- emitWorkerTelemetry(options.telemetry, "worker.cycle.completed", options.workerId, result);
6723
+ emitWorkerTelemetry(options.telemetry, "worker.cycle.completed", options.workerId, {
6724
+ claimed: result.claimed,
6725
+ completed: result.completed,
6726
+ failed: result.failed,
6727
+ ...result.waitingForApproval !== void 0 ? { waitingForApproval: result.waitingForApproval } : {},
6728
+ ...result.deferred !== void 0 ? { deferred: result.deferred } : {},
6729
+ ...result.staged ? {
6730
+ stagedClaimed: result.staged.claimed,
6731
+ stagedDispatched: result.staged.dispatched
6732
+ } : {}
6733
+ });
5375
6734
  return result;
5376
6735
  }
5377
6736
  function startLeaseHeartbeat(options, invocation, leaseDurationMs) {
@@ -5629,15 +6988,16 @@ async function adapterProbe(subject) {
5629
6988
  };
5630
6989
  }
5631
6990
  /**
5632
- * The suite any deployment of the host must pass before it claims isolation.
6991
+ * The suite a deployment of the host must pass before it claims isolation.
5633
6992
  *
5634
- * Eight checks: an invocation cannot be read, executed, or completed under
6993
+ * Nine checks: an invocation cannot be read, executed, or completed under
5635
6994
  * another tenant's scope; an idempotency key is a tenant's own, so the same
5636
6995
  * key in two tenants is two invocations; a tenant's event stream carries no
5637
6996
  * other tenant's events; a worker scoped to one tenant does not claim another's
5638
- * work; an adapter checkpoint cannot be read or written under another tenant,
5639
- * space, or owning invocation; and, when the subject lends a read, a viewer in
5640
- * the other tenant sees nothing of ours.
6997
+ * work; staged listing and claiming retain tenant and space scope; an adapter
6998
+ * checkpoint cannot be read or written under another tenant, space, or owning
6999
+ * invocation; and, when the subject lends a read, a viewer in the other tenant
7000
+ * sees nothing of ours.
5641
7001
  */
5642
7002
  function tenantIsolationChecks() {
5643
7003
  return [
@@ -5681,6 +7041,103 @@ function tenantIsolationChecks() {
5681
7041
  return ["one idempotency key: one invocation within a tenant, two across tenants"];
5682
7042
  }
5683
7043
  },
7044
+ {
7045
+ id: "fabric.tenancy.staged-invocation-scoped.v1",
7046
+ async run(subject) {
7047
+ await subject.reset?.();
7048
+ const store = subject.store;
7049
+ 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"];
7050
+ const suffix = globalThis.crypto.randomUUID();
7051
+ const probeTenant = `iso-stage-tenant-${suffix}`;
7052
+ const otherProbeTenant = `iso-stage-tenant-other-${suffix}`;
7053
+ const probeSpace = `iso-stage-space-${suffix}`;
7054
+ const otherProbeSpace = `iso-stage-space-other-${suffix}`;
7055
+ const parents = [
7056
+ {
7057
+ id: `iso_stage_parent_ours_${suffix}`,
7058
+ tenantId: probeTenant,
7059
+ spaceId: probeSpace,
7060
+ actorId: subject.ours.actorId,
7061
+ actorType: subject.ours.actorType
7062
+ },
7063
+ {
7064
+ id: `iso_stage_parent_theirs_${suffix}`,
7065
+ tenantId: otherProbeTenant,
7066
+ spaceId: probeSpace,
7067
+ actorId: subject.theirs.actorId,
7068
+ actorType: subject.theirs.actorType
7069
+ },
7070
+ {
7071
+ id: `iso_stage_parent_space_${suffix}`,
7072
+ tenantId: probeTenant,
7073
+ spaceId: otherProbeSpace,
7074
+ actorId: subject.ours.actorId,
7075
+ actorType: subject.ours.actorType
7076
+ }
7077
+ ];
7078
+ for (const parent of parents) await store.createActionInvocation({
7079
+ ...parent,
7080
+ actionId: subject.actionId,
7081
+ actionVersion: 1,
7082
+ status: "completed",
7083
+ parameters: {},
7084
+ result: {},
7085
+ correlationId: suffix
7086
+ });
7087
+ const now = /* @__PURE__ */ new Date();
7088
+ const records = parents.map((parent, index) => ({
7089
+ id: `iso_stage_${index}_${suffix}`,
7090
+ tenantId: parent.tenantId,
7091
+ spaceId: parent.spaceId,
7092
+ parentInvocationId: parent.id,
7093
+ parentActionId: subject.actionId,
7094
+ key: "child",
7095
+ actionId: subject.actionId,
7096
+ expectedActionVersion: 1,
7097
+ parameters: {},
7098
+ parameterDigest: `digest-${index}`,
7099
+ batchDigest: `batch-${index}`,
7100
+ status: "pending",
7101
+ attemptCount: 0,
7102
+ availableAt: now,
7103
+ leaseToken: 0,
7104
+ createdAt: now,
7105
+ updatedAt: now
7106
+ }));
7107
+ try {
7108
+ await store.transactionWithEvents(async (transaction) => {
7109
+ if (!transaction.stageInvocations) fail$1("transactionalStagedInvocations did not supply stageInvocations");
7110
+ for (const record of records) await transaction.stageInvocations([record]);
7111
+ });
7112
+ const ours = await store.listStagedInvocations({
7113
+ tenantId: probeTenant,
7114
+ spaceId: probeSpace
7115
+ });
7116
+ 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");
7117
+ if (ours.some((record) => record.id === records[1].id || record.id === records[2].id)) fail$1("staged invocation listing crossed tenant or space");
7118
+ if ((await store.listStagedInvocations({
7119
+ tenantId: otherProbeTenant,
7120
+ spaceId: probeSpace,
7121
+ parentInvocationId: parents[0].id
7122
+ })).length !== 0) fail$1("a staged invocation was listable under another tenant's scope");
7123
+ const claimed = await store.claimStagedInvocations({
7124
+ workerId: `iso-stage-worker-${suffix}`,
7125
+ tenantId: probeTenant,
7126
+ spaceId: probeSpace,
7127
+ leaseDurationMs: 6e4,
7128
+ now
7129
+ });
7130
+ 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");
7131
+ if (claimed.some((record) => record.tenantId !== probeTenant || record.spaceId !== probeSpace)) fail$1("staged invocation claim crossed tenant or space");
7132
+ return ["staged listing and claiming retained both tenant and space scope"];
7133
+ } finally {
7134
+ for (const parent of parents) await store.updateActionInvocation(parent.id, parent.tenantId, parent.spaceId, {
7135
+ status: "failed",
7136
+ error: "tenant isolation probe; retired"
7137
+ });
7138
+ }
7139
+ }
7140
+ },
5684
7141
  {
5685
7142
  id: "fabric.tenancy.events-scoped.v1",
5686
7143
  async run(subject) {
@@ -6475,6 +7932,6 @@ async function runEventAppendChecks(subject) {
6475
7932
  };
6476
7933
  }
6477
7934
  //#endregion
6478
- export { ActionAdmissionError, ExternalCompletionError, IdempotencyConflictError, MemoryPlatformHostStore, PARAMETER_DIGEST_ALGORITHM, PLATFORM_HOST_CONTRACT_VERSION, PLATFORM_HOST_HEALTH_CONTRACT_VERSION, PLATFORM_HOST_METRIC_NAMES, PostgresPlatformHostStore, applyPostgresMigrations, canonicalJson, cloneOutboxRecord, createDurableSagaParentLifecycle, createGovernedActionHost, createStoreBackedActionDispatcher, digestParameters, emitPlatformHostTelemetry, eventAppendChecks, getPlatformHostHealthSnapshot, outboxActorDisclosure, runEventAppendChecks, runOutboxRelayCycle, runPlatformActionWorker, runPlatformActionWorkerCycle, runTenantIsolationChecks, tenantIsolationChecks, toEnterpriseEventEnvelope };
7935
+ export { ActionAdmissionError, ExternalCompletionError, IdempotencyConflictError, MAX_STAGED_INVOCATIONS, MAX_STAGED_INVOCATION_BATCH_BYTES, MAX_STAGED_INVOCATION_BYTES, MAX_STAGED_INVOCATION_KEY_LENGTH, MemoryPlatformHostStore, PARAMETER_DIGEST_ALGORITHM, PLATFORM_HOST_CONTRACT_VERSION, PLATFORM_HOST_HEALTH_CONTRACT_VERSION, PLATFORM_HOST_METRIC_NAMES, PostgresPlatformHostStore, STAGED_INVOCATION_KEY_PATTERN, SubmissionRefusalError, applyPostgresMigrations, asStagedInvocationStore, canonicalJson, cloneOutboxRecord, cloneStagedInvocationRecord, createDurableSagaParentLifecycle, createGovernedActionHost, createStoreBackedActionDispatcher, digestParameters, emitPlatformHostTelemetry, eventAppendChecks, getPlatformHostHealthSnapshot, outboxActorDisclosure, prepareStagedInvocationBatch, runEventAppendChecks, runOutboxRelayCycle, runPlatformActionWorker, runPlatformActionWorkerCycle, runTenantIsolationChecks, stagedInvocationRequestIdentity, tenantIsolationChecks, toEnterpriseEventEnvelope };
6479
7936
 
6480
7937
  //# sourceMappingURL=index.js.map