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