Behavior-contract checklist
A documented runtime promise holds only while a test fails when it breaks. Each row below is one such promise: a one-line claim, the doc section that owns it, and the tests that pin it. The explanation lives in the linked section, not here.
Rules for rows:
- Only documented runtime promises. A behavior a caller or adapter author relies on, stated in the docs or a public doc-comment. Not every sentence, and not internal mechanics.
- Exact test names. Cite
file.spec.tsโ "the exact test name". CI (pnpm check:contracts) fails when a cited test does not exist in the cited file, so a rename has to update its row. - Short. One line of claim, no history or ticket numbers. When the promise needs explaining, explain it in the owning doc.
Store claims are pinned in the TCK (libs/act-tck/src/store-tck.ts), so
they run on InMemory, Postgres and SQLite at once.
Cache and snapshotsโ
| Claim | Source | Backing test |
|---|---|---|
seed() upgrades any released schema additively and losslessly, and is safe on every boot from every worker | Writing a store | act-pg + act-sqlite seed-upgrade.spec.ts โ "upgrades the oldest supported shape losslessly and idempotently", "serializes concurrent cold boots on an empty schema" |
Postgres streams.retry is an integer, so an unbounded retry count never overflows claim | Writing a store | 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 losslessly and idempotently" |
with_snaps resumes each stream from its latest snapshot, in both scan directions; after overrides it | Cache and snapshots | store-tck.ts โ "with_snaps resumes from the latest snapshot per stream", "with_snaps applies the resume floor on a backward scan too" |
A cold start folds the snapshot into state and resets patches to 0 | Cache and snapshots | event-sourcing.spec.ts โ "should load from a snapshot event on cold start" |
| A failed snapshot write does not propagate | Cache and snapshots | event-sourcing.spec.ts โ "should not throw on snap error and warn with stream, reason, and hint" |
| A failed snapshot write never fails the action and warns with the stream, reason and replay hint | Cache and snapshots | event-sourcing.spec.ts โ "should complete the action and warn when the snapshot write fails" |
A passing snap predicate commits a __snapshot__ event | Cache and snapshots | event-sourcing.spec.ts โ "should persist snapshot event on snap success" |
| A cache miss populates the cache | Cache and snapshots | cache.spec.ts โ "cache miss populates cache on load" |
A warm load resumes with with_snaps, so a snapshot above a stale checkpoint is still folded | Cache and snapshots | close-windowed.spec.ts โ "warm reload after a prune folds through the rebaselining snapshot"; optimizations.spec.ts โ "a cache-warm load queries with after and replays zero rows" |
The cache is invalidated only on ConcurrencyError | Cache and snapshots | cache.spec.ts โ "cache invalidated on ConcurrencyError" |
| A failing cache never changes which error the caller sees, or whether the action retries | Cache and snapshots | 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 โ "logs and swallows a failing invalidate on the gapped path" |
| Invariant and validation failures leave the warm cache untouched | Cache and snapshots | cache.spec.ts โ "invariant failure leaves the warm cache untouched (no commit)", "validation failure leaves the warm cache untouched (no commit)" |
A cache.set rejection warns and does not fail the action | Cache and snapshots | cache.spec.ts โ "cache.set rejection warns but does not fail the action" |
A time-travel (asOf) load skips the cache and ignores snapshots outside its cutoff, for every bound | Cache and snapshots | time-travel.spec.ts โ "should not read from cache for time-travel loads", "should not write to cache for time-travel loads", "should not use snapshots for time-travel loads", "time-travel ignores snapshots outside the cutoff (RFC 1274)" |
query_stats head and tail never carry pii | Store.query_stats doc | store-tck.ts โ "query_stats head/tail never carry pii โ introspection surface is pii-safe"; act-sqlite pii-encryption.spec.ts โ "never carries pii on query_stats head/tail โ the operator-introspection surface is pii-safe" |
Reactions, drain, and errorsโ
| Claim | Source | Backing test |
|---|---|---|
| On Postgres, event ids become visible in id order, so no commit surfaces below an acked watermark | Concurrency model | act-pg commit-visibility.spec.ts โ "a commit waits for an in-flight append transaction to finish", "truncate seeds take the same visibility lock" |
A reaction's app.do gets reactingTo injected unless the handler passes one | State management | reacting-to.spec.ts โ "should auto-inject reactingTo when handler omits it", "should respect explicit reactingTo when provided" |
The injection follows the reaction through a captured app, and not outside one | State management | reacting-to.spec.ts โ "should auto-inject when the handler dispatches through a captured app", "should mint a fresh correlation for dispatches outside any reaction"; reacting-to-singleton.spec.ts |
A live reaction skips the inferred concurrency guard but honors an explicit expectedVersion | Concurrency model | reacting-to.spec.ts โ "should honor an explicit expectedVersion inside a reaction", "should restore the inferred guard for work that outlived the handler", "should NOT treat work the handler did not await as a reaction" |
A z.date() field reads back as a Date; an ISO-shaped z.string() stays a string | Extension points | libs/act/test/schema-dates.spec.ts; libs/act-pg/test/schema-dates.spec.ts |
NonRetryableError blocks on the first attempt when blockOnError is true | Error handling | non-retryable.spec.ts โ "blocks on first attempt when blockOnError is true (default)" |
NonRetryableError does not override blockOnError: false | Error handling | non-retryable.spec.ts โ "ignores NonRetryableError when blockOnError is false" |
| A worker whose lease was taken mid-handler reports the discarded round | Concurrency model | non-retryable.spec.ts โ "reports the round of work the stolen lease discarded"; store-tck.ts โ "drops the ack of a holder whose lease was taken, and accrues retry per claim" |
A stream reaching claim with retry > maxRetries blocks before dispatch; retry === maxRetries still runs | Concurrency model | 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" |
| Backoff holds the retry until its window passes | Error handling | backoff.spec.ts โ "defers retry until backoff window elapses (persisted schedule)" |
| A successful ack clears the backoff | Error handling | backoff.spec.ts โ "clears backoff entry on successful ack" |
| Backoff still blocks once retries run out | Error handling | backoff.spec.ts โ "preserves blocking behavior when retries are exhausted" |
A failing stream runs maxRetries + 1 times before it blocks | Error handling | backoff.spec.ts โ "spends the whole retry budget and blocks", "preserves blocking behavior when retries are exhausted" |
compute_backoff_delay applies strategy, clamp and jitter | Error handling | backoff.spec.ts โ compute_backoff_delay unit block |
The backoff window is honored precisely and does not depend on leaseMillis | Error handling | backoff.spec.ts โ "backoff window is honored precisely via persisted deferred_at, not the lease duration" |
| A backoff keeps the retry count through the window; an explicit defer does not count as a failure | Error handling | store-tck.ts โ "persists the lease's retry on a backoff-style due lease" / "holds the watermark and resets retry on an explicit-defer due lease (retry: -1)" |
| A partial-progress defer keeps the handled prefix and holds only the failing tail | Error handling | store-tck.ts โ "advances the watermark to at while persisting the schedule on a partial-progress due lease"; backoff.spec.ts โ "advances the watermark past the succeeded prefix AND persists the window on partial progress" |
| A batch that makes progress and then blocks both advances the watermark and blocks, in one cycle | Error handling | non-retryable.spec.ts โ "blocks in the SAME cycle after partial progress โ ack must not drop the block" |
| The settle loop closes an OPEN breaker only when its correlate actually read the store | Error handling | settle-wakeup.spec.ts โ "leaves the breaker OPEN when correlate was disarmed and read nothing", "closes the breaker when the scan returns (control)" |
| A failure with no backoff is retried after a fixed 10-second default, whatever the drain's lease | Error handling | backoff.spec.ts โ "retries after the default backoff, whatever the drain's lease", "spends the whole retry budget and blocks" |
app.unblock resumes from the watermark; app.reset replays everything | Error handling | non-retryable.spec.ts โ "recovers via app.unblock without replaying history"; rebuild.spec.ts โ "should enable replay of projection after reset" |
correlate arms the lane that a newly subscribed stream lands on | Correlation and drain | correlate.spec.ts โ "revives a lane that disarmed before the subscription landed" |
restore and scan carry __snapshot__ events, and drop_snapshots drops them | Event sourcing | restore.spec.ts โ "carries a snapshot through a store-to-store restore" / "does not lose a stream whose history was pruned by a windowed close" / "drop_snapshots actually drops them from a Store source" |
| Correlate stops scanning only on a short page, not on a full page that resolved nothing | Correlation and drain | correlate.spec.ts โ "keeps scanning past a FULL window that resolved no target" / "a full inert window does not make settle report a false catch-up" / "still parks when the window came back short" / "parks after an UNBOUNDED scan that resolved no target" |
Each start_correlations tick correlates and drains, so polling delivers another process's commits | Cross-process reactions | correlate.spec.ts โ "polling discovers and drains a commit this process never saw" |
| Correlate's cold-start checkpoint re-scans events committed but not correlated before a crash | Correlation and drain | correlate.spec.ts โ "still discovers a dynamic target committed-but-not-correlated before restart" |
settle() never drops a wake-up requested while a cycle runs | Correlation and drain | settle-wakeup.spec.ts โ "runs a cycle for a schedule() that fires while a cycle is in flight" |
| A state projection flushes before it acks, so a failed flush holds the watermark | Projections to a database | state-projection.spec.ts โ "holds the watermark when flush fails and converges on retry" |
| A state projection flushes an evicted stream before dropping it | Projections to a database | state-projection.spec.ts โ "flushes the evictee before dropping it under maxCachedStates pressure" |
| A state projection rebuild writes one row per stream per flush | Projections to a database | state-projection.spec.ts โ "rebuilds in O(streams) upserts, not O(events)" |
| A state projection's first load resumes from the latest snapshot | Projections to a database | state-projection.spec.ts โ "resumes the first-sight load from the latest snapshot" |
A cache entry's state is always the fold of events up to its event_id | Cache and snapshots | cache-frontier.spec.ts โ "never leaves a stale-at-head cache entry after guardless commits" |
A state projection's first fold pairs state with the head id from the same load() | Projections to a database | fold-frontier-toctou.spec.ts โ "does not skip events when the cache frontier moves during first-sight load" |
A failing cache.set inside load() is logged, not thrown | Cache and snapshots | load-cache-error.spec.ts โ "returns the correct state and swallows a failing cache.set" |
A caller-supplied expectedVersion conflict is rethrown at once, without retry | Error handling | action-retry.spec.ts โ "rethrows immediately without consuming the retry budget or sleeping" |
| The concurrency guard holds on a warm cache hit | Concurrency model | cache-frontier.spec.ts โ "keeps the optimistic guard on warm cache hits" |
| A load between a close's tombstone and its truncate does not cache, so the next action sees the tombstone | Close cycle | event-sourcing.spec.ts โ "a cold load during the guard window keeps the tombstone check live" |
| A snapshot is written only from a contiguous fold and is cached before the action returns | Cache and snapshots | cache.spec.ts โ "has the snapshot in the log by the time the action returns" |
| Lanes drain in parallel, so a stalled slow lane does not block a fast one | Configuration | lanes.spec.ts โ "drains lanes in parallel โ a stalled slow handler does not block the fast lane" |
When onlyLanes leaves a declared lane undrained, the build logs one advisory naming it | Configuration | lanes.spec.ts โ "logs an orphaned-lane advisory when onlyLanes excludes a declared lane", "does not log the orphaned-lane advisory when onlyLanes covers every declared lane", "does not log the orphaned-lane advisory when onlyLanes is unset" |
| Each drain controller claims only its own lane | Configuration | 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" |
Workers on different onlyLanes shards each correlate; workers on the same shard share one lease | Configuration | lanes.spec.ts โ "lets a worker on another shard correlate while the first holds its lease", "keeps one lease for workers on the same shard" |
| A drain that claims nothing still keeps the wake for every parked stream | State management | defer-outcome.spec.ts โ "keeps the wake for other parked streams when a woken drain claims nothing" |
A defer redelivers its whole reaction group, unless isolated with .to(...) | State management | defer-outcome.spec.ts โ "re-runs a sibling that shares the deferred reaction's target, but not an isolated one" |
| A deferred stream is due by the claiming worker's clock, on every adapter | Writing a store | store-tck.ts โ "defer", "judges deferred_at by the claiming worker's clock"; act-pg defer-skew.spec.ts |
After restore, restored events and new commits reach their reactions | Writing a store | store-tck.ts โ "resets every correlate checkpoint, since the restored log is renumbered"; restore.spec.ts โ "delivers restored events and new commits to their reactions" |
| A batch projection handler sees each event once, in order | Projection.batchHandler doc | projection.spec.ts โ "hands the batch handler each event once, however many reactions it has" |
A cold start re-arms each still-future deferred_at | Close cycle | defer-durability.spec.ts โ "re-seeds the DeferTimer from persisted deferred_at at cold start", "runs the drain at the persisted due-time with no intervening commit" |
| A deferred or backed-off reaction fires at its due-time on an idle app, with no further calls | Close cycle | defer-outcome.spec.ts โ "on the default lane", "control โ on a lane that polls with cycleMs", "keeps the wake for other parked streams when a woken drain claims nothing", "keeps the wake for parked streams when it fires while the breaker is open", "a wake that lands while another drain is running", "still runs the stream that came due"; backoff.spec.ts โ "clears backoff entry on successful ack" |
A defer holds the event pending without bumping retry, then redelivers when due | Close cycle | defer-outcome.spec.ts โ "holds pending until the due-time, then redelivers and acks" |
| Registering the same projection object twice registers it once | State management | target-ownership.spec.ts โ "runs its handler once per event, not twice", "control โ two DIFFERENT handlers sharing a name still dedupe via _p" |
| A projection target has one batch handler or one state projection, never both | State management | target-ownership.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" |
The registry is complete and frozen at build() | Close cycle | registry-freeze.spec.ts โ "freezes the registry containers at build", "synthesizes the autoclose reaction at build, not construction" |
.autocloses closes on a live-head match, skips a reopened stream, waits out after, and defers outside its window | Close policies | 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" |
An unmet .autocloses predicate never defers to a past due-time | Close policies | autoclose-reaction.spec.ts โ "does not defer to a PAST due-time when after has elapsed but the predicate is unmet" |
.autocloses decides on the domain head, so a trailing snapshot does not hide the terminal event | Close cycle | autoclose-reaction.spec.ts โ "closes on the terminal event even when a snapshot trails it", "counts only domain events toward reaches when snapshots trail" |
| On a DST spring-forward day, the autoclose window still opens at the skipped hour's replacement | Close policies | autoclose-builder.spec.ts โ "opens ~1h out on a DST spring-forward day whose start hour is in the gap", "in_autoclose_window is true at the DST gap replacement instant" |
.defer(when) holds a reaction until due, then runs it once | State management | 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" |
The .defer function form can read the event to pick the schedule | State management | declarative-defer.spec.ts โ "function form reads the payload to choose the schedule, then runs" |
Drain finalization is atomic: schedules ride the same ack as the watermarks | Close cycle | 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 โ "ack finalize (due-marked leases)" |
| A failed finalize leaves the drain armed, so the defer is persisted on the retry | Close cycle | defer-durability.spec.ts โ "keeps the drain armed while finalization is unhealed" |
A malformed literal .defer(...) throws ZodError at build | State management | declarative-defer.spec.ts โ "rejects a bad literal schedule at build time" |
throw new DeferSignal(when) holds the stream until due, then redelivers | State management | 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)" |
Store contractโ
Pinned in the TCK unless the citation names an adapter.
| Claim | Source | Backing test |
|---|---|---|
subscribe is idempotent | Store.subscribe doc | store-tck.ts โ "subscribes new streams and is idempotent on repeat" |
A subscribe batch carries at most one entry per stream | Store.subscribe doc | build-classify.spec.ts โ "collapses same-target reactions from different sources, keeping the max priority" / "keeps the max priority regardless of declaration order"; act-pg priority.spec.ts โ "restores the declared maximum priority on boot" |
subscribe keeps the maximum priority, so a boot restores a prioritize() downgrade | Store.subscribe doc | store-tck.ts โ "keeps the maximum priority when a stream is re-subscribed", "keeps the maximum for negative and zero priorities too", "restores the declared priority on the next subscribe after a prioritize() downgrade" |
subscribe writes a lane only at or above the stored priority | Configuration | store-tck.ts โ "lanes", "keeps the lane when a lower-priority subscribe re-registers", "keeps the lane when a mark-only subscribe re-registers", "re-lanes when the subscribe outranks the stored priority", "re-lanes at equal priority, which is what keeps re-laning restart-driven"; correlate.spec.ts โ "dynamic targets survive a forgotten record" |
| A dynamic target's priority rises when a later scan resolves higher, and never drops | Priority lanes | correlate.spec.ts โ "cross-scan priority upgrade", "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 goes to default and is reported once | Configuration | 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" |
| Dynamic lane reports fire once per declaration, not per target | Configuration | dynamic-lane-guard.spec.ts โ "reporting once per declaration, not once per aggregate", "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" |
| Two dynamic lanes for one target at equal priority keep the first and report the conflict | Configuration | dynamic-lane-guard.spec.ts โ "CONTROL โ the static form is still rejected at build", "says so โ first-discovery-wins is not silent" |
| A dynamic target keeps its lane at equal priority after correlate forgets it, and the conflict is logged | Configuration | correlate.spec.ts โ "CONTROL โ no eviction: the target keeps its first lane and the conflict is logged", "a target's lane survives correlate forgetting it", "a fresh process keeps the lane the row holds", "a forgotten target still moves to a higher-priority resolution" |
| The dynamic lane conflict compares resolved names and fires only on a real tie | Configuration | dynamic-lane-guard.spec.ts โ "an omitted lane disagreeing with a declared one", "reports the same disagreement on the dynamic path", "reports it in the other discovery order too", "reports it across scans, where the held lane comes from the row", "reports a rerouted undeclared lane against a stream already laned", "CONTROL โ a first resolution onto a never-seen target is not a conflict", "CONTROL โ a higher-priority resolution outranks, it does not conflict" |
An omitted lane and lane: "default" agree at build | Configuration | lanes.spec.ts โ "accepts an explicit 'default' followed by an omitted lane", "accepts an omitted lane followed by an explicit 'default'", "builds the lane-conflict failure scenario with no cast anywhere", "still rejects a declared lane against an omitted one" |
A zero-length lease is granted and its ack still lands | Store.claim doc | store-tck.ts โ "records the lease holder for a zero-length lease, so its ack lands" |
limit: 0 on query_streams and query_stats returns nothing | QueryStreams.limit doc | store-tck.ts โ "limit: 0 returns no rows" |
prioritize sets priority directly, overriding subscribe's maximum | Priority lanes | store-tck.ts โ "sets priority directly, overriding subscribe's max() rule" |
unblock and reset return 0 for nothing to change | 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 keeps the watermark; reset rewinds it to -1 | Error handling | store-tck.ts โ "clears blocked flag and preserves the watermark", "rewinds a stream watermark to -1", "clears blocked status when resetting" |
A full truncate deletes the events and seeds a snapshot or tombstone | Writing a store | store-tck.ts โ truncate describe block |
A keep window advances on commits; a quiet stream is pruned with app.close([{ stream, before }]) | Close policies | autoclose-reaction.spec.ts โ "does not prune again while the stream stays idle", "an on-demand close prunes the idle stream the reaction left alone" |
A manual app.close archives only through its target's archive callback | Close policies | autoclose-reaction.spec.ts โ "an on-demand close archives only through the target's own archive, never the state's .archives" |
PostgresStore.dispose() resolves during a LISTEN reconnect and leaks nothing | PostgresStore.dispose doc | notify.resilience.spec.ts โ "PostgresStore disposal racing an in-flight re-LISTEN", "CONTROL โ with no open in flight, disposal clears the listen client", "releases the client an in-flight open was holding, instead of stranding it"; notify.spec.ts โ "resolves while a reconnect sits in the pool checkout" |
autocloseWindow is per Act and validated on every build() | Close policies | autoclose-reaction.spec.ts โ "the off-hours window is per-Act, not per-builder", "a second Act built with no window closes immediately", "a second Act built WITH a window still defers, whoever built first", "validates a window on every build, not only the first", "CONTROL โ the same invalid window throws on a first build too" |
A windowed truncate deletes only below the closest safe snapshot, and the stream stays live | Writing a store | store-tck.ts โ "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" |
A windowed truncate never prunes past its max_id cap | Close cycle | 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" |
| A windowed target with no qualifying snapshot is skipped | Close cycle | 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" |
forget_pii wipes every event's PII and is idempotent | Extension points | 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" |
| A generated action route parses its input once | Auto-generated API | act-http hono/index.spec.ts, trpc/index.spec.ts โ "action input is parsed once" |
withBroker checks each notification's shape and contains each handler | act-notify README | act-notify notify.spec.ts โ "delivery to notify handlers" |
A stalled notify broker never holds up commit or shutdown() | act-notify README | act-notify notify.spec.ts โ "a broker that stalls or fails never holds the orchestrator up" |
Every generated subscription runs the actor extractor before opening | Auto-generated API | act-http trpc/sse.spec.ts โ "CONTROL โ an accepting extractor still yields the cached state", "a denying extractor runs, and no state reaches the caller", "the denial is UNAUTHORIZED, not a framework 500", "the extractor runs before a connection slot is taken" |
Gated reads drop the pii sidecar, and reads with no actor default to redacted | Sensitive data | read-gate.spec.ts โ "the gated load event drops the pii sidecar โ plaintext never rides in event.pii" + the "query / query_array default-deny PII gate", "drain() default-deny PII gate" blocks |
| A projection fold strips PII the same way warm or cold | Sensitive data | fold-pii-path.spec.ts โ "cold-loaded and warm-folded rows of an identical history match" |
query_streams reports maxEventId as the highest committed id | Store.query_streams doc | store-tck.ts โ "maxEventId tracks the highest committed id" |
query_streams reports a future deferred_at | StreamPosition.deferred_at doc | store-tck.ts โ "defer", โ surfaces a future deferred_at on query_streams |
claim skips a stream until deferred_at; a defer does not bump retry; reset clears it | Writing a store | 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" |
| Stream filters share one portable grammar; anything richer matches exactly or throws | Extension points | 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" |
| Stream and source filters are case-sensitive on every adapter | Writing a store | store-tck.ts โ "stream filter grammar" describe block: "stream filters match case-sensitively (query)" |
names: [] matches no events; an omitted names matches all | Writing a store | store-tck.ts โ "query" describe block: "names:[] matches no events" |
before: 0 and after: 0 are honored as bounds | Writing a store | store-tck.ts โ "query" describe block: "after:0 and before:0 are honored as id bounds" |
A Date in data is stored and read back as its ISO string | Writing a store | store-tck.ts โ "round-trips a Date losslessly in pii, like data/meta"; csv.spec.ts โ "round-trips a Date in data through a store, not just CsvFile" |
A Date in pii round-trips the same way, encrypted or not | Writing a store | store-tck.ts โ pii_isolation: "round-trips a Date losslessly in pii, like data/meta" |
A (stream, version) collision maps to ConcurrencyError; other driver errors to StoreError | Writing a store | act-sqlite store.error.spec.ts โ "commit: maps a (stream,version) unique collision to ConcurrencyError" |
claim serves only streams with work past their watermark | Writing a store | store-tck.ts โ "does not claim a fresh subscription with no matching events", "claims a fresh subscription as soon as its first event is marked"; in-memory-store.spec.ts โ "does not lose event id 0 when a commit lands during a fresh stream's cycle" |
A literal source selects events by exact stream name | Store.subscribe doc | correlate.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" |
A pattern source matches as a regex, or is rejected at subscribe | Store.subscribe doc | correlate.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" |
| A lease timeout counts against the retry budget | Concurrency model | store-tck.ts โ "counts a timed-out lease reclaimed by another worker against the retry budget; ack resets it" |
| A stream is not blocked for a retry count the store ran up | Concurrency model | 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" |
| An unexpired lease is invisible to other claimers; an expired one goes to exactly one | Concurrency model | store-tck.ts โ concurrency : "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" |
A claim locks only its own candidates, so disjoint workers both get their slice | Concurrency model | act-pg contention.spec.ts โ "locks only the claimed candidates, not the whole eligible frontier" |
notify never delivers an instance's own commits | Cross-process reactions | store-tck.ts โ notify : "does not deliver an instance's own commits (self-filtering)" |
notify delivers one notification per commit, with the whole batch | Store.notify doc | store-tck.ts โ notify : "delivers one notification per commit transaction carrying the full event batch" |
| An adapter built from its defaults either refuses to construct or keeps what it commits | StoreTckOptions.default_factory doc | store-tck.ts โ "either refuses to construct or round-trips a commit" |
SqliteStore needs a url and maps every in-memory spelling to a shared cache | act-sqlite README | act-sqlite store.error.spec.ts โ "rejects a blank or non-string url: %o", "normalizes the in-memory url %s to shared cache" |
PostgresStore notifyโ
The portable notify contract is in the TCK's notify suite. These rows are the PostgreSQL plumbing it can't express: the LISTEN reconnect and the 8000-byte payload cap.
| Claim | Source | Backing test |
|---|---|---|
| A commit wakes other instances' listeners, never its own | Cross-process reactions | store-tck.ts notify capability โ "does not deliver an instance's own commits (self-filtering)" |
| An oversize NOTIFY payload is skipped and the commit still succeeds | Cross-process reactions | notify.contract.spec.ts โ "oversize notify payload skips the NOTIFY โ the commit succeeds and events stay discoverable via the poll path" |
| A LISTEN connection loss cannot crash the process, and the listener reconnects | Cross-process reactions | act-pg notify.resilience.spec.ts โ "attaches an error listener to the LISTEN client so a connection blip cannot crash the process" |
| Disposal cancels a pending reconnect | Cross-process reactions | notify.resilience.spec.ts โ "dispose during a pending reconnect cancels it โ no client is checked out after teardown" |
| A second socket error during reconnect is swallowed, and reconnects never overlap | Cross-process reactions | notify.resilience.spec.ts โ "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 (no leak, no double-subscribe)", "a re-entrant reconnect cancels the pending timer instead of leaking a second one (no double-LISTEN)" |
Receiver idempotencyโ
The receiver-side IdempotencyStore is two-phase: claim reserves a key, then commit keeps it or release drops it, so a failed handler never turns the sender's retry into a silent success.
| Claim | Source | Backing test |
|---|---|---|
claim is tentative; commit keeps the key, release drops it, and committed keys expire | External integration | act-ops in-memory.spec.ts โ "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 โ a committed claim stays claimed", "committed entries still expire after ttlMs" |
receiver releases on a throw, commits on success, and answers an in-flight duplicate 503 | External integration | 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)", "answers a duplicate that arrives while the handler is in flight with a retryable 503", "does not lose a delivery when the original fails after a duplicate arrived mid-flight", "tracks in-flight keys through a store whose claim is async" |
| Hono and tRPC adapters commit or release from the outcome; Express and Fastify let the handler | External integration | act-http receiver/{hono,trpc,express,fastify}/index.spec.ts; receiver/finalize.spec.ts |
| The generated API answers an in-flight duplicate with a retryable error | Auto-generated API | act-http hono/index.spec.ts โ "a duplicate of a still-running request gets a retryable 503, and is not lost when the original fails"; receiver/start.spec.ts โ "answers 503 even when an async store replies to the duplicate before the original"; api/in-flight.spec.ts |
withIdempotency commits on success and releases on failure | Auto-generated API | act-http api/idempotency.spec.ts โ "commits the key after the handler succeeds", "releases the key and propagates handler rejections after a fresh claim" |
minSafeTtl throws for a sender that retries forever | External integration | act-ops min-safe-ttl.spec.ts โ "unbounded sender (maxRetries: Infinity)"; in-memory.spec.ts โ "refuses an unbounded retryProfile with no explicit ttlMs", "accepts an unbounded retryProfile when ttlMs sizes the window" |
SSE framesโ
| Claim | Source | Backing test |
|---|---|---|
| Overlay keys survive a commit, so a reconnecting client matches a live one | Real-time | sse/wire-safe.spec.ts โ "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" |
A Set reaches the client as an array, live and on reconnect | Real-time | act-http 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", "gives a reconnecting client the same Set-valued field a live one holds", "normalizes a Set nested inside the published state" |
Every out-of-range SSE option throws ZodError | Auto-generated API | api/sse-wiring.spec.ts โ "applies the maxPendingPerConnection default", "preserves a caller-supplied maxPendingPerConnection", "rejects maxPendingPerConnection below the floor", "rejects maxPendingPerConnection above the ceiling", "rejects a non-finite or fractional maxPendingPerConnection", "throws ZodError, the documented type, for every out-of-range knob" |
An empty frame to a client with no baseline reports behind | Real-time | 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" |
| A cleared field reaches the client as a delete | Real-time | 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" |
SSE subscriptionsโ
| Claim | Source | Backing test |
|---|---|---|
A connection's pending backlog keeps at most maxPendingPerConnection frames | Auto-generated API | act-http api/sse-wiring.spec.ts โ "bounds the per-connection pending buffer, dropping the oldest when a slow consumer stalls" |
| A subscription that never got a slot releases none | Auto-generated API | 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" |
An _overlay frame at the current version merges onto a caught-up client | Real-time | 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" |
openapi() emits an open schema for a field with no JSON Schema form | Auto-generated API | act-http openapi/index.spec.ts |
Orchestrator and buildersโ
| Claim | Source | Backing test |
|---|---|---|
A fold projection reloads on a version gap, and reset clears every fold cache | Projections to a database | fold-contiguity.spec.ts โ "does not fold onto stale state when a sibling worker drained the gap", "still folds incrementally for a single worker โ no reload per event", "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, not just one" |
| A target has exactly one claimant, and re-registering a projection is a no-op | State management | target-ownership.spec.ts โ "a reaction may not target a projection's target", "a per-event projection may not share a served target", "re-registering one projection is idempotent" |
claim trusts a row's work mark (correlated_at), which never moves back | Writing a store | store-tck.ts โ "subscription work set", "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" |
| The correlate checkpoint is durable and only moves forward | Writing a store | store-tck.ts โ "correlate checkpoint", "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.spec.ts โ "resumes where the previous process stopped, re-reading nothing", "picks up events committed after the checkpoint, and only those", "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"; act-pg scope-isolation.spec.ts |
| Correlate marks the work for every target, static and dynamic | Correlation and drain | correlate.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", "static targets survive LRU eviction" |
| The close safety probe reads pending work over events correlate has read | Close cycle | correlate.spec.ts โ "closes a stream whose head has no reaction to consume it", "holds back a stream whose tail correlate has not read yet"; store-tck.ts โ "surfaces the mark on query_streams positions"; recurring-reminder.spec.ts โ "autocloses (reaps) the timer stream once the loop ends" |
The sse subpath imports in a browser | Real-time | scripts/check-browser-safe.mjs โ walks the built subpath's static import graph, following @rotorsoft/* into their builds, and fails on any Node builtin |
| A windowed close caps its prune at pending work, not watermark lag | Close cycle | close-windowed.spec.ts โ "prune cap reads pending work, not watermark lag", "prunes a stream whose trailing events resolve to no target", "still caps at a consumer that genuinely has unconsumed work" |
app.reset() arms the drain, so a settled app still replays | Event sourcing | rebuild.spec.ts โ "should replay events when drain runs after reset on a settled app" |
An unreachable Act is collected even without shutdown() | 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", "releases an Act once nothing else references it (needs --expose-gc)" |
shutdown() waits for an in-flight settle cycle too | Production checklist | shutdown.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 returns immediately", "the DEFAULT budget waits for a settle that is the only thing in flight", "treats a lane's leaseMillis: 0 as no pinned lease, not a zero budget", "does not abandon a parked handler on a zero-lease lane" |
shutdown() hands back the correlation lease after the grace wait | Production checklist | shutdown.spec.ts โ "hands the correlation lease back last, so nothing re-takes it" |
shutdown() waits for in-flight drains, up to a grace budget | Production checklist | shutdown.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 โ returns without waiting", "proceeds when the budget is exhausted โ one stuck handler cannot hang teardown", and the derivation trio |
reset rewinds watermarks to -1 and unblocks | 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 | deprecation.spec.ts โ "throws when an action statically emits a deprecated event" |
| Two event names with the same version number throw at build | Event schema evolution | deprecation.spec.ts โ "rejects a leading-zero version colliding with its canonical form regardless of order" |
.on and state(...) throw on zero or several keys | State management | 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 partials merge z.object state schemas, or the build throws | State management | partial-state.spec.ts โ "should throw on overlapping keys with different base types", "partials whose state schema is not a plain z.object"; builder.spec.ts โ "should refuse to merge partials whose state schemas are not ZodObjects" |
| Overlapping full closes of one stream truncate once and never delete a later commit | Close cycle | close.spec.ts โ "overlapping full closes"; act-pg close-race.spec.ts |
| Partials sharing an event must use the same Zod schema instance | State management | slice.spec.ts โ "throws when same-name state partials use different schema references for the same event" |
| State policies survive a partial merge, whichever partial declared them | State management | partial-state.spec.ts โ "partial-state โ policies declared on a later partial", "two conflicting declarations are a conflict, not first-wins"; merge.spec.ts โ "register_state โ single-declaration policies" |
| Scoped Acts keep their stores and caches apart | Extension points | 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 work uses its scoped ports | Extension points | 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" |
The Act wires Store.notify at construction when the store has it and reactions exist | Cross-process reactions | 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" |
| A windowed close leaves the cache alone and the stream live | Close cycle | close-windowed.spec.ts โ "prunes the prefix behind the boundary snapshot and keeps the stream live", "leaves the cache untouched โ no invalidation on prune" |
| Two windowed closes of one stream archive each range at most once | Close cycle | close-windowed.spec.ts โ "fires the archive callback at most once when two windowed closes race the same stream" |
.autocloses({ keep }) needs .snap(...), and rejects windows under a day and keep inside or | Close policies | autoclose-builder.spec.ts โ ".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" |
| A stream is never acked past an event any reaction has not handled | Error handling | 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" |
A keep prune waits for the oldest event to age out, and a terminate match wins | Close policies | autoclose-reaction.spec.ts โ "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" |
validateFoldedState throws ValidationError at the event that broke the schema | Configuration | 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" |
| A throwing lifecycle listener is contained; the operation and other listeners still run | Observability | emit-containment.spec.ts โ "a throwing forgotten listener does not fail forget() after the PII is wiped", "a throwing closed listener does not fail close() after the truncate lands" |
A throwing settled listener does not reach the circuit breaker | Observability | 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" |
The settled payload covers every pass of the settle | Observability | settle-wakeup.spec.ts โ "reports the work the settle did, not its final empty pass" |
Store.block returns the stored row, with the durable watermark | Store.block doc | store-tck.ts โ "returns the post-block row, not the caller's lease" |
A blocked stream emits blocked once, even if the following ack fails | Store.block doc | non-retryable.spec.ts โ "emits blocked even when the following ack fails" |
A failed correlate init() is retried by the next correlate | CorrelateCycle.init doc | correlate.spec.ts โ "subscribes static targets on a retry after subscribe throws", "shares one init across concurrent callers (single-flight)" |
| A full SSE backlog never silently drops a version-neutral frame | SseOptions.maxPendingPerConnection doc | act-http api/sse-wiring.spec.ts โ "evicts a version-keyed frame rather than the overlay beside it", "collapses an all-neutral backlog into a single resync", "still drops the oldest frame when every frame is version-keyed" |
An _overlay frame is classified by its version against the client's | Real-time | apply-patch.spec.ts โ "frame kind ร version position" matrix |
A _resync frame reports behind, and an evicted overlay baseline sends one | Real-time | broadcast.spec.ts โ "a resync frame makes the client refetch rather than go stale", "reports an overlay whose baseline was evicted", and "fires the overlay-miss hook when the evicted entry carried overlay keys", "tells live subscribers of the evicted stream to refetch", "says nothing when the evicted entry carried no overlay state" |
A throwing onOverlayMiss costs no frame | Real-time | broadcast.spec.ts โ "a throwing hook costs neither stream its frame", "a throwing hook on the overlay path loses neither the resync nor the call", "control โ a non-throwing hook reports the miss and both frames land" |
snapshot.snaps counts snapshots in the loaded window, not a lifetime total | Cache and snapshots | cache.spec.ts โ "reports the window cold and the carried total warm" |
with_pii: false never decrypts and returns pii: null | Writing a store | store-tck.ts โ the with_pii block, run against InMemory / act-pg / act-sqlite; act-pg pii-encryption.spec.ts โ "a declined payload survives a row the default read cannot decrypt" |
An unreadable row stops neither reactions nor app.audit() | Auditing a store | act audit.spec.ts โ "names the poison row and audits every row behind it", "honours the caller's limit across a resume", "rethrows when the locating read finds no row to blame", "rethrows a backward scan rather than answering it partially", "control โ an intact store reports no unreadable rows" |
| One stream's failed fetch does not stall the others, and blocks once its budget runs out | Fetch[].error doc | act store-failure-budget.spec.ts โ "delivers to the healthy stream, and never skips the poison one", "contains a non-Error throw too"; "a read that keeps failing spends the retry budget" |
webhook timeoutMs covers the whole exchange, body included | WebhookConfig.timeoutMs doc | act-http webhook/webhook.spec.ts โ "times out a 5xx whose body never ends", "times out a 4xx whose body never ends", "reports the status when the body read fails without a timeout", "still captures a 5xx body that DOES end", "does not arm anything on the success path" |
truncate applies windowed targets before full ones, with one seed per full target | Writing a store | store-tck.ts โ "truncate", โ "leaves exactly one seed when a stream is listed twice", "applies windowed targets before full ones", run against InMemory / act-pg / act-sqlite |
act_streams_blocked keeps its last value when no provider answered | instrument doc | act-otel instrument.spec.ts โ "a degraded read keeps the gauge's last value instead of publishing 0", "a registry with no providers still publishes 0", "a blocked_streams rejection does not poison the whole scrape" |