@fabricorg/platform-host 7.6.0 → 8.1.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/README.md CHANGED
@@ -68,7 +68,8 @@ delay is the remaining lease duration, followed by the worker's next claim; ther
68
68
  retry or separate backoff. The next claim increments `attemptCount`. At the cap, or for an unleased,
69
69
  non-idempotent, or approval-resume invocation, Host records `failed` with the reason retry was refused,
70
70
  through the error-text redactor. Approval resume is excluded because its temporary lease does not
71
- establish that a recovery worker is configured. Ordinary thrown errors retain terminal failure behavior.
71
+ establish that a recovery worker is configured. Ordinary application governance errors retain terminal failure behavior. Host store errors after
72
+ a recorded provider effect follow the recovery rules below.
72
73
 
73
74
  The retry region includes Host's single-record store writes for mutation-governance and HITL
74
75
  checkpoints. Later attempts reuse these persisted decisions through the `mutationFootprint` and
@@ -76,7 +77,9 @@ checkpoints. Later attempts reuse these persisted decisions through the `mutatio
76
77
  A deferral happens before the attempt writes any policy evidence: the complete definition batch must
77
78
  evaluate before Host starts appending policy records. Policy persistence, the `ComplianceBlocked`
78
79
  event, obligation listing/resolution/persistence, state-machine checks, the handler transaction,
79
- handlers, adapters, and finalization retain their existing failure/reconciliation/recovery semantics.
80
+ handlers and adapters retain their separate failure semantics. Finalization recovery is bounded by
81
+ `recoveryMaxAttempts`. Its effective default is at least `retryableExecutionMaxAttempts` because
82
+ both count worker claims.
80
83
  A retryable obligation-resolver error fails terminally with the policy evidence from that attempt.
81
84
  Preview evaluation does not enter this execution catch.
82
85
 
@@ -114,10 +117,10 @@ Host does not run adapters after this signal or add a new lifecycle event for it
114
117
  committed by earlier attempts are not undone.
115
118
 
116
119
  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,
120
+ exceptions, adapters, or finalization. Application governance errors and definite adapter failures retain their
121
+ `failed` paths; post-success errors follow the recovery rules below. State guards still fail closed.
122
+ Transient `after_adapters` finalization failures escape as `RecoverableFinalizationError` for
123
+ recovery within `recoveryMaxAttempts`. An ordinary handler error still records `failed`. This signal requests an operator decision,
121
124
  not an automatic retry or a claim that the current transaction committed.
122
125
 
123
126
  New rows bind that key to the action version, actor/authority binding, and a versioned canonical
@@ -127,16 +130,295 @@ parameter comparison and call `onLegacyRecord`; actor, authority-binding, and ac
127
130
  still reject in enforce mode. See `MIGRATION-2-IDEMPOTENCY.md`.
128
131
 
129
132
  Lifecycle records use deterministic checkpoint ids. Recovered idempotent actions do not repeat an
130
- adapter that already reached `succeeded`, and event, policy, and adapter writes are append-safe. A
131
- stale action that was not declared idempotent fails terminally for manual reconciliation instead of
132
- silently rerunning unknown side effects.
133
-
134
- Adapters may classify outcomes as `success`, `transient_failure`, `permanent_failure`, `timeout`,
135
- or `ambiguous`. The retry layer retries only transient idempotent outcomes; permanent failures,
136
- timeouts, and ambiguous results are never retried. An ambiguous outcome — where the external effect
137
- may or may not have been applied — is routed to `reconciliation_required` with durable
138
- `ExternalReconciliation` evidence and an `AdapterInvocationAmbiguous` lifecycle event rather than
139
- blindly retrying an unsafe effect.
133
+ adapter that already reached `succeeded`. Built-in stores deduplicate event and policy inserts by
134
+ checkpoint ID. Adapter rows are mutable; stale-worker protection requires the optional
135
+ `writeAdapterInvocation` capability described below. A
136
+ reclaimed non-idempotent action inspects its adapter checkpoints: running or succeeded rows route
137
+ to reconciliation; no rows or only failed rows retain terminal failure for manual review.
138
+
139
+ Returned `ambiguous` or `timeout` outcomes, timeouts thrown from `adapter.execute` (including an
140
+ aborted adapter deadline), contradictory successful results, and reverse contradictions
141
+ (`success: false` with `outcome: "success"`) route to `reconciliation_required`.
142
+ Host detects a thrown timeout from the adapter deadline's aborted signal or an error message matching
143
+ `/timeout|deadline|timed?\s*out/i`. A definite rejection whose message matches therefore also routes to
144
+ reconciliation; the message matcher does not prove that the provider timed out.
145
+
146
+ An uninterpretable provider reply (a non-object, `null`, or an array), a getter that throws before Host
147
+ can interpret the reply, and an unrecognised outcome string such as `"timed_out"` also take this path.
148
+ Host copies the reply's contract fields and other own enumerable fields into a plain object before
149
+ interpretation, reading each top-level field once. A getter that throws during the copy takes the
150
+ unreadable-reply path. These outcomes are not retried by the retry helper. Object replies retain the
151
+ previous truthiness reading of `success`, including truthy non-boolean values from JavaScript adapters.
152
+ The reconciliation kind remains `adapter_outcome_ambiguous`.
153
+
154
+ Unknown-outcome settlement uses `transactionWithEvents` when its transaction supplies
155
+ `updateAdapterInvocation`, `appendExternalReconciliation` on a governance-capable store, and
156
+ `updateLeasedActionInvocation` for a leased invocation. A store advertising conditional adapter writes
157
+ also needs transaction-scoped `writeAdapterInvocation` for leased finalization. In that transaction Host writes the
158
+ `ambiguous` checkpoint, `AdapterInvocationAmbiguous` with its stable event ID, governance
159
+ `ExternalReconciliation` evidence where supported, and the fenced `reconciliation_required` verdict,
160
+ in that order. These writes commit together. Host retries once on a non-lease error; a second
161
+ non-lease failure selects the verdict-first fallback. Lease loss raises
162
+ `RecoverableFinalizationError` without retry or fallback.
163
+
164
+ Without those transaction members, or after two non-lease transaction failures, the verdict-first
165
+ fallback writes the terminal verdict (retrying once except on lease loss), then the checkpoint,
166
+ event, and reconciliation record. If both checkpoint shapes fail, Host still attempts the ambiguity
167
+ event and reconciliation record. After the verdict commits, Host attempts each remaining evidence
168
+ write even if the event append fails, then surfaces the first error as `RecoverableFinalizationError`.
169
+ An evidence failure can leave a terminal verdict with missing evidence; a later direct execution returns that verdict
170
+ and workers exclude it from recovery, without repairing the evidence gap. If both verdict attempts
171
+ fail, Host surfaces `RecoverableFinalizationError` without attempting an unfenced checkpoint.
172
+
173
+ With the original action definition and adapter-step order available, recovery checks `ambiguous`
174
+ rows before the parked-handoff shortcut, stale-action gates, authorization, and handler execution.
175
+ This applies to actions declared idempotent and actions not declared idempotent. Settlement uses the
176
+ stored reconciliation metadata or the legacy row's error as its message and reason, without running
177
+ the handler or an adapter. For a running invocation whose action resolves, `executeInvocation` reads
178
+ each adapter step's row before the pending-handoff shortcut, stopping on recorded ambiguity or a
179
+ failed read. A failed read surfaces `RecoverableFinalizationError`, where base 939bcad returned the
180
+ parked result. The shortcut's condition is unchanged from base: a running invocation with
181
+ `pendingCompletion` and no lease owner returns early without requiring `pendingCompletion.parkedAt`.
182
+ If the action no longer resolves, the shortcut still returns the handoff before the unknown-action
183
+ failure. Completion callbacks that settle the invocation require parking, including after a deadline sweep.
184
+
185
+ `AdapterInvocationRecord.reconciliation` is an optional, redacted checkpoint preserving reason,
186
+ external reference, and the original `adapterEventSubject`. The subject stays out of the returned
187
+ `AdapterReconciliationOutcome`. Recovery uses this checkpoint without scanning `listEvents()`.
188
+ Without stored subject metadata it uses `{ subjectType: "AdapterInvocation", subjectId: adapterInvocationId }`;
189
+ the stable event ID deduplicates an event already written. Custom stores should persist the checkpoint
190
+ through adapter create, update, and read methods.
191
+
192
+ Ordinary adapter writes do not name the migration 8 column and remain compatible with schemas
193
+ lacking it. If a checkpoint write carrying `reconciliation` fails, fallback retries the row once
194
+ without that field, using legacy `status: "ambiguous"` plus `error`. Unknown outcomes therefore still
195
+ settle without migration 8, but without stored checkpoint metadata. Apply PostgreSQL migration 8
196
+ through `ensureSchema()` in controlled startup or migrations to retain that metadata. An error inside
197
+ an atomic PostgreSQL transaction first rolls it back; the legacy write runs outside that transaction
198
+ on the fallback path.
199
+
200
+ On a store without atomic support, if the verdict cannot be written at all, no durable write after the
201
+ call succeeds: neither verdict nor checkpoint is saved, as after a crash mid-call. A leftover `running`
202
+ step can then re-run on recovery for an idempotent action under the same `adapterInvocationId`
203
+ idempotency key, within the worker recovery budget. A reclaimed non-idempotent action with a
204
+ running row without a matching durable handoff settles that step as unknown. A succeeded row or
205
+ durable handoff instead ends `reconciliation_required` as an applied effect. Adapters should be
206
+ idempotent or carry that stable key. The same exposure applies if two atomic attempts roll back
207
+ and the fallback verdict cannot be written.
208
+
209
+ Once an invocation has a durable `succeeded` adapter row or accepted handoff, errors in Host
210
+ store reads or writes raise `RecoverableFinalizationError` instead of ending it `failed`.
211
+ This includes later adapter checkpoints, obligation reads, and the completion write in both
212
+ `before_adapters` and `after_adapters`. Recovery also protects Host store operations before
213
+ and around the handler when an earlier provider effect is recorded. Authorization, policy and handler callbacks
214
+ retain their own failure semantics. Idempotent recovery repairs recorded success/handoff evidence
215
+ without calling that step's provider. Non-idempotent interruption ends `reconciliation_required`
216
+ instead of repairing the invocation. Lease loss retains its fencing error.
217
+
218
+ If neither record is durable, Host retries the failed handoff or marker write once. After a
219
+ second failure it reuses degraded atomic finalization where available, otherwise writes the
220
+ `reconciliation_required` verdict first, then attempts the succeeded marker with safe output,
221
+ `ExternalOperationAccepted` when applicable, and `AdapterInvocationSucceeded`. Each fallback
222
+ evidence write is attempted independently; the first error raises `RecoverableFinalizationError`
223
+ after the remaining writes are attempted. One exception keeps its 7.x behaviour: when
224
+ unsatisfied obligations end a handed-off invocation `reconciliation_required` and the
225
+ reconciliation-log append fails, Host still returns the verdict. A terminal verdict can retain
226
+ missing evidence. If even the
227
+ verdict cannot be saved, Host throws `RecoverableFinalizationError`; the row can remain `running`.
228
+ Recovery may replay that unrecorded outcome for an idempotent action under its stable
229
+ `adapterInvocationId`, within the worker budget. A reclaimed non-idempotent action instead
230
+ ends `reconciliation_required`. A durable handoff takes precedence over its running row and is
231
+ recorded as an applied effect with its external reference. A running row without such a handoff
232
+ is settled as unknown. A succeeded row also ends `reconciliation_required`. With no rows or only failed rows, the existing
233
+ non-idempotent interruption gates still fail the invocation.
234
+
235
+ For idempotent recovery, the handler still runs before the adapter loop. The loop reads each
236
+ checkpoint before `getInput()` or adapter lookup. Succeeded rows and durable handoffs use stored
237
+ adapter type, operation, vendor, input and output; a falsy new input or removed adapter does not
238
+ skip evidence repair. Stable event IDs deduplicate replayed accepted and success events; newly
239
+ repaired success events include `recovered: true` and stored input/output when available.
240
+
241
+ Recovery looks up the stable attestation ID. If present, Host reuses it and reapplies its
242
+ `satisfiesObligations` updates without calling the extractor. If absent and stored output exists,
243
+ Host calls `extractExecutionAttestation` with stored input/output and checkpoint `createdAt` and
244
+ `updatedAt` as start/completion times. The extractor may run again after an interrupted append;
245
+ it must return deterministic evidence for the same input. A handoff checkpoint with no output,
246
+ or withheld output, skips extraction. Repair errors remain recoverable within the budget.
247
+
248
+ Known limits: a later step's definite provider failure or pre-call refusal can still end the
249
+ invocation `failed` after an earlier step succeeded. Authorization denials, ordinary handler
250
+ failures, validation/state-guard rejection, and unsatisfied policy obligations on recovery retain
251
+ their existing terminal outcomes. These are decisions or application failures, not Host store
252
+ errors. Idempotent recovery can call a provider again for an unrecorded running outcome, or for a
253
+ definite failed row whose invocation failure verdict was lost. The handler still re-runs before
254
+ the adapter loop. Saga compensation is unchanged. Withheld-output and refused-handoff
255
+ reconciliation retain the degraded pattern. Adapter-row fencing and overdue-sweep/late-callback
256
+ rules are described below.
257
+
258
+ Event appends are first-write-wins by event ID. Host re-appends lifecycle events under stable IDs
259
+ on retry and recovery, and each later attempt carries a fresh `sequence` and timestamps and can carry
260
+ a different payload. A custom store's `appendEvent` and `appendEventWithOutbox` must treat an ID it
261
+ already holds as a no-op that keeps the first event: do not throw, replace, or merge it. A store that
262
+ refuses these appends can leave recovery unable to converge, so the invocation stays `running` until
263
+ the recovery budget settles it, even when the adapter effect succeeded. Custom stores that currently
264
+ throw on a conflicting append need to change. The shipped stores enforce ID uniqueness across the
265
+ whole store, not per tenant or space. The rule covers the outbox too: `appendEventWithOutbox` for an
266
+ ID already stored adds no outbox row, including when the first append went through plain
267
+ `appendEvent`, so that event is never published later. Keep `outbox.shouldPublish` deterministic for
268
+ a given event ID. Custom store authors can verify these paths with the
269
+ [event append conformance checks](#event-append-conformance).
270
+
271
+ ### Conditional adapter-row writes (8.0.0, issue #79)
272
+
273
+ `PlatformHostStore.writeAdapterInvocation(input)` is optional. Memory and PostgreSQL implement it;
274
+ custom stores can keep their existing methods. Without it, Host retains unchecked adapter creates
275
+ and updates, so a reclaimed worker can overwrite a newer worker's adapter checkpoint.
276
+
277
+ The input carries `scope` (tenant, space, invocation), a create or update `mutation`, a `condition`,
278
+ and optional `expectedStatuses`. A lease condition requires a `running` parent with the supplied
279
+ `leaseOwner` and `leaseToken`. A verdict condition requires the exact parent `status` and `error`
280
+ written by this execution. Host also captures the handoff row ID as
281
+ `completionAdapterInvocationId`, so `externalCompletionDigest` guards that row's callback output:
282
+ an absent digest requires no recorded completion; a supplied digest requires that same completion.
283
+ Other adapter rows retain the status/error and expected-row-status checks without the digest check.
284
+ Omitting the captured row ID retains the conservative digest check on adapter writes. Absent error
285
+ matches no error. Both parent and adapter must match scope.
286
+ A create must also match the record's scope and refuses a duplicate ID. The method returns `false`
287
+ when its conditions fail. Conditional updates reject `succeeded` or `ambiguous` to `running` or
288
+ `failed`, including when callers omit `expectedStatuses`.
289
+
290
+ Host captures the supplied execution credentials before loading the invocation. It uses them for
291
+ fenced invocation writes and leased adapter creation, retry preparation, success, failure, and
292
+ ambiguity checkpoints. A refused lease or row-status check raises `RecoverableFinalizationError`.
293
+ This prevents stale checkpoint writes; adapters still need stable idempotency keys because a lease
294
+ cannot cancel a provider call already in flight. Inline writes use the verdict condition after
295
+ this execution writes its own verdict; earlier unleased adapter writes retain their existing path.
296
+
297
+ Verdict-first fallbacks use the settled verdict condition after the terminal write releases the
298
+ lease. Their expected row status is the last state observed or written by that execution. A changed
299
+ parent status/error or row refuses the adapter update; a changed completion also refuses a write
300
+ to the captured handoff row. These refusals emit
301
+ `invocation.adapter_evidence_refused` (`fabric.platform.invocation.adapter_evidence_refused`) through
302
+ the telemetry hook. The adapter row stays untouched, with no unchecked retry. Host still attempts
303
+ `AdapterInvocationAmbiguous` and reconciliation evidence; withheld-output and refused-handoff
304
+ success evidence may already have been appended before the refused marker write. A refusal is not
305
+ an evidence-collector error. Thrown store errors are retained while each independent fallback
306
+ write is attempted, then the first error is raised as `RecoverableFinalizationError`.
307
+
308
+ An inline writer preserving a sweep verdict does not adopt it as this execution's own verdict.
309
+ Its adapter evidence keeps the unchecked inline path; conditional settled-parent enrichment is
310
+ used only for a verdict this execution actually wrote. The inline guard reads the current row
311
+ only when the execution snapshot or its patch carries `pendingCompletion`.
312
+
313
+ Budget exhaustion saves its plain verdict before reading evidence. It then enriches the parent
314
+ through optional `updateSettledActionInvocation`, comparing that execution's exact status, error,
315
+ and completion snapshot. Parent enrichment joins the independent evidence collector, alongside
316
+ adapter checkpoints, events and reconciliation records. Refusal emits
317
+ `invocation.adapter_evidence_refused` with `evidence: "parent_enrichment"` and is not an error;
318
+ a missing `updateSettledActionInvocation` skips only parent enrichment. Evidence writes still run.
319
+ A thrown store error is collected and raised as `RecoverableFinalizationError` after the remaining
320
+ writes are attempted. The two optional store methods are expected together. Successful enrichment
321
+ stores the error and `adapterReconciliation` before returning them; skipped or refused enrichment
322
+ can leave replay with only the plain verdict. Stores omitting `writeAdapterInvocation` retain
323
+ unchecked adapter writes. A retained handoff's reference is included in the initial budget verdict.
324
+
325
+ Subclasses of the built-in stores that override `createAdapterInvocation` or
326
+ `updateAdapterInvocation` for encryption or auditing must also override `writeAdapterInvocation`.
327
+ Checked writes can bypass the unchecked methods. Alternatively, explicitly disable the capability
328
+ and accept the weaker stale-worker guarantee. Parent annotations similarly have their own
329
+ `updateSettledActionInvocation` seam.
330
+
331
+ Atomic finalizers require transaction-scoped `writeAdapterInvocation` when the store advertises the
332
+ capability and execution holds a lease. A missing member selects verdict-first fallback before
333
+ transaction evidence writes. PostgreSQL locks the matching parent with `FOR UPDATE` before the
334
+ adapter mutation in one SQL statement. Memory rechecks buffered lease and adapter predicates at
335
+ commit and excludes claims and other conditional writes while applying the commit. A commit-time
336
+ conflict rolls back and raises `RecoverableFinalizationError`. No schema migration is added by this
337
+ capability; it uses the existing parent lease and verdict columns.
338
+
339
+ External-completion adapter writes use the callback's transaction-scoped `updateAdapterInvocation`
340
+ when available, sharing its invocation lock. These are callback writes rather than worker-lease
341
+ writes; legacy transactions without that member retain the outer-store write. The sweep retains
342
+ its transaction-scoped reconciliation append and parked/overdue settlement rules.
343
+
344
+ ### Bounded worker recovery (8.0.0)
345
+
346
+ `recoveryMaxAttempts` is an optional positive integer. Its effective default is
347
+ `max(5, retryableExecutionMaxAttempts)`; `Infinity` is rejected. Both caps count the same worker
348
+ claims. Construction rejects an explicit `recoveryMaxAttempts` below
349
+ `retryableExecutionMaxAttempts`. The governance cap stops transient pre-policy deferrals at its
350
+ cap; the recovery budget stops resumed execution above its cap. With the defaults, governance
351
+ retry exhaustion therefore happens on claim 5 before recovery exhaustion could happen on claim 6.
352
+
353
+ Host checks a resumed leased `running` invocation immediately after lease ownership, before
354
+ normal action resolution, ambiguity scans, authorization, the handler, or adapter work. When
355
+ `attemptCount > recoveryMaxAttempts`, it settles `reconciliation_required` with
356
+ `recovery attempts exhausted (n/max)`. It writes that verdict before resolving the action or
357
+ reading adapter evidence, releasing the lease. A running row without a matching durable handoff
358
+ receives unknown-outcome settlement. A recorded ambiguous row enriches the parent error and
359
+ `adapterReconciliation`, as well as adapter/event evidence, using its stored reconciliation evidence
360
+ and a conditional parent update. A durable handoff takes precedence over that step's running row.
361
+ It is recorded as an applied effect with its external reference in the message and, on governance
362
+ stores, the reconciliation record. It is not marked ambiguous solely because its marker was lost.
363
+ If resolution or row reads throw, or no relevant row exists, the plain verdict already written
364
+ stands. Enrichment failure can retain an evidence gap. Transition telemetry is emitted once when
365
+ the status changes. If the verdict is unwritable, Host throws `RecoverableFinalizationError`;
366
+ later claims continue but do not execute mutation code.
367
+
368
+ The counter counts worker claims, including the first run, rather than provider calls or completed
369
+ recoveries. With the default of 5: the first run plus four recoveries; the sixth claim settles.
370
+ A raised governance cap raises the implicit recovery budget with it. Inline/direct execution does
371
+ not increment the counter. Approval acceptance sets it to at least 1 without incrementing it on
372
+ resume. Approval resume holds a temporary lease and uses the existing count: a count equal to the
373
+ budget is admitted, while a count above the budget settles reconciliation before the handler.
374
+ Approval does not reset an exhausted budget. A worker dying before reaching the check consumes a
375
+ claim without calling a provider at that point. Parked handoffs have no lease, are not claimed by
376
+ workers, and are outside this budget. The existing parked shortcut and ambiguity scan remain in
377
+ place. `retryableExecutionMaxAttempts` still limits pre-policy deferrals. No new store method is
378
+ required.
379
+
380
+ **Inline and direct callers.** Inline `submitAction()` without a dispatcher and direct
381
+ `executeInvocation()` can now reject with an error whose `name` is
382
+ `"RecoverableFinalizationError"` where 7.x returned `failed`. The class is not exported. Without a
383
+ lease, the invocation stays `running` and workers do not claim it. Before a handoff is durable,
384
+ call `executeInvocation()` again yourself and cap your own retries. Once a handoff is durable,
385
+ re-running `executeInvocation()` on the unleased running row returns without resuming execution
386
+ (it first settles any step already recorded `ambiguous`).
387
+ If execution stopped before parking, callbacks are refused with `still_executing` until a sweep.
388
+ Declare `completionDeadlineMs` for actions that hand off so the sweep surfaces the row, then
389
+ reconcile it per the [external-completion runbook](../../docs/operations/runbooks.md#overdue-handoffs).
390
+ The leased worker budget does not bound unleased calls. A
391
+ previously leased direct call retains its lease until expiry and can also be recovered by a
392
+ worker. Terminal degraded verdicts can retain evidence gaps and are not automatically repaired.
393
+
394
+ A transaction implementation may rethrow the handler's error wrapped in its own error; Host
395
+ still treats a failure inside the handler callback as a handler outcome.
396
+
397
+ **Known limit.** If a later step's adapter row is created but its attempt-preparation write keeps
398
+ failing, the row stays `running` although the provider was not called. For an action not declared
399
+ idempotent, the next claim settles that step as an unknown provider outcome.
400
+
401
+
402
+ A deadline sweep retains the handoff and sets `pendingCompletion.overdueAt` with its
403
+ `reconciliation_required` verdict. A late callback settles that verdict only when the handoff also
404
+ has `parkedAt`, proving Host reached parking after its remaining steps and finalization. An unparked
405
+ handoff stays in reconciliation even if later-step ambiguity evidence is missing; the callback's
406
+ redacted result and outcome are recorded as evidence for an operator without applying the action's
407
+ result schema. This evidence-only behavior applies to invocations already
408
+ `reconciliation_required`; callbacks for unparked `running` invocations are refused with
409
+ `still_executing`. Any parked row without `overdueAt`, including a 7.x row, still uses the old
410
+ `"did not complete by"` error substring. That compatibility path is weaker: an unrelated error
411
+ containing the substring on a parked row lacking the marker can also authorize settlement.
412
+
413
+ Circuit-open refusals and deadline expiry before execution retain `failed` semantics because
414
+ the refused attempt made no provider call. A `prepareAttempt` store error retains its prior failure
415
+ behavior only while the invocation has no durable success or handoff. With an earlier recorded
416
+ effect, an exhausted Host-store error raises `RecoverableFinalizationError`. Returned `permanent_failure`
417
+ and exhausted `transient_failure` outcomes, plus exhausted non-timeout adapter throws, also retain
418
+ `failed` semantics and their existing events, unless reading any field of the reply throws: Host
419
+ copies the reply before interpreting it, so such a reply takes the unreadable-reply path to
420
+ reconciliation. Adapters report definite non-effects with
421
+ `permanent_failure` or `transient_failure`; no additional definite-failure flag is needed.
140
422
 
141
423
  ### Adapter circuit breakers
142
424
 
@@ -250,9 +532,10 @@ in action parameters or workflow history.
250
532
  Production stores can implement `AtomicMutationPlatformHostStore.transactionWithEvents`. The
251
533
  transaction-scoped `db`, event sequence, event append, event listing, and invocation update methods
252
534
  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.
535
+ The optional transaction-scoped adapter-update and `appendExternalReconciliation` operations
536
+ extend this unit of work to degraded adapter finalization. Leased finalizers use
537
+ `writeAdapterInvocation` when the store advertises it, otherwise `updateAdapterInvocation`,
538
+ as described under redaction below.
256
539
 
257
540
  `PostgresPlatformHostStore` enables this capability when constructed with a
258
541
  `PostgresPlatformHostTransactionProvider`. The application owns that narrow binder because only the
@@ -265,7 +548,8 @@ With `transactionWithEvents`:
265
548
  - `after_adapters`: declared domain events, the Host-generated `StateTransitioned` fact, and
266
549
  invocation finalization commit together after required adapter steps succeed or accept a
267
550
  parked external handoff. Handler writes have already committed separately. A finalization
268
- failure leaves the invocation recoverable; succeeded adapter checkpoints are not repeated.
551
+ failure leaves the invocation recoverable within `recoveryMaxAttempts`; recovery repairs evidence
552
+ for succeeded adapter checkpoints without repeating those calls.
269
553
 
270
554
  Stores without `transactionWithEvents` retain the legacy non-atomic boundary for domain writes,
271
555
  events, and invocation persistence. A partial append or finalization failure can leave some
@@ -341,7 +625,7 @@ Three optional `PlatformHostOptions` hooks cover other Host-recorded values.
341
625
  | --- | --- |
342
626
  | `redactAdapterOutput(actionId, adapterType, operation, output)` | Once after a successful adapter result is converted to output, before handoff handling. The adapter row, `AdapterInvocationSucceeded` payload, and `extractExecutionAttestation` input use the redacted output. |
343
627
  | `redactErrorText(context, message)` | At the origin of caught handler/execution errors, adapter result errors and ambiguous-outcome reasons, and completion errors. The invocation/adapter errors, reconciliation messages and reasons, completion evidence, failure events, and returned error reuse that text. |
344
- | `redactExternalCompletionResult(actionId, provider, result)` | After a completion matches its invocation, before merging, result-schema validation, persistence, and digesting. See [External completion](#external-completion). |
628
+ | `redactExternalCompletionResult(actionId, provider, result)` | After a completion matches its invocation, before persistence and digesting; successful settling callbacks also merge and validate against the result schema. See [External completion](#external-completion). |
345
629
 
346
630
  `ErrorTextRedactionContext` is exported. It contains `actionId` and `source` (`handler`, `adapter`,
347
631
  `external_completion`, or `execution`); adapter errors also supply `adapterType`, `operation`, and
@@ -367,40 +651,53 @@ callback can still land as evidence. References come from the original adapter r
367
651
  For withheld output and refused handoffs (immediate completion or a second handoff), Host uses
368
652
  `transactionWithEvents` when the transaction supplies `updateAdapterInvocation`,
369
653
  `updateLeasedActionInvocation` for a leased invocation, and `appendExternalReconciliation` for
370
- withheld output on a governance-capable store. These optional members must
654
+ withheld output or refused handoffs on a governance-capable store. Leased finalization also needs
655
+ transaction-scoped `writeAdapterInvocation` when the store advertises it. These optional members must
371
656
  share the event/verdict transaction; the adapter update must enforce the store method's supplied
372
657
  tenant, space, and invocation scope. Both built-in stores provide them when configured with a
373
658
  transaction provider. Custom stores must implement them to get this atomic behavior.
374
659
 
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
660
+ When atomic finalization succeeds, evidence events, any withheld-output or refused-handoff
661
+ reconciliation entry, the adapter's `succeeded` marker, and the terminal `reconciliation_required`
662
+ verdict commit together. Host writes evidence and
377
663
  the marker before the fenced verdict, which releases the lease, then emits status telemetry after
378
664
  commit. It retries the whole transaction once on a non-lease error. If the retry also fails with a
379
665
  non-lease error, Host selects the verdict-first fallback described below. Lease loss is not retried
380
666
  or converted to fallback; it raises `RecoverableFinalizationError`. Atomic rollback leaves the
381
667
  invocation and adapter row `running` until a retry or fallback commits. If lease loss or an unavailable
382
668
  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.
669
+ again under the same `adapterInvocationId` idempotency key for idempotent actions within the
670
+ worker recovery budget. Non-idempotent reclaim inspects its checkpoints instead of replaying a call.
385
671
 
386
672
  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`.
673
+ the verdict-first order: the `reconciliation_required` verdict precedes evidence and the adapter marker.
674
+ It retries the verdict write once, except on lease loss. After the verdict is durable, Host attempts
675
+ each evidence write independently: a failed event, reconciliation append, or adapter checkpoint does
676
+ not skip the remaining writes. Host raises the first evidence failure as `RecoverableFinalizationError`
677
+ only after those attempts finish. This includes unknown-outcome settlement and post-success degraded
678
+ finalization. Failed writes can still leave a terminal verdict with missing evidence; terminal replay
679
+ does not repair that evidence gap. If both verdict-write attempts fail, the adapter can run again on
680
+ recovery for an idempotent action within the worker recovery budget, the same exposure as a crash mid-step.
681
+ A reclaimed non-idempotent action settles a running checkpoint as unknown.
682
+
683
+ A refused handoff's provider reference is included in the step's `AdapterInvocationSucceeded` event
684
+ even when explicit adapter output omits it. Capable atomic transactions append its reconciliation
685
+ record with the step's evidence. The fallback independently attempts the reference-bearing event,
686
+ the governance reconciliation append, the outer `recordCompletionReconciliation` append, and the
687
+ adapter marker. Both reconciliation appends use the same stable id to deduplicate repeated attempts.
688
+ A failure of either append is reported after the remaining fallback evidence writes are attempted.
689
+ A non-sweep verdict may receive a log-unavailable error note; a preserved sweep verdict keeps its error.
690
+ After successful atomic finalization, the outer append retains its existing tolerated-failure behavior.
691
+ If its annotation write fails, Host raises `RecoverableFinalizationError` while keeping the committed
692
+ verdict and adapter marker intact; terminal replay retains the verdict.
397
693
 
398
694
  `extractExecutionAttestation` receives the redacted output, so an extractor that needs a stripped
399
- field can fail after the external effect, following the existing extractor-failure behavior.
695
+ field can fail after the external effect. Host leaves a durable success checkpoint recoverable
696
+ within `recoveryMaxAttempts`; repair may call the extractor again.
400
697
 
401
698
  `redactActionParameters` and `redactAdapterInput` behavior is unchanged. Independently of hook
402
- configuration, a stale worker that loses its lease inside an adapter step rethrows without marking
403
- the adapter failed, preserving the new owner's row. Refused-handoff ordering and audit events also
699
+ configuration, a refused fenced invocation write inside an adapter step rethrows. With
700
+ `writeAdapterInvocation`, stale adapter updates also refuse before marking the new owner's row failed. Refused-handoff ordering and audit events also
404
701
  change without the new hooks.
405
702
 
406
703
  Applications own the safety of domain events/results, policy and HITL evidence, explicit
@@ -643,8 +940,10 @@ binding's target; otherwise Host's later fact sets the recorded state to the tar
643
940
 
644
941
  Configure `redactExternalCompletionResult` to withhold sensitive completion fields. Host applies it
645
942
  once to a supplied result (including a result attached to a failed callback). The redacted value
646
- feeds `externalCompletion.result`; for a completed outcome it also feeds the adapter output,
647
- `ExternalOperationCompleted` payload, merged invocation result, and returned result. A supplied
943
+ feeds `externalCompletion.result`; for a completed outcome it also feeds the adapter output and
944
+ `ExternalOperationCompleted` payload. Only a settling successful callback merges it into the
945
+ invocation result and validates that merge against the action's result schema. A non-settling
946
+ callback returns the stored invocation result and keeps the callback result in its evidence. A supplied
648
947
  `completion.error` passes through `redactErrorText` before recording, including when attached to a
649
948
  completed outcome. A missing error uses Host's fixed failure message, which bypasses the hook.
650
949
 
@@ -691,13 +990,55 @@ refused. A repeated identical completion is absorbed, identity being a digest of
691
990
  reference, outcome, result, error, and evidence. A contradictory one, a flipped outcome or the same
692
991
  outcome with different evidence, moves the invocation to
693
992
  `reconciliation_required`, keeps the first outcome on record, logs the second, and emits
694
- `ExternalOperationContradicted`. Under an atomic store the read and write happen under one lock. The
695
- callback's redacted result is merged with the invocation result, stripped of private fields, and
696
- validated against the action's result schema before writes. The stored merge is what is validated.
993
+ `ExternalOperationContradicted`. With `transactionWithEvents` and transaction-scoped
994
+ `getActionInvocationForUpdate`, the callback read and verdict write share a lock. The
995
+ successful settling callback's redacted result is merged with the invocation result, stripped of
996
+ private fields, and validated against the action's result schema before writes. A non-settling
997
+ callback records its redacted result/outcome as evidence without schema validation or merging it
998
+ into the invocation result.
697
999
  An invocation that ended for another reason while a handoff was pending keeps its status and records
698
1000
  the callback as evidence. Declare `completionDeadlineMs` on `long-running` actions;
699
- `reconcileOverdueCompletions()` moves overdue handoffs to reconciliation while keeping the handoff so
700
- a late completion can still land, and the worker cycle runs that sweep before it claims.
1001
+ `reconcileOverdueCompletions()` moves running, unleased, due handoffs to reconciliation and retains
1002
+ the handoff with `overdueAt` set to the sweep's current time. The worker cycle runs the sweep before
1003
+ claiming work. A callback can settle a parked running invocation, even past its deadline before the sweep. From
1004
+ reconciliation, settlement requires the parked, swept handoff or the substring fallback described above;
1005
+ a swept unparked handoff retains its verdict and records the callback in `externalCompletion`,
1006
+ adapter evidence, and a completion/failure event. It clears the pending handoff without claiming
1007
+ that the invocation's remaining steps ran. The markerless parked-row fallback is described above.
1008
+
1009
+ For each sweep candidate, Host re-reads status, lease ownership, handoff identity
1010
+ (`adapterInvocationId` and `externalReference`), due time, and absence of `overdueAt`. With
1011
+ `transactionWithEvents` and `getActionInvocationForUpdate`, this check and the verdict/marker write
1012
+ share the row lock. Reconciliation evidence joins that transaction when it exposes
1013
+ `appendExternalReconciliation`; otherwise evidence is written on the outer store and can survive a
1014
+ rolled-back verdict. A custom transaction exposing `getActionInvocationForUpdate` without
1015
+ `appendExternalReconciliation` makes the sweep append on the outer store while holding the
1016
+ invocation row lock. If the reconciliation table references that row through a foreign key, the
1017
+ outer append can block waiting for the held lock. Implement both members in the same transaction.
1018
+ Stores without a transaction lock use an immediate re-read, but retain a race
1019
+ between that read and the write: a concurrent callback or execution can still be overwritten.
1020
+ Custom stores must preserve and reload the optional `overdueAt` Date in the handoff JSON; PostgreSQL
1021
+ hydrates it without a new migration.
1022
+
1023
+ Inline writes bypass the extra guard read and transaction when neither the loaded invocation nor
1024
+ its patch carries `pendingCompletion`. Ordinary inline completion retains its base store cost.
1025
+ Writing a handoff into a running invocation from anything other than its executing step is not
1026
+ supported; an execution without that handoff in its snapshot does not guard against such imports.
1027
+ Inline execution preserves a stored `reconciliation_required` verdict while committing independent
1028
+ adapter checkpoints, events, and reconciliation records, including references from refused handoffs.
1029
+ If execution reaches parking after the sweep, it records the handler result and `parkedAt` on the
1030
+ same handoff, retaining the sweep's status, error, and `overdueAt`. Successful `after_adapters`
1031
+ finalization also commits its deferred domain events: the handler and remaining adapters have
1032
+ succeeded. Inline callers receive the latest
1033
+ record observed by the preserving writer, merged with its evidence update, after these writes
1034
+ succeed; there is no post-attempt read. Leased execution retains `RecoverableFinalizationError`
1035
+ on fencing failure. The transaction lock serializes the check with the sweep where available; stores
1036
+ without it retain a read/write race. This does not cancel an adapter in flight or undo earlier
1037
+ domain writes. Verdict-first fallbacks attempt independent evidence writes before raising the first
1038
+ failure as `RecoverableFinalizationError`. Reconciliation-log failures leave the sweep error unchanged. Concurrent inline executions of the
1039
+ same invocation can still raise `RecoverableFinalizationError` with `Invocation is no longer running`
1040
+ when another execution finalized the row first. This error is internal; callers classifying it can
1041
+ match its `name` and inspect the stored invocation before deciding how to recover.
701
1042
 
702
1043
  The handoff is persisted the moment it is accepted, before the adapter step is marked done, so a
703
1044
  worker that dies between the two leaves the handoff and not the marker. A custom store has four
@@ -736,10 +1077,93 @@ Host passes `AdapterInvocationScope` from the store-loaded action invocation to
736
1077
  must match `tenantId`, `spaceId`, and `actionInvocationId` when scope is supplied. A mismatch returns
737
1078
  `undefined` on read and leaves the row unchanged on update (a not-found error is permitted).
738
1079
  The trailing argument is optional to preserve existing custom-store and direct-call compatibility;
739
- calls without it retain ID-only behavior in the shipped stores. The two adapter isolation checks
1080
+ calls without it retain ID-only behavior in the shipped stores. When `writeAdapterInvocation` is present, the write probe also checks its scoped create/update
1081
+ refusals and an admitted update in our scope. The two adapter isolation checks
740
1082
  fail for stores that ignore scope. Deployments running `runTenantIsolationChecks` against a custom
741
1083
  store will see new failures until that store honours the argument.
742
1084
 
1085
+ ## Event append conformance
1086
+
1087
+ `eventAppendChecks()` and `runEventAppendChecks(subject)` run six checks for first-write-wins
1088
+ event IDs: plain appends, outbox appends, outbox-after-plain, transactional appends,
1089
+ transactional outbox appends, and transactional outbox-after-plain. Each sends earlier and later
1090
+ duplicates with lower and higher sequences, earlier and later occurrence/recording times, and
1091
+ different remaining envelope and outbox metadata fields. Transaction checks cover buffered
1092
+ duplicates and duplicates against a committed event; they do not require transaction reads to
1093
+ expose buffered writes. Each committed-duplicate transaction appends a fresh plain control event
1094
+ after the duplicate and verifies its presence after commit, detecting a silently rolled-back unit
1095
+ of work. Event-count checks count only the original probe ID; controls use distinct sequences.
1096
+
1097
+ The checks detect common first-write-wins violations: throwing, replacing or merging events,
1098
+ keeping both events, selecting the earlier-ordered or later-ordered duplicate, adding outbox
1099
+ rows, and resetting or rewriting an existing outbox row in the probed states. They are not
1100
+ exhaustive. Known limits include rules that compare ordering fields in mixed directions,
1101
+ store-wide uniqueness across spaces or tenants, the dead-letter state, and claims that lease
1102
+ foreign rows without returning them. They also cannot detect a buffering transaction that merges
1103
+ fields the store never returns (such as `includeProvenance`) from a buffered duplicate, or
1104
+ duplicate handling backed by an in-process cache that works during one run but fails across
1105
+ restarts. Stores requiring gapless sequences can fail falsely: the first event uses sequence 50
1106
+ in a fresh space. So can stores with a unique key over fields the control events share with the
1107
+ probe event, such as `actionInvocationId` with the subject.
1108
+
1109
+ Outbox-creating checks verify the first event and metadata, then compare whole-row snapshots
1110
+ while pending, leased by the probe worker, retry-pending, and settled as published. Retry-pending
1111
+ accepts a cleared lease, an expired lease with no owner or the probe worker, or the probe owner
1112
+ retained as a record without an expiry. A probe lease that still expires more than five seconds
1113
+ after the probe's clock fails with `markOutboxFailed did not release the lease`; an expiry inside
1114
+ that margin is treated as clock skew and waited out before the row is claimed again. A lease held
1115
+ by another worker fails with the request to pause relays. The checks prescribe neither a positive attempt
1116
+ count nor error formatting; they snapshot the values the store returns. Retry availability uses
1117
+ whole seconds a few seconds in the past, tolerating coarse timestamp precision and small clock
1118
+ skew, then the probe reclaims the row before publication. Snapshots include the full envelope,
1119
+ lease and publication fields. Comparisons sort object keys, normalize dates and date strings to milliseconds, treat null like undefined, and
1120
+ compare numeric strings in `sequence`, `attemptCount` and `leaseToken` as numbers. An absent
1121
+ actor disclosure is accepted; included disclosure or actor identity on the first outbox envelope
1122
+ fails because the first metadata does not opt in. A store's first returned records supply the
1123
+ snapshot references; extra envelope fields and fields the store omits or returns as null are
1124
+ accepted, including required fields. This tolerance does not validate envelope completeness. For
1125
+ buffered duplicates in the first transaction, exposed fields that differ between the first input
1126
+ and a duplicate must retain the first value. Outbox-after-plain checks write a fresh control
1127
+ event through the matching outbox seam and require its row to be the only outbox row visible in
1128
+ the probe space, so absence verification cannot pass solely because `listOutbox` returns
1129
+ nothing.
1130
+
1131
+ `EventAppendSubject<TDb>` supplies `store`, `tenantId`, and optional `reset()`. Only
1132
+ `appendEvent` and `listEvents(tenantId, spaceId)` are required; the subject does not need a full
1133
+ recoverable store. The result has `passed` and per-check `id`, `status`, `evidence`, and
1134
+ optional `error`, following the tenant-isolation suite. Absent outbox or transaction append
1135
+ capabilities produce `skipped: ` evidence; `passed` means no failures, so inspect skips for
1136
+ capability coverage. An available outbox append without `listOutbox`, `claimOutbox`,
1137
+ `markOutboxPublished` or `markOutboxFailed` fails because the check cannot verify or clean up.
1138
+ Append errors and probe retirement failures also fail.
1139
+
1140
+ Run against test or staging stores, preferably with a dedicated probe tenant. **Pause outbox
1141
+ relays for the probe tenant.** Event timestamps and written availability values (including
1142
+ retirement) use whole seconds a few seconds in the past. Claims and publication markers use
1143
+ real current time. Probe rows are expected to be due when availability follows `recordedAt`;
1144
+ stores assigning availability by their own clock may need additional setup. Visible foreign-worker
1145
+ leases or status changes before the probe acts fail with a request to pause relays. This detects visible interference; it does not prevent a relay race.
1146
+ Append-only events remain in fresh `event-append-probe-${uuid}` spaces. Successful
1147
+ outbox-creating checks leave their row **published although nothing was sent**; the space name
1148
+ identifies probe rows. Published is needed to exercise duplicate appends against that delivery
1149
+ state. Absence-control rows and failure rows instead go through final cleanup, which attempts
1150
+ to dead-letter visible probe rows still pending with `event append probe; never published`.
1151
+ Operator tooling can requeue those dead-lettered rows for real publication: exclude probe
1152
+ spaces from real publication and requeue workflows.
1153
+
1154
+ Claims use the real current time, a 30-second lease, and a limit equal to the pending probe row
1155
+ count. Cleanup filters listed rows locally by tenant and space and writes only to probe rows. If
1156
+ a claim returns foreign rows, the failure names their IDs, the checks write nothing to those
1157
+ rows, and no further claims occur in that run. Later outbox checks fail with an explanation that
1158
+ they were skipped because of the earlier filter violation; a standalone `check.run(subject)`
1159
+ stops claims for that check. Cleanup after a filter violation can dead-letter only probe rows
1160
+ returned by the violating claim, using the probe worker and that claim's tokens without another
1161
+ claim. The failure lists visible probe rows still pending, including unreturned rows or failed
1162
+ retirements, and instructs the operator to dead-letter them by hand. A filter-ignoring claim can
1163
+ still impose a short lease and make whatever writes the claim itself makes, such as incrementing attempt counts. Those leases lapse
1164
+ within 30 seconds of the claim if the store honours the requested duration; the checks do not
1165
+ release foreign leases or change foreign statuses, availability or errors.
1166
+
743
1167
  ## Runtime observability and health
744
1168
 
745
1169
  Platform Host exposes vendor-neutral lifecycle telemetry through the optional `telemetry` sink on