@fabricorg/platform-host 7.5.0 → 7.6.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 +24 -0
- package/README.md +101 -20
- package/dist/index.cjs +167 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +27 -2
- package/dist/index.d.ts +27 -2
- package/dist/index.js +168 -20
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @fabricorg/platform-host
|
|
2
2
|
|
|
3
|
+
## 7.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 74295fd: Add optional `updateAdapterInvocation` and `appendExternalReconciliation` members to `PlatformHostMutationTransaction` (issue #71). The adapter update retains the store method's tenant/space/invocation scope semantics. Memory transactions buffer these writes and undo ledger writes if commit fails; PostgreSQL delegates them to the transaction-bound scoped store.
|
|
8
|
+
|
|
9
|
+
Host uses these members for withheld-output and refused-handoff finalization. Evidence events and the adapter's succeeded marker precede the fenced terminal verdict inside the transaction. Withheld-output governance reconciliation shares that transaction when applicable. Leased atomic finalization also requires transaction-scoped `updateLeasedActionInvocation`; missing required members select the verdict-first fallback before evidence writes. Host retries the whole transaction once on a non-lease error, then selects the verdict-first fallback if the retry also fails with a non-lease error. Lease loss on either attempt raises `RecoverableFinalizationError` without fallback. Status telemetry fires after an atomic commit or through fallback verdict persistence. Refused-handoff completion reconciliation logging and its tolerated-failure error update remain after commit. Event IDs, payloads, returned results, and pending-completion selection are unchanged.
|
|
10
|
+
|
|
11
|
+
**Release note: failure semantics (issue #71).** When atomic finalization succeeds on a store with the required transaction members, the degraded verdict and its evidence commit together. A rolled-back atomic attempt leaves the invocation and adapter row `running` until the retry or fallback commits. Repeated non-lease transaction failures select the verdict-first fallback so the invocation can reach terminal `reconciliation_required` even when atomic evidence cannot be written. If the fallback evidence write also fails, Host throws `RecoverableFinalizationError` after the verdict commits, retaining the prior terminal-verdict/evidence-gap behavior. Lease loss or failure to persist the fallback verdict can still leave the invocation reclaimable; the adapter then re-runs under its `adapterInvocationId` idempotency key, as after a crash immediately following the adapter return. Previously a terminal verdict could remain with missing evidence and a running adapter row. Non-atomic stores and transactions lacking the required members keep the old verdict-first order and its evidence gap. Custom stores must implement the new transaction members in the same transaction to get the atomic behavior. Finalization failures retain `RecoverableFinalizationError`; adapters still need idempotency or a stable idempotency key.
|
|
12
|
+
|
|
13
|
+
A store wrapper that spreads a built-in transaction object (`...transaction`) inherits the new members: Host commits these writes through the inner transaction rather than through the wrapper's store-level overrides of `updateAdapterInvocation` or `appendExternalReconciliation`.
|
|
14
|
+
|
|
15
|
+
A successful COMMIT with a lost acknowledgement can cause the leased retry to report lease loss (`RecoverableFinalizationError`) even though the stored state is complete, as with the previous verdict retry.
|
|
16
|
+
|
|
17
|
+
- 5d93521: Add `ReconciliationRequiredError` and `isReconciliationRequiredError` to Platform's root and policies exports (issue #70). The error accepts a message and optional cause; its stable name and `EXECUTION_RECONCILIATION_REQUIRED` code support recognition across package copies.
|
|
18
|
+
|
|
19
|
+
Host honours this signal at the handler-transaction catch, including a store wrapper detecting that its own recovery receipt no longer matches the action's declared events or result schema before handing it back. Wrappers cannot tell which Host transaction they wrap: the same API also serves finalization and external completion. Translate only conditions the store detects itself, not Host internal errors matched by message text; those errors have no public stable code or message contract. Put non-sensitive operator detail in the message because `cause` is not stored. It redacts the message, persists terminal `reconciliation_required`, and returns consistency and HITL metadata. The existing status telemetry fires through persistence. Re-execution returns the terminal record without running the handler; adapters do not run after this signal. Atomic `transactionWithEvents` stores roll back the attempt's domain writes, domain events, and `StateTransitioned` together. On the `transaction`-only fallback, Host appends events directly and the built-in stores do not roll back: domain writes and any events already appended during the attempt stay persisted beside the `reconciliation_required` record, as they already do beside `failed`. A custom fallback transaction may roll back domain writes, but Host does not make event appends atomic with those writes.
|
|
20
|
+
|
|
21
|
+
**Release note: failure semantics (issue #70).** Previously these handler-transaction throws recorded terminal `failed`. Opting into `ReconciliationRequiredError` now requests an operator decision where a domain effect may already have committed. Ordinary handler errors, pre-handler governance/validation, state guards, adapters, and finalization retain their existing failure or recovery semantics. `after_adapters` finalization still uses `RecoverableFinalizationError`. Workers continue counting returned reconciliation results as `failed`. No new event is introduced. Host's Platform peer remains `workspace:^` so published packages require the Platform release containing the runtime exports.
|
|
22
|
+
|
|
23
|
+
- 73d5f98: Preserve the validated transition for idempotent `after_adapters` actions in the optional durable invocation field `pendingStateTransition` (issue #69). Host saves the entity type, entity ID, original state, and target. With a mutation transaction, it writes through transaction-scoped `updateActionInvocation` after the handler succeeds and result/events validate, just before the callback returns. This avoids holding the invocation row lock during the handler while keeping the evidence atomic with domain writes. Without a mutation transaction, Host writes before the handler. Non-idempotent actions do not write the field. The saved transition is retained as evidence after completion. Memory and PostgreSQL stores persist it through invocation patches; PostgreSQL supports both ordinary and leased updates.
|
|
24
|
+
|
|
25
|
+
**Release note: recovery semantics (issue #69).** A recovered replay admitted by the same-invocation state shortcut can emit the original `StateTransitioned` after adapters when the saved entity type, entity ID, and target match the current binding and state. It uses the saved `fromState`, the existing payload shape, and the stable `state:transition` event ID; built-in event and outbox stores deduplicate that ID. A handler-supplied transition for the same subject still suppresses Host emission. Custom stores must persist and reload `pendingStateTransition`, including through transaction-scoped `updateActionInvocation`, to get this behavior. Invocations started before upgrading have no saved transition and get no re-emitted event. Missing or mismatched evidence preserves replay acceptance without fabricating a prior state. PostgreSQL `ensureSchema()` adds the `pending_state_transition jsonb` column through additive migration 7, preserving the checksums of earlier migrations. `before_adapters` continues to record the transition with the handler's events and does not write this field.
|
|
26
|
+
|
|
3
27
|
## 7.5.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -80,6 +80,46 @@ handlers, adapters, and finalization retain their existing failure/reconciliatio
|
|
|
80
80
|
A retryable obligation-resolver error fails terminally with the policy evidence from that attempt.
|
|
81
81
|
Preview evaluation does not enter this execution catch.
|
|
82
82
|
|
|
83
|
+
**Handler transaction reconciliation.** A handler or store transaction wrapper can throw
|
|
84
|
+
`ReconciliationRequiredError(message, { cause })` from `@fabricorg/platform` (also exported from
|
|
85
|
+
`@fabricorg/platform/policies`) when a domain effect may already have committed and an operator
|
|
86
|
+
must decide how to proceed. For example, a recovery-receipt store can check its own stored result
|
|
87
|
+
against the action's current declared events and result schema before handing that result back.
|
|
88
|
+
If its own check detects an incompatible receipt from an earlier attempt, it can throw this signal.
|
|
89
|
+
Put non-sensitive operator detail in the message: Host stores the redacted message, not `cause`.
|
|
90
|
+
The cross-copy predicate
|
|
91
|
+
`isReconciliationRequiredError` recognises the stable name and `EXECUTION_RECONCILIATION_REQUIRED`
|
|
92
|
+
code as well as local instances.
|
|
93
|
+
|
|
94
|
+
A `transactionWithEvents` wrapper cannot tell which Host transaction it is wrapping from the public
|
|
95
|
+
callback contract: Host also uses this API for `after_adapters` finalization and external completion.
|
|
96
|
+
Translate only a condition the store detects itself while recovering its own receipt, and let other
|
|
97
|
+
errors propagate unchanged. Do not match Host's internal validation-error messages; those errors
|
|
98
|
+
have no public stable code or message contract.
|
|
99
|
+
|
|
100
|
+
Host honours the signal only at the handler-transaction catch, covering `transactionWithEvents`
|
|
101
|
+
and the fallback `transaction`. It redacts the message with `redactErrorText` using `source: "handler"`,
|
|
102
|
+
records terminal `reconciliation_required`, and returns the error with the action's consistency and
|
|
103
|
+
persisted HITL route/risk tier. Status persistence emits the existing reconciliation telemetry.
|
|
104
|
+
Re-execution returns the terminal record without running the handler. Workers retain their existing
|
|
105
|
+
counting: a returned reconciliation result increments `failed`, not `completed` or `deferred`.
|
|
106
|
+
|
|
107
|
+
Atomic `transactionWithEvents` stores roll back the attempt's domain writes, domain events, and
|
|
108
|
+
Host's `StateTransitioned` together when the transaction throws. On the `transaction`-only fallback,
|
|
109
|
+
Host appends events directly. The built-in stores simply run the callback without rollback: domain
|
|
110
|
+
writes and any events Host appended during the attempt stay persisted beside the
|
|
111
|
+
`reconciliation_required` record, as they already do beside `failed`. A custom fallback transaction
|
|
112
|
+
may roll back its domain writes, but Host does not make its event appends atomic with those writes.
|
|
113
|
+
Host does not run adapters after this signal or add a new lifecycle event for it. Effects or evidence
|
|
114
|
+
committed by earlier attempts are not undone.
|
|
115
|
+
|
|
116
|
+
The signal does not change failures in pre-handler governance or validation, state-transition guard
|
|
117
|
+
exceptions, adapters, or finalization. Governance and adapter throws retain their existing `failed`
|
|
118
|
+
paths. State guards still fail closed. `after_adapters` finalization failures still escape as
|
|
119
|
+
`RecoverableFinalizationError` for recovery; other finalization throws retain their existing failure
|
|
120
|
+
path. An ordinary handler error still records `failed`. This signal requests an operator decision,
|
|
121
|
+
not an automatic retry or a claim that the current transaction committed.
|
|
122
|
+
|
|
83
123
|
New rows bind that key to the action version, actor/authority binding, and a versioned canonical
|
|
84
124
|
parameter digest. Host 3 defaults to `enforce`, which throws `IdempotencyConflictError`; configure
|
|
85
125
|
`idempotency.conflictMode` as `audit-only` for a preflight soak. Legacy null-digest rows skip only the
|
|
@@ -210,6 +250,9 @@ in action parameters or workflow history.
|
|
|
210
250
|
Production stores can implement `AtomicMutationPlatformHostStore.transactionWithEvents`. The
|
|
211
251
|
transaction-scoped `db`, event sequence, event append, event listing, and invocation update methods
|
|
212
252
|
must all use the same database transaction.
|
|
253
|
+
The optional transaction-scoped `updateAdapterInvocation` and `appendExternalReconciliation`
|
|
254
|
+
operations extend this unit of work to degraded adapter finalization, as described under redaction
|
|
255
|
+
below.
|
|
213
256
|
|
|
214
257
|
`PostgresPlatformHostStore` enables this capability when constructed with a
|
|
215
258
|
`PostgresPlatformHostTransactionProvider`. The application owns that narrow binder because only the
|
|
@@ -314,22 +357,43 @@ failures refuse with non-retryable `redaction_failed` before writes, allowing a
|
|
|
314
357
|
callback after the redactor is repaired.
|
|
315
358
|
|
|
316
359
|
If `redactAdapterOutput` throws or returns null, an array, or a non-object value, the external effect
|
|
317
|
-
has already happened. Host
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
360
|
+
has already happened. Host withholds output and records a `reconciliation_required` verdict. Its
|
|
361
|
+
safe evidence includes the retained handoff's `ExternalOperationAccepted`,
|
|
362
|
+
`AdapterInvocationSucceeded` with redacted input and `outputWithheld: true` without output, and
|
|
363
|
+
fixed-text reconciliation evidence when the store supports governance. The events use the normal
|
|
364
|
+
step deduplication keys. A first accepted reference is retained as `pendingCompletion`, so its
|
|
365
|
+
callback can still land as evidence. References come from the original adapter result.
|
|
366
|
+
|
|
367
|
+
For withheld output and refused handoffs (immediate completion or a second handoff), Host uses
|
|
368
|
+
`transactionWithEvents` when the transaction supplies `updateAdapterInvocation`,
|
|
369
|
+
`updateLeasedActionInvocation` for a leased invocation, and `appendExternalReconciliation` for
|
|
370
|
+
withheld output on a governance-capable store. These optional members must
|
|
371
|
+
share the event/verdict transaction; the adapter update must enforce the store method's supplied
|
|
372
|
+
tenant, space, and invocation scope. Both built-in stores provide them when configured with a
|
|
373
|
+
transaction provider. Custom stores must implement them to get this atomic behavior.
|
|
374
|
+
|
|
375
|
+
When atomic finalization succeeds, evidence events, any withheld-output reconciliation entry, the adapter's `succeeded`
|
|
376
|
+
marker, and the terminal `reconciliation_required` verdict commit together. Host writes evidence and
|
|
377
|
+
the marker before the fenced verdict, which releases the lease, then emits status telemetry after
|
|
378
|
+
commit. It retries the whole transaction once on a non-lease error. If the retry also fails with a
|
|
379
|
+
non-lease error, Host selects the verdict-first fallback described below. Lease loss is not retried
|
|
380
|
+
or converted to fallback; it raises `RecoverableFinalizationError`. Atomic rollback leaves the
|
|
381
|
+
invocation and adapter row `running` until a retry or fallback commits. If lease loss or an unavailable
|
|
382
|
+
fallback verdict prevents finalization, the invocation remains reclaimable and the adapter can run
|
|
383
|
+
again under the same `adapterInvocationId` idempotency key. Adapters should be idempotent or carry
|
|
384
|
+
that stable idempotency key.
|
|
385
|
+
|
|
386
|
+
Without the required transaction members, or after two non-lease transaction failures, Host keeps
|
|
387
|
+
the verdict-first order: the `reconciliation_required` verdict precedes evidence and the adapter marker. It retries the verdict
|
|
388
|
+
write once, except on lease loss. A later evidence or marker failure raises
|
|
389
|
+
`RecoverableFinalizationError` after the verdict commits and can leave a terminal verdict with missing
|
|
390
|
+
evidence, including when the same deterministic failure affects the transaction and fallback. If both verdict-write attempts fail, the adapter can run again on recovery,
|
|
391
|
+
the same exposure as a crash mid-step; adapters should be idempotent or carry a stable idempotency key.
|
|
392
|
+
Direct execution returns a durable terminal verdict and recovery excludes it through terminal-status
|
|
393
|
+
checks. Refused-handoff completion reconciliation logging stays after finalization. After atomic
|
|
394
|
+
finalization, a failed log write leaves the committed verdict and adapter marker intact if the
|
|
395
|
+
unfenced invocation-error update succeeds. If that error update also fails, the existing adapter-error
|
|
396
|
+
catch can mark the adapter and invocation `failed`.
|
|
333
397
|
|
|
334
398
|
`extractExecutionAttestation` receives the redacted output, so an extractor that needs a stripped
|
|
335
399
|
field can fail after the external effect, following the existing extractor-failure behavior.
|
|
@@ -540,13 +604,30 @@ can filter it. It does not need an `emitsEvents` declaration from the action.
|
|
|
540
604
|
For `before_adapters`, the transition is appended with the handler's domain events. For
|
|
541
605
|
`after_adapters`, it is deferred to finalization after required adapter steps succeed or
|
|
542
606
|
accept a parked external handoff, immediately after domain events. Adapter failure withholds
|
|
543
|
-
both domain events and the transition, leaving
|
|
544
|
-
invocation even though handler writes may have committed.
|
|
607
|
+
both domain events and the transition, leaving the built-in stores' ledger-derived entity state
|
|
608
|
+
unchanged by that invocation even though handler writes may have committed. Custom stores that
|
|
609
|
+
read domain rows can already observe the target state at this point. With `transactionWithEvents`, the
|
|
545
610
|
transition shares the transaction used for domain events in the selected phase; otherwise it
|
|
546
611
|
follows the same legacy non-atomic boundary as domain events.
|
|
547
612
|
|
|
548
|
-
|
|
549
|
-
|
|
613
|
+
For idempotent `after_adapters` actions, Host saves `pendingStateTransition` on the invocation.
|
|
614
|
+
It contains `{ entityType, entityId, fromState, toState }` from the validated transition.
|
|
615
|
+
Custom stores must persist and return this optional invocation-record field through
|
|
616
|
+
`updateActionInvocation`, including the transaction-scoped method. With a mutation transaction,
|
|
617
|
+
Host writes the field after the handler succeeds and result/events validate, just before returning
|
|
618
|
+
from the transaction callback. This keeps the invocation row lock out of the handler run while the
|
|
619
|
+
saved transition commits or rolls back with the domain writes. The `transaction`-only fallback saves
|
|
620
|
+
it separately before the handler. Non-idempotent actions do not write this field. It is retained as
|
|
621
|
+
evidence after completion. PostgreSQL `ensureSchema()` adds `pending_state_transition jsonb`.
|
|
622
|
+
|
|
623
|
+
On an admitted same-invocation replay, Host uses the saved `fromState` when the saved entity type,
|
|
624
|
+
entity ID, and target match the current binding and state. The fact follows the usual event phase
|
|
625
|
+
and stable `state:transition` ID, so built-in stores deduplicate an already appended event.
|
|
626
|
+
Invocations started before upgrading, or stores that omit this evidence, retain replay acceptance
|
|
627
|
+
without a re-emitted transition. Mismatched evidence also suppresses replay emission.
|
|
628
|
+
|
|
629
|
+
Invalid transitions, failed handlers, replay acceptance without matching saved evidence, and
|
|
630
|
+
bindings without a non-empty entity ID or target do not produce a Host transition fact. If the handler
|
|
550
631
|
returns its own `StateTransitioned` with the bound entity's subject type and ID, Host suppresses
|
|
551
632
|
its generated fact in either event phase. Applications using that workaround should remove it
|
|
552
633
|
to use Host emission.
|
package/dist/index.cjs
CHANGED
|
@@ -1448,6 +1448,8 @@ function createGovernedActionHost(options) {
|
|
|
1448
1448
|
throw new HostError("Outbox egress requires transactionWithEvents to provide appendEventWithOutbox before handler execution.");
|
|
1449
1449
|
}
|
|
1450
1450
|
const binding = action.stateMachine;
|
|
1451
|
+
let replayedTransition;
|
|
1452
|
+
let pendingStateTransition;
|
|
1451
1453
|
let validatedTransition;
|
|
1452
1454
|
if (binding) {
|
|
1453
1455
|
const machine = stateMachineResolver(binding.entityType);
|
|
@@ -1500,6 +1502,21 @@ function createGovernedActionHost(options) {
|
|
|
1500
1502
|
}
|
|
1501
1503
|
replayingAppliedTransition = source?.actionInvocationId === actionInvocationId && source.state === currentState;
|
|
1502
1504
|
}
|
|
1505
|
+
const pending = invocation.pendingStateTransition;
|
|
1506
|
+
if (replayingAppliedTransition && pending && pending.entityType === binding.entityType && pending.entityId === entityId && pending.toState === currentState) {
|
|
1507
|
+
replayedTransition = pending;
|
|
1508
|
+
}
|
|
1509
|
+
if (action.idempotent && transition.valid && entityId && action.eventPhase === "after_adapters") {
|
|
1510
|
+
pendingStateTransition = {
|
|
1511
|
+
entityType: binding.entityType,
|
|
1512
|
+
entityId,
|
|
1513
|
+
fromState: currentState,
|
|
1514
|
+
toState: targetState
|
|
1515
|
+
};
|
|
1516
|
+
if (!transaction) {
|
|
1517
|
+
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { pendingStateTransition });
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1503
1520
|
if (!transition.valid && !replayingAppliedTransition) {
|
|
1504
1521
|
throw transition.error != null ? new Error(transition.error) : new HostError("Invalid state transition");
|
|
1505
1522
|
}
|
|
@@ -1553,7 +1570,7 @@ function createGovernedActionHost(options) {
|
|
|
1553
1570
|
}
|
|
1554
1571
|
}
|
|
1555
1572
|
let transitionEvent;
|
|
1556
|
-
if (binding && validatedTransition?.
|
|
1573
|
+
if (binding && validatedTransition?.entityId && (validatedTransition.valid || replayedTransition)) {
|
|
1557
1574
|
const { entityId, currentState, targetState } = validatedTransition;
|
|
1558
1575
|
const handlerEmittedTransition = events.some((event) => event.eventType === "StateTransitioned" && event.subjectType === binding.entityType && event.subjectId === entityId);
|
|
1559
1576
|
if (!handlerEmittedTransition) {
|
|
@@ -1564,7 +1581,7 @@ function createGovernedActionHost(options) {
|
|
|
1564
1581
|
payload: {
|
|
1565
1582
|
entityType: binding.entityType,
|
|
1566
1583
|
entityId,
|
|
1567
|
-
fromState: currentState,
|
|
1584
|
+
fromState: replayedTransition?.fromState ?? currentState,
|
|
1568
1585
|
toState: targetState,
|
|
1569
1586
|
causedByAction: action.actionId
|
|
1570
1587
|
}
|
|
@@ -1574,6 +1591,9 @@ function createGovernedActionHost(options) {
|
|
|
1574
1591
|
if (transitionEvent && action.eventPhase !== "after_adapters") {
|
|
1575
1592
|
await appendEvent(invocation, transitionEvent, "state:transition", 1, transaction);
|
|
1576
1593
|
}
|
|
1594
|
+
if (transaction && pendingStateTransition) {
|
|
1595
|
+
await transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, { pendingStateTransition });
|
|
1596
|
+
}
|
|
1577
1597
|
return { data: handlerData, domainEvents: events, pendingTransitionEvent: transitionEvent };
|
|
1578
1598
|
};
|
|
1579
1599
|
const atomicStore = asAtomicMutationStore(options.store);
|
|
@@ -1584,6 +1604,17 @@ function createGovernedActionHost(options) {
|
|
|
1584
1604
|
domainEvents = executed.domainEvents;
|
|
1585
1605
|
pendingTransitionEvent = executed.pendingTransitionEvent;
|
|
1586
1606
|
} catch (error) {
|
|
1607
|
+
if (platform.isReconciliationRequiredError(error)) {
|
|
1608
|
+
const message = caughtError({ actionId: invocation.actionId, source: "handler" }, error);
|
|
1609
|
+
await persistInvocation(invocation, { status: "reconciliation_required", error: message });
|
|
1610
|
+
return withConsistency({
|
|
1611
|
+
actionInvocationId,
|
|
1612
|
+
status: "reconciliation_required",
|
|
1613
|
+
error: message,
|
|
1614
|
+
...invocation.hitlRoute ? { hitlRoute: invocation.hitlRoute } : {},
|
|
1615
|
+
...invocation.hitlRiskTier ? { hitlRiskTier: invocation.hitlRiskTier } : {}
|
|
1616
|
+
}, action.actionId);
|
|
1617
|
+
}
|
|
1587
1618
|
return fail2(invocation, "failed", caughtError({ actionId: invocation.actionId, source: "handler" }, error));
|
|
1588
1619
|
}
|
|
1589
1620
|
const adapterInvocationScope = { tenantId: invocation.tenantId, spaceId: invocation.spaceId, actionInvocationId: invocation.id };
|
|
@@ -1651,7 +1682,7 @@ function createGovernedActionHost(options) {
|
|
|
1651
1682
|
subjectId: adapterEventSubject.subjectId,
|
|
1652
1683
|
payload: { adapterType: step.adapterType, operation: step.operation }
|
|
1653
1684
|
}, `adapter:${stepIndex}:started`);
|
|
1654
|
-
async function appendDegradedAdapterEvidence(outputWithheld, message, accepted, output, externalReference) {
|
|
1685
|
+
async function appendDegradedAdapterEvidence(outputWithheld, message, accepted, output, externalReference, transaction) {
|
|
1655
1686
|
if (accepted) {
|
|
1656
1687
|
await appendEvent(invocation, {
|
|
1657
1688
|
eventType: "ExternalOperationAccepted",
|
|
@@ -1663,7 +1694,7 @@ function createGovernedActionHost(options) {
|
|
|
1663
1694
|
externalReference: accepted.externalReference,
|
|
1664
1695
|
...accepted.dueAt ? { dueAt: accepted.dueAt.toISOString() } : {}
|
|
1665
1696
|
}
|
|
1666
|
-
}, `adapter:${stepIndex}:accepted
|
|
1697
|
+
}, `adapter:${stepIndex}:accepted`, 1, transaction);
|
|
1667
1698
|
}
|
|
1668
1699
|
await appendEvent(invocation, {
|
|
1669
1700
|
eventType: "AdapterInvocationSucceeded",
|
|
@@ -1675,10 +1706,10 @@ function createGovernedActionHost(options) {
|
|
|
1675
1706
|
input: recordedInput,
|
|
1676
1707
|
...outputWithheld ? { outputWithheld: true } : { output }
|
|
1677
1708
|
}
|
|
1678
|
-
}, `adapter:${stepIndex}:succeeded
|
|
1709
|
+
}, `adapter:${stepIndex}:succeeded`, 1, transaction);
|
|
1679
1710
|
const governanceStore2 = asGovernanceStore(options.store);
|
|
1680
1711
|
if (outputWithheld && governanceStore2) {
|
|
1681
|
-
await governanceStore2.appendExternalReconciliation({
|
|
1712
|
+
await (transaction ?? governanceStore2).appendExternalReconciliation({
|
|
1682
1713
|
id: lifecycleId("rec", actionInvocationId, `${adapter.vendor}:${adapterInvocationId}`),
|
|
1683
1714
|
actionInvocationId,
|
|
1684
1715
|
tenantId,
|
|
@@ -1692,6 +1723,52 @@ function createGovernedActionHost(options) {
|
|
|
1692
1723
|
});
|
|
1693
1724
|
}
|
|
1694
1725
|
}
|
|
1726
|
+
async function finalizeDegradedAdapterAtomically(patch, outputWithheld, message, accepted, output, externalReference) {
|
|
1727
|
+
const atomicStore = asAtomicMutationStore(options.store);
|
|
1728
|
+
if (!atomicStore) return false;
|
|
1729
|
+
const needsReconciliation = outputWithheld && !!asGovernanceStore(options.store);
|
|
1730
|
+
const previousStatus = invocation.status;
|
|
1731
|
+
const finalize = () => atomicStore.transactionWithEvents(async (transaction) => {
|
|
1732
|
+
if (!transaction.updateAdapterInvocation || needsReconciliation && !transaction.appendExternalReconciliation || invocation.leaseOwner && invocation.leaseToken !== void 0 && !transaction.updateLeasedActionInvocation) return false;
|
|
1733
|
+
await appendDegradedAdapterEvidence(outputWithheld, message, accepted, output, externalReference, transaction);
|
|
1734
|
+
await transaction.updateAdapterInvocation(adapterInvocationId, {
|
|
1735
|
+
status: "succeeded",
|
|
1736
|
+
...outputWithheld ? {} : { output },
|
|
1737
|
+
updatedAt: now()
|
|
1738
|
+
}, adapterInvocationScope);
|
|
1739
|
+
if (invocation.leaseOwner && invocation.leaseToken !== void 0) {
|
|
1740
|
+
if (!transaction.updateLeasedActionInvocation) {
|
|
1741
|
+
throw new HostError("Leased atomic finalization requires transaction-scoped fencing support.");
|
|
1742
|
+
}
|
|
1743
|
+
const updated = await transaction.updateLeasedActionInvocation({
|
|
1744
|
+
id: actionInvocationId,
|
|
1745
|
+
tenantId,
|
|
1746
|
+
spaceId,
|
|
1747
|
+
workerId: invocation.leaseOwner,
|
|
1748
|
+
leaseToken: invocation.leaseToken,
|
|
1749
|
+
patch
|
|
1750
|
+
});
|
|
1751
|
+
if (!updated) throw new RecoverableFinalizationError(`Invocation lease lost: ${actionInvocationId}`);
|
|
1752
|
+
} else {
|
|
1753
|
+
await transaction.updateActionInvocation(actionInvocationId, tenantId, spaceId, patch);
|
|
1754
|
+
}
|
|
1755
|
+
return true;
|
|
1756
|
+
});
|
|
1757
|
+
let committed;
|
|
1758
|
+
try {
|
|
1759
|
+
committed = await finalize();
|
|
1760
|
+
} catch (error) {
|
|
1761
|
+
if (error instanceof RecoverableFinalizationError) throw error;
|
|
1762
|
+
try {
|
|
1763
|
+
committed = await finalize();
|
|
1764
|
+
} catch (retryError) {
|
|
1765
|
+
if (retryError instanceof RecoverableFinalizationError) throw retryError;
|
|
1766
|
+
return false;
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
if (committed) emitInvocationStatusTelemetry(invocation, patch.status, options.telemetry, now(), previousStatus);
|
|
1770
|
+
return committed;
|
|
1771
|
+
}
|
|
1695
1772
|
const adapterErrorContext = { actionId: action.actionId, source: "adapter", adapterType: step.adapterType, operation: step.operation, provider: adapter.vendor };
|
|
1696
1773
|
const executionErrorContext = { actionId: action.actionId, source: "execution" };
|
|
1697
1774
|
let errorContext = executionErrorContext;
|
|
@@ -1851,9 +1928,13 @@ function createGovernedActionHost(options) {
|
|
|
1851
1928
|
...deadlineMs2 ? { dueAt: new Date(acceptedAt.getTime() + deadlineMs2) } : {}
|
|
1852
1929
|
} : void 0);
|
|
1853
1930
|
try {
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
await
|
|
1931
|
+
const patch = { status: "reconciliation_required", error: message, ...pendingCompletion ? { pendingCompletion } : {} };
|
|
1932
|
+
const accepted = pendingCompletion?.adapterInvocationId === adapterInvocationId ? pendingCompletion : void 0;
|
|
1933
|
+
if (!await finalizeDegradedAdapterAtomically(patch, true, message, accepted, void 0, reference || void 0)) {
|
|
1934
|
+
await persistReconciliationVerdict(invocation, patch);
|
|
1935
|
+
await appendDegradedAdapterEvidence(true, message, accepted, void 0, reference || void 0);
|
|
1936
|
+
await options.store.updateAdapterInvocation(adapterInvocationId, { status: "succeeded", updatedAt: now() }, adapterInvocationScope);
|
|
1937
|
+
}
|
|
1857
1938
|
} catch {
|
|
1858
1939
|
throw new RecoverableFinalizationError(message);
|
|
1859
1940
|
}
|
|
@@ -1881,13 +1962,18 @@ function createGovernedActionHost(options) {
|
|
|
1881
1962
|
const completion = action.execution?.completion ?? "immediate";
|
|
1882
1963
|
const refuse = completion === "immediate" ? `Adapter ${step.adapterType}:${step.operation} handed off external operation "${reference}" but ${action.actionId} declares immediate completion; the external effect may be in flight.` : pendingHandoff ? `Adapter ${step.adapterType}:${step.operation} handed off a second external operation "${reference}" while "${pendingHandoff.externalReference}" is already pending; one invocation waits on one completion.` : void 0;
|
|
1883
1964
|
if (refuse) {
|
|
1965
|
+
let adapterFinalizedAtomically;
|
|
1884
1966
|
try {
|
|
1885
|
-
|
|
1967
|
+
const patch = {
|
|
1886
1968
|
status: "reconciliation_required",
|
|
1887
1969
|
error: refuse,
|
|
1888
1970
|
...pendingHandoff ? {} : { pendingCompletion: accepted }
|
|
1889
|
-
}
|
|
1890
|
-
await
|
|
1971
|
+
};
|
|
1972
|
+
adapterFinalizedAtomically = await finalizeDegradedAdapterAtomically(patch, false, refuse, pendingHandoff ? void 0 : accepted, output);
|
|
1973
|
+
if (!adapterFinalizedAtomically) {
|
|
1974
|
+
await persistReconciliationVerdict(invocation, patch);
|
|
1975
|
+
await appendDegradedAdapterEvidence(false, refuse, pendingHandoff ? void 0 : accepted, output);
|
|
1976
|
+
}
|
|
1891
1977
|
} catch {
|
|
1892
1978
|
throw new RecoverableFinalizationError(refuse);
|
|
1893
1979
|
}
|
|
@@ -1899,7 +1985,7 @@ function createGovernedActionHost(options) {
|
|
|
1899
1985
|
await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, { error });
|
|
1900
1986
|
}
|
|
1901
1987
|
try {
|
|
1902
|
-
await options.store.updateAdapterInvocation(adapterInvocationId, { status: "succeeded", output, updatedAt: now() }, adapterInvocationScope);
|
|
1988
|
+
if (!adapterFinalizedAtomically) await options.store.updateAdapterInvocation(adapterInvocationId, { status: "succeeded", output, updatedAt: now() }, adapterInvocationScope);
|
|
1903
1989
|
} catch {
|
|
1904
1990
|
throw new RecoverableFinalizationError(refuse);
|
|
1905
1991
|
}
|
|
@@ -2673,11 +2759,14 @@ var MemoryPlatformHostStore = class {
|
|
|
2673
2759
|
await previous;
|
|
2674
2760
|
let domainSnapshot;
|
|
2675
2761
|
let snapshotCreated = false;
|
|
2762
|
+
const undoLedgerWrites = [];
|
|
2676
2763
|
try {
|
|
2677
2764
|
domainSnapshot = transactionProvider.snapshot(this.db);
|
|
2678
2765
|
snapshotCreated = true;
|
|
2679
2766
|
const pendingEvents = [];
|
|
2680
2767
|
const pendingUpdates = [];
|
|
2768
|
+
const pendingAdapterUpdates = [];
|
|
2769
|
+
const pendingReconciliations = [];
|
|
2681
2770
|
const appendPending = async (event, metadata) => {
|
|
2682
2771
|
if (this.events.some((candidate) => candidate.id === event.id) || pendingEvents.some((candidate) => candidate.event.id === event.id)) return;
|
|
2683
2772
|
pendingEvents.push({ event, ...metadata ? { metadata } : {} });
|
|
@@ -2712,26 +2801,62 @@ var MemoryPlatformHostStore = class {
|
|
|
2712
2801
|
}
|
|
2713
2802
|
return state;
|
|
2714
2803
|
},
|
|
2804
|
+
updateAdapterInvocation: async (id, patch, scope) => {
|
|
2805
|
+
if (!await this.getAdapterInvocation(id, scope)) throw new Error(`AdapterInvocation not found: ${id}`);
|
|
2806
|
+
pendingAdapterUpdates.push([id, patch, scope]);
|
|
2807
|
+
},
|
|
2808
|
+
appendExternalReconciliation: async (record) => {
|
|
2809
|
+
pendingReconciliations.push(record);
|
|
2810
|
+
},
|
|
2715
2811
|
updateActionInvocation: async (id, tenantId, spaceId, patch) => {
|
|
2716
|
-
pendingUpdates.push({ id, tenantId, spaceId, patch });
|
|
2812
|
+
pendingUpdates.push({ id, tenantId, spaceId, patch: copyInvocationPatch(patch) });
|
|
2717
2813
|
},
|
|
2718
2814
|
updateLeasedActionInvocation: async (input) => {
|
|
2719
2815
|
const record = await this.getActionInvocation(input.id, input.tenantId, input.spaceId);
|
|
2720
2816
|
if (!record || record.status !== "running" || record.leaseOwner !== input.workerId || record.leaseToken !== input.leaseToken) return false;
|
|
2721
|
-
pendingUpdates.push({ id: input.id, tenantId: input.tenantId, spaceId: input.spaceId, patch: input.patch });
|
|
2817
|
+
pendingUpdates.push({ id: input.id, tenantId: input.tenantId, spaceId: input.spaceId, patch: copyInvocationPatch(input.patch) });
|
|
2722
2818
|
return true;
|
|
2723
2819
|
}
|
|
2724
2820
|
});
|
|
2725
2821
|
for (const update of pendingUpdates) {
|
|
2726
2822
|
if (!await this.getActionInvocation(update.id, update.tenantId, update.spaceId)) throw new Error(`ActionInvocation not found: ${update.id}`);
|
|
2727
2823
|
}
|
|
2824
|
+
const rememberRecord = (record) => {
|
|
2825
|
+
const before = { ...record };
|
|
2826
|
+
undoLedgerWrites.push(() => {
|
|
2827
|
+
for (const key of Object.keys(record)) Reflect.deleteProperty(record, key);
|
|
2828
|
+
Object.assign(record, before);
|
|
2829
|
+
});
|
|
2830
|
+
};
|
|
2831
|
+
const rememberAppend = (rows, id) => {
|
|
2832
|
+
if (!rows.some((row) => row.id === id)) undoLedgerWrites.push(() => {
|
|
2833
|
+
const index = rows.findIndex((row) => row.id === id);
|
|
2834
|
+
if (index !== -1) rows.splice(index, 1);
|
|
2835
|
+
});
|
|
2836
|
+
};
|
|
2728
2837
|
for (const pending of pendingEvents) {
|
|
2838
|
+
rememberAppend(this.events, pending.event.id);
|
|
2839
|
+
rememberAppend(this.outbox, pending.event.id);
|
|
2729
2840
|
if (pending.metadata) await this.appendEventWithOutbox(pending.event, pending.metadata);
|
|
2730
2841
|
else await this.appendEvent(pending.event);
|
|
2731
2842
|
}
|
|
2732
|
-
for (const
|
|
2843
|
+
for (const record of pendingReconciliations) {
|
|
2844
|
+
rememberAppend(this.externalReconciliations, record.id);
|
|
2845
|
+
await this.appendExternalReconciliation(record);
|
|
2846
|
+
}
|
|
2847
|
+
for (const [id, patch, scope] of pendingAdapterUpdates) {
|
|
2848
|
+
const record = await this.getAdapterInvocation(id, scope);
|
|
2849
|
+
if (!record) throw new Error(`AdapterInvocation not found: ${id}`);
|
|
2850
|
+
rememberRecord(record);
|
|
2851
|
+
await this.updateAdapterInvocation(id, patch, scope);
|
|
2852
|
+
}
|
|
2853
|
+
for (const update of pendingUpdates) {
|
|
2854
|
+
rememberRecord(await this.getActionInvocation(update.id, update.tenantId, update.spaceId));
|
|
2855
|
+
await this.updateActionInvocation(update.id, update.tenantId, update.spaceId, update.patch);
|
|
2856
|
+
}
|
|
2733
2857
|
return result;
|
|
2734
2858
|
} catch (error) {
|
|
2859
|
+
for (const undo of undoLedgerWrites.reverse()) undo();
|
|
2735
2860
|
if (snapshotCreated) transactionProvider.restore(this.db, domainSnapshot);
|
|
2736
2861
|
throw error;
|
|
2737
2862
|
} finally {
|
|
@@ -2759,6 +2884,10 @@ var MemoryPlatformHostStore = class {
|
|
|
2759
2884
|
const record = await this.getActionInvocation(id, tenantId, spaceId);
|
|
2760
2885
|
if (!record) throw new Error(`ActionInvocation not found: ${id}`);
|
|
2761
2886
|
Object.assign(record, patch, { updatedAt: /* @__PURE__ */ new Date() });
|
|
2887
|
+
if (Object.hasOwn(patch, "pendingStateTransition")) {
|
|
2888
|
+
if (patch.pendingStateTransition) record.pendingStateTransition = { ...patch.pendingStateTransition };
|
|
2889
|
+
else delete record.pendingStateTransition;
|
|
2890
|
+
}
|
|
2762
2891
|
if (Object.hasOwn(patch, "pendingCompletion") && patch.pendingCompletion === void 0) delete record.pendingCompletion;
|
|
2763
2892
|
if (Object.hasOwn(patch, "externalCompletion") && patch.externalCompletion === void 0) delete record.externalCompletion;
|
|
2764
2893
|
if (Object.hasOwn(patch, "error") && patch.error === void 0) delete record.error;
|
|
@@ -3046,6 +3175,9 @@ function cloneOutboxRequeue(requeue) {
|
|
|
3046
3175
|
priorAvailableAt: new Date(requeue.priorAvailableAt)
|
|
3047
3176
|
};
|
|
3048
3177
|
}
|
|
3178
|
+
function copyInvocationPatch(patch) {
|
|
3179
|
+
return { ...patch, ...patch.pendingStateTransition ? { pendingStateTransition: { ...patch.pendingStateTransition } } : {} };
|
|
3180
|
+
}
|
|
3049
3181
|
async function applyPostgresMigrations(client, migrations) {
|
|
3050
3182
|
const ordered = validateMigrations(migrations);
|
|
3051
3183
|
const statements = ordered.map((migration) => migrationStatement(migration));
|
|
@@ -3142,7 +3274,9 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
|
|
|
3142
3274
|
getEntityStateSource: (tenantId, spaceId, entityType, entityId) => scoped.getEntityStateSource(tenantId, spaceId, entityType, entityId),
|
|
3143
3275
|
getEntityState: (tenantId, spaceId, entityType, entityId) => scoped.getEntityState(tenantId, spaceId, entityType, entityId),
|
|
3144
3276
|
updateActionInvocation: (id, tenantId, spaceId, patch) => scoped.updateActionInvocation(id, tenantId, spaceId, patch),
|
|
3145
|
-
updateLeasedActionInvocation: (input) => scoped.updateLeasedActionInvocation(input)
|
|
3277
|
+
updateLeasedActionInvocation: (input) => scoped.updateLeasedActionInvocation(input),
|
|
3278
|
+
updateAdapterInvocation: (id, patch, scope) => scoped.updateAdapterInvocation(id, patch, scope),
|
|
3279
|
+
appendExternalReconciliation: (record) => scoped.appendExternalReconciliation(record)
|
|
3146
3280
|
});
|
|
3147
3281
|
});
|
|
3148
3282
|
}
|
|
@@ -3325,6 +3459,13 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
|
|
|
3325
3459
|
ALTER TABLE fabric_platform.action_invocations
|
|
3326
3460
|
ADD COLUMN admitted_at timestamptz;
|
|
3327
3461
|
`
|
|
3462
|
+
}, {
|
|
3463
|
+
version: 7,
|
|
3464
|
+
name: "pending_state_transition",
|
|
3465
|
+
sql: `
|
|
3466
|
+
ALTER TABLE fabric_platform.action_invocations
|
|
3467
|
+
ADD COLUMN IF NOT EXISTS pending_state_transition jsonb;
|
|
3468
|
+
`
|
|
3328
3469
|
}]);
|
|
3329
3470
|
}
|
|
3330
3471
|
async transaction(run) {
|
|
@@ -3405,6 +3546,7 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
|
|
|
3405
3546
|
pending_completion=CASE WHEN $12::boolean THEN $13::jsonb ELSE pending_completion END,
|
|
3406
3547
|
completion_due_at=CASE WHEN $12::boolean THEN ($13::jsonb->>'dueAt')::timestamptz ELSE completion_due_at END,
|
|
3407
3548
|
external_completion=CASE WHEN $14::boolean THEN $15::jsonb ELSE external_completion END,
|
|
3549
|
+
pending_state_transition=CASE WHEN $16::boolean THEN $17::jsonb ELSE pending_state_transition END,
|
|
3408
3550
|
lease_owner=CASE WHEN ($13::jsonb IS NOT NULL AND $4='running') OR $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
|
|
3409
3551
|
THEN NULL ELSE lease_owner END,
|
|
3410
3552
|
lease_expires_at=CASE WHEN ($13::jsonb IS NOT NULL AND $4='running') OR $4 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
|
|
@@ -3426,7 +3568,9 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
|
|
|
3426
3568
|
Object.hasOwn(patch, "pendingCompletion"),
|
|
3427
3569
|
patch.pendingCompletion ? JSON.stringify(patch.pendingCompletion) : null,
|
|
3428
3570
|
Object.hasOwn(patch, "externalCompletion"),
|
|
3429
|
-
patch.externalCompletion ? JSON.stringify(patch.externalCompletion) : null
|
|
3571
|
+
patch.externalCompletion ? JSON.stringify(patch.externalCompletion) : null,
|
|
3572
|
+
Object.hasOwn(patch, "pendingStateTransition"),
|
|
3573
|
+
patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null
|
|
3430
3574
|
]
|
|
3431
3575
|
);
|
|
3432
3576
|
}
|
|
@@ -3978,6 +4122,7 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
|
|
|
3978
4122
|
pending_completion=CASE WHEN $14::boolean THEN $15::jsonb ELSE pending_completion END,
|
|
3979
4123
|
completion_due_at=CASE WHEN $14::boolean THEN ($15::jsonb->>'dueAt')::timestamptz ELSE completion_due_at END,
|
|
3980
4124
|
external_completion=CASE WHEN $16::boolean THEN $17::jsonb ELSE external_completion END,
|
|
4125
|
+
pending_state_transition=CASE WHEN $18::boolean THEN $19::jsonb ELSE pending_state_transition END,
|
|
3981
4126
|
lease_owner=CASE WHEN ($15::jsonb IS NOT NULL AND $6='running') OR $6 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
|
|
3982
4127
|
THEN NULL ELSE lease_owner END,
|
|
3983
4128
|
lease_expires_at=CASE WHEN ($15::jsonb IS NOT NULL AND $6='running') OR $6 IN ('waiting_for_approval','completed','failed','blocked_by_policy','reconciliation_required','validation_failed')
|
|
@@ -4003,7 +4148,9 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
|
|
|
4003
4148
|
Object.hasOwn(patch, "pendingCompletion"),
|
|
4004
4149
|
patch.pendingCompletion ? JSON.stringify(patch.pendingCompletion) : null,
|
|
4005
4150
|
Object.hasOwn(patch, "externalCompletion"),
|
|
4006
|
-
patch.externalCompletion ? JSON.stringify(patch.externalCompletion) : null
|
|
4151
|
+
patch.externalCompletion ? JSON.stringify(patch.externalCompletion) : null,
|
|
4152
|
+
Object.hasOwn(patch, "pendingStateTransition"),
|
|
4153
|
+
patch.pendingStateTransition ? JSON.stringify(patch.pendingStateTransition) : null
|
|
4007
4154
|
]
|
|
4008
4155
|
);
|
|
4009
4156
|
return result.rows.length === 1;
|
|
@@ -4102,6 +4249,7 @@ function toActionRecord(row) {
|
|
|
4102
4249
|
...row.execution_reason ? { executionReason: String(row.execution_reason) } : {},
|
|
4103
4250
|
...row.authorization_reconciliation ? { authorizationReconciliation: row.authorization_reconciliation } : {},
|
|
4104
4251
|
...row.adapter_reconciliation ? { adapterReconciliation: row.adapter_reconciliation } : {},
|
|
4252
|
+
...row.pending_state_transition ? { pendingStateTransition: row.pending_state_transition } : {},
|
|
4105
4253
|
...row.pending_completion ? { pendingCompletion: toPendingCompletion(row.pending_completion) } : {},
|
|
4106
4254
|
...row.external_completion ? { externalCompletion: toExternalCompletion(row.external_completion) } : {},
|
|
4107
4255
|
...row.authorization_binding_id ? { authorizationBindingId: String(row.authorization_binding_id) } : {},
|