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" |
Cache maintenance never changes which error the caller sees, nor whether a retry happens. Every cache write on the action path is fire-and-forget and contained β a failing Cache adapter must not turn a ConcurrencyError into a 500, nor make the retry loop's instanceof ConcurrencyError gate miss and lose the work | cache-and-snapshots.md; event-sourcing.ts action path | action-retry.spec.ts β "surfaces ConcurrencyError, not the cache error", "still retries a resolvable conflict through the action budget", "control β a healthy cache behaves identically"; cache-frontier.spec.ts β "the action itself must not fail on a cache maintenance error" (#1438) |
| 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" |
The injection is ambient β a dispatch through a captured app inherits the chain; one outside any reaction does not | state-management.md Β§ Auto-injected reactingTo | reacting-to.spec.ts (#1541 cases); reacting-to-singleton.spec.ts |
A live reaction skips the inferred concurrency guard but honors an explicit expectedVersion; work that outlived the handler is not a reaction and keeps the guard | concurrency-model.md Β§ Reactions skip optimistic concurrency | reacting-to.spec.ts β "should honor an explicit expectedVersion inside a reaction (#1543)", "should restore the inferred guard for work that outlived the handler (#1562)", "should NOT treat work the handler did not await as a reaction (#1562)" |
A z.date() field round-trips as a Date; a z.string() holding an ISO-shaped value stays a string β the schema decides, not the value's shape | extension-points.md Β§ Dates; store-tck.ts date case | libs/act/test/schema-dates.spec.ts (fold, snapshot, query, query_array, reaction, batch); libs/act-pg/test/schema-dates.spec.ts (serializing store) |
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) |
Every drain controller filters claim() by its own lane, so an onlyLanes worker never drains a lane it wasn't given β including onlyLanes: ["default"], where default is an ordinary filtered lane. The unfiltered claim shape is reserved for apps that never declare a lane | configuration.md Β§ onlyLanes | lanes.spec.ts β "a default-only worker never claims the slow lane's streams", "a slow-only worker never claims the default lane's streams", "a lane's streams are only claimed by the controller carrying its lease budget" (#1545) |
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) |
Registering the same projection object twice β exported from a module and embedded by two slices, or .withProjection(p) written twice β registers it once. The _p rename resolves a NAME collision between different handlers and still does; identity is checked first, so a duplicate registration is skipped rather than run twice per event | state-management.md Β§ Projections | projection-dupes.spec.ts β "runs its handler once per event, not twice", "control β two DIFFERENT handlers sharing a name still dedupe via _p" (#1439) |
A projection target is served by one batch handler or one state projection, never both, and all four {batch, fold}Β² pairings throw at build. A fold has a target but no batch handler, so register_batch_handler cannot see it; the fold side of the guard runs where batch_handlers is already fully populated, which catches both registration orders | state-management.md Β§ Projections | projection-dupes.spec.ts β "rejects batch + batch", "rejects batch + fold", "rejects fold + batch β the reverse order too", "rejects fold + fold", "refuses the cross-state hijack at build instead of corrupting the read model", "control β distinct targets coexist, and both run" (#1440) |
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 β across the whole number line, not just above zero. Because subscribe is restart-driven, a prioritize() downgrade below the declared priority is restored on the next boot rather than being sticky forever | Store.subscribe priority doc-comment; Store.prioritize doc | store-tck.ts β "keeps the maximum priority when a stream is re-subscribed" (gap filled β #1029), "keeps the maximum for negative and zero priorities too", "restores the declared priority on the next subscribe after a prioritize() downgrade" (#1445 β SQLite gated its merge on priority > 0, so a negative priority was unraisable; the existing cases all used positive values, where the gate is a no-op) |
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 _dynamic_subscriptions 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)" |
A dynamic resolution naming an undeclared lane is rerouted to "default" and reported once per offending declaration, rather than stranding the stream at watermark -1 where no controller claims it and no health surface shows it. The build-time guard sees static lanes only; TLanes rejects both forms at compile time, so this backstop fires only when the types are bypassed | configuration.md Β§ Conflicting lane assignments; CLAUDE.md "Lanes give intra-process responsiveness" | dynamic-lane-guard.spec.ts β "still runs the reaction instead of stranding the stream", "says so β a rerouted lane is not silent", "reports once, not once per matching event" (#1564) |
| Both dynamic-lane reports are keyed on the declaration β handler name plus lane(s) β never on the resolved target, so one misdeclaration costs one report no matter how many aggregates its resolver mints targets for. Two distinct misdeclarations still report separately | configuration.md Β§ Conflicting lane assignments; report-once.ts module doc | dynamic-lane-guard.spec.ts β describe("reporting once per declaration, not once per aggregate (#1584)"): "still one report when the same declaration reroutes 25 targets", "still one report when the same pair disagrees on 25 targets", "CONTROL β two declarations naming the same bad lane report twice", "CONTROL β two distinct bad pairs report twice" (#1584) |
| Two dynamic resolutions disagreeing on one target's lane at equal priority keep the first-discovered lane and report the conflict β the lane is not corrected mid-run, because a live stream's lane is the one its in-flight leases were taken under and re-laning is restart-driven. The equivalent static declaration still throws at build | configuration.md Β§ Conflicting lane assignments | dynamic-lane-guard.spec.ts β "CONTROL β the static form is still rejected at build", "says so β first-discovery-wins is not silent" (#1567) |
The static lane-agreement guard compares the resolved lane name, so an omitted lane and an explicit lane: "default" on the same target agree and the build succeeds; a genuine disagreement still throws, naming the two distinct lanes | configuration.md Β§ Conflicting lane assignments | lanes.spec.ts β "accepts an explicit 'default' followed by an omitted lane", "accepts an omitted lane followed by an explicit 'default'", "builds the failure scenario from #1583 with no cast anywhere", "still rejects a declared lane against an omitted one" (#1583) |
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 follows work, not registration. A subscription with no matching event past its watermark is not claimed β including a fresh one at at = -1. An id > at comparison already answers correctly at -1, so no adapter special-cases it; the first event on a fresh stream is claimed with the lease still opening at -1, which is what keeps a zero-based first event id inside the fetch window | Store.claim doc; writing-a-store.md Β§ The contract | store-tck.ts β "does not claim a fresh subscription with no matching events", "claims a fresh subscription as soon as its first event lands" (all three adapters); in-memory-store.spec.ts β "does not lose event id 0 when a commit lands during a fresh stream's cycle" (#1446 β InMemory and PG short-circuited at < 0 into claimable, SQLite did not, and the TCK pinned nothing; on InMemory, whose ids start at 0, the resulting empty-lease ack stepped over event id 0) |
A literal subscription source (no regex metacharacter) selects events by string equality β a source s1 never reads a sibling stream s12, and the target receives exactly its stream's events (drain fetch uses stream_exact for literals). Since #1488 this governs what a subscription fetches; claim has no opinion on source and reads no events at all, so the matching that decides eligibility happens in correlate, when it decides which events may raise a target's mark | Store.subscribe / Resolved.source docs; extension-points.md | correlate-work-mark.spec.ts β "skips an event outside a literal source"; store-tck.ts β "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; moved out of claim by #1488) |
A pattern source (one carrying regex metacharacters, e.g. ^(A|B)$) is compiled and matched against stream names, so a static regex-source reaction (the shipped calculator Board projection) still receives every stream it anchors; adapters that cannot run an arbitrary regex reject a non-portable source at subscribe time rather than accepting one they would mis-match. Applied by correlate when marking since #1488, not by claim | Store.subscribe / Resolved.source docs; extension-points.md | correlate-work-mark.spec.ts β "skips an event outside a pattern source, and marks one inside it"; store-tck.ts β "throws at subscribe for a non-portable (alternation) claim source" (SQLite) (#1220; moved out of claim by #1488) |
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) |
| A stream is never quarantined for a retry count the store ran up. The claim-time block, which fires when the budget is spent with no handler error, stands down while the store has failed since the last completed pass | concurrency-model.md Β§ When the database, not the handler, spent the count; error-handling.md Β§ Blocked Streams | store-failure-budget.spec.ts β "keeps the stream running while acks keep failing, and recovers", "still quarantines a stream that loses its lease every round" (#1592) |
| 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) |
| An adapter constructed from its own documented defaults either refuses to construct or round-trips a commit β including a second commit that advances the version. Accepting a write and losing it is not an allowed outcome | StoreTckOptions.default_factory doc | store-tck.ts β default configuration: "either refuses to construct or round-trips a commit" (InMemory takes the round-trip branch, SQLite the refusal branch) (#1443 β a zero-config SqliteStore defaulted to a per-connection private in-memory database, so seed()'s DDL landed where later statements could not see it: commit reported success and readback threw "no such table: events") |
SqliteStore requires an explicit url and normalizes every in-memory spelling (:memory:, file::memory:) to libSQL's shared-cache form β the only in-memory mode that serves one database to the whole store; an explicitly private one (cache=private) throws at construction | SqliteConfig.url doc; act-sqlite README Β§ Configuration | act-sqlite store.error.spec.ts β "rejects a missing url", "rejects a blank or non-string url", "rejects an explicitly private in-memory url", "normalizes the in-memory url β¦ to shared cache" (#1443) |
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 frame fidelity (act-http)β
| Claim | Source | Backing test |
|---|---|---|
Overlay state survives a commit, so a reconnecting client converges with a live one. publish() carries overlay-contributed keys onto the new cached state; a domain state that sets or drops such a key still wins, and a key the overlay itself cleared is not resurrected. The bookkeeping is a symbol on the cached object, so it is evicted with the entry and never reaches the wire | real-time.md Β§ Overlays; BroadcastChannel.publish doc | sse/wire-safe.spec.ts β the #1473 block: "gives a reconnecting client what a live one holds after a publish", "lets a later domain state overwrite an overlay key", "keeps overlay keys across several commits", "does not resurrect an overlay key the overlay itself cleared", "carries nothing when no overlay ever ran", "keeps the marker off the wire" (#1473) |
A Set survives the wire as an array, on both the live and reconnect paths. PresenceTracker.online() returns a Set and JSON.stringify encodes one as {}, so the documented presence recipe shipped an empty object β and then froze, since a client holding {} treats every later empty patch as a no-op. publish()/overlay() normalize before the cache write, so a reseed and a live frame agree. Map is deliberately untouched (no unambiguous encoding) | real-time.md Β§ Presence; BroadcastChannel.overlay doc | sse/wire-safe.spec.ts β "delivers a Set-valued overlay as an array to a live client", "gives a reconnecting client the same value as a live one", "normalizes a Set nested inside a patch", "leaves a Map alone" (#1472) |
maxPendingPerConnection is validated like its siblings, and every out-of-range SSE knob throws ZodError. Default 256, range [1, 100_000], rejecting non-finite and fractional values β a NaN there makes pending.length >= NaN always false and the backlog unbounded again | SseOptions.maxPendingPerConnection doc; auto-generated-api.md Β§ SSE knobs | api/sse-wiring.spec.ts β "applies the maxPendingPerConnection default", "preserves a caller-supplied maxPendingPerConnection", "rejects maxPendingPerConnection below the floor / above the ceiling", "rejects a non-finite or fractional maxPendingPerConnection", "throws ZodError, the documented type, for every out-of-range knob" (#1474) |
A fresh client is never stale. An empty frame reaching a client with no baseline reports behind (refetch), not stale (no-op) β stale would strand it | apply-patch.ts doc; real-time.md Β§ Classification | sse/apply-patch.spec.ts β "reports behind, not stale, for an empty frame with no baseline", "still reports stale for an empty frame when a baseline exists" (#1474) |
A delete survives the wire. @rotorsoft/act-patch treats undefined and null as the same delete signal, and every SSE transport serializes frames as JSON β which drops undefined-valued keys. publish() / overlay() normalize undefined to null (recursively, plain objects only) when building the frame, so a reducer that clears a field the idiomatic way reaches a live client. Server-side cached state is untouched: a reconnect reseed has the key absent, not null | real-time.md Β§ Clearing a field; BroadcastChannel.publish doc | sse/wire-safe.spec.ts β "delivers an undefined-valued delete to a live client", "treats a null-valued delete identically", "clears a nested field without disturbing its siblings", "delivers an undefined-valued delete through overlay() too", "does not rewrite the internals of a non-plain object", "keeps the server-side cached state untouched by normalization" (#1471) |
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 |
|---|---|---|
A fold projection folds forward only across a contiguous version step, and a rebuild does not trust its cache. The per-stream fold cache is per-Act while the subscription watermark is shared, so a worker can be handed an event whose predecessors a sibling drained; a gap re-loads head state from the store instead of folding onto stale state. app.reset() clears every fold cache, so a replay re-derives rows rather than re-flushing process-local state | projections-to-database.md Β§ two workers racing on the same stream; IAct.reset doc | fold-contiguity.spec.ts β "does not fold onto stale state when a sibling worker drained the gap", "still folds incrementally for a single worker", "keeps each stream's frontier independent", "re-derives the row from the store instead of re-flushing the cache", "reset clears the cache for every fold projection" (#1465, #1466) |
| A target is served by exactly one thing, and re-registering one projection is a no-op. A batch handler, a state-projection fold, and an ordinary reaction are three claimants on a target; any two of them on one target is a build error, including a reaction pointed at a target a projection already serves (which would never run, while the fold cold-loaded the foreign stream as its own state). Registering the same projection object twice is idempotent on every path | act-builder duplicate-target errors; merge.ts identity dedup | target-ownership.spec.ts β the #1467 block (fold target, batch target, either registration order, a projection's own reactions allowed, unclaimed target allowed) and the #1469 block (same fold/batch projection twice accepted; two different projections, and fold-vs-batch, still rejected) (#1467, #1469) |
A subscription row can say whether it has work, and claim believes it over the event log. SubscribeInput.correlated_at is the highest event id observed to resolve to that target; a marked stream is claimable exactly while at < correlated_at, answered from the row with no reference to the log. The mark never regresses β GREATEST/MAX for every value including zero and negatives β and an omitted mark leaves the stored value untouched. A row with no mark is not claimable, definitionally (#1488) β NULL means correlate has not spoken for it, and claim reads no event log to find out otherwise; rows predating the column are marked at the log's head by seed(), an over-estimate the first drain corrects. reset rewinds the watermark and keeps the mark, so a rebuild stays claimable; unblock, defer and prioritize leave it alone too | SubscribeInput.correlated_at doc; writing-a-store.md Β§ The work set; RFC 1486 | store-tck.ts β "subscription work set" block on all three adapters: "claims a marked stream without probing the event log", "never regresses the mark, for every value on the number line", "retires a stream when ack catches the watermark up to the mark", "keeps the mark across reset, so a rebuild is claimable", "keeps the mark across unblock, defer, and prioritize", "never claims an unmarked stream, however much work the log holds" (#1485, #1488) |
The correlate checkpoint is durable, and free. How far correlate has read the log survives a restart. subscribe both returns it and advances it via correlated_at, so it costs no round trip of its own β correlate already calls subscribe with each scan's discovered targets β and its only writer is the component that knows the value. It persists only when greater than the stored value, so a lagging worker cannot rewind it and re-sending is a no-op; omitting the argument leaves it untouched. It lives in its own single-row relation per scope, so prioritize / reset / unblock / query_streams / blocked_streams never count it. Not derivable from processing progress: once a target is subscribed the drain reads the log directly, so acked watermarks run ahead of the read cursor and would skip discovery | Store.subscribe doc (correlated_at, both directions); RFC 1484 | store-tck.ts β "correlate checkpoint" block on all three adapters: "round-trips an advance through subscribe", "persists only when greater than the stored value", "is left untouched when subscribe omits it", "advances in the same call that registers discovered targets", "is invisible to every stream-scoped surface"; correlate-checkpoint.spec.ts β "resumes where the previous process stopped, re-reading nothing" and "picks up events committed after the checkpoint, and only those" (both count the events the scan reads β a restart converges on the same subscribed and last_id either way, so only the re-scanned work is observable), "advances as soon as a scan registers what it found", "never regresses on a later, lower value", "persists how far the scan read, because it scans now" for a static-only app; act-pg scope-isolation.spec.ts β two stores in one schema keep separate checkpoints (#1484) |
Correlate is the universal producer of the work mark. Every target an event resolves to is subscribed with correlated_at = that event's id, for both resolver kinds β a static target is subscribed once at init, but a target correlate never marks is a target claim never serves, so the scan runs for every app rather than early-returning on static-only ones. The mark is an assertion about the log: only an event inside the target's own source filter raises it, and a scan that resolves a target it cannot mark leaves the row alone. A resolution that does not beat the target's last-subscribed priority floor re-sends the row's own priority and lane, so a mark never re-lanes or downgrades what it rides along with. A static target's floor is not merely high, it is durable for the process: it lives outside the maxSubscribedStreams LRU, so no amount of dynamic-target churn can evict it and hand the next dynamic resolution a blank slate to re-lane the stream with (#1582) | correlation-and-drain.md Β§ Correlation; concurrency-model.md Β§ Stream leasing; configuration.md Β§ maxSubscribedStreams; RFC 1449 step 4 | correlate-work-mark.spec.ts β "marks the target with the id of the event that resolved to it", "raises the mark as later events arrive", "skips an event outside a pattern source, and marks one inside it", "skips an event outside a literal source", "marks nothing for an event its resolver declines", "a later lower-priority scan neither lowers the priority nor re-lanes", "a dynamic resolution never re-opens a static target's priority"; correlate-lane.spec.ts β describe("static targets survive LRU eviction (#1582)"): "eviction does not let a dynamic resolution re-lane a static target", "eviction does not starve the static target's stream under onlyLanes", "a static target keeps its lane however many dynamic targets churn", with the no-eviction and restart controls (#1487, #1582) |
The close-cycle safety probe reads pending work, not watermark lag β over events correlate has actually read. A subscription's watermark advances only over events that resolve to it, so a reader of a subset of a state's events sits permanently below the stream's head with nothing pending; the Phase-2 probe therefore asks the row claim's own question (at < correlated_at), and an unmarked row is not pending because claim will never serve it either (#1488). Without it, close would skip every such stream forever. The probe first advances the read cursor to the head of the streams being closed, bounded: an uncorrelated tail raises no marks, so every reader β including one whose subscription that tail would create β would answer "caught up" and the truncate would delete work no reaction has run. Anything still above the cursor afterwards lands in skipped | close-cycle.md Β§ Phase 2; extension-points.md Β§ query_streams | correlate-work-mark.spec.ts β "closes a stream whose head has no reaction to consume it" and "holds back a stream whose tail correlate has not read yet"; store-tck.ts β "surfaces the mark on query_streams positions" (all three adapters); recurring-reminder.spec.ts β "autocloses (reaps) the timer stream once the loop ends" (#1487) |
The sse subpath is importable from a browser. Its client half (applyPatchMessage, patch, the wire types) depends only on the zero-dependency @rotorsoft/act-patch, and the server half reaches the framework through a dynamic import β so no bundler following the subpath's static graph pulls in @rotorsoft/act, which constructs an AsyncLocalStorage at module scope and throws when a browser evaluates it. A static import there is a shipped outage for every client app on the documented import path | real-time.md Β§ applyPatchMessage; broadcast.ts default_subscriber_error doc | scripts/check-browser-safe.mjs (CI: pnpm check:browser-safe) β walks the built subpath's static import graph, following @rotorsoft/* into their builds, and fails on any Node builtin (#1423) |
A windowed close caps its prune at pending work, not watermark lag. A consumer with unconsumed work (at < correlated_at) caps the prune at its watermark β the fail-safe property windowed close has always had. A consumer that has consumed everything marked for it caps at the correlate checkpoint instead, since its watermark reflects which event types it handles rather than how far it is safe to prune; capping at infinity would be wrong too, because events above the checkpoint are unresolved and a mark may still be coming. An unmarked row keeps the conservative watermark cap. Without this a retention window on the ordinary "reaction handles a subset of a state's events" shape prunes almost nothing, permanently and silently | close-cycle.md Β§ windowed branch | close-windowed.spec.ts β describe("prune cap reads pending work, not watermark lag (#1520)"): "prunes a stream whose trailing events resolve to no target", "still caps at a consumer that genuinely has unconsumed work" (#1520) |
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" |
An Act is releasable. Registering with the process-wide dispose registry does not pin the instance: an Act that becomes unreachable is collected whether or not shutdown() ran, and its registry entry is skipped at teardown and pruned on the next registration. A live Act is still reached by dispose()(). Apps that mint short-lived Acts β one per tenant, per request, per test β do not leak one apiece | Act.shutdown doc; dispose doc | disposers.spec.ts β "skips a weak disposer whose target is gone", "prunes dead weak entries when anything else registers", "does not keep a shut-down Act reachable through the registry", and the real-GC case "releases an Act once nothing else references it" (enabled by execArgv: ["--expose-gc"] in vite.config.ts; reports 5 of 5 retained before the fix) (#1441) |
shutdown() waits for the in-flight settle cycle too, not just drain cycles. SettleLoop.stop() cancels scheduling only, so a cycle already inside its correlate β drain loop keeps running; teardown awaits it under the same grace budget, so no store operation is issued after shutdown() resolves | Act.shutdown doc; SettleLoop.inflight doc | shutdown-settle.spec.ts β "issues no store operations after teardown resolves", "does not return while a settle is parked mid-correlate", "proceeds when the grace budget expires on a stuck settle", "graceMs: 0 keeps the pre-#1442 immediate return" (#1468) |
shutdown() waits for drain cycles already in flight, bounded. Scheduling stops first, then in-flight cycles are awaited up to a grace budget so a parked handler reaches its ack and releases its stream's lease; lifecycle listeners are removed after that wait, so an acked subscriber still sees work completed during the window. The budget defaults to the largest leaseMillis among the lanes actually running a cycle (10s fallback, capped at 30s), graceMs: 0 restores the pre-#1442 immediate return, and an exhausted budget proceeds anyway β one stuck handler never hangs a deploy | Act.shutdown doc; ShutdownOptions.graceMs doc; production-checklist.md Β§ 6 | shutdown-grace.spec.ts β "awaits an in-flight cycle so the handler finishes and the lease is released", "still observes acked listeners for work that completes during the window", "graceMs: 0 keeps the old behavior", "proceeds when the budget is exhausted", and the derivation trio (lane lease / 10s fallback / 30s cap) (#1442) |
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 per listener, inside Act.emit: the operation still succeeds, every other listener on that event still fires, and the caller of do() / forget() / close() never sees a rejection for work that already landed. Containment covers the emits only β a StoreError raised inside on_close's close machinery still reaches the circuit breaker, and a real ConcurrencyError still reaches the caller | observability.md Β§ Two things to know; act.ts emit | emit-containment.spec.ts β "a throwing committed listener does not fail do() after the commit lands", "β¦forgotten listenerβ¦", "β¦closed listenerβ¦", "a throwing listener does not suppress the listeners after it", "control β a real failure inside do() still reaches the caller"; non-retryable.spec.ts β "keeps emitting blocked when an acked listener throws"; autoclose-reaction.spec.ts β "surfaces a store failure inside the close as an error event" (#1373, #1388, #1437) |
The settled emit is contained too, and specifically must not reach the circuit breaker: it is the one non-store statement inside the settle loop's breaker-guarded block, so an uncontained throw was recorded as a store failure β spurious error events, and at failureThreshold: 1 an OPEN breaker that stalled the reaction pipeline for a full cooldown. A real store failure in that block still reaches the breaker | observability.md Β§ Two things to know; settle.ts | settle-wakeup.spec.ts β "does not record the listener's throw as a store failure", "does not open the breaker, so the pipeline keeps draining", "control β a non-throwing settled listener still fires and stays clean", "control β a real store failure still reaches the breaker" (#1436) |
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.