@fabricorg/webhook-delivery 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # @fabricorg/webhook-delivery
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ac87d29: Add atomic, scoped `redactEventRecords` to remove persisted payload and admission facts, fence outstanding workers, and scrub late inserts using durable erasure markers. PostgreSQL requires schema migration 4 via `ensureSchema()` before the new version serves any traffic: all job and intent inserts read the new marker table. The migration also indexes scoped event lookups; that index is built without `CONCURRENTLY`, so on an existing deployment all intent writes wait until the build finishes, for a time that grows with the intents table.
8
+
9
+ Compatibility: applications may supply custom `WebhookDeliveryStore` implementations through the public port; those implementations break and must add this method. Wire format and existing method signatures are unchanged. Jobs expose an additive `erased` flag; undecided subscriptions receive the additive `event_erased` denial code without calling admission. Admission ports may also return that code. Markers are retained indefinitely because accepted future-dated events invalidate timestamp-only marker expiry. Do not call `redactEventRecords` until every instance runs this version: older instances ignore markers and the `erased` flag, so they can write the payload back, deliver it, or ask admission with cleared facts. Repeating redaction scrubs rows they wrote but cannot recall deliveries; no-op repeats preserve fences. Requests already handed to transport cannot be recalled, and an attempt begun before erasure can still send its old body afterward. Its receipt can record the outcome, but settlement at the advanced fence is refused; the pending intent can be re-sent metadata-only under at-least-once delivery. Audit attempts and evidence are preserved.
10
+
11
+ - 828a579: Add opt-in governed historical replay with `replay_events`, metadata-only `WebhookReplayRequested`, immutable request receipts, retained per-event outcomes, current-subscription reads and `runWebhookReplayCycle()`. Replay snapshots retained webhook projections, checks current eligibility and admission, and serializes pair settlement with erasure and the retention horizon. Ordinary fan-out completion stays final for its historical selection.
12
+
13
+ Deploy PostgreSQL migration 5 through `ensureSchema()` before enabling replay. All relevant writers, erasure handlers and purgers must support this contract before enablement; the migration lock does not certify mixed-version behavior. The new tables and indexes are created without `CONCURRENTLY`, so source writes can wait during index creation.
14
+
15
+ Compatibility: replay requires explicit module configuration and separate capabilities; existing custom base stores and insert signatures are unchanged. New action-name, event-type and refusal-code union members may affect exhaustive consumer switches. Replay receipts and work items contain metadata only and are never removed by this package; terminal outcomes live on the items. Items carry bounded acceptance-time interval/filter context so processing does not read or scan request membership. Replay honors the shared fan-out worker defaults and callback semantics, including `dlv_` UUIDs, batch size 25 and configured retry jitter/custom delays. Because both workers now share that option validation, `runWebhookFanOutCycle` given two or more invalid options may report a different one first; a single invalid option reports the same error as before.
16
+
17
+ Limits: replay covers retained webhook projections, not the canonical ledger or discarded fields. Completed legacy jobs with an absent actor cannot replay to subscriptions requiring identity. Over-cap requests are refused without partial acceptance; same-timestamp density can exceed the cap even for a narrow range. Replay never creates a second intent for an existing pair and never overrides a denial. Dispatch remains at-least-once. Subscription changes after the worker's last read can race insertion; subscription and delivery stores need not share a transaction.
18
+
19
+ ### Patch Changes
20
+
21
+ - d69dd7a: README: recommend Node 22.23.4, 24.22.1, or 26.12.1 or newer in the same release line for the
22
+ Brotli decoder fix in CVE-2026-111497, and note that Node 20 is end-of-life with no fixed release.
23
+ The `engines` range is unchanged (`>=20`).
24
+
25
+ ## 0.1.3
26
+
27
+ ### Patch Changes
28
+
29
+ - 8d87a36: Accept `@fabricorg/platform-host` 8 as a peer. Host 8 changes the meaning of some failure statuses but no API these packages use, so they work with 7 and 8 unchanged.
30
+ - 939bcad: Build packages with tsdown in place of tsup. Preserve the published ESM/CommonJS
31
+ entry paths, ES2022 target, declaration filenames, and raw external module
32
+ specifier spellings, including the existing Node built-in spellings.
33
+
34
+ Generated JavaScript, source-map contents, declaration formatting, and internal
35
+ chunk layout change. Comparison with the saved tsup build found unchanged named
36
+ runtime exports and public declaration signatures. Declaration maps remain
37
+ unpublished. Facade maps are no longer published for Platform's root, evidence,
38
+ and policies entries, or experience-runtime's contracts entry (both `.js.map`
39
+ and `.cjs.map`). Their new JavaScript output has no source-map URL comment.
40
+
41
+ CommonJS interop: 29 of 72 baseline CJS files expose an `__esModule` marker;
42
+ 0 of 57 tsdown CJS files do. The compared package entries have no default runtime
43
+ export, and the emitted `.d.cts` files declare none. Typed consumers of the
44
+ unchanged declared exports are unaffected. For entries whose marker was removed,
45
+ an untyped CJS `import X from` compiled with a Babel-style default-interop helper
46
+ now yields the namespace instead of `undefined`.
47
+
3
48
  ## 0.1.2
4
49
 
5
50
  ### Patch Changes
package/README.md CHANGED
@@ -14,7 +14,8 @@ pnpm add @fabricorg/webhook-delivery@^0.1.0 @fabricorg/platform@^1.7.0 @fabricor
14
14
  submission. Every operator action declares the check, so against an older Host the actions
15
15
  refuse at execution as `admission_not_enforced` ([Secrets and parameters](#secrets-and-parameters)).
16
16
  `@fabricorg/platform` is imported for types only.
17
- - **Runtime.** Node.js 20 or newer. The package ships ESM and CommonJS builds with two entry
17
+ - **Runtime.** Node.js 20 or newer; run 22.23.4, 24.22.1, or 26.12.1 or newer in the same release line in production for
18
+ the Brotli decoder fix in [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases); Node 20 is end-of-life and has no fixed release. The package ships ESM and CommonJS builds with two entry
18
19
  points: `@fabricorg/webhook-delivery` and `@fabricorg/webhook-delivery/node` (the Node transport
19
20
  and DNS lookup). It has no runtime dependencies; the PostgreSQL stores take an injected pool
20
21
  such as `pg.Pool`.
@@ -175,14 +176,85 @@ need a horizon), and per-record retention clocks (two clocks for one decision).
175
176
  | Fan-out job (holds the actor source for admission) | terminal and older than the horizon | purge |
176
177
  | Intent and its tombstone | terminal and older than the horizon | purge |
177
178
  | Denial and its tombstone | older than the horizon | purge |
179
+ | Event erasure markers | retained indefinitely (future-dated inserts are accepted) | none |
178
180
  | Eligibility revisions | never removed | none |
179
181
  | Delivery configuration versions | never removed | none |
182
+ | Replay request receipts and work items (including terminal outcomes) | never removed by the package (audit/idempotency) | application retention policy |
180
183
  | Attempt log, requeue evidence | never removed by the package (audit) | application retention policy |
181
184
 
182
185
  The rejection is visible at the store, but the publisher (PR 6) decides what Host sees. A
183
186
  `past_horizon` from `insertFanOutJob` should fail the publish with a fixed code, so the Host
184
187
  outbox record dead-letters rather than counting as delivered.
185
188
 
189
+ ### Event erasure
190
+
191
+ `redactEventRecords({ tenantId, spaceId, eventIds }, options)` atomically clears
192
+ job payloads (`null`), projection flags (`false`) and admission facts (`{}`), and
193
+ replaces intent bodies with metadata-only JSON using the shared serializer. It
194
+ covers pending and terminal records. It keeps metadata and the identifier-only
195
+ actor. Input accepts 1–100 distinct identifiers, using the identifier validation
196
+ below; empty lists and duplicates are refused. Results count newly marked events
197
+ and rows whose content changed. A repeat scrubs payload reintroduced by older writers;
198
+ a no-op repeat returns zero counts and preserves fences and leases. Jobs carry the
199
+ additive `erased` flag. After the tombstone check, fan-out denies undecided pairs with
200
+ `event_erased` without calling admission. Decided pairs keep their metadata-only
201
+ intents (`tests/fan-out.test.ts`, “erased events”). `event_erased` is an additive
202
+ admission denial code; application admission ports may return it too.
203
+
204
+ For changed rows, the job fence and pending intent fences advance, and their leases are cleared for
205
+ immediate reclaim, matching actor enrichment. Old fan-out writes return `lease_lost`;
206
+ old delivery `beginAttempt` returns `{ begun: false }`. A request already handed to
207
+ transport before commit cannot be recalled. The fence check linearizes at
208
+ `beginAttempt`: if begin commits first, its receipt remains valid. A PostgreSQL
209
+ response may arrive after redaction commits, so this store-only port cannot promise
210
+ that such an already-authorized handoff occurs before the erasure commit. Stronger
211
+ transport exclusion requires coordination beyond this port. Status, attempt budgets, availability,
212
+ attempt logs, outcomes, denials and requeue evidence are preserved. Requeue and actor
213
+ enrichment keep the redacted fields. These operations do not erase the application's
214
+ event store or deliveries already received by subscribers. An in-flight attempt can
215
+ still append its outcome using its receipt, but settlement at the old fence is
216
+ refused. Its pending intent can then be sent again with the metadata-only body,
217
+ consistent with at-least-once delivery.
218
+
219
+ A scoped marker is recorded even when no job exists. Inserts check it inside their
220
+ atomic step and scrub the new draft/body. PostgreSQL migration 4 adds
221
+ `webhook_event_erasures` and an intent index on `(tenant_id, space_id, event_id)`.
222
+ Run `ensureSchema()` before the new version serves any traffic: job and intent
223
+ inserts now read the marker table even when redaction is unused. The migration builds
224
+ the intent index without `CONCURRENTLY`, so on an existing deployment every intent
225
+ write (claims, attempts, settlements, inserts, cancellations) waits until the build
226
+ finishes, for a time that grows with the intents table. Plan the upgrade for that
227
+ pause. Redaction takes
228
+ the horizon EXCLUSIVE lock first, then reads the clock and locks matching jobs in
229
+ `event_id` order and intents in `delivery_id` order using set-based queries.
230
+ Cancellation also locks intents in `delivery_id` order. Claims use ordered
231
+ `FOR UPDATE SKIP LOCKED`, which does not wait on held rows. Ordinary horizon-checked
232
+ writers continue to use SHARE. The live “check-to-write serialization” erasure cases
233
+ exercise both lock orders against inserts.
234
+
235
+ Do not call `redactEventRecords` until every instance runs this version. Older
236
+ instances ignore markers and the `erased` flag: they can write the payload back after
237
+ an erasure, deliver it before any repair, and ask admission with the cleared facts,
238
+ which is the fail-open path `event_erased` closes. Repeating redaction scrubs rows
239
+ an older instance wrote (`tests/store-contract.ts`, “repeat erasure repairs legacy
240
+ rows”, which runs against PostgreSQL), but it cannot recall what was already delivered.
241
+ Custom `WebhookDeliveryStore` implementations break at the type boundary and must
242
+ implement the new method and its atomicity, marker and fail-closed semantics.
243
+
244
+ Markers are retained indefinitely. The proposed timestamp expiry proof would require
245
+ `event.recordedAt <= redaction time`, but the store accepts future-dated jobs (shared
246
+ contract: “job claim respects availableAt, lease expiry and limit, oldest first”).
247
+ A pre-recorded marker could otherwise expire before that event's horizon. Purge does
248
+ not remove markers or count them in `removed`. Applications should budget this storage
249
+ until a future contract supplies a safe upper bound on event time.
250
+
251
+ The shared `tests/store-contract.ts` erasure cases cover scoped late inserts, terminal
252
+ and pending data, stale fences, idempotence, and validation. Attempts, outcomes, denials
253
+ and requeue evidence contain identifiers, timestamps and closed diagnostic fields,
254
+ not payload or admission facts (`delivery-store-core.ts` input readers and evidence
255
+ builders). Actor evidence retains identifiers; this API is payload erasure, not removal
256
+ of identifying metadata.
257
+
186
258
  ### Identifiers and stored fields
187
259
 
188
260
  Every identifier a store takes is refused as `input_invalid` if it is empty, longer than 256
@@ -319,9 +391,9 @@ writer a clock that has not crossed the horizon and the purge one that has, so o
319
391
  floor can refuse the writer, which is the case the lock exists for. The tests then check the
320
392
  outcome:
321
393
 
322
- - A replay paused after reading the cutoff races a purge. The replay finds the job
394
+ - Host outbox re-publication paused after reading the cutoff races a purge. It finds the job
323
395
  (`already_present`), then the purge removes it.
324
- - A purge paused before its commit races a replay. The replay waits, then sees the raised floor
396
+ - A purge paused before its commit races Host outbox re-publication. The insert waits, then sees the raised floor
325
397
  (`past_horizon`).
326
398
  - A late intent insert races a purge. It cannot re-create the purged intent or its tombstone.
327
399
  - A requeue races a purge that has raised the floor past the record. The requeue is refused
@@ -388,10 +460,16 @@ before the clock is read or anything is written. It also runs at `written`, afte
388
460
  before the commit. A call that returns a refusal does not reach `written`. Leave both unset
389
461
  outside tests.
390
462
 
391
- **Running the live suite.** Run `FABRIC_POSTGRES_TEST_URL=postgresql://… pnpm exec vitest run
392
- tests/postgres-live.test.ts` in this package, or `pnpm test:postgres` at the root, which the
393
- `postgres-certification` CI job runs. Without the variable the suite is skipped. It creates one
394
- database per run and one schema per store pair.
463
+ **Running the live suite.** From the repository root, run `pnpm build`, then
464
+ `pnpm test:postgres`. The runner starts a throwaway `postgres:16` database on a free localhost
465
+ port and removes it in its cleanup block after success, failure, Ctrl-C, or SIGTERM. Docker
466
+ must be installed and usable. `FABRIC_POSTGRES_TEST_IMAGE` overrides the image, and
467
+ `FABRIC_POSTGRES_TEST_TIMEOUT_SECONDS` overrides the 60-second readiness timeout. An existing
468
+ `FABRIC_POSTGRES_TEST_URL` bypasses Docker and is passed unchanged, as in the
469
+ `postgres-certification` CI job. The runner reports missing builds with `Run pnpm build first`;
470
+ rebuild after source changes. See the Live certification section in the repository file
471
+ `docs/operations/releasing.md` for cleanup limits and runner tests. Direct Vitest runs skip this file without the URL.
472
+ The suite creates one database per run and one schema per store pair.
395
473
 
396
474
  ### Contract suite
397
475
 
@@ -780,8 +858,8 @@ to `dlv_` plus a random UUID.
780
858
 
781
859
  **Decision.** A job decides an event's eligibility once, after the event's eligibility has
782
860
  settled, and the job is complete when `completeFanOutJob` succeeds. Eligibility is settled when
783
- the worker's clock is at or past `recordedAt + eligibilitySettleMs`. From completion on, no
784
- eligibility change adds or removes an intent or denial for that event.
861
+ the worker's clock is at or past `recordedAt + eligibilitySettleMs`. Ordinary fan-out completion stays final for its historical eligibility selection. A governed
862
+ historical replay request has its own fixed membership and can decide previously undecided pairs.
785
863
 
786
864
  **What it rests on.** Two assumptions about clocks that the package cannot enforce:
787
865
 
@@ -805,8 +883,9 @@ backs each:
805
883
  | A revision written after an event settled does not contain its `recordedAt` | both clock assumptions above, plus a new revision starting at the store's clock and never before the previous one (`mathMax(nowMs, current.effectiveFromMs)` in the memory store's `#transition`; the `WebhookSubscriptionWriter` contract for other stores) | "a pattern change after recordedAt does not remove the event …", "… does not add the event …" (both run with one clock, so they do not test the assumptions) |
806
884
  | A late revision does not reopen a completed job | claims take only `pending` jobs; a job requeue accepts only dead letters | "a revision stamped behind an already-settled event …" |
807
885
  | The revision is chosen by `recordedAt`, not by when the job runs | `recordedAt: job.event.recordedAt` passed to `listEligibilityRevisions` | the pattern change, pause and resume cases |
808
- | A completed job is not run again | claims take only `pending` jobs (`claimFrom`, `src/memory-delivery-store.ts`); a job requeue accepts only dead letters | "after completion no eligibility change adds or removes an intent …" |
809
- | A replay or bridge adds nothing | `insertFanOutJob` is insert-if-absent by `eventId`; intents and denials are insert-if-absent by (subscriptionId, eventId) | "replay: …", "concurrent relays: …", the bridge overlap cases |
886
+ | Ordinary fan-out completion is final for its historical selection | claims take only `pending` jobs (`claimFrom`, `src/memory-delivery-store.ts`); a job requeue accepts only dead letters | ordinary fan-out test "after completion no eligibility change adds or removes an intent …" |
887
+ | Governed replay can decide previously undecided pairs without reopening the source job | `createReplayRequest` fixes separate membership; `settleReplayJob` preserves existing pair tombstones | replay contract "historical event for a later subscription …", "existing pair denial is retained …" |
888
+ | Re-publishing through the Host outbox or bridge adds no second source job | `insertFanOutJob` is insert-if-absent by `eventId`; intents and denials are insert-if-absent by (subscriptionId, eventId) | "replay: …", "concurrent relays: …", the bridge overlap cases |
810
889
  | A worker that lost its lease adds nothing | the parent fence check in `insertDeliveryIntent` / `recordAdmissionDenial`, and `return` on `lease_lost` in `processJob` | "concurrent workers: …" |
811
890
  | Purged records stay purged | the replay horizon floor (see [Replay horizon](#replay-horizon-r5)) | the store contract's race cases |
812
891
 
@@ -822,7 +901,9 @@ first, so a pattern change neither removes the event from a subscription that ma
822
901
  to one that did not. A pause written after `recordedAt` stops delivery, not fan-out: the governed
823
902
  action's `cancelPendingIntents` and the delivery cycle's pre-handoff `readSubscription` check
824
903
  ([Delivery](#delivery)) act on the intent. A pause effective before `recordedAt` makes the revision paused, so the
825
- event gets no intent or denial, and a later resume does not resurrect it.
904
+ event gets no intent or denial in ordinary fan-out; a later resume does not change that historical
905
+ selection. A new governed replay request after resume may decide still-undecided pairs. Remaining
906
+ items of a request accepted before a pause are skipped under its original active-interval rule.
826
907
 
827
908
  **Bridge overlap.** Covered above: the first insert of an `eventId` wins and every later one is a
828
909
  no-op, before and after the job completes.
@@ -831,8 +912,8 @@ no-op, before and after the job completes.
831
912
  dead-lettered with a diagnostic code: a visible failure that the fan-out-job requeue (PR 9)
832
913
  recovers. That is R1's "visible recoverable failure".
833
914
 
834
- **Rejected.** Completing without waiting and sweeping later for late-effective revisions: a sweep
835
- would add intents after completion, so completion would mean nothing, and could not withdraw an
915
+ **Rejected.** Completing ordinary fan-out without waiting and automatically sweeping later for
916
+ late-effective revisions: that would undermine its historical selection boundary and could not withdraw an
836
917
  intent a late revision excludes. Pinning eligibility at Host's commit: that is in-transaction
837
918
  fan-out, which ADR 0018 rejects. Settling on Host's clock: it is not the clock that stamps
838
919
  revisions.
@@ -959,7 +1040,9 @@ letter, not an endless loop.
959
1040
 
960
1041
  ### Pause and resume are enforced at dispatch
961
1042
 
962
- ADR 0018 decision 3: pause cancels pending intents, and resume does not resurrect them. The
1043
+ ADR 0018 decision 3: pause cancels pending intents, and resume does not resurrect those existing
1044
+ intents. Governed replay also preserves existing pair decisions; a new request after resume may
1045
+ decide previously undecided pairs, while pre-pause requests retain their original active interval. The
963
1046
  property the package holds is stronger than "the pause's cleanup ran": once a pause or delete
964
1047
  revision is committed, no intent bound to an eligibility revision before it is handed to the
965
1048
  transport after a dispatch check has seen that revision. That holds whether the action's
@@ -1309,6 +1392,7 @@ The namespace defaults to `webhook-delivery` (option `namespace`). Every action
1309
1392
  | `rotate_secret_emergency` | `subscriptionId`, `secretRef`, `expectedVersion?` | `replaceDeliveryConfiguration` | `WebhookSecretRotated` (`mode: "emergency"`, …, `previousSecretRetainUntil: null`) |
1310
1393
  | `requeue_delivery_intent` | `deliveryId`, `expectedFence` | `requeueDeliveryIntent` | `WebhookDeliveryIntentRequeued` (`deliveryId`, `subscriptionId`, `eventId`, `priorFence`, `priorAttemptCount`, `processingDeadline`) |
1311
1394
  | `requeue_fan_out_job` | `eventId`, `expectedFence` | `requeueFanOutJob` | `WebhookFanOutJobRequeued` (`eventId`, `priorFence`, `priorAttemptCount`, `processingDeadline`) |
1395
+ | `replay_events` | `subscriptionId`, `from`, `to`, optional `eventTypePatterns` | opt-in `createReplayRequest` | `WebhookReplayRequested` (range, count, acceptance and deadline metadata) |
1312
1396
  | `enrich_fan_out_job_actor` | `eventId`, `expectedFence` | `enrichFanOutJobActor` | `WebhookFanOutJobActorEnriched` (`eventId`, `priorFence`, `priorAttemptCount`, `processingDeadline`) |
1313
1397
 
1314
1398
  Events carry ids, numbers, patterns and timestamps only: no destination URL, no secret reference,
@@ -1692,3 +1776,88 @@ package's tests with `node:crypto` only. They are not part of the packed package
1692
1776
  Ports are passed to an instance, not registered in module scope. The Boundary Guard's module-state
1693
1777
  scan (G5) rejects module-scope `let` bindings, `Map` and `Set` registries and import-time
1694
1778
  environment reads in this package (`boundary/tiers.json`, `moduleStateScan`).
1779
+
1780
+
1781
+ ## Historical replay
1782
+
1783
+ Enable replay explicitly at the composition root:
1784
+
1785
+ ```ts
1786
+ const module = createWebhookDeliveryModule({
1787
+ ...operatorOptions,
1788
+ replay: { store: deliveries, subscriptions, maxEvents: 10_000, processingLifetimeMs: 3_600_000 },
1789
+ });
1790
+ ```
1791
+
1792
+ The built-in memory and PostgreSQL stores implement `WebhookReplayStore`; both subscription
1793
+ stores implement `WebhookReplaySubscriptionReader.readReplaySubscription`. These are separate
1794
+ capabilities: existing custom base ports and ordinary fan-out method checks are unchanged. Missing
1795
+ enabled methods fail module construction. Without configuration, `replay_events` refuses
1796
+ `replay_not_supported` before asking the operator policy. The authorization input optionally includes
1797
+ normalized `replay` range/filter metadata.
1798
+
1799
+ Submit `<namespace>.replay_events` through Platform Host with `subscriptionId`, canonical ISO
1800
+ `from` (inclusive), `to` (exclusive), and optional `eventTypePatterns`. The range must be nonempty,
1801
+ inside the store's authoritative horizon and end no later than its clock. Patterns use the existing
1802
+ exact, prefix and wildcard grammar. Both the requested filter and acceptance-time subscription
1803
+ patterns must match each candidate; package-owned events are excluded. Scope and requester come
1804
+ from trusted Host context. The operator policy still requires literal `true`.
1805
+
1806
+ Default `maxEvents` is 10,000; default `processingLifetimeMs` is one hour. These composition settings
1807
+ must be positive safe integers. Requests over the cap are refused with `replay_limit_exceeded`,
1808
+ without a receipt or partial work. Many events can share one timestamp and type, so narrowing the
1809
+ range need not make it fit. No pagination across changing source membership is implied.
1810
+
1811
+ Deploy PostgreSQL migration 5 with `ensureSchema()` before enabling replay. Upgrade relevant
1812
+ writers, erasure handlers and purgers before enablement. Migration locking alone does not establish
1813
+ mixed-version behavioral compatibility. The migration adds receipt/item tables and source-range,
1814
+ claim and listing indexes; index creation can block writes while it runs.
1815
+
1816
+ Acceptance atomically fixes membership from **retained webhook projections**, not the canonical
1817
+ ledger. Events never published/bridged or already purged are absent. Payload and facts are not copied
1818
+ to replay records. Each item carries its acceptance-time `observedStatusSinceRevision`, normalized
1819
+ request `eventTypePatterns` (default `["*"]`) and `observedEventTypePatterns`. These pattern lists are
1820
+ bounded by the existing 64-pattern limit. The worker reads and validates only that item context,
1821
+ without reading, copying or scanning the receipt's `selected` membership per item. A receipt exists
1822
+ even for zero matches. Reusing the trusted `actionInvocationId`
1823
+ returns the original receipt before mutable checks; conflicting action parameters refuse
1824
+ `replay_request_conflict`. A later publication cannot expand that receipt. `WebhookReplayRequested`
1825
+ means durable acceptance, not delivery. The webhook transaction and Host event append are separate;
1826
+ a lost response or Host append failure can leave accepted work without that event. As with requeue,
1827
+ the webhook store cannot fence overlapping Host attempts using Host's lease.
1828
+
1829
+ Run `runWebhookReplayCycle()` with the replay capability, current subscription reader, ordinary
1830
+ delivery store, admission, payload policy, clock and worker identity. Worker options shared with
1831
+ fan-out use the same resolver: default batch size 25, lease 60,000 ms, and `dlv_` plus UUID delivery
1832
+ IDs. A custom `retryDelay` is honored; otherwise pass `random` for the configured schedule's jitter.
1833
+ The delivery-ID callback receives a frozen argument without a receiver, and must return a string.
1834
+ `eligibilitySettleMs` is omitted: replay uses fixed membership and current eligibility rather than
1835
+ waiting for the historical fan-out selection boundary. It shares actor-requirement,
1836
+ matching, admission normalization and rendering behavior with ordinary fan-out. Existing pair
1837
+ markers bypass admission; replay never creates a second intent for an existing pair and never
1838
+ overrides a denial. Dispatch remains at-least-once using the normal delivery worker, current
1839
+ destination/configuration checks, authorization, signing and attempt receipts.
1840
+
1841
+ The worker enforces the acceptance-time active interval. Pause/resume ends that interval; deletion,
1842
+ inactivity or narrowed patterns skip remaining items without permanently denying the pair. Pattern
1843
+ broadening does not expand membership. Subscription changes after the worker's last read can race
1844
+ insertion because subscription and delivery stores can be independent. Dispatch retains its existing
1845
+ pause/resume checks; it does not recheck event patterns.
1846
+
1847
+ Admission uses the original event actor, not the requesting operator. Suppressed/legacy absence
1848
+ remains unavailable when identity is required. In particular, completed legacy source jobs cannot
1849
+ be enriched by the existing action. Replay reapplies current payload policy and scope predicates to
1850
+ retained projected data; it cannot restore discarded fields or promise chronological HTTP delivery.
1851
+
1852
+ Settlement checks replay ownership, deadline, source scope/fence, horizon, erasure marker and pair
1853
+ uniqueness atomically. A changed source requires fresh evaluation. An erased undecided pair gets
1854
+ `event_erased`. Original event `recordedAt` anchors the horizon; admission near its edge is best
1855
+ effort. Purge can remove a terminal source while replay remains pending: expiry is reported from
1856
+ the stored anchor; a missing source inside the horizon is visibly dead-lettered as `source_missing`.
1857
+ Intent delivery receives its ordinary finite deadline. Reclaims do not extend replay's persisted
1858
+ deadline. Unknown settlement writes end the current pass without a contradictory follow-up write.
1859
+
1860
+ Inspect `readReplayRequest()` and scoped, event-ID-paged `listReplayItems()`. Each terminal item
1861
+ keeps its outcome once: intent plus delivery ID, denial plus code, already decided, skip plus reason,
1862
+ horizon expiry, or dead letter plus diagnostic. Evaluated revision is retained for pair decisions.
1863
+ Receipts and items are never removed by package purge; operators own their eventual audit retention.