@fabricorg/platform-host 7.1.1 → 7.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +9 -0
- package/dist/index.cjs +433 -229
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +63 -1
- package/dist/index.d.ts +63 -1
- package/dist/index.js +432 -230
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.cjs
CHANGED
|
@@ -284,6 +284,221 @@ function isPromiseLike(value) {
|
|
|
284
284
|
);
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
+
// src/host-internals.ts
|
|
288
|
+
function asAtomicMutationStore(store) {
|
|
289
|
+
const candidate = store;
|
|
290
|
+
return typeof candidate.transactionWithEvents === "function" ? store : void 0;
|
|
291
|
+
}
|
|
292
|
+
function asGovernanceStore(store) {
|
|
293
|
+
const candidate = store;
|
|
294
|
+
return typeof candidate.recordMutationGovernance === "function" && typeof candidate.appendPolicyObligations === "function" && typeof candidate.appendExecutionAttestation === "function" ? store : void 0;
|
|
295
|
+
}
|
|
296
|
+
function withoutPrivateHostFields(data, eventResultFields) {
|
|
297
|
+
return Object.fromEntries(
|
|
298
|
+
Object.entries(data).filter(([key]) => !eventResultFields.includes(key))
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
function errorMessage(error) {
|
|
302
|
+
return error instanceof Error ? error.message : String(error);
|
|
303
|
+
}
|
|
304
|
+
function lifecycleId(prefix, invocationId, key) {
|
|
305
|
+
const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 96);
|
|
306
|
+
return `${prefix}_${invocationId}_${safeKey}`;
|
|
307
|
+
}
|
|
308
|
+
function emitInvocationStatusTelemetry(invocation, status, telemetry, occurredAt, previousStatus) {
|
|
309
|
+
if (!status) return;
|
|
310
|
+
const event = status === "completed" ? { name: "invocation.completed", metricName: PLATFORM_HOST_METRIC_NAMES.invocationCompleted } : status === "blocked_by_policy" ? { name: "invocation.policy_blocked", metricName: PLATFORM_HOST_METRIC_NAMES.invocationPolicyBlocked } : status === "validation_failed" ? { name: "invocation.validation_failed", metricName: PLATFORM_HOST_METRIC_NAMES.invocationValidationFailed } : status === "waiting_for_approval" ? { name: "invocation.approval_waited", metricName: PLATFORM_HOST_METRIC_NAMES.invocationApprovalWaited } : status === "reconciliation_required" ? { name: "invocation.reconciliation_required", metricName: PLATFORM_HOST_METRIC_NAMES.invocationReconciliationRequired } : status === "failed" ? { name: "invocation.failed", metricName: PLATFORM_HOST_METRIC_NAMES.invocationFailed } : void 0;
|
|
311
|
+
if (!event) return;
|
|
312
|
+
emitPlatformHostTelemetry(telemetry, {
|
|
313
|
+
kind: "event",
|
|
314
|
+
name: event.name,
|
|
315
|
+
metricName: event.metricName,
|
|
316
|
+
occurredAt,
|
|
317
|
+
tenantId: invocation.tenantId,
|
|
318
|
+
spaceId: invocation.spaceId,
|
|
319
|
+
attributes: {
|
|
320
|
+
...invocation.actionId ? { actionId: invocation.actionId } : {},
|
|
321
|
+
...invocation.actionVersion !== void 0 ? { actionVersion: invocation.actionVersion } : {},
|
|
322
|
+
...previousStatus ? { fromStatus: previousStatus } : {}
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// src/external-completion.ts
|
|
328
|
+
function createExternalCompletionLifecycle(deps) {
|
|
329
|
+
const { now, actionResolver, appendEvent, persistInvocation, actionResult: actionResult2, withConsistency, eventResultFields } = deps;
|
|
330
|
+
async function completeExternalInvocation(actionInvocationId, tenantId, spaceId, completion) {
|
|
331
|
+
if (typeof completion.externalReference !== "string" || completion.externalReference.trim() === "") {
|
|
332
|
+
throw new ExternalCompletionError("invalid_completion", "External completion must name the external reference it completes.");
|
|
333
|
+
}
|
|
334
|
+
if (completion.outcome !== "completed" && completion.outcome !== "failed") {
|
|
335
|
+
throw new ExternalCompletionError("invalid_completion", `External completion outcome must be "completed" or "failed", received "${String(completion.outcome)}".`);
|
|
336
|
+
}
|
|
337
|
+
const atomicStore = asAtomicMutationStore(deps.store);
|
|
338
|
+
if (atomicStore) {
|
|
339
|
+
return atomicStore.transactionWithEvents(async (transaction) => {
|
|
340
|
+
const read2 = transaction.getActionInvocationForUpdate ? await transaction.getActionInvocationForUpdate(actionInvocationId, tenantId, spaceId) : await deps.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
341
|
+
return settleExternalCompletion(read2, actionInvocationId, completion, {
|
|
342
|
+
update: (patch) => transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, patch),
|
|
343
|
+
transaction
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
const read = await deps.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
348
|
+
return settleExternalCompletion(read, actionInvocationId, completion, {
|
|
349
|
+
update: (patch) => deps.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, patch)
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
async function settleExternalCompletion(invocation, actionInvocationId, completion, writer) {
|
|
353
|
+
if (!invocation) throw new ExternalCompletionError("not_found", `ActionInvocation not found: ${actionInvocationId}`);
|
|
354
|
+
const recorded = invocation.externalCompletion;
|
|
355
|
+
const stillExecuting = !recorded && (invocation.status === "pending" || invocation.status === "running" && (invocation.leaseOwner !== void 0 || invocation.pendingCompletion?.parkedAt === void 0));
|
|
356
|
+
if (stillExecuting) {
|
|
357
|
+
throw new ExternalCompletionError("still_executing", `ActionInvocation ${actionInvocationId} is still executing; retry the completion after it parks.`);
|
|
358
|
+
}
|
|
359
|
+
if (recorded) {
|
|
360
|
+
if (recorded.externalReference !== completion.externalReference) {
|
|
361
|
+
throw new ExternalCompletionError("reference_mismatch", `ActionInvocation ${actionInvocationId} was completed by "${recorded.externalReference}", not "${completion.externalReference}".`);
|
|
362
|
+
}
|
|
363
|
+
if (completion.provider !== void 0 && recorded.provider !== void 0 && completion.provider !== recorded.provider) {
|
|
364
|
+
throw new ExternalCompletionError("provider_mismatch", `ActionInvocation ${actionInvocationId} was completed by "${recorded.provider}", not "${completion.provider}".`);
|
|
365
|
+
}
|
|
366
|
+
const incomingDigest = completionDigest(completion, recorded.provider ?? completion.provider);
|
|
367
|
+
if (recorded.digest === incomingDigest) {
|
|
368
|
+
return withConsistency(actionResult2(invocation), invocation.actionId);
|
|
369
|
+
}
|
|
370
|
+
const message = recorded.outcome === completion.outcome ? `External operation "${completion.externalReference}" reported ${completion.outcome} again with different evidence.` : `External operation "${completion.externalReference}" reported ${completion.outcome} after reporting ${recorded.outcome}.`;
|
|
371
|
+
await recordCompletionReconciliation(invocation, { ...completion, kind: `contradicted:${incomingDigest.slice(0, 16)}` }, message);
|
|
372
|
+
await appendEvent(invocation, {
|
|
373
|
+
eventType: "ExternalOperationContradicted",
|
|
374
|
+
subjectType: "AdapterInvocation",
|
|
375
|
+
subjectId: invocation.pendingCompletion?.adapterInvocationId ?? actionInvocationId,
|
|
376
|
+
payload: { externalReference: completion.externalReference, recorded: recorded.outcome, reported: completion.outcome }
|
|
377
|
+
}, `external:${completion.externalReference}:contradicted:${completion.outcome}`, 1, writer.transaction);
|
|
378
|
+
await writer.update({ status: "reconciliation_required", error: message });
|
|
379
|
+
emitInvocationStatusTelemetry(invocation, "reconciliation_required", deps.telemetry, now(), invocation.status);
|
|
380
|
+
return withConsistency(actionResult2({ ...invocation, status: "reconciliation_required", error: message }), invocation.actionId);
|
|
381
|
+
}
|
|
382
|
+
const pending = invocation.pendingCompletion;
|
|
383
|
+
if (!pending) {
|
|
384
|
+
const action = actionResolver(invocation.actionId);
|
|
385
|
+
if ((action?.execution?.completion ?? "immediate") === "immediate") {
|
|
386
|
+
throw new ExternalCompletionError("immediate_contract", `Action ${invocation.actionId} declares immediate completion; nothing external completes it.`);
|
|
387
|
+
}
|
|
388
|
+
throw new ExternalCompletionError("not_awaiting", `ActionInvocation ${actionInvocationId} is not awaiting an external completion.`);
|
|
389
|
+
}
|
|
390
|
+
if (pending.externalReference !== completion.externalReference) {
|
|
391
|
+
throw new ExternalCompletionError("reference_mismatch", `ActionInvocation ${actionInvocationId} awaits "${pending.externalReference}", not "${completion.externalReference}".`);
|
|
392
|
+
}
|
|
393
|
+
if (completion.provider !== void 0 && completion.provider !== pending.provider) {
|
|
394
|
+
throw new ExternalCompletionError("provider_mismatch", `ActionInvocation ${actionInvocationId} awaits completion from "${pending.provider}", not "${completion.provider}".`);
|
|
395
|
+
}
|
|
396
|
+
const recordedAt = now();
|
|
397
|
+
const externalCompletion = {
|
|
398
|
+
...completion,
|
|
399
|
+
provider: completion.provider ?? pending.provider,
|
|
400
|
+
observedAt: completion.observedAt instanceof Date ? completion.observedAt.toISOString() : completion.observedAt,
|
|
401
|
+
recordedAt,
|
|
402
|
+
digest: completionDigest(completion, completion.provider ?? pending.provider)
|
|
403
|
+
};
|
|
404
|
+
const subject = { subjectType: "AdapterInvocation", subjectId: pending.adapterInvocationId };
|
|
405
|
+
const settles = invocation.status === "running" && pending.parkedAt !== void 0 || invocation.status === "reconciliation_required" && invocation.error?.includes("did not complete by") === true;
|
|
406
|
+
if (completion.outcome === "completed") {
|
|
407
|
+
const action = actionResolver(invocation.actionId);
|
|
408
|
+
const merged = withoutPrivateHostFields({ ...invocation.result, ...completion.result ?? {} }, eventResultFields);
|
|
409
|
+
if (action?.resultSchema) {
|
|
410
|
+
const parsedResult = action.resultSchema.safeParse(merged);
|
|
411
|
+
if (!parsedResult.success) {
|
|
412
|
+
throw new ExternalCompletionError("result_invalid", `External completion result validation failed at ${parsedResult.error.issues.map((issue) => issue.path.map(String).join(".") || "result").join(", ")}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
await deps.store.updateAdapterInvocation(pending.adapterInvocationId, {
|
|
416
|
+
...completion.result ? { output: withoutPrivateHostFields(completion.result, eventResultFields) } : {},
|
|
417
|
+
updatedAt: recordedAt
|
|
418
|
+
});
|
|
419
|
+
await appendEvent(invocation, {
|
|
420
|
+
eventType: "ExternalOperationCompleted",
|
|
421
|
+
...subject,
|
|
422
|
+
payload: { externalReference: pending.externalReference, provider: pending.provider, ...completion.result ? { result: withoutPrivateHostFields(completion.result, eventResultFields) } : {} }
|
|
423
|
+
}, `external:${pending.adapterInvocationId}:completed`, 1, writer.transaction);
|
|
424
|
+
const patch2 = settles ? { status: "completed", result: merged, externalCompletion, pendingCompletion: void 0, error: void 0 } : { externalCompletion, pendingCompletion: void 0 };
|
|
425
|
+
await writer.update(patch2);
|
|
426
|
+
if (settles) emitInvocationStatusTelemetry(invocation, "completed", deps.telemetry, now(), invocation.status);
|
|
427
|
+
return withConsistency(actionResult2({ ...invocation, ...patch2, result: settles ? merged : invocation.result, ...settles ? { error: void 0 } : {} }), invocation.actionId);
|
|
428
|
+
}
|
|
429
|
+
const error = completion.error ?? `External operation "${pending.externalReference}" failed.`;
|
|
430
|
+
await deps.store.updateAdapterInvocation(pending.adapterInvocationId, { status: "failed", error, updatedAt: recordedAt });
|
|
431
|
+
await appendEvent(invocation, {
|
|
432
|
+
eventType: "ExternalOperationFailed",
|
|
433
|
+
...subject,
|
|
434
|
+
payload: { externalReference: pending.externalReference, provider: pending.provider, error }
|
|
435
|
+
}, `external:${pending.adapterInvocationId}:failed`, 1, writer.transaction);
|
|
436
|
+
const patch = settles ? { status: "failed", error, externalCompletion, pendingCompletion: void 0 } : { externalCompletion, pendingCompletion: void 0 };
|
|
437
|
+
await writer.update(patch);
|
|
438
|
+
if (settles) emitInvocationStatusTelemetry(invocation, "failed", deps.telemetry, now(), invocation.status);
|
|
439
|
+
return withConsistency(actionResult2({ ...invocation, ...patch }), invocation.actionId);
|
|
440
|
+
}
|
|
441
|
+
function completionDigest(completion, provider) {
|
|
442
|
+
return crypto.createHash("sha256").update(canonicalJson({
|
|
443
|
+
provider: provider ?? null,
|
|
444
|
+
externalReference: completion.externalReference,
|
|
445
|
+
outcome: completion.outcome,
|
|
446
|
+
result: completion.result ?? null,
|
|
447
|
+
error: completion.error ?? null,
|
|
448
|
+
evidenceReferences: completion.evidenceReferences ?? null
|
|
449
|
+
})).digest("hex");
|
|
450
|
+
}
|
|
451
|
+
async function recordCompletionReconciliation(invocation, completion, reason) {
|
|
452
|
+
const governanceStore = asGovernanceStore(deps.store);
|
|
453
|
+
if (!governanceStore) throw new Error("External completion reconciliation requires a governance-capable store.");
|
|
454
|
+
const provider = completion.provider ?? invocation.pendingCompletion?.provider ?? invocation.externalCompletion?.provider ?? "external";
|
|
455
|
+
const seed = crypto.createHash("sha256").update(`${provider}\0${completion.externalReference}\0${completion.kind ?? "refused"}`).digest("hex").slice(0, 32);
|
|
456
|
+
await governanceStore.appendExternalReconciliation({
|
|
457
|
+
id: lifecycleId("rec", invocation.id, `ext:${seed}`),
|
|
458
|
+
actionInvocationId: invocation.id,
|
|
459
|
+
tenantId: invocation.tenantId,
|
|
460
|
+
spaceId: invocation.spaceId,
|
|
461
|
+
status: "pending",
|
|
462
|
+
provider,
|
|
463
|
+
externalOperationId: completion.externalReference,
|
|
464
|
+
attempt: 1,
|
|
465
|
+
reason,
|
|
466
|
+
...completion.evidenceReferences ? { evidenceReferences: completion.evidenceReferences } : {},
|
|
467
|
+
observedAt: now()
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
async function reconcileOverdueCompletions(input = {}) {
|
|
471
|
+
const recoverable = deps.store;
|
|
472
|
+
if (!recoverable.listActionInvocations) {
|
|
473
|
+
throw new Error("Overdue completion reconciliation requires a store that can list invocations.");
|
|
474
|
+
}
|
|
475
|
+
if (!asGovernanceStore(deps.store)) {
|
|
476
|
+
throw new Error("Overdue completion reconciliation requires a governance-capable store to record its findings.");
|
|
477
|
+
}
|
|
478
|
+
const current = input.now ?? now();
|
|
479
|
+
const candidates = await recoverable.listActionInvocations({
|
|
480
|
+
statuses: ["running"],
|
|
481
|
+
completionDueBefore: current,
|
|
482
|
+
unleased: true,
|
|
483
|
+
...input.tenantId ? { tenantId: input.tenantId } : {},
|
|
484
|
+
...input.spaceId ? { spaceId: input.spaceId } : {},
|
|
485
|
+
limit: Math.max(1, Math.min(input.limit ?? 100, 1e3))
|
|
486
|
+
});
|
|
487
|
+
const overdue = [];
|
|
488
|
+
for (const invocation of candidates) {
|
|
489
|
+
const pending = invocation.pendingCompletion;
|
|
490
|
+
if (!pending?.dueAt || pending.dueAt.getTime() > current.getTime()) continue;
|
|
491
|
+
if (invocation.leaseOwner) continue;
|
|
492
|
+
const message = `External operation "${pending.externalReference}" from ${pending.provider} did not complete by ${pending.dueAt.toISOString()}.`;
|
|
493
|
+
await recordCompletionReconciliation(invocation, { externalReference: pending.externalReference, provider: pending.provider, kind: "overdue" }, message);
|
|
494
|
+
await persistInvocation(invocation, { status: "reconciliation_required", error: message });
|
|
495
|
+
overdue.push(invocation.id);
|
|
496
|
+
}
|
|
497
|
+
return { overdue };
|
|
498
|
+
}
|
|
499
|
+
return { completeExternalInvocation, reconcileOverdueCompletions, recordCompletionReconciliation };
|
|
500
|
+
}
|
|
501
|
+
|
|
287
502
|
// src/host.ts
|
|
288
503
|
var DEFAULT_EXTRACT_EVENTS = (data) => {
|
|
289
504
|
const value = data._events;
|
|
@@ -500,24 +715,24 @@ function createGovernedActionHost(options) {
|
|
|
500
715
|
}
|
|
501
716
|
const action = actionResolver(invocation.actionId);
|
|
502
717
|
if (!action) {
|
|
503
|
-
return
|
|
718
|
+
return fail2(invocation, "failed", `Unknown action: ${invocation.actionId}`);
|
|
504
719
|
}
|
|
505
720
|
if (action.kind === "saga") {
|
|
506
|
-
return
|
|
721
|
+
return fail2(
|
|
507
722
|
invocation,
|
|
508
723
|
"failed",
|
|
509
724
|
`Saga action ${invocation.actionId} requires a durable PlatformActionDispatcher and cannot execute as an atomic Host action.`
|
|
510
725
|
);
|
|
511
726
|
}
|
|
512
727
|
if (resumingRunningInvocation && invocation.attemptCount === 0 && !invocation.leaseOwner && !action.idempotent) {
|
|
513
|
-
return
|
|
728
|
+
return fail2(
|
|
514
729
|
invocation,
|
|
515
730
|
"failed",
|
|
516
731
|
`Interrupted inline action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`
|
|
517
732
|
);
|
|
518
733
|
}
|
|
519
734
|
if (invocation.attemptCount > 1 && !action.idempotent) {
|
|
520
|
-
return
|
|
735
|
+
return fail2(
|
|
521
736
|
invocation,
|
|
522
737
|
"failed",
|
|
523
738
|
`Interrupted action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`
|
|
@@ -541,12 +756,12 @@ function createGovernedActionHost(options) {
|
|
|
541
756
|
const parsed = action.schema.safeParse(invocation.parameters);
|
|
542
757
|
if (!parsed.success) {
|
|
543
758
|
const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
544
|
-
return
|
|
759
|
+
return fail2(invocation, "validation_failed", message);
|
|
545
760
|
}
|
|
546
761
|
if (options.resolveMutationGovernance && !invocation.mutationFootprint) {
|
|
547
762
|
const governanceStore2 = asGovernanceStore(options.store);
|
|
548
763
|
if (!governanceStore2) {
|
|
549
|
-
return
|
|
764
|
+
return fail2(invocation, "failed", "The configured mutation-governance resolver requires a governance-capable store.");
|
|
550
765
|
}
|
|
551
766
|
const context = await options.resolveMutationGovernance({
|
|
552
767
|
actionInvocationId,
|
|
@@ -564,7 +779,7 @@ function createGovernedActionHost(options) {
|
|
|
564
779
|
const hitlParameters = isRecord(parsed.data) ? parsed.data : invocation.parameters;
|
|
565
780
|
const approvalStore = asApprovalStore(options.store);
|
|
566
781
|
if (!approvalStore) {
|
|
567
|
-
return
|
|
782
|
+
return fail2(
|
|
568
783
|
invocation,
|
|
569
784
|
"failed",
|
|
570
785
|
"The configured HITL evaluator requires a store with approval persistence support."
|
|
@@ -595,7 +810,7 @@ function createGovernedActionHost(options) {
|
|
|
595
810
|
);
|
|
596
811
|
}
|
|
597
812
|
if (invocation.hitlRoute === "rejected") {
|
|
598
|
-
return
|
|
813
|
+
return fail2(
|
|
599
814
|
invocation,
|
|
600
815
|
"failed",
|
|
601
816
|
`HITL rejected: ${invocation.hitlReason ?? "no reason provided"}`
|
|
@@ -614,7 +829,7 @@ function createGovernedActionHost(options) {
|
|
|
614
829
|
const authorizationInput = toAuthorizationInput(action, invocation);
|
|
615
830
|
const executionReason = executionReasonOverride ?? (invocation.attemptCount > 1 || resumingRunningInvocation && invocation.attemptCount === 0 ? "recovery" : invocation.executionReason ?? "initial");
|
|
616
831
|
if (!await options.authorization.checkEntitlement(authorizationInput)) {
|
|
617
|
-
return
|
|
832
|
+
return fail2(
|
|
618
833
|
invocation,
|
|
619
834
|
"failed",
|
|
620
835
|
`Module "${action.namespace}" is no longer enabled for tenant ${tenantId}`
|
|
@@ -654,7 +869,7 @@ function createGovernedActionHost(options) {
|
|
|
654
869
|
}) : await options.authorization.authorize(authorizationInput);
|
|
655
870
|
if (!executionAuthorized) {
|
|
656
871
|
if (!action.execution) {
|
|
657
|
-
return
|
|
872
|
+
return fail2(invocation, "failed", `Actor ${invocation.actorId} is not authorized to execute action ${action.actionId}`);
|
|
658
873
|
}
|
|
659
874
|
const reconciliation = {
|
|
660
875
|
kind: "authorization_denied",
|
|
@@ -704,12 +919,12 @@ function createGovernedActionHost(options) {
|
|
|
704
919
|
subjectId: actionInvocationId,
|
|
705
920
|
payload: { actionId: action.actionId, policyId: aggregate.policyId, reason }
|
|
706
921
|
}, `compliance:${aggregate.policyId}`);
|
|
707
|
-
return
|
|
922
|
+
return fail2(invocation, "blocked_by_policy", reason);
|
|
708
923
|
}
|
|
709
924
|
if (options.resolvePolicyObligations) {
|
|
710
925
|
const governanceStore2 = asGovernanceStore(options.store);
|
|
711
926
|
if (!governanceStore2) {
|
|
712
|
-
return
|
|
927
|
+
return fail2(invocation, "failed", "Policy obligations require a governance-capable store.");
|
|
713
928
|
}
|
|
714
929
|
const existing = await governanceStore2.listPolicyObligations(actionInvocationId, tenantId, spaceId);
|
|
715
930
|
if (existing.length === 0) {
|
|
@@ -824,7 +1039,7 @@ function createGovernedActionHost(options) {
|
|
|
824
1039
|
data = executed.data;
|
|
825
1040
|
domainEvents = executed.domainEvents;
|
|
826
1041
|
} catch (error) {
|
|
827
|
-
return
|
|
1042
|
+
return fail2(invocation, "failed", errorMessage(error));
|
|
828
1043
|
}
|
|
829
1044
|
let pendingHandoff = invocation.pendingCompletion;
|
|
830
1045
|
for (const [stepIndex, step] of (action.adapterSteps ?? []).entries()) {
|
|
@@ -1004,7 +1219,7 @@ function createGovernedActionHost(options) {
|
|
|
1004
1219
|
subjectId: adapterEventSubject.subjectId,
|
|
1005
1220
|
payload: { adapterType: step.adapterType, operation: step.operation }
|
|
1006
1221
|
}, `adapter:${stepIndex}:failed`);
|
|
1007
|
-
return
|
|
1222
|
+
return fail2(invocation, "failed", result2.error ?? "Adapter failed");
|
|
1008
1223
|
}
|
|
1009
1224
|
const resultRecord = result2;
|
|
1010
1225
|
const output = isRecord(resultRecord.output) ? resultRecord.output : Object.fromEntries(
|
|
@@ -1042,7 +1257,7 @@ function createGovernedActionHost(options) {
|
|
|
1042
1257
|
});
|
|
1043
1258
|
let error = refuse;
|
|
1044
1259
|
try {
|
|
1045
|
-
await recordCompletionReconciliation(invocation, { externalReference: reference, provider: adapter.vendor }, refuse);
|
|
1260
|
+
await externalCompletion.recordCompletionReconciliation(invocation, { externalReference: reference, provider: adapter.vendor }, refuse);
|
|
1046
1261
|
} catch (logError) {
|
|
1047
1262
|
error = `${refuse} Reconciliation log unavailable: ${errorMessage(logError)}`;
|
|
1048
1263
|
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { error });
|
|
@@ -1108,7 +1323,7 @@ function createGovernedActionHost(options) {
|
|
|
1108
1323
|
error: message,
|
|
1109
1324
|
updatedAt: now()
|
|
1110
1325
|
});
|
|
1111
|
-
return
|
|
1326
|
+
return fail2(invocation, "failed", message);
|
|
1112
1327
|
} finally {
|
|
1113
1328
|
if (adapterDeadlineTimer) clearTimeout(adapterDeadlineTimer);
|
|
1114
1329
|
}
|
|
@@ -1123,14 +1338,14 @@ function createGovernedActionHost(options) {
|
|
|
1123
1338
|
await persistInvocation(invocation, { status: "reconciliation_required", error: message });
|
|
1124
1339
|
let error = message;
|
|
1125
1340
|
try {
|
|
1126
|
-
await recordCompletionReconciliation(invocation, { externalReference: pendingHandoff.externalReference, provider: pendingHandoff.provider, kind: "obligations" }, message);
|
|
1341
|
+
await externalCompletion.recordCompletionReconciliation(invocation, { externalReference: pendingHandoff.externalReference, provider: pendingHandoff.provider, kind: "obligations" }, message);
|
|
1127
1342
|
} catch (logError) {
|
|
1128
1343
|
error = `${message} Reconciliation log unavailable: ${errorMessage(logError)}`;
|
|
1129
1344
|
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { error });
|
|
1130
1345
|
}
|
|
1131
1346
|
return withConsistency({ actionInvocationId, status: "reconciliation_required", error, pendingCompletion: pendingHandoff }, action.actionId);
|
|
1132
1347
|
}
|
|
1133
|
-
return
|
|
1348
|
+
return fail2(invocation, "failed", message);
|
|
1134
1349
|
}
|
|
1135
1350
|
}
|
|
1136
1351
|
const result = withoutPrivateHostFields(data, eventResultFields);
|
|
@@ -1203,7 +1418,7 @@ function createGovernedActionHost(options) {
|
|
|
1203
1418
|
};
|
|
1204
1419
|
} catch (error) {
|
|
1205
1420
|
if (error instanceof RecoverableFinalizationError) throw error;
|
|
1206
|
-
return
|
|
1421
|
+
return fail2(invocation, "failed", errorMessage(error));
|
|
1207
1422
|
}
|
|
1208
1423
|
}
|
|
1209
1424
|
async function resumeApprovedInvocation(actionInvocationId, tenantId, spaceId, decision) {
|
|
@@ -1222,7 +1437,7 @@ function createGovernedActionHost(options) {
|
|
|
1222
1437
|
};
|
|
1223
1438
|
}
|
|
1224
1439
|
const action = actionResolver(invocation.actionId);
|
|
1225
|
-
if (!action) return
|
|
1440
|
+
if (!action) return fail2(invocation, "failed", `Unknown action: ${invocation.actionId}`);
|
|
1226
1441
|
const approvalAuthorizationInput = toAuthorizationInput(action, {
|
|
1227
1442
|
tenantId,
|
|
1228
1443
|
spaceId,
|
|
@@ -1316,175 +1531,6 @@ function createGovernedActionHost(options) {
|
|
|
1316
1531
|
spaceId
|
|
1317
1532
|
});
|
|
1318
1533
|
}
|
|
1319
|
-
async function completeExternalInvocation(actionInvocationId, tenantId, spaceId, completion) {
|
|
1320
|
-
if (typeof completion.externalReference !== "string" || completion.externalReference.trim() === "") {
|
|
1321
|
-
throw new ExternalCompletionError("invalid_completion", "External completion must name the external reference it completes.");
|
|
1322
|
-
}
|
|
1323
|
-
if (completion.outcome !== "completed" && completion.outcome !== "failed") {
|
|
1324
|
-
throw new ExternalCompletionError("invalid_completion", `External completion outcome must be "completed" or "failed", received "${String(completion.outcome)}".`);
|
|
1325
|
-
}
|
|
1326
|
-
const atomicStore = asAtomicMutationStore(options.store);
|
|
1327
|
-
if (atomicStore) {
|
|
1328
|
-
return atomicStore.transactionWithEvents(async (transaction) => {
|
|
1329
|
-
const read2 = transaction.getActionInvocationForUpdate ? await transaction.getActionInvocationForUpdate(actionInvocationId, tenantId, spaceId) : await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
1330
|
-
return settleExternalCompletion(read2, actionInvocationId, completion, {
|
|
1331
|
-
update: (patch) => transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, patch),
|
|
1332
|
-
transaction
|
|
1333
|
-
});
|
|
1334
|
-
});
|
|
1335
|
-
}
|
|
1336
|
-
const read = await options.store.getActionInvocation(actionInvocationId, tenantId, spaceId);
|
|
1337
|
-
return settleExternalCompletion(read, actionInvocationId, completion, {
|
|
1338
|
-
update: (patch) => options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, patch)
|
|
1339
|
-
});
|
|
1340
|
-
}
|
|
1341
|
-
async function settleExternalCompletion(invocation, actionInvocationId, completion, writer) {
|
|
1342
|
-
if (!invocation) throw new ExternalCompletionError("not_found", `ActionInvocation not found: ${actionInvocationId}`);
|
|
1343
|
-
const recorded = invocation.externalCompletion;
|
|
1344
|
-
const stillExecuting = !recorded && (invocation.status === "pending" || invocation.status === "running" && (invocation.leaseOwner !== void 0 || invocation.pendingCompletion?.parkedAt === void 0));
|
|
1345
|
-
if (stillExecuting) {
|
|
1346
|
-
throw new ExternalCompletionError("still_executing", `ActionInvocation ${actionInvocationId} is still executing; retry the completion after it parks.`);
|
|
1347
|
-
}
|
|
1348
|
-
if (recorded) {
|
|
1349
|
-
if (recorded.externalReference !== completion.externalReference) {
|
|
1350
|
-
throw new ExternalCompletionError("reference_mismatch", `ActionInvocation ${actionInvocationId} was completed by "${recorded.externalReference}", not "${completion.externalReference}".`);
|
|
1351
|
-
}
|
|
1352
|
-
if (completion.provider !== void 0 && recorded.provider !== void 0 && completion.provider !== recorded.provider) {
|
|
1353
|
-
throw new ExternalCompletionError("provider_mismatch", `ActionInvocation ${actionInvocationId} was completed by "${recorded.provider}", not "${completion.provider}".`);
|
|
1354
|
-
}
|
|
1355
|
-
const incomingDigest = completionDigest(completion, recorded.provider ?? completion.provider);
|
|
1356
|
-
if (recorded.digest === incomingDigest) {
|
|
1357
|
-
return withConsistency(actionResult(invocation), invocation.actionId);
|
|
1358
|
-
}
|
|
1359
|
-
const message = recorded.outcome === completion.outcome ? `External operation "${completion.externalReference}" reported ${completion.outcome} again with different evidence.` : `External operation "${completion.externalReference}" reported ${completion.outcome} after reporting ${recorded.outcome}.`;
|
|
1360
|
-
await recordCompletionReconciliation(invocation, { ...completion, kind: `contradicted:${incomingDigest.slice(0, 16)}` }, message);
|
|
1361
|
-
await appendEvent(invocation, {
|
|
1362
|
-
eventType: "ExternalOperationContradicted",
|
|
1363
|
-
subjectType: "AdapterInvocation",
|
|
1364
|
-
subjectId: invocation.pendingCompletion?.adapterInvocationId ?? actionInvocationId,
|
|
1365
|
-
payload: { externalReference: completion.externalReference, recorded: recorded.outcome, reported: completion.outcome }
|
|
1366
|
-
}, `external:${completion.externalReference}:contradicted:${completion.outcome}`, 1, writer.transaction);
|
|
1367
|
-
await writer.update({ status: "reconciliation_required", error: message });
|
|
1368
|
-
emitInvocationStatusTelemetry(invocation, "reconciliation_required", options.telemetry, now(), invocation.status);
|
|
1369
|
-
return withConsistency(actionResult({ ...invocation, status: "reconciliation_required", error: message }), invocation.actionId);
|
|
1370
|
-
}
|
|
1371
|
-
const pending = invocation.pendingCompletion;
|
|
1372
|
-
if (!pending) {
|
|
1373
|
-
const action = actionResolver(invocation.actionId);
|
|
1374
|
-
if ((action?.execution?.completion ?? "immediate") === "immediate") {
|
|
1375
|
-
throw new ExternalCompletionError("immediate_contract", `Action ${invocation.actionId} declares immediate completion; nothing external completes it.`);
|
|
1376
|
-
}
|
|
1377
|
-
throw new ExternalCompletionError("not_awaiting", `ActionInvocation ${actionInvocationId} is not awaiting an external completion.`);
|
|
1378
|
-
}
|
|
1379
|
-
if (pending.externalReference !== completion.externalReference) {
|
|
1380
|
-
throw new ExternalCompletionError("reference_mismatch", `ActionInvocation ${actionInvocationId} awaits "${pending.externalReference}", not "${completion.externalReference}".`);
|
|
1381
|
-
}
|
|
1382
|
-
if (completion.provider !== void 0 && completion.provider !== pending.provider) {
|
|
1383
|
-
throw new ExternalCompletionError("provider_mismatch", `ActionInvocation ${actionInvocationId} awaits completion from "${pending.provider}", not "${completion.provider}".`);
|
|
1384
|
-
}
|
|
1385
|
-
const recordedAt = now();
|
|
1386
|
-
const externalCompletion = {
|
|
1387
|
-
...completion,
|
|
1388
|
-
provider: completion.provider ?? pending.provider,
|
|
1389
|
-
observedAt: completion.observedAt instanceof Date ? completion.observedAt.toISOString() : completion.observedAt,
|
|
1390
|
-
recordedAt,
|
|
1391
|
-
digest: completionDigest(completion, completion.provider ?? pending.provider)
|
|
1392
|
-
};
|
|
1393
|
-
const subject = { subjectType: "AdapterInvocation", subjectId: pending.adapterInvocationId };
|
|
1394
|
-
const settles = invocation.status === "running" && pending.parkedAt !== void 0 || invocation.status === "reconciliation_required" && invocation.error?.includes("did not complete by") === true;
|
|
1395
|
-
if (completion.outcome === "completed") {
|
|
1396
|
-
const action = actionResolver(invocation.actionId);
|
|
1397
|
-
const merged = withoutPrivateHostFields({ ...invocation.result, ...completion.result ?? {} }, eventResultFields);
|
|
1398
|
-
if (action?.resultSchema) {
|
|
1399
|
-
const parsedResult = action.resultSchema.safeParse(merged);
|
|
1400
|
-
if (!parsedResult.success) {
|
|
1401
|
-
throw new ExternalCompletionError("result_invalid", `External completion result validation failed at ${parsedResult.error.issues.map((issue) => issue.path.map(String).join(".") || "result").join(", ")}`);
|
|
1402
|
-
}
|
|
1403
|
-
}
|
|
1404
|
-
await options.store.updateAdapterInvocation(pending.adapterInvocationId, {
|
|
1405
|
-
...completion.result ? { output: withoutPrivateHostFields(completion.result, eventResultFields) } : {},
|
|
1406
|
-
updatedAt: recordedAt
|
|
1407
|
-
});
|
|
1408
|
-
await appendEvent(invocation, {
|
|
1409
|
-
eventType: "ExternalOperationCompleted",
|
|
1410
|
-
...subject,
|
|
1411
|
-
payload: { externalReference: pending.externalReference, provider: pending.provider, ...completion.result ? { result: withoutPrivateHostFields(completion.result, eventResultFields) } : {} }
|
|
1412
|
-
}, `external:${pending.adapterInvocationId}:completed`, 1, writer.transaction);
|
|
1413
|
-
const patch2 = settles ? { status: "completed", result: merged, externalCompletion, pendingCompletion: void 0, error: void 0 } : { externalCompletion, pendingCompletion: void 0 };
|
|
1414
|
-
await writer.update(patch2);
|
|
1415
|
-
if (settles) emitInvocationStatusTelemetry(invocation, "completed", options.telemetry, now(), invocation.status);
|
|
1416
|
-
return withConsistency(actionResult({ ...invocation, ...patch2, result: settles ? merged : invocation.result, ...settles ? { error: void 0 } : {} }), invocation.actionId);
|
|
1417
|
-
}
|
|
1418
|
-
const error = completion.error ?? `External operation "${pending.externalReference}" failed.`;
|
|
1419
|
-
await options.store.updateAdapterInvocation(pending.adapterInvocationId, { status: "failed", error, updatedAt: recordedAt });
|
|
1420
|
-
await appendEvent(invocation, {
|
|
1421
|
-
eventType: "ExternalOperationFailed",
|
|
1422
|
-
...subject,
|
|
1423
|
-
payload: { externalReference: pending.externalReference, provider: pending.provider, error }
|
|
1424
|
-
}, `external:${pending.adapterInvocationId}:failed`, 1, writer.transaction);
|
|
1425
|
-
const patch = settles ? { status: "failed", error, externalCompletion, pendingCompletion: void 0 } : { externalCompletion, pendingCompletion: void 0 };
|
|
1426
|
-
await writer.update(patch);
|
|
1427
|
-
if (settles) emitInvocationStatusTelemetry(invocation, "failed", options.telemetry, now(), invocation.status);
|
|
1428
|
-
return withConsistency(actionResult({ ...invocation, ...patch }), invocation.actionId);
|
|
1429
|
-
}
|
|
1430
|
-
function completionDigest(completion, provider) {
|
|
1431
|
-
return crypto.createHash("sha256").update(canonicalJson({
|
|
1432
|
-
provider: provider ?? null,
|
|
1433
|
-
externalReference: completion.externalReference,
|
|
1434
|
-
outcome: completion.outcome,
|
|
1435
|
-
result: completion.result ?? null,
|
|
1436
|
-
error: completion.error ?? null,
|
|
1437
|
-
evidenceReferences: completion.evidenceReferences ?? null
|
|
1438
|
-
})).digest("hex");
|
|
1439
|
-
}
|
|
1440
|
-
async function recordCompletionReconciliation(invocation, completion, reason) {
|
|
1441
|
-
const governanceStore = asGovernanceStore(options.store);
|
|
1442
|
-
if (!governanceStore) throw new Error("External completion reconciliation requires a governance-capable store.");
|
|
1443
|
-
const provider = completion.provider ?? invocation.pendingCompletion?.provider ?? invocation.externalCompletion?.provider ?? "external";
|
|
1444
|
-
const seed = crypto.createHash("sha256").update(`${provider}\0${completion.externalReference}\0${completion.kind ?? "refused"}`).digest("hex").slice(0, 32);
|
|
1445
|
-
await governanceStore.appendExternalReconciliation({
|
|
1446
|
-
id: lifecycleId("rec", invocation.id, `ext:${seed}`),
|
|
1447
|
-
actionInvocationId: invocation.id,
|
|
1448
|
-
tenantId: invocation.tenantId,
|
|
1449
|
-
spaceId: invocation.spaceId,
|
|
1450
|
-
status: "pending",
|
|
1451
|
-
provider,
|
|
1452
|
-
externalOperationId: completion.externalReference,
|
|
1453
|
-
attempt: 1,
|
|
1454
|
-
reason,
|
|
1455
|
-
...completion.evidenceReferences ? { evidenceReferences: completion.evidenceReferences } : {},
|
|
1456
|
-
observedAt: now()
|
|
1457
|
-
});
|
|
1458
|
-
}
|
|
1459
|
-
async function reconcileOverdueCompletions(input = {}) {
|
|
1460
|
-
const recoverable = options.store;
|
|
1461
|
-
if (!recoverable.listActionInvocations) {
|
|
1462
|
-
throw new Error("Overdue completion reconciliation requires a store that can list invocations.");
|
|
1463
|
-
}
|
|
1464
|
-
if (!asGovernanceStore(options.store)) {
|
|
1465
|
-
throw new Error("Overdue completion reconciliation requires a governance-capable store to record its findings.");
|
|
1466
|
-
}
|
|
1467
|
-
const current = input.now ?? now();
|
|
1468
|
-
const candidates = await recoverable.listActionInvocations({
|
|
1469
|
-
statuses: ["running"],
|
|
1470
|
-
completionDueBefore: current,
|
|
1471
|
-
unleased: true,
|
|
1472
|
-
...input.tenantId ? { tenantId: input.tenantId } : {},
|
|
1473
|
-
...input.spaceId ? { spaceId: input.spaceId } : {},
|
|
1474
|
-
limit: Math.max(1, Math.min(input.limit ?? 100, 1e3))
|
|
1475
|
-
});
|
|
1476
|
-
const overdue = [];
|
|
1477
|
-
for (const invocation of candidates) {
|
|
1478
|
-
const pending = invocation.pendingCompletion;
|
|
1479
|
-
if (!pending?.dueAt || pending.dueAt.getTime() > current.getTime()) continue;
|
|
1480
|
-
if (invocation.leaseOwner) continue;
|
|
1481
|
-
const message = `External operation "${pending.externalReference}" from ${pending.provider} did not complete by ${pending.dueAt.toISOString()}.`;
|
|
1482
|
-
await recordCompletionReconciliation(invocation, { externalReference: pending.externalReference, provider: pending.provider, kind: "overdue" }, message);
|
|
1483
|
-
await persistInvocation(invocation, { status: "reconciliation_required", error: message });
|
|
1484
|
-
overdue.push(invocation.id);
|
|
1485
|
-
}
|
|
1486
|
-
return { overdue };
|
|
1487
|
-
}
|
|
1488
1534
|
function declaredConsistency(actionId) {
|
|
1489
1535
|
return actionResolver(actionId)?.execution?.consistency;
|
|
1490
1536
|
}
|
|
@@ -1544,7 +1590,7 @@ function createGovernedActionHost(options) {
|
|
|
1544
1590
|
await (transaction ?? options.store).appendEvent(envelope);
|
|
1545
1591
|
}
|
|
1546
1592
|
}
|
|
1547
|
-
async function
|
|
1593
|
+
async function fail2(invocation, status, error) {
|
|
1548
1594
|
await persistInvocation(invocation, { status, error });
|
|
1549
1595
|
return {
|
|
1550
1596
|
actionInvocationId: invocation.id,
|
|
@@ -1581,7 +1627,26 @@ function createGovernedActionHost(options) {
|
|
|
1581
1627
|
);
|
|
1582
1628
|
emitInvocationStatusTelemetry(invocation, patch.status, options.telemetry, now(), previousStatus);
|
|
1583
1629
|
}
|
|
1584
|
-
|
|
1630
|
+
const externalCompletion = createExternalCompletionLifecycle({
|
|
1631
|
+
store: options.store,
|
|
1632
|
+
telemetry: options.telemetry,
|
|
1633
|
+
eventResultFields,
|
|
1634
|
+
now,
|
|
1635
|
+
actionResolver,
|
|
1636
|
+
appendEvent,
|
|
1637
|
+
persistInvocation,
|
|
1638
|
+
actionResult,
|
|
1639
|
+
withConsistency
|
|
1640
|
+
});
|
|
1641
|
+
return {
|
|
1642
|
+
submitAction,
|
|
1643
|
+
executeInvocation,
|
|
1644
|
+
resumeApprovedInvocation,
|
|
1645
|
+
recordExecutionAttestation,
|
|
1646
|
+
recordExternalReconciliation,
|
|
1647
|
+
completeExternalInvocation: externalCompletion.completeExternalInvocation,
|
|
1648
|
+
reconcileOverdueCompletions: externalCompletion.reconcileOverdueCompletions
|
|
1649
|
+
};
|
|
1585
1650
|
}
|
|
1586
1651
|
function actionResult(invocation) {
|
|
1587
1652
|
return {
|
|
@@ -1600,18 +1665,10 @@ function asApprovalStore(store) {
|
|
|
1600
1665
|
const candidate = store;
|
|
1601
1666
|
return typeof candidate.recordHitlDecision === "function" && typeof candidate.beginApprovalDecision === "function" ? store : void 0;
|
|
1602
1667
|
}
|
|
1603
|
-
function asAtomicMutationStore(store) {
|
|
1604
|
-
const candidate = store;
|
|
1605
|
-
return typeof candidate.transactionWithEvents === "function" ? store : void 0;
|
|
1606
|
-
}
|
|
1607
1668
|
function asOutboxStore(store) {
|
|
1608
1669
|
const candidate = store;
|
|
1609
1670
|
return typeof candidate.appendEventWithOutbox === "function" && typeof candidate.claimOutbox === "function" ? store : void 0;
|
|
1610
1671
|
}
|
|
1611
|
-
function asGovernanceStore(store) {
|
|
1612
|
-
const candidate = store;
|
|
1613
|
-
return typeof candidate.recordMutationGovernance === "function" && typeof candidate.appendPolicyObligations === "function" && typeof candidate.appendExecutionAttestation === "function" ? store : void 0;
|
|
1614
|
-
}
|
|
1615
1672
|
function numberValue(value) {
|
|
1616
1673
|
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
1617
1674
|
}
|
|
@@ -1647,39 +1704,9 @@ function initialState(machine) {
|
|
|
1647
1704
|
function isTerminal(status) {
|
|
1648
1705
|
return ["completed", "failed", "blocked_by_policy", "reconciliation_required", "validation_failed"].includes(status);
|
|
1649
1706
|
}
|
|
1650
|
-
function emitInvocationStatusTelemetry(invocation, status, telemetry, occurredAt, previousStatus) {
|
|
1651
|
-
if (!status) return;
|
|
1652
|
-
const event = status === "completed" ? { name: "invocation.completed", metricName: PLATFORM_HOST_METRIC_NAMES.invocationCompleted } : status === "blocked_by_policy" ? { name: "invocation.policy_blocked", metricName: PLATFORM_HOST_METRIC_NAMES.invocationPolicyBlocked } : status === "validation_failed" ? { name: "invocation.validation_failed", metricName: PLATFORM_HOST_METRIC_NAMES.invocationValidationFailed } : status === "waiting_for_approval" ? { name: "invocation.approval_waited", metricName: PLATFORM_HOST_METRIC_NAMES.invocationApprovalWaited } : status === "reconciliation_required" ? { name: "invocation.reconciliation_required", metricName: PLATFORM_HOST_METRIC_NAMES.invocationReconciliationRequired } : status === "failed" ? { name: "invocation.failed", metricName: PLATFORM_HOST_METRIC_NAMES.invocationFailed } : void 0;
|
|
1653
|
-
if (!event) return;
|
|
1654
|
-
emitPlatformHostTelemetry(telemetry, {
|
|
1655
|
-
kind: "event",
|
|
1656
|
-
name: event.name,
|
|
1657
|
-
metricName: event.metricName,
|
|
1658
|
-
occurredAt,
|
|
1659
|
-
tenantId: invocation.tenantId,
|
|
1660
|
-
spaceId: invocation.spaceId,
|
|
1661
|
-
attributes: {
|
|
1662
|
-
...invocation.actionId ? { actionId: invocation.actionId } : {},
|
|
1663
|
-
...invocation.actionVersion !== void 0 ? { actionVersion: invocation.actionVersion } : {},
|
|
1664
|
-
...previousStatus ? { fromStatus: previousStatus } : {}
|
|
1665
|
-
}
|
|
1666
|
-
});
|
|
1667
|
-
}
|
|
1668
|
-
function withoutPrivateHostFields(data, eventResultFields) {
|
|
1669
|
-
return Object.fromEntries(
|
|
1670
|
-
Object.entries(data).filter(([key]) => !eventResultFields.includes(key))
|
|
1671
|
-
);
|
|
1672
|
-
}
|
|
1673
|
-
function errorMessage(error) {
|
|
1674
|
-
return error instanceof Error ? error.message : String(error);
|
|
1675
|
-
}
|
|
1676
1707
|
function isRecord(value) {
|
|
1677
1708
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
1678
1709
|
}
|
|
1679
|
-
function lifecycleId(prefix, invocationId, key) {
|
|
1680
|
-
const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 96);
|
|
1681
|
-
return `${prefix}_${invocationId}_${safeKey}`;
|
|
1682
|
-
}
|
|
1683
1710
|
function validateAuthorizationBinding(input, parameterDigest, authorityMoment, currentTime = /* @__PURE__ */ new Date()) {
|
|
1684
1711
|
const binding = input.authorizationBinding;
|
|
1685
1712
|
if (binding.id !== input.authorizationBindingId && input.authorizationBindingId) throw new Error("Authorization binding ID does not match authorizationBindingId.");
|
|
@@ -3537,6 +3564,181 @@ function createDurableSagaParentLifecycle(options) {
|
|
|
3537
3564
|
};
|
|
3538
3565
|
}
|
|
3539
3566
|
|
|
3567
|
+
// src/tenant-isolation.ts
|
|
3568
|
+
function fail(message) {
|
|
3569
|
+
throw new Error(message);
|
|
3570
|
+
}
|
|
3571
|
+
function submitInput(subject, scope, idempotencyKey) {
|
|
3572
|
+
return {
|
|
3573
|
+
actionId: subject.actionId,
|
|
3574
|
+
parameters: subject.parameters ?? {},
|
|
3575
|
+
tenantId: scope.tenantId,
|
|
3576
|
+
spaceId: scope.spaceId,
|
|
3577
|
+
actorId: scope.actorId,
|
|
3578
|
+
actorType: scope.actorType,
|
|
3579
|
+
idempotencyKey
|
|
3580
|
+
};
|
|
3581
|
+
}
|
|
3582
|
+
function tenantIsolationChecks() {
|
|
3583
|
+
return [
|
|
3584
|
+
{
|
|
3585
|
+
id: "fabric.tenancy.invocation-scoped-read.v1",
|
|
3586
|
+
async run(subject) {
|
|
3587
|
+
await subject.reset?.();
|
|
3588
|
+
if (subject.ours.tenantId === subject.theirs.tenantId) fail("the two scopes must be in different tenants");
|
|
3589
|
+
const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:read"));
|
|
3590
|
+
const crossRead = await subject.store.getActionInvocation(ours.actionInvocationId, subject.theirs.tenantId, subject.theirs.spaceId);
|
|
3591
|
+
if (crossRead !== void 0) fail("an invocation was readable under another tenant's scope");
|
|
3592
|
+
const shape = (record) => JSON.stringify({ status: record?.status, result: record?.result, error: record?.error, attemptCount: record?.attemptCount, updatedAt: record?.updatedAt });
|
|
3593
|
+
const before = await subject.store.getActionInvocation(ours.actionInvocationId, subject.ours.tenantId, subject.ours.spaceId);
|
|
3594
|
+
if (!before) fail("our own invocation was not readable under our scope, so the check proves nothing");
|
|
3595
|
+
const untouched = shape(before);
|
|
3596
|
+
let crossExecute;
|
|
3597
|
+
try {
|
|
3598
|
+
crossExecute = await subject.host.executeInvocation(ours.actionInvocationId, subject.theirs.tenantId, subject.theirs.spaceId);
|
|
3599
|
+
} catch {
|
|
3600
|
+
crossExecute = void 0;
|
|
3601
|
+
}
|
|
3602
|
+
if (crossExecute !== void 0) fail("an invocation was executable under another tenant's scope");
|
|
3603
|
+
const after = await subject.store.getActionInvocation(ours.actionInvocationId, subject.ours.tenantId, subject.ours.spaceId);
|
|
3604
|
+
if (untouched !== shape(after)) fail("an execution attempted under another tenant's scope changed our invocation");
|
|
3605
|
+
return ["invocation readable under our scope, invisible and untouched under the other tenant's"];
|
|
3606
|
+
}
|
|
3607
|
+
},
|
|
3608
|
+
{
|
|
3609
|
+
id: "fabric.tenancy.idempotency-key-scoped.v1",
|
|
3610
|
+
async run(subject) {
|
|
3611
|
+
await subject.reset?.();
|
|
3612
|
+
const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:shared-key"));
|
|
3613
|
+
const again = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:shared-key"));
|
|
3614
|
+
if (ours.actionInvocationId !== again.actionInvocationId) fail("the same idempotency key in one tenant produced two invocations, so the key is not doing anything and the check proves nothing");
|
|
3615
|
+
const theirs = await subject.host.submitAction(submitInput(subject, subject.theirs, "iso:shared-key"));
|
|
3616
|
+
if (ours.actionInvocationId === theirs.actionInvocationId) fail("the same idempotency key in two tenants resolved to one invocation");
|
|
3617
|
+
return ["one idempotency key: one invocation within a tenant, two across tenants"];
|
|
3618
|
+
}
|
|
3619
|
+
},
|
|
3620
|
+
{
|
|
3621
|
+
id: "fabric.tenancy.events-scoped.v1",
|
|
3622
|
+
async run(subject) {
|
|
3623
|
+
await subject.reset?.();
|
|
3624
|
+
const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:events"));
|
|
3625
|
+
const theirEvents = await subject.store.listEvents(subject.theirs.tenantId, subject.theirs.spaceId);
|
|
3626
|
+
const leaked = theirEvents.filter((event) => event.tenantId === subject.ours.tenantId || event.actionInvocationId === ours.actionInvocationId);
|
|
3627
|
+
if (leaked.length > 0) fail(`${leaked.length} of our events appeared in the other tenant's stream`);
|
|
3628
|
+
const ourEvents = await subject.store.listEvents(subject.ours.tenantId, subject.ours.spaceId);
|
|
3629
|
+
if (!ourEvents.some((event) => event.actionInvocationId === ours.actionInvocationId)) fail("our own stream does not carry the event we just caused, so the check proves nothing");
|
|
3630
|
+
if (ourEvents.some((event) => event.tenantId !== subject.ours.tenantId)) fail("another tenant's event appeared in our stream");
|
|
3631
|
+
return ["event streams carry their own tenant's events and only those"];
|
|
3632
|
+
}
|
|
3633
|
+
},
|
|
3634
|
+
{
|
|
3635
|
+
id: "fabric.tenancy.worker-claim-scoped.v1",
|
|
3636
|
+
async run(subject) {
|
|
3637
|
+
await subject.reset?.();
|
|
3638
|
+
const probeSpace = `iso-probe-${Date.now()}`;
|
|
3639
|
+
const created = await subject.store.createActionInvocation({
|
|
3640
|
+
id: `iso_pending_${Date.now()}`,
|
|
3641
|
+
tenantId: subject.ours.tenantId,
|
|
3642
|
+
spaceId: probeSpace,
|
|
3643
|
+
actionId: subject.actionId,
|
|
3644
|
+
actionVersion: 1,
|
|
3645
|
+
actorId: subject.ours.actorId,
|
|
3646
|
+
actorType: subject.ours.actorType,
|
|
3647
|
+
status: "pending",
|
|
3648
|
+
parameters: subject.parameters ?? {},
|
|
3649
|
+
result: {},
|
|
3650
|
+
correlationId: `iso-corr-${Date.now()}`
|
|
3651
|
+
});
|
|
3652
|
+
try {
|
|
3653
|
+
const theirs = await subject.store.claimActionInvocations({
|
|
3654
|
+
workerId: "iso-worker",
|
|
3655
|
+
leaseDurationMs: 1e3,
|
|
3656
|
+
limit: 1e3,
|
|
3657
|
+
tenantId: subject.theirs.tenantId,
|
|
3658
|
+
spaceId: probeSpace
|
|
3659
|
+
});
|
|
3660
|
+
if (theirs.some((row) => row.id === created.id)) fail("a worker scoped to another tenant claimed our pending invocation");
|
|
3661
|
+
const ours = await subject.store.claimActionInvocations({
|
|
3662
|
+
workerId: "iso-worker",
|
|
3663
|
+
leaseDurationMs: 1e3,
|
|
3664
|
+
limit: 1e3,
|
|
3665
|
+
tenantId: subject.ours.tenantId,
|
|
3666
|
+
spaceId: probeSpace
|
|
3667
|
+
});
|
|
3668
|
+
if (!ours.some((row) => row.id === created.id)) fail("a worker scoped to our own tenant did not claim our pending invocation, so the check proves nothing");
|
|
3669
|
+
} finally {
|
|
3670
|
+
await subject.store.updateActionInvocation(created.id, subject.ours.tenantId, probeSpace, {
|
|
3671
|
+
status: "failed",
|
|
3672
|
+
error: "tenant isolation probe; never executed"
|
|
3673
|
+
});
|
|
3674
|
+
}
|
|
3675
|
+
return ["tenant-scoped claim takes our work and leaves other tenants' alone"];
|
|
3676
|
+
}
|
|
3677
|
+
},
|
|
3678
|
+
{
|
|
3679
|
+
id: "fabric.tenancy.completion-scoped.v1",
|
|
3680
|
+
async run(subject) {
|
|
3681
|
+
await subject.reset?.();
|
|
3682
|
+
const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:complete"));
|
|
3683
|
+
let outcome;
|
|
3684
|
+
try {
|
|
3685
|
+
await subject.host.completeExternalInvocation(ours.actionInvocationId, subject.theirs.tenantId, subject.theirs.spaceId, {
|
|
3686
|
+
externalReference: "iso-ref",
|
|
3687
|
+
outcome: "completed",
|
|
3688
|
+
observedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3689
|
+
});
|
|
3690
|
+
outcome = "accepted";
|
|
3691
|
+
} catch (error) {
|
|
3692
|
+
const refusal = error.refusal;
|
|
3693
|
+
outcome = refusal ?? "threw";
|
|
3694
|
+
}
|
|
3695
|
+
if (outcome !== "not_found") fail(`a completion under another tenant's scope was ${outcome}; it must be refused as not_found`);
|
|
3696
|
+
let mine;
|
|
3697
|
+
try {
|
|
3698
|
+
await subject.host.completeExternalInvocation(ours.actionInvocationId, subject.ours.tenantId, subject.ours.spaceId, {
|
|
3699
|
+
externalReference: "iso-ref",
|
|
3700
|
+
outcome: "completed",
|
|
3701
|
+
observedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3702
|
+
});
|
|
3703
|
+
mine = "accepted";
|
|
3704
|
+
} catch (error) {
|
|
3705
|
+
mine = error.refusal ?? "threw";
|
|
3706
|
+
}
|
|
3707
|
+
if (mine === "not_found") fail("a completion under our own scope was refused as not_found, so the check proves nothing");
|
|
3708
|
+
return ["completion under the other tenant's scope refused as not_found; found under ours"];
|
|
3709
|
+
}
|
|
3710
|
+
},
|
|
3711
|
+
{
|
|
3712
|
+
id: "fabric.tenancy.projection-scoped.v1",
|
|
3713
|
+
async run(subject) {
|
|
3714
|
+
if (!subject.read) return ["skipped: subject lends no read"];
|
|
3715
|
+
await subject.reset?.();
|
|
3716
|
+
await subject.host.submitAction(submitInput(subject, subject.ours, "iso:projection"));
|
|
3717
|
+
const ours = await subject.read(subject.ours);
|
|
3718
|
+
const theirs = await subject.read(subject.theirs);
|
|
3719
|
+
if (!ours.allowed) fail("our own viewer was denied the read");
|
|
3720
|
+
if (ours.rows.length === 0) fail("our own viewer saw nothing, so the check proves nothing");
|
|
3721
|
+
if (ours.rows.some((row) => row?.tenantId !== subject.ours.tenantId)) fail("our own viewer saw rows that are not ours, or rows carrying no tenant");
|
|
3722
|
+
const leaked = theirs.allowed ? theirs.rows.filter((row) => row?.tenantId === subject.ours.tenantId) : [];
|
|
3723
|
+
if (leaked.length > 0) fail(`a viewer in the other tenant saw ${leaked.length} of our rows`);
|
|
3724
|
+
return [theirs.allowed ? "other tenant's viewer sees none of our rows" : "other tenant's viewer is denied"];
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
];
|
|
3728
|
+
}
|
|
3729
|
+
async function runTenantIsolationChecks(subject) {
|
|
3730
|
+
const checks = [];
|
|
3731
|
+
for (const check of tenantIsolationChecks()) {
|
|
3732
|
+
try {
|
|
3733
|
+
const evidence = await check.run(subject);
|
|
3734
|
+
checks.push({ id: check.id, status: evidence[0]?.startsWith("skipped") ? "skipped" : "passed", evidence });
|
|
3735
|
+
} catch (error) {
|
|
3736
|
+
checks.push({ id: check.id, status: "failed", evidence: [], error: error instanceof Error ? error.message : String(error) });
|
|
3737
|
+
}
|
|
3738
|
+
}
|
|
3739
|
+
return { passed: checks.every((check) => check.status !== "failed"), checks };
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3540
3742
|
exports.ExternalCompletionError = ExternalCompletionError;
|
|
3541
3743
|
exports.IdempotencyConflictError = IdempotencyConflictError;
|
|
3542
3744
|
exports.MemoryPlatformHostStore = MemoryPlatformHostStore;
|
|
@@ -3557,6 +3759,8 @@ exports.getPlatformHostHealthSnapshot = getPlatformHostHealthSnapshot;
|
|
|
3557
3759
|
exports.runOutboxRelayCycle = runOutboxRelayCycle;
|
|
3558
3760
|
exports.runPlatformActionWorker = runPlatformActionWorker;
|
|
3559
3761
|
exports.runPlatformActionWorkerCycle = runPlatformActionWorkerCycle;
|
|
3762
|
+
exports.runTenantIsolationChecks = runTenantIsolationChecks;
|
|
3763
|
+
exports.tenantIsolationChecks = tenantIsolationChecks;
|
|
3560
3764
|
exports.toEnterpriseEventEnvelope = toEnterpriseEventEnvelope;
|
|
3561
3765
|
//# sourceMappingURL=index.cjs.map
|
|
3562
3766
|
//# sourceMappingURL=index.cjs.map
|