Behavior-contract checklist
A documented runtime guarantee is a lie until a test fails when it stops
holding. The with_snaps regression (#1024) shipped a doc/intent that had
quietly diverged from the code because nothing executable pinned the claim.
This page is the antidote: every load-bearing behavioral claim Act makes in
its docs and port doc-comments, mapped to the test that enforces it.
The rule that follows from it lives in the pre-handoff workflow: a doc claim about runtime behavior ships with its test. When you add or change a claim here, add or update the row.
Scope is deliberately narrow β load-bearing guarantees a caller or adapter
author would rely on, not every sentence in the docs. Adapter-level claims are
backed in the TCK (libs/act-tck/src/store-tck.ts) so they run against
InMemory, Postgres, and SQLite at once; orchestrator and builder claims are
backed by unit/integration specs under libs/act/test/.
Cache and snapshotsβ
| Claim | Source | Backing test |
|---|---|---|
seed() is the schema maintenance story: additive, idempotent, lossless on any prior released shape, and safe to run on every boot from every worker (advisory-locked on Postgres for concurrent cold boots) | writing-a-store.md Β§ The store schema is the framework's job; extension-points.md | act-pg + act-sqlite seed-upgrade.spec.ts β "upgrades the oldest supported shape losslessly and idempotently", "serializes concurrent cold boots on an empty schema" (#1140) |
On Postgres, streams.retry is int (widened from smallint via the seed ladder), so a persistently-failing blockOnError: false stream whose retry counter climbs unbounded never overflows claim()'s retry + 1 update β matching the unbounded SQLite/InMemory adapters | writing-a-store.md Β§ The store schema is the framework's job | act-pg retry-overflow.spec.ts β "claims a stream whose retry sits at the old smallint ceiling"; seed-upgrade.spec.ts β "upgrades the oldest supported shapeβ¦" (asserts retry migrated to integer) (#1190) |
with_snaps: true resumes from the latest snapshot per stream β in both scan directions (forward resumes at the snapshot, backward stops at it); an explicit after overrides the floor; a stream with no snapshot returns full history. The store applies the floor whenever with_snaps is set and does not cross-check bounds β the caller owns eligibility (RFC 1274) | cache-and-snapshots.md, Store.query doc | store-tck.ts β "with_snaps resumes from the latest snapshot per stream", "with_snaps applies the resume floor on a backward scan too" |
Cold start absorbs the snapshot event into state and resets patches to 0 (snaps increments) | cache-and-snapshots.md "How the two interact on cold start" | event-sourcing.spec.ts β "should load from a snapshot event on cold start" |
| Snapshot writes are fire-and-forget; a snap failure does not propagate | cache-and-snapshots.md "Snapshot creation" | event-sourcing.spec.ts β "should not throw on snap error and warn with stream, reason, and hint" |
| A snapshot write failure never fails the action and is surfaced at warn level with the stream, reason, and full-replay hint | cache-and-snapshots.md "Snapshot creation" | event-sourcing.spec.ts β "should complete the action and warn when the snapshot write fails" |
A successful snap predicate commits a __snapshot__ event | cache-and-snapshots.md | event-sourcing.spec.ts β "should persist snapshot event on snap success" |
| Cache miss populates the cache; a warm hit replays nothing older | cache-and-snapshots.md "Read path" | cache.spec.ts β "cache miss populates cache on load" |
A warm cache hit resumes with with_snaps ({ after: cached.event_id, with_snaps: true }), so a rebaselining __snapshot__ landing above a stale checkpoint is still folded β even after a windowed close pruned the domain events between the checkpoint and that snapshot. Without it the fold silently applies the surviving tail on top of stale state while version still reports the true head (guard passes β silent corruption) | cache-and-snapshots.md "Read path"; event-sourcing.ts load doc | close-windowed.spec.ts β "warm reload after a prune folds through the rebaselining snapshot (#1345)"; optimizations.spec.ts β "a cache-warm load queries with after and replays zero rows" |
The cache is invalidated only on ConcurrencyError | cache-and-snapshots.md "Cache invalidation β narrow contract" | cache.spec.ts β "cache invalidated on ConcurrencyError" |
| Invariant / validation failures (no commit) leave the warm cache untouched | cache-and-snapshots.md "Anything else β¦ leaves the cache untouched" | cache.spec.ts β "invariant failure leaves the warm cache untouched", "validation failure leaves the warm cache untouched" (gap filled β #1029) |
A cache.set rejection is logged, not fatal to the action | cache-and-snapshots.md | cache.spec.ts β "cache.set rejection is logged but does not fail the action" |
Time-travel (asOf) bypasses cache read and write, and ignores snapshots outside the cutoff for every bound β before (id), created_before/created_after (timestamp), and limit. The orchestrator drops with_snaps under any asOf bound so the load full-scans under its filter instead of jumping to a snapshot outside the window (RFC 1274) | cache-and-snapshots.md "Time-travel reads" | time-travel.spec.ts β "should not read from cacheβ¦", "should not write to cacheβ¦", "should not use snapshotsβ¦", and the "time-travel ignores snapshots outside the cutoff (RFC 1274)" matrix (before / limit / created_before / created_after) |
query_stats head/tail never carry pii β it is an operator-introspection surface with no actor context and no disclosure gate, so it omits the pii sidecar on every adapter (matching load's default-deny for un-gated reads, #1277/#1294) | extension-points.md Β§ Store; ports.ts Store.query_stats doc | store-tck β "query_stats head/tail never carry pii β introspection surface is pii-safe (#1294)" (runs on every pii-capable adapter); act-sqlite pii-encryption.spec.ts β "never carries pii on query_stats head/tail" |
Reactions, drain, and errorsβ
| Claim | Source | Backing test |
|---|---|---|
On Postgres, event ids become visible in id order β the append path (commit, truncate) holds a transaction-scoped advisory lock so a concurrent cross-stream commit can never surface below an id a watermark consumer already acked past | concurrency-model.md Β§ Commit visibility ordering | act-pg commit-visibility.spec.ts β "a commit waits for an in-flight append transaction to finish", "truncate seeds take the same visibility lock" (#1178) |
reactingTo is auto-injected when a reaction handler omits it; an explicit value is respected | CLAUDE.md safety one-liner; state-management.md | reacting-to.spec.ts β "should auto-inject reactingTo when handler omits it", "should respect explicit reactingTo when provided" |
NonRetryableError forces a block on the first attempt when blockOnError is true (default) | error-handling.md "Non-retryable errors" | non-retryable.spec.ts β "blocks on first attempt when blockOnError is true (default)" |
NonRetryableError does not override blockOnError: false | error-handling.md; CLAUDE.md safety one-liner | non-retryable.spec.ts β "ignores NonRetryableError when blockOnError is false" |
A worker whose lease was stolen mid-handler gets a short ack return, and the drain reports the difference rather than discarding the round silently | concurrency-model.md Β§ Observability | non-retryable.spec.ts β "reports the round of work the stolen lease discarded"; act-pg lease-loss.spec.ts (same, over a real store); store TCK β "drops the ack of a holder whose lease was taken, and accrues retry per claim" (#1418) |
A stream arriving at claim with retry strictly greater than maxRetries blocks before dispatch β the budget was spent with no error, so only lease loss can explain it. retry === maxRetries still runs its final attempt, and blockOnError: false still retries forever | concurrency-model.md Β§ When every attempt loses its lease; error-handling.md Β§ Blocked Streams | non-retryable.spec.ts β "blocks the stream once the budget is spent with no error raised", "control β a claim at exactly maxRetries still gets its attempt", "control β blockOnError:false still means retry forever"; act-pg lease-loss.spec.ts (same four, over a real store) (#1418) |
| Per-reaction backoff defers retry until the window elapses (persisted per-stream schedule) | error-handling.md "Backoff" | backoff.spec.ts β "defers retry until backoff window elapses (persisted schedule)" |
| Backoff entry clears on a successful ack | error-handling.md | backoff.spec.ts β "clears backoff entry on successful ack" |
| Backoff still blocks when retries are exhausted | error-handling.md | backoff.spec.ts β "preserves blocking behavior when retries are exhausted" |
compute_backoff_delay strategy/clamp/jitter semantics | error-handling.md | backoff.spec.ts β compute_backoff_delay unit block |
Backoff persists deferred_at and is honored precisely β decoupled from leaseMillis (the due-ack releases the lease), so no mid-window re-claim phantom-bumps retry | error-handling.md "Backoff"; CLAUDE.md "Reaction backoff is a persisted per-stream schedule" | backoff.spec.ts β "backoff window is honored precisely via persisted deferred_at, not the lease duration (#1262)" |
A backoff-style due-ack persists the lease's retry (budget survives the window); an explicit defer passes retry: -1 (a defer is not a failure) | ports.ts ack doc-comment; error-handling.md "Backoff" | store-tck β "persists the lease's retry on a backoff-style due lease (#1262)" / "holds the watermark and resets retry on an explicit-defer due lease (retry: -1)" |
A due-marked ack advances the watermark to at and persists the schedule β advance and defer are independent legs, so a partial-progress backoff/defer keeps the handled prefix (it never re-runs) while the failing tail waits for deferred_at | reaction.ts Lease.due doc; error-handling.md "Backoff" Β§ Per-stream schedule | store-tck β "advances the watermark to at while persisting the schedule on a partial-progress due lease (#1278)"; backoff.spec.ts β "advances the watermark past the succeeded prefix AND persists the window on partial progress (#1278)" |
A batch that makes partial progress and then blocks (handled > 0 and block: true β e.g. a NonRetryableError on the tail event) lands in both acked and blocked in the same cycle: the watermark advances past the succeeded prefix and the stream blocks. block finalizes before ack because ack releases the lease block's WHERE clause requires β acking first would silently drop the block and re-run the permanently-failed tail next cycle | drain-cycle.ts finalize comment; error-handling.md "Non-retryable errors" | non-retryable.spec.ts β "blocks in the SAME cycle after partial progress β ack must not drop the block (#1296)" |
The settle loop records a circuit-breaker passed() only when its correlate actually probed the store (the app has dynamic resolvers). A static-reaction app's correlate is a no-op, so settle never closes an OPEN breaker off it β otherwise a settle during a store outage would re-close the breaker and let the drain hammer the down store; the breaker recovers via the drain claim's own passed()/failed() in half-open | act.ts breaker on_retry doc-comment; ACT-984 | settle-wakeup.spec.ts β "does not close an OPEN breaker when correlate did not probe the store" / "still closes the breaker when correlate probed the store (#1329)" |
app.unblock resumes a blocked stream from its watermark without replaying history; app.reset rewinds to -1 and replays everything (the resume-vs-rebuild distinction) | CLAUDE.md "Blocked-stream recovery"; error-handling.md | non-retryable.spec.ts β "recovers via app.unblock without replaying history"; rebuild.spec.ts β "should enable replay of projection after reset" (gap filled β #1065) |
correlate() arms the lane controllers when it subscribes new streams β the same contract reset/unblock honor β so a freshly-discovered dynamic target cannot starve on a lane whose worker disarmed before the subscription landed | correlation-and-drain.md | correlate-arm.spec.ts β "revives a lane that disarmed before the subscription landed" |
Correlate's cold-start checkpoint floors at watermark - back_scan (when dynamic resolvers exist) β a dynamic-resolver event committed but not correlated before a crash is re-scanned on restart instead of skipped past a busier stream's watermark | correlation-and-drain.md | correlate-cold-start.spec.ts β "still discovers a dynamic target committed-but-not-correlated before restart" (#1207) |
settle() never drops a wake-up requested while a cycle is running β the running cycle's finally re-arms the pending pass, so armed controllers cannot starve on an instance with no lane cycleMs and no polling | correlation-and-drain.md | settle-wakeup.spec.ts β "runs a cycle for a schedule() that fires while a cycle is in flight"; act.spec.ts β "re-arms a wake-up requested while a cycle is running (ACT-1205)" (#1205) |
| A state projection's flush precedes its watermark ack β a failed flush holds the watermark and the retry re-folds to the same rows (idempotent at-least-once) | projections-to-database.md | state-projection.spec.ts β "holds the watermark when flush fails and converges on retry" |
A state projection evicting under maxCachedStates pressure flushes the evictee first β eviction never loses folded work | projections-to-database.md | state-projection.spec.ts β "flushes the evictee before dropping it under maxCachedStates pressure" |
| A state projection rebuild writes one row per stream per flush round, not one per event | event-sourcing.md Β§ Projection Rebuild | state-projection.spec.ts β "rebuilds in O(streams) upserts, not O(events)" |
| A state projection'''s first-sight load resumes from the latest snapshot floor and folds only the tail | projections-to-database.md Β§ State projections | state-projection.spec.ts β "resumes the first-sight load from the latest snapshot" |
A cache entry's state equals the fold of events at or below its event_id β a guardless commit that lands past the loaded frontier invalidates instead of caching a gapped fold | cache-and-snapshots.md | cache-frontier.spec.ts β "never leaves a stale-at-head cache entry after guardless commits" |
A state projection's first-sight fold pairs the loaded state with the head event id load() returns atomically (Snapshot.id) β never a separate cache read, which could race a concurrent commit and pair older state with a newer id, permanently skipping the intervening events | cache-and-snapshots.md; projections-to-database.md | fold-frontier-toctou.spec.ts β "does not skip events when the cache frontier moves during first-sight load" (#1204) |
load() fire-and-forgets its checkpoint write: a failing cache.set is logged, not thrown β the read returns the correctly-computed state (matching action()'s fire-and-forget cache write) | cache-and-snapshots.md; extension-points.md Β§ Cache | load-cache-error.spec.ts β "returns the correct state and swallows a failing cache.set" (#1206) |
action() rethrows a ConcurrencyError immediately when expectedVersion was caller-supplied β a pinned version is a fixed target that every retry re-conflicts on, so the budget and backoff are only spent on framework-derived versions | error-handling.md Β§ Retry pattern | action-retry.spec.ts β "rethrows immediately without consuming the retry budget or sleeping (ACT-1208)" (#1208) |
The optimistic guard holds on warm cache hits β a concurrent plain action surfaces ConcurrencyError, never a silent append past unfolded events | concurrency-model.md | cache-frontier.spec.ts β "keeps the optimistic guard on warm cache hits" |
A cold load during the close guard window (tombstone committed, truncate pending) does not cache β leaving the entry cold keeps action()'s cold-path tombstone check live, so a subsequent action throws StreamClosedError instead of committing past the tombstone | concurrency-model.md; close-cycle.md | event-sourcing.spec.ts β "a cold load during the guard window keeps the tombstone check live" (#1188) |
| A snapshot event is only persisted from a contiguous fold, and the action awaits it and caches the snap checkpoint before returning β sequential callers never see a conflict caused by the framework's own bookkeeping | cache-and-snapshots.md | optimizations.spec.ts cadence + calculator.spec.ts lifecycle (fail on regression) |
Lanes drain in parallel β _drain_all runs every controller's drain() via Promise.all, so a stalled slow-lane handler does not block the fast lane | CLAUDE.md "Lanes give intra-process responsiveness"; configuration.md Β§ Lanes | lanes.spec.ts β "drains lanes in parallel β a stalled slow handler does not block the fast lane" (gap filled β #1065) |
When onlyLanes excludes a declared lane (default or a .withLane name), the constructor logs a one-line advisory naming the orphaned lane(s); it stays silent when onlyLanes is unset or covers every declared lane β the cluster invariant is βͺ onlyLanes β declared lanes | configuration.md Β§ onlyLanes; production-checklist.md Β§ Sizing lanes | lanes.spec.ts β "orphaned-lane advisory" (#1220) |
A cold start re-seeds the defer timer from each still-future persisted deferred_at, so an idle deferred stream re-arms at its due-time with no intervening commit; a stream whose lane isn't active on this instance is skipped | close-cycle.md Β§ Defer, not poll; writing-a-store.md Β§ Deferring a stream; extension-points.md Β§ query_streams | defer-durability.spec.ts β "re-seeds the DeferTimer", "re-arms the drain at the persisted due-time" (#1221) |
A defer outcome holds the triggering event pending β the drain does not advance the watermark past the deferred event and does not bump retry β then re-delivers once the persisted due-time passes (any fully-handled prefix ahead of the trigger still advances) | close-cycle.md Β§ Defer, not poll; extension-points.md Β§ defer | defer-outcome.spec.ts β "holds pending until the due-time, then redelivers and acks" (#1090) |
The registry is complete and frozen at act().build(): autoclose reactions are synthesized by the builder (not the orchestrator), and post-build mutation of the registry containers throws | close-cycle.md Β§ Online close-the-books | registry-freeze.spec.ts β "freezes the registry containers at build", "synthesizes the autoclose reaction at build, not construction" (#1121) |
.autocloses closes a stream when its policy matches the live head, re-evaluates the live head so a reopened stream is not closed, parks on the cooldown while after has not elapsed, closes on a reaches threshold, and defers (not closes) outside the autocloseWindow | close-policies.md; close-cycle.md Β§ Online close-the-books | autoclose-reaction.spec.ts β "closes immediately on the terminal event for an is policy", "evaluates the live head β a reopened stream is not closed", "parks on the cooldown instead of closing while after has not elapsed", "closes on the threshold event for a reaches policy", "respects the off-hours window β outside it, defers instead of closing" (#1090) |
When after has already elapsed but the AND-combined predicate is still unmet, .autocloses defers only to a future due-time β never a past one (which claim can't exclude, causing a per-cycle re-claim busy-loop); with no future due it returns and waits for the next event | close-policies.md; autoclose-reaction.ts dues doc-comment | autoclose-reaction.spec.ts β "does not defer to a PAST due-time when after has elapsed but the predicate is unmet (#1330)" |
.autocloses keys its terminate/reaches decision on the domain head and count (snapshots excluded from query_stats), and the full-close guard tombstones at the stream's true head version β so a policy fires correctly even when a __snapshot__ trails the terminal event (snap() commits it into the next version slot). Without both, is never matches the snapshot head and never recovers, reaches counts snapshot events, and the close-cycle skips the tombstone on a stale expected version | close-policies.md; close-cycle.md Β§ Phase 1 | autoclose-reaction.spec.ts β "closes on the terminal event even when a snapshot trails it (is, #1356)", "counts only domain events toward reaches when snapshots trail (#1356)" |
On a DST spring-forward day whose autocloseWindow.start hour is skipped, the window still opens at the gap's replacement instant β in_autoclose_window admits it and next_window_open defers ~1 h to it, not ~24 h | close-policies.md Β§ off-hours window | autoclose-builder.spec.ts β "opens ~1h out on a DST spring-forward day whose start hour is in the gap (#1233)", "in_autoclose_window is true at the DST gap replacement instant (#1233)" (#1233) |
The declarative .defer(when) builder step holds the reaction until its schedule is due, then runs the handler once (available on both act() and slice()) | state-management.md Β§ Deferred reactions | declarative-defer.spec.ts β "literal { after } holds the reaction (handler not run, not acked)", "runs immediately once the schedule is already due", "is available on the slice() builder too" (#1091) |
The .defer function form reads the triggering event's payload to choose the schedule | state-management.md Β§ Deferred reactions | declarative-defer.spec.ts β "function form reads the payload to choose the schedule, then runs" (#1091) |
Drain finalization is atomic: defer schedules ride the same Store.ack call as the acks (due-marked leases), so a failed finalize lands nothing β close requests are never lost, no watermark moves without its cycle's schedules | close-cycle.md Β§ Defer, not poll; extension-points.md Β§ Store; recipes/temporal/recurring-timers Β§ Failure modes | defer-durability.spec.ts β "a failed finalize lands nothing β close requests are never lost", "persists the defer schedule in the same store call as the acks"; TCK store-tck.ts β describe("ack finalize (due-marked leases)") (#1124) |
A failed finalize never stalls a deferred stream: the controller stays armed and the next drain redelivers, so the handler re-throws its DeferSignal and the retried finalize persists the schedule | close-cycle.md Β§ Defer, not poll | defer-durability.spec.ts β "keeps the drain armed while finalization is unhealed" (#1124) |
A malformed literal .defer(...) schedule (neither/both of after/at, or an empty duration) is rejected at build time with a ZodError | state-management.md Β§ Deferred reactions | declarative-defer.spec.ts β "rejects a bad literal schedule at build time" (#1091) |
Imperative throw new DeferSignal(when) resolves the schedule against the triggering event and holds the stream until the due-time, then re-delivers and acks | state-management.md Β§ The DeferSignal escape hatch | public-defer.spec.ts β "{ at: Date } holds until the due-time, then acts", "{ after } is measured from the event's created time (parks, not acked)" (#1091) |
Store contract (runs on all three adapters via the TCK)β
| Claim | Source | Backing test |
|---|---|---|
subscribe is idempotent on repeat | Store.subscribe doc | store-tck.ts β "subscribes new streams and is idempotent on repeat" |
subscribe keeps the maximum priority when a stream is re-subscribed with a different priority | Store.subscribe priority doc-comment | store-tck.ts β "keeps the maximum priority when a stream is re-subscribed" (gap filled β #1029) |
A dynamic-resolver target's priority/lane obeys the runtime max() invariant across correlate scans β a later scan resolving a higher priority re-subscribes it (raising the store's priority + carrying the winning lane); a lower-or-equal resolution dedups without downgrading. The orchestrator's _subscribed map records the last-subscribed priority per target instead of a plain presence set, so the guarantee isn't frozen at first discovery | priority-lanes.md Β§ "the same max() invariant holds at runtime" | correlate-lane.spec.ts β describe("cross-scan priority upgrade (#1363)"): "a later higher-priority scan raises the target's priority and lane", "a later lower-priority scan does NOT lower the target (max holds)" |
prioritize sets priority directly, overriding subscribe's max() rule | Store.prioritize doc | store-tck.ts β "sets priority directly, overriding subscribe's max() rule" |
unblock returns 0 when the stream is not blocked; reset/unblock return 0 for unknown/empty input | Store.unblock / Store.reset docs | store-tck.ts β "returns 0 when the stream is not blocked", "returns 0 for unknown streams and empty input" |
unblock clears the blocked flag and preserves the watermark; reset rewinds it to -1 (resume-vs-rebuild at the port level) | Store.unblock / Store.reset docs | store-tck.ts β "clears blocked flag and preserves the watermark", "rewinds a stream watermark to -1", "clears blocked status when resetting" (gap filled β #1065) |
truncate (full target) deletes events, removes the stream entry, and seeds a snapshot or tombstone | Store.truncate doc | store-tck.ts β truncate describe block |
A windowed truncate target (before) deletes only the prefix below the closest safe snapshot (latest __snapshot__ with created < before), keeps the snapshot + tail, seeds nothing, and leaves subscriptions untouched β the stream stays writable and readable | Store.truncate doc; extension-points.md Β§ Store contract; writing-a-store.md Β§ Truncating streams | store-tck.ts β describe("windowed (before boundary)"): "deletes the prefix below the closest safe snapshot and keeps the snapshot + tail", "leaves subscriptions untouched, unlike a full truncate", "keeps the stream writable and readable after a prune", "mixes full and windowed targets in one call" (#1011) |
The max_id cap on a windowed target is honored β the boundary never rises past a lagging consumer's watermark, so a lagging reaction degrades the prune, never loses data | Store.truncate doc; close-cycle.md Β§ Windowed close | store-tck.ts β "honors the max_id cap β boundary never rises past a lagging consumer"; close-windowed.spec.ts β "caps the boundary at a lagging consumer's watermark" (#1011) |
A windowed target with no qualifying snapshot is a no-op: events untouched, stream absent from the truncate result, reported in CloseResult.skipped by the orchestrator | Store.truncate doc; close-cycle.md Β§ Skipped semantics | store-tck.ts β "no-ops when no snapshot qualifies β stream absent from the result, events untouched"; close-windowed.spec.ts β "skips streams with no qualifying snapshot" (#1011) |
forget_pii wipes PII for every event and is idempotent (second call returns 0; no-PII stream returns 0) | Store.forget_pii doc | store-tck.ts β "wipes pii for every event on the stream via forget_pii", "is idempotent β second forget_pii returns 0, no error", "forget_pii on a stream with no pii events returns 0" |
Every gated external read β load, query, query_array β drops the isolated pii sidecar from the returned event; plaintext survives only inside data, and only on the authorized path. query/query_array carry no actor, so they default-deny ([REDACTED], [SHREDDED] post-forget) like a bare-string load. The single pii_gate produces the view; there is no raw read mode (#1277) | sensitive-data.md Β§ Reading it back; IAct.load / query / query_array doc | read-gate.spec.ts β "the gated load event drops the pii sidecar" + "query / query_array default-deny PII gate (#1277)" block |
The projection fold engine applies the same PII treatment on its cold first-sight load() path as on its warm incremental path β sensitive keys are structurally stripped (removed, not [REDACTED]), so a folded PII-aware projection row is identical regardless of cache warmth, eviction, or rebuild timing | sensitive-data.md Β§ Projections; pii_strip doc-comment | fold-pii-path.spec.ts β "cold-loaded and warm-folded rows of an identical history match" (#1320) |
query_streams reports maxEventId tracking the highest committed id | Store.query_streams doc | store-tck.ts β "maxEventId tracks the highest committed id" |
query_streams surfaces an active future deferred_at (ms since epoch) on a stream's position, so a cold start can read it and re-seed the defer timer | Store.query_streams / StreamPosition.deferred_at doc; writing-a-store.md; extension-points.md | store-tck.ts β describe("defer") β surfaces a future deferred_at on query_streams (#1221) |
claim skips a stream until its deferred_at passes, then makes it claimable again; a defer never bumps retry; reset clears a pending defer; a filter-form defer counts the streams it matched | Store.defer doc; extension-points.md Β§ defer | store-tck.ts β defer describe block: "hides a stream from claim until its deferred_at passes", "makes a stream claimable once the deferred_at is in the past", "does not bump retry while a stream is deferred", "reset clears a pending defer", "defers streams matching a filter and counts matches" (#1090) |
Stream filters guarantee a portable grammar β ^ / $ anchors, ., .*, literal characters (including literal _ / %) β matching identically on every adapter; a richer pattern either matches with full regex semantics or throws ValidationError, never a silent approximation | QueryStreams.stream / StreamFilter / Query.stream docs; extension-points.md Β§ Stream filters | store-tck.ts β "stream filter grammar" describe block: "portable subset: anchors, ., and .* match identically", "literal _ and % in patterns are not wildcards", "portable subset applies to stream-position filters", "non-portable patterns match with full regex semantics or throw", "bulk stream ops reject non-portable filters instead of mis-matching" (#1114) |
Stream/source filters match case-sensitively on every adapter β Postgres ~, InMemory RegExp, SQLite GLOB (not LIKE, whose ASCII case-insensitivity let ^order- overmatch Order-x) | writing-a-store.md Β§ The contract (query bullet); extension-points.md Β§ Stream filters | store-tck.ts β "stream filter grammar" describe block: "stream filters match case-sensitively" (#1197) |
An empty names: [] matches no events (explicit empty allow-list); an omitted names matches all | writing-a-store.md Β§ The contract (query bullet); extension-points.md Β§ Query edge inputs | store-tck.ts β "query" describe block: "names:[] matches no events" (#1199) |
Falsy-zero before: 0 / after: 0 are honored as strict id bounds (id < before / id > after), never dropped as falsy filters | writing-a-store.md Β§ The contract (query bullet); extension-points.md Β§ Query edge inputs | store-tck.ts β "query" describe block: "after:0 and before:0 are honored as id bounds" (#1199) |
A Date in event data round-trips as a Date on every adapter and through CsvFile β every JSON-persisting reader revives ISO-8601-shaped strings, including the bundled backup/restore source (#1399) | writing-a-store.md Β§ The contract (query bullet); extension-points.md Β§ payload Date revival | act-pg date-roundtrip.spec.ts; act-sqlite date-roundtrip.spec.ts (#1198); csv.spec.ts β "round-trips a Date in data through a store, not just CsvFile" (#1399) |
A Date in an event's pii field revives to a Date too β the pii read path runs the same reviver as data/meta, so a sensitive Date isn't a string on SQLite only | writing-a-store.md Β§ The contract (query bullet, data/pii) | store-tck.ts β pii_isolation: "revives a Date in a pii field to a Date, like data (#1365)" (runs on every pii-capable adapter) (#1365) |
SQLite commit maps a (stream, version) unique-constraint violation to ConcurrencyError (parity with Postgres' 23505) and wraps every other driver error in StoreError('commit') | writing-a-store.md Β§ The contract (commit bullet); extension-points.md Β§ Invariants an adapter must hold | act-sqlite store.error.spec.ts β "maps a (stream,version) unique collision to ConcurrencyError" (#1202) |
claim matches a literal subscription source (no regex metacharacter) by string equality β a source s1 never matches a sibling stream s12, and it receives exactly its stream's events (drain fetch uses stream_exact for literals) | Store.claim / Store.subscribe docs; Resolved.source doc | store-tck.ts β describe("claim source matching"): "treats source as an exact stream name β no substring or pattern overmatch", "receives exactly its source stream's events", "fetches only the exact source stream's events, never a sibling prefix" (#1182 exact fix; #1220 fetch consistency β the overmatch case failed red on InMemory's unanchored-RegExp and SQLite's contains-LIKE probes before #1182's fix) |
claim matches a pattern source (one carrying regex metacharacters, e.g. `^(A | B)$) as a compiled regex against candidate streams, so a static regex-source reaction (the shipped calculator Board projection) is claimed for every stream it anchors; adapters that cannot run an arbitrary regex reject a non-portable claim source at subscribe` time | Store.claim / Store.subscribe docs; Resolved.source doc |
A lease timeout counts against the retry budget β reclaiming an expired lease increments retry no matter which worker reclaims it, and only ack resets the counter | concurrency-model.md Β§ Timeout; Store.claim doc | store-tck.ts β "counts a timed-out lease reclaimed by another worker against the retry budget; ack resets it" (#1183 β the doc previously claimed the opposite; the code was right) |
| An unexpired lease is invisible to competing claimers; an expired lease is handed to exactly one of them, with the shared retry counter intact | concurrency-model.md Β§ Timeout / lease lifecycle | store-tck.ts β concurrency (capability): "does not hand an unexpired lease to a competing claimer", "hands an expired lease to exactly one competing claimer, with retry accounting shared across workers" (#1184) |
A claim() locks only its own lagging+leading candidate rows, not the whole eligible frontier β two competing workers whose candidate sets are disjoint both win their full slice in the same concurrent round, instead of the first worker locking every claimable stream and starving the rest | concurrency-model.md Β§ competing consumers ("gets the next available stream. Zero contention") | act-pg contention.spec.ts β "locks only the claimed candidates, not the whole eligible frontier (ACT-1201)" (#1201 β the available CTE previously held FOR UPDATE SKIP LOCKED with no LIMIT, so it materialized and locked the entire frontier) |
notify is self-filtering β an instance never receives its own commits, only a sibling instance's writing to the same backing store | Store.notify doc ("implementations must skip their own commits") | store-tck.ts β notify (capability): "does not deliver an instance's own commits (self-filtering)" (#1184 β promoted from adapter-local suites to the TCK so third-party stores can't badge conformance while echoing their own commits) |
notify delivers one notification per commit transaction, carrying the full ordered event batch | Store.notify doc; cross-process-reactions.md | store-tck.ts β notify (capability): "delivers one notification per commit transaction carrying the full event batch" (#1184) |
PostgresStore notify (adapter-specific β outside the TCK)β
The portable notify contract (cross-instance delivery, self-filtering,
batch-per-commit) lives in the TCK's notify capability suite as of #1184.
What stays here is PostgreSQL plumbing the TCK can't express portably β
the LISTEN reconnect discipline and the 8000-byte NOTIFY payload cap β
pinned in libs/act-pg/test/.
| Claim | Source | Backing test |
|---|---|---|
notify is self-filtered per instance β a commit wakes other instances' listeners, never its own (the LISTEN handler skips payloads where by === this._by) | cross-process-reactions.md Β§ Self-filter | notify.contract.spec.ts (act-pg) β "notify is self-filtered per instance β a commit wakes the other instance, never its own" (#1120) |
| An oversize NOTIFY payload (β₯ 8000 bytes) skips the NOTIFY instead of aborting the commit β the commit succeeds and delivery falls back to the poll path, preserving at-least-once | cross-process-reactions.md Β§ Payload cap | notify.contract.spec.ts (act-pg) β "oversize notify payload skips the NOTIFY β the commit succeeds and events stay discoverable via the poll path" (gap exposed a real bug, fixed β #1120) |
The dedicated LISTEN client carries an error listener, so a connection loss cannot crash the process; on error it tears the dead client down and re-LISTENs on a fresh one with capped exponential backoff, degrading to the poll path in between | cross-process-reactions.md Β§ Connection loss β the LISTEN client self-heals | notify.resilience.spec.ts (act-pg) β "attaches an error listenerβ¦", "self-heals: on client error it tears down the dead client and re-LISTENs on a fresh one", "grows the backoff on repeated failures and resets it after a healthy reconnect" (#1189) |
| A pending reconnect is cancelled by disposal (or a re-subscribe) β no reconnect fires after teardown | cross-process-reactions.md Β§ Connection loss β the LISTEN client self-heals | notify.resilience.spec.ts (act-pg) β "dispose during a pending reconnect cancels it β no client is checked out after teardown" (#1189) |
The dead LISTEN client keeps an error listener across the whole reconnect window β a second socket error on teardown (in-flight LISTEN rejection, then ECONNRESET/end) is swallowed, not re-raised as an uncaught exception; a re-entrant reconnect cancels the pending timer so two never race to re-LISTEN | cross-process-reactions.md Β§ Connection loss β the LISTEN client self-heals | notify.resilience.spec.ts (act-pg) β "a SECOND error on the dead client during the backoff window does not crash the process", "repeated reconnect cycles return the checked-out and listener counts to baseline", "a re-entrant reconnect cancels the pending timer instead of leaking a second one" (regression fix β #1231) |
Receiver idempotency (act-http / act-ops β two-phase claim)β
The receiver-side IdempotencyStore is two-phase: claim reserves a key
tentatively, and the caller confirms the outcome with commit (durable) or
release (drop). The load-bearing guarantee is that a transient handler
failure does not permanently drop the delivery β the sender's retry
re-processes instead of being deduped into a silent success (#1193).
| Claim | Source | Backing test |
|---|---|---|
claim is tentative; commit makes a key survive so a retry dedups, release drops an uncommitted claim so a retry re-processes, release after commit is a no-op, and committed entries still expire on TTL | external-integration.md Β§ The IdempotencyStore port; IdempotencyStore doc-comment | act-ops in-memory.spec.ts β describe("two-phase commit / release"): "release drops an uncommitted claim so a retry re-processes", "commit makes a claim survive so a retry dedups", "release after commit is a no-op", "committed entries still expire after ttlMs" (#1193) |
The receiver builder releases the claim on a handler throw (retry re-runs the handler) and commits on success (retry dedups); a concurrent duplicate mid-flight is still deduped | external-integration.md Β§ status table (500 row); receiver builder | act-http receiver/start.spec.ts β "re-runs the handler on retry after a transient handler failure (no lost delivery)", "dedups a retry after a successful delivery (handler runs exactly once)", "dedups a concurrent duplicate that arrives while the handler is in flight" (#1193) |
The wrapping adapters (Hono, tRPC) auto-finalize off the downstream outcome β commit on a 2xx / resolved result, release on a 5xx / thrown / { ok: false } result; Express and Fastify expose commit/release on the request context for the handler to call | external-integration.md Β§ Composing into an existing app | act-http receiver/{hono,trpc,express,fastify}/index.spec.ts (auto-commit/release + commit/release finalize cases); receiver/finalize.spec.ts β make_finalizers finalize-once + deduped-inert (#1193) |
withIdempotency (generated API) commits after the handler resolves and releases + re-throws when it rejects | api/idempotency.ts withIdempotency doc-comment | act-http api/idempotency.spec.ts β "commits the key after the handler succeeds", "releases the key and propagates handler rejections after a fresh claim" (#1193) |
SSE subscription wiring (act-http β bounded backlog, slot accounting)β
The shared runSseSubscription loop bounds each connection's undelivered-frame
backlog and pairs every slot release with a prior acquire, so one stalled
consumer can't grow memory without bound and a cap-full subscription can't
underflow the connection counter (#1196).
| Claim | Source | Backing test |
|---|---|---|
The per-connection pending backlog is bounded by maxPendingPerConnection (default 256, drop-oldest): a stalled consumer flooded past the bound retains only the newest N frames | auto-generated-api.md Β§ Defaults and validation; SseOptions.maxPendingPerConnection doc-comment | act-http api/sse-wiring.spec.ts β "bounds the per-connection pending buffer, dropping the oldest when a slow consumer stalls" (#1196) |
A subscription that fails to acquire a slot (cap full, no on_cap_exceeded) releases nothing on teardown β only an acquired slot is released, so the counter never underflows below the true open count | auto-generated-api.md Β§ SSE flow (teardown); runSseSubscription doc-comment | act-http api/sse-wiring.spec.ts β "does not release a slot it never acquired when the cap is full and no on_cap_exceeded is supplied" (#1196) |
An overlay() frame (_overlay: true, keyed at the current version) is merged onto a caught-up client's state by applyPatchMessage, keeping _v; a same-version message WITHOUT the marker is still stale | real-time.md Β§ Overlays / Β§ Client-side; PatchMessage._overlay + applyPatchMessage doc-comments | act-http sse/apply-patch.spec.ts β "merges a marked overlay at the current version, keeping _v" / "still treats a same-version patch WITHOUT the marker as stale" (#1312) |
openapi() emits a document for any registry hono() serves β an action-input field whose Zod type has no JSON-Schema form (z.date/z.bigint/z.map/β¦) emits an open ({}) schema (unrepresentable: "any"), never aborting the whole doc | auto-generated-api.md Β§ OpenAPI; openapi/index.ts conversion doc-comment | act-http openapi/index.spec.ts β "emits an open schema for a z.date() field instead of aborting the doc (#1328)" |
Orchestrator and buildersβ
| Claim | Source | Backing test |
|---|---|---|
app.reset() arms the orchestrator's drain flag, so a settled app still replays (store().reset() alone does not) | CLAUDE.md safety one-liner; event-sourcing.md | rebuild.spec.ts β "should replay events when drain runs after reset on a settled app" |
reset resets watermarks to -1 and unblocks blocked streams | Store.reset doc | rebuild.spec.ts β "should reset subscribed stream watermarks to -1", "should unblock blocked streams after reset" |
A static .emit() of a deprecated event version throws at build | event-schema-evolution.md; CLAUDE.md safety one-liner | deprecation.spec.ts β "throws when an action statically emits a deprecated event" |
Two event names in one version group that map to the same numeric version (e.g. a leading-zero Foo_v02 alongside Foo_v2) are rejected at build with a clear, order-independent error β never silently deprecating whichever declares second | event-schema-evolution.md Β§ The versioning convention is the deprecation signal | deprecation.spec.ts β "rejects a leading-zero version colliding with its canonical form regardless of order (#1234)" (#1234) |
Single-key records (.on, state(...)) throw on zero or multiple keys | CLAUDE.md safety one-liner; state-management.md | state-builder.spec.ts β "should throw when .on() receives multiple keys", "should throw when record has more than one key", "should throw when record has zero keys" |
| Same-name state partials sharing an event must reference the same Zod schema instance; mismatched references throw | CLAUDE.md safety one-liner; state-management.md | slice.spec.ts β "throws when same-name state partials use different schema references for the same event" |
| Scoped Acts keep per-Act store and cache isolated (no cross-talk) | CLAUDE.md safety one-liner; extension-points.md | scope.spec.ts β "two Acts with their own scoped ports β no cross-talk", "scoped cache keeps per-Act snapshots isolated" |
A scoped Act's background paths β settle-driven correlate init, the start_correlations poll timer, and per-lane worker ticks β resolve store()/cache() to the scoped ports, not the singleton: static targets subscribe and drains run on the scoped store | CLAUDE.md "Per-Act scoped ports"; extension-points.md Β§ Scoped ports | scope.spec.ts β "settle() subscribes static targets on the scoped store, not the singleton", "start_correlations polls against the scoped store, not the singleton", "lane worker ticks drain against the scoped store, not the singleton" (#1191) |
Cross-process reactions: the orchestrator auto-wires Store.notify at construction when the store supports it and reactive events exist; it does not wire when the store lacks notify or there are no reactive events | CLAUDE.md "Cross-process reactions"; cross-process-reactions.md | notify.spec.ts β "subscribes when store has notify and reactions exist", "does not subscribe when store lacks notify", "does not subscribe when there are no reactive events" (gap filled β #1065) |
| A windowed close leaves the cache untouched and the stream live β no invalidation on prune, and the stream keeps accepting actions | close-cycle.md Β§ Windowed close; IAct.close doc-comment | close-windowed.spec.ts β "prunes the prefix behind the boundary snapshot and keeps the stream live", "leaves the cache untouched β no invalidation on prune" (#1011) |
Two windowed closes racing the same stream (a manual app.close({ before }) and an autoclose windowed close bypass each other's lease) fire the archive callback at most once per pruned range β serialized by a per-stream lock, the second closer sees the already-pruned prefix and skips | close-cycle.md Β§ Why there is no head guard, but there is a per-stream lock; close-policies.md Β§ The archive contract | close-windowed.spec.ts β "fires the archive callback at most once when two windowed closes race the same stream" (#1222) |
.autocloses({ keep }) requires .snap(...) earlier in the chain β type-gated on the ActionBuilder, with a runtime throw backstopping untyped callers β and rejects windows below one day and keep inside or | close-policies.md Β§ keep; AutoclosePolicy.keep doc-comment | autoclose-builder.spec.ts β describe(".autocloses({ keep }) β rolling window"): "requires .snap earlier in the chain β the runtime guard for untyped callers", "gates keep behind .snap at the type level", "rejects windows below one day β close is low-cadence housekeeping", "rejects keep inside the or block" (#1011) |
A stream is never acked past an event while any reaction on it is unhandled β a mid-group failure holds the watermark, redelivers the whole group (already-succeeded reactions re-run, at-least-once), and counts as no progress so the retry counter marches toward blockOnError | error-handling.md Β§ Retry pattern; concurrency-model.md at-least-once | intra-event-ack.spec.ts β "holds the watermark when a later reaction on the same event fails", "acks the group once every reaction on the event succeeds", "keeps cross-event partial progress β completed events ack, the failing group holds" (#1179) |
A keep prune is staged only once the stream's oldest domain event ages out of the window; otherwise the reaction defers to tail.created + keep, and a terminate match takes precedence (full close) with terminate and prune staying independent | close-policies.md Β§ keep; close-cycle.md Β§ Online close-the-books | autoclose-reaction.spec.ts β describe("autoclose rolling window (keep)"): "defers while the window holds, prunes once the tail ages out", "terminate and prune stay independent β is full-closes even with keep declared", "passes the cutoff to the archiver on a windowed close", "skips the prune when no snapshot qualifies, retrying next trigger" (#1011) |
With validateFoldedState, a reducer producing schema-violating state throws a ValidationError at the triggering event (message names <state>.<event>#<id>) on the command, cold-replay, and projection-fold paths; with the flag off (default) the same reduction produces the bad state and does not throw | configuration.md Β§ Debugging: validating folded state; ActOptions.validateFoldedState doc-comment | validate-folded-state.spec.ts β "throws at the triggering event when a reducer produces schema-violating state", "names the state and the triggering event in the error", "does NOT throw and produces the bad state when the flag is off (default)", "catches the bad state on cold replay, not only on the committing action", "validates folded projection state (make_fold_handler) on replay" (#1238) |
A throwing lifecycle listener is contained: the drain cycle still finalizes, the remaining sinks still fire, drain() returns its real result, and no error (circuit-breaker) event is emitted β observer failures are never the cycle's failure β and containment covers the emits only: a StoreError raised inside on_close's close machinery still reaches the circuit breaker, which records passed() only after the close succeeds | observability.md Β§ Two things to know; drain-cycle.ts finalize | non-retryable.spec.ts β "keeps emitting blocked when an acked listener throws", "contains a throwing blocked listener without losing the ack"; autoclose-reaction.spec.ts β "contains a throwing closed listener without failing the cycle", "surfaces a store failure inside the close as an error event" (#1373, #1388) |
The settled payload accumulates every pass of the settle, so it reports what the whole settle did β the union of its passes matches, lease for lease, what the dedicated acked/blocked listeners saw. It is NOT the final (no-progress) pass alone | observability.md Β§ The lifecycle events | settle-wakeup.spec.ts β "reports the work the settle did, not its final empty pass" (#1383) |
Store.block returns the post-block row, not the caller's lease: the returned at is the durable watermark (a block never advances it), even when the drain hands in a lease whose at was fast-forwarded to the fetch ceiling | Store.block doc; Lease.at ("last successfully processed event ID") | store-tck.ts β "returns the post-block row, not the caller's lease (#1382)" (#1382, #1347) |
A durably blocked stream emits exactly one blocked lifecycle event, even if the ack that follows the block fails β the emit happens as soon as block confirms, because block is guarded on blocked = false and a blocked stream is excluded from claim, so it never runs again | drain-cycle.ts finalize; Store.block doc | non-retryable.spec.ts β "emits blocked even when the following ack fails" (#1390) |
Correlate's one-shot init() is retryable: a store failure during the static-target subscribe does not latch, so a later correlate()/settle() re-runs it and the reactions come up. Concurrent callers share one in-flight init | CorrelateCycle.init doc | correlate-cold-start.spec.ts β "subscribes static targets on a retry after subscribe throws", "shares one init across concurrent callers (single-flight)" (#1387) |
An SSE _overlay frame is version-neutral and is classified exhaustively: caught-up merges keeping _v, ahead by ANY amount (including exactly one) reports behind, below the baseline reports stale, and no baseline reports behind. An overlay never reaches the contiguous fold, so its payload is never adopted as a version's domain patch | real-time.md Β§ Overlays | apply-patch.spec.ts β "frame kind Γ version position" matrix (#1312, #1346, #1419) |
A _resync frame carries no versions and always reports behind, so a client refetches rather than treating it as stale. overlay() emits one when the stream's baseline has been evicted from the LRU, instead of silently broadcasting nothing | real-time.md Β§ Overlays; RFC 1434 | broadcast.spec.ts β "a resync frame makes the client refetch rather than go stale", "reports an overlay whose baseline was evicted" (#1423) |
Gaps closed by #1029β
Two load-bearing claims had no executable backing before this checklist:
- Cache narrow-invalidation, negative half. The docs guarantee that
invariant / validation failures leave the warm cache untouched (only
ConcurrencyErrorinvalidates). Nothing tested the negative case β a regression that invalidated on every throw would have passed CI. Closed incache.spec.ts. subscribemax-priority merge. TheStore.subscribedoc-comment mandates that re-subscribing a stream keeps the maximum priority so the highest-priority reaction wins. No adapter test enforced it; a last-write-wins regression on any adapter would have shipped silently. Closed instore-tck.ts(runs on InMemory, Postgres, SQLite).
No documented claim was found to be false against the code during this audit; both gaps were missing tests for behavior that already held.
Audit closed by #1065β
#1029 seeded the checklist; #1065 walked the remaining load-bearing
documented runtime guarantees to exhaustion. Most already had backing tests
that were simply not yet recorded here β the blocked-stream resume-vs-rebuild
distinction (non-retryable.spec.ts, store-tck.ts, rebuild.spec.ts) and
the cross-process notify auto-wiring contract (notify.spec.ts) are now
mapped to their rows. Two guarantees had no executable backing and gained a
focused test:
- Backoff's effective window. Backoff persists
deferred_aton the stream via a due-markedackand releases the lease, so the effective delay is the configuredbackoff, decoupled fromleaseMillis. Pinned inbackoff.spec.ts(a 50ms backoff under a 500ms lease retries after 50ms, not the lease). Earlier this was inverted β the held lease floored the backoff up toleaseMillisand mid-window re-claims phantom-bumped the retry counter (#1262). - Lane drain parallelism.
lanes.spec.tscovered controller wiring, arming, and worker lifecycle but never the actual concurrency guarantee: that_drain_all'sPromise.alllets the fast lane complete while a slow-lane handler is stalled. Closed with a gated-handler test.
No documented claim was found false against the code during this audit; every remaining gap was a missing test for behavior that already held.