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" |
act-pg seed() creates its schema even on a connection that looked the schema up before another worker created it | Writing a store | act-pg seed-upgrade.spec.ts → "creates the schema on a connection that looked it up before it existed" |
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" |
query leaves snapshot rows out unless with_snaps is set, even when called with no query object | Query.with_snaps doc | store-tck.ts → "excludes snapshots when called with no query object" |
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 when a reaction loses the race" |
| Precondition and validation failures leave the warm cache untouched | Cache and snapshots | cache.spec.ts → "precondition 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" |
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" |
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", "should NOT treat work the handler did not await as a reaction"; reacting-to-singleton.spec.ts |
Every action commits with the version it loaded or an explicit expectedVersion, a reaction's dispatch and a new stream's -1 included: a writer whose load went stale gets ConcurrencyError, so its preconditions and state check never pass on old state, and a retry re-checks them | Concurrency model | version-guard.spec.ts → "lets only one of two callers create a new stream", "re-checks the precondition when the creator that lost retries", "refuses a reaction whose load went stale", "re-checks a reaction's precondition on the state it retries with", "never lets a reaction store a state the schema rejects"; reacting-to.spec.ts → "should honor an explicit expectedVersion inside a reaction"; store-tck.ts → "treats expectedVersion -1 as an empty stream" |
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; store-tck.ts → "schema-driven dates on read", "types z.date() and leaves an ISO-shaped z.string() alone, warm and cold" |
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" |
A resolver that throws during correlate stops the scan before that event, is logged once, never emits error or opens the breaker, and resumes from that event once it stops throwing | Error handling | app-code-errors.spec.ts → "stops the scan before that event, logs once, and resumes without losing it" |
| A resolver that throws during the drain fails only its own stream, retrying on the reaction's backoff and blocking on its budget | Error handling | app-code-errors.spec.ts → "fails only its own stream, which blocks on the reaction's budget", "retries on the reaction's backoff while budget remains" |
A reaction-requested close whose archive callback throws is logged and never counts on the store breaker; a StoreError inside it still does | Error handling | app-code-errors.spec.ts → "is logged and never counts on the store breaker"; autoclose-reaction.spec.ts → "surfaces a store failure inside the close as an error event"; act-pg store.error.spec.ts → "surfaces a DELETE error as a StoreError that keeps the cause"; act-sqlite store.error.spec.ts → "truncate: rolls back and surfaces a DELETE failure as a StoreError" |
A handler that throws a DeferSignal with a schedule the schema rejects fails only its own reaction: it never voids the drain pass or counts on the store breaker | Error handling | defer.spec.ts → "an invalid schedule fails its own reaction, never the drain pass" |
The circuit breaker shares one failure count across loops, so a partly broken store can leave it closed while every failure still emits error | Error handling | store-error-circuit.spec.ts → "stays closed while another loop's store calls keep succeeding", "control — opens when nothing else touches the store" |
A local commit, app.reset(), app.unblock(), app.restore() or a notification from another process that frees reaction work settles it with no wiring unless the app sets autoSettle: false; a scheduled pass is never put off, the framework's own wake-ups never replace a caller's settle options, and nothing settles after shutdown() | Event sourcing | settle-wakeup.spec.ts → "runs the reaction with no settle wiring", "runs nothing on its own with autoSettle: false, until the caller walks the pipeline", "control — schedules nothing for an event no reaction handles", "replays a reset target with no settle call", "resumes an unblocked stream with no settle call", "control — a reset that resets nothing schedules nothing", "runs no reaction for a commit after shutdown", "keeps a caller's options when an internal wake-up lands mid-cycle", "doesn't put off a scheduled pass when more calls arrive", "control — stop() drops the caller's options with the pending pass", "brings a scheduled pass forward for a shorter delay", "keeps a caller's settle options while start_correlations polls", "scans with the poll's limit only while the poll runs", "a poll with limit 0 scans at the background default, not the whole log", "a pass queued mid-cycle keeps the caller's delay"; notify.spec.ts → "wakes settle when notification carries a reactive event", "does not wake settle with autoSettle: false"; restore.spec.ts → "settles the restored events' reactions with no settle call" |
| 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)"; store-error-circuit.spec.ts → "leaves the breaker OPEN" |
After any store failure, open or not, the breaker re-drives settle cooldownMs later, so an idle app recovers on its own | Error handling | store-error-circuit.spec.ts → "re-drives settle cooldownMs after a store failure, before the breaker opens", "control — runs the reaction when settle's read doesn't fail"; circuit-breaker.spec.ts → "fires the on_retry hook cooldownMs after a failure that leaves it closed" |
| 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" |
Without notify, an idle process never sees another process's commits, or a lease a dead worker let expire; each start_correlations tick correlates and wakes every lane, so polling delivers both | Cross-process reactions | correlate.spec.ts → "polling discovers and drains a commit this process never saw", "polling reclaims a lease a dead worker let expire" |
| 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" |
A correlate scan reads only limit from its query (app.correlate reads 10 when there is none, 0 reads everything), so a filter or after loses no reaction | Correlation and drain | correlate.spec.ts → "runs every reaction after an unfiltered scan", "ignores %s in app.correlate(), losing no reaction", "ignores a filter in settle's correlate option, losing no reaction", "reads 10 with no query, an empty one, or a leftover filter", "reads the given limit, and everything with limit 0" |
An action checks its preconditions in declaration order; the first that doesn't hold refuses it with PreconditionError ("failed precondition") and nothing commits | State management | preconditions.spec.ts → "runs the action when every precondition holds", "refuses at the first precondition that doesn't hold, and commits nothing", "passes the validated payload, the state and the actor to holds" |
The deprecated .given rules run before .requires and refuse with InvariantError ("failed invariant"); PreconditionError extends InvariantError, so a catch on it still catches every refused action | State management | preconditions.spec.ts → "refuses with InvariantError, as before, and runs no precondition", "lets code that catches InvariantError keep catching preconditions" |
| One precondition serves every action whose payload has its fields, and merged partial states keep each partial's preconditions | State management | preconditions.spec.ts → "shares one precondition between actions", "keeps each partial's preconditions when partial states merge" |
settle() never drops a wake-up requested while a cycle runs, even one that lands while its scan or claim is still out | Correlation and drain | settle-wakeup.spec.ts → "runs a cycle for a schedule() that fires while a cycle is in flight", "drains a stream unblocked while an empty claim is in flight", "control — drains a stream unblocked after the pass", "reacts to a commit that lands while an empty scan is in flight", "control — reacts to commits that land between passes" |
| 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 when a reaction loses the race" |
A state projection's first fold pairs state with the head id from the same load() | Projections to a database | state-projection.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" |
| A lane name declares one config: the same config declared again on the Act or a slice, in any order, is one lane; a different config throws at composition | Configuration § Lanes | lanes.spec.ts → "merges a lane the slice and the Act both declare, in either order", "accepts a lane declared again with the same config", "rejects a lane declared twice with different configs", "rejects a slice lane declared twice with different configs", "rejects slice/Act lane configs that disagree on timing" |
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.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.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", "delivers restored events when the restore lands during a correlate scan", "delivers restored events when the scan's last write lands after the restore starts", "a correlate pass that starts during the restore reads nothing, and loses nothing", "delivers restored events when the restore lands during correlate's startup"; act-pg restore-live.spec.ts → "delivers restored events when a scan's write was sent during the restore" |
| 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.spec.ts → "runs the drain at the persisted due-time with no intervening commit", "re-seeds a deferred stream sorting past the first stream page", "seeds a non-default lane's controller from persisted deferred_at" |
| A deferred or backed-off reaction fires at its due-time on an idle app, with no further calls | Close cycle | defer.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", "runs every stream of a burst whose first pass only deferred", "re-fires after the finalize that would persist its schedule fails"; 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.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" |
.autocloses with keep and a time-gated terminate closes a stream that went quiet after a prune, and keeps the event the terminate reads | Close policies | autoclose-reaction.spec.ts → "closes a stream that went quiet right after a prune", "keeps the terminal head when the only snapshot is above it" |
A parked .autocloses reaction doesn't stop app.close | Close cycle | autoclose-reaction.spec.ts → "doesn't let a parked autoclose refuse app.close" |
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 | 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 | 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.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.spec.ts → "keeps the drain armed while finalization is unhealed" |
A malformed literal .defer(...) throws ZodError at build | State management | defer.spec.ts → "rejects a bad literal schedule at build time" |
throw new DeferSignal(when) holds the stream until due, then redelivers | State management | defer.spec.ts → "{ at: Date } holds until the due-time, then acts", "{ after } is measured from the event's created time (parks, not acked)" |
A sensitive field is stored in pii, never in data, behind .optional(), .nullable(), .default(), .readonly(), .catch(), either side of a pipe (.transform(), .pipe(), z.preprocess()), z.lazy(), and any refinement chained after sensitive() | Sensitive data | commit-pii-split.spec.ts → "survives a refinement chained after sensitive()", "survives .transform(), .pipe(), z.preprocess() and z.lazy() around sensitive()"; zod-layers.spec.ts → "seeing through Zod wrappers" |
| The first build warns once, naming each sensitive event field and same-named state field whose type isn't a string (it would read as a redaction marker) | Sensitive data | sensitive-build.spec.ts → "the sensitive-type advisory" |
A state whose events declare a sensitive(...) field and that declares .snap() throws at build, naming the state and those events | Sensitive data | sensitive-build.spec.ts → "throws at build, naming only the offending state and every sensitive event", "builds a sensitive state without .snap(), and a plain state with it" |
A committed listener scoped to a state (by State or name) hears only commits by that state's actions, with snapshots typed from it; it is contained like every listener, and a state the app doesn't have throws at registration | Event sourcing | committed-by-state.spec.ts → "a committed listener scoped to one state" |
An action whose handler returns no events commits nothing and emits no committed, on a new, warm or cold stream, and the trace logs no commit | State management | act.spec.ts → "commits nothing and emits no committed, on a new, warm or cold stream", "control: an action that emits announces its commit"; tracing.spec.ts → "withActionTrace skips commit log when nothing emitted on a stream with history" |
A per-call correlator set to undefined falls back to the app's correlator | Event sourcing | correlator.spec.ts → "keeps the app's correlator when a call passes correlator: undefined" |
Store contract
Pinned in the TCK unless the citation names an adapter.
| Claim | Source | Backing test |
|---|---|---|
One store instance takes concurrent commits, reads and writes from one process, and a race on one stream has one winner (ConcurrencyError for the other) | act-sqlite README § Concurrency model | store-tck.ts → "takes concurrent commits, reads and writes on one instance" |
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 | 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 → "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 refused action maps to 409: PreconditionError to PRECONDITION, the deprecated InvariantError to INVARIANT | Auto-generated API | act-http api/errors.spec.ts → "maps PreconditionError to 409 + PRECONDITION", "maps the deprecated InvariantError to 409 + INVARIANT" |
The OpenAPI document types each action's success response from its state: the state, each event's data by name, the patch; a sensitive event field, and every field of a state with sensitive events, may read as a redaction marker | Auto-generated API | act-http openapi/index.spec.ts → "openapi — typed action responses" |
The generated tRPC and Hono APIs are typed from the app: each action's payload in, its snapshots out (ApiError on a Hono error status), and with sse each state's subscription or route | Auto-generated API | act-http typed-api.spec.ts → "trpc(app) types", "hono(app) types" (checked by pnpm typecheck) |
| 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" |
trpc(app, { sse }) and hono(app, { sse }) refuse at build a state whose events declare sensitive fields | Auto-generated API | act-http trpc/sse.spec.ts → "trpc sse — a state with sensitive events"; hono/sse.spec.ts → "hono sse — a state with sensitive events" |
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 | state-projection.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" |
The SQL adapters' recovery writes (reset, unblock, prioritize, defer) throw a driver failure as a StoreError naming the operation, with the driver error as cause | Writing a store | act-pg store.error.spec.ts → "recovery writes"; act-sqlite store.error.spec.ts → "defer: rolls back and wraps UPDATE failure in StoreError" |
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 default reaction delivers on any stream name, and a close waits for it, on every store | Extension points | store-tck.ts → "takes a target reading itself, whatever its name holds"; stream-names.spec.ts → "a close waits for a default reaction still behind on such a name" |
| 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" |
| Time travel keeps what a prune or a restart moved into a snapshot | Cache and snapshots | time-travel-prune.spec.ts → "keeps the state a windowed close moved into a snapshot", "keeps the state a restart seeded"; act-pg and act-sqlite history-edges.spec.ts |
| A stream name with a regex metacharacter has no autoclose, and doesn't stop other reactions | Close policies | autoclose-reaction.spec.ts → "leaves a stream whose name has a regex metacharacter to app.close" |
| A webhook doesn't follow a redirect, so a 3xx blocks instead of losing the event | act-http README | act-http webhook-redirect.spec.ts → "blocks on a real redirect, with one POST" |
| Audit doesn't list a healthy closed stream as a correlation gap, and counts every snapshot of a restart candidate | Auditing a store | audit.spec.ts → "doesn't list a healthy closed stream as a correlation gap", "counts every snapshot of a restart candidate" |
An or.after backstop still fires on an idle stream after an earlier after has passed | Close policies | autoclose-reaction.spec.ts → "keeps a later or.after backstop once the earlier after has passed" |
| Subscribing to the same streams in different orders doesn't deadlock on Postgres | Writing a store | act-pg close-race.spec.ts → "subscribing in different orders doesn't deadlock" |
A failed BEGIN on SQLite is a StoreError, on a commit, a write and a restore | Writing a store | act-sqlite store.error.spec.ts → "wraps a BEGIN that fails, on a commit, a write and a restore" |
| 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 Hono route answers a body that fails the schema, or JSON that doesn't parse, with 422 and the ApiError envelope | Auto-generated API | act-http hono/index.spec.ts → "body that fails Zod validation returns 422 with the ApiError envelope (matches the in-do ValidationError path)", "a JSON body that doesn't parse gets the same 422 ApiError envelope" |
| 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 | state-projection.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" |
| Reaction and projection handlers are named functions: an arrow function throws at build, and two handlers for one event can't share a name | State management | builder.spec.ts → "should throw for anonymous handlers in act builder"; projection.spec.ts → "should throw for anonymous handlers"; slice.spec.ts → "should throw for anonymous handlers"; target-ownership.spec.ts → "throws on duplicate reaction names within one slice", "throws on duplicate projection handler names" |
| 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() schedules the replay, so a settled app still replays | Event sourcing | settle-wakeup.spec.ts → "replays a reset target with no settle call"; 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" |
Nothing starts work after shutdown() resolves: not the settle loop, not a pass still scanning when the grace budget runs out, and not a pass scheduled while the notify subscription comes down | Production checklist | shutdown.spec.ts → "starts no settle pass after shutdown resolves", "control — a settle that needs one pass also runs none", "a pass still scanning when the grace runs out starts nothing after shutdown", "control — a grace the scan fits in also starts nothing after", "a slow notify disposal can't let a scheduled pass run", "control — an instant disposal cancels it too" |
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", "a pass still scanning when the grace runs out starts nothing after shutdown", "a renewal reply landing after shutdown leaves the lease handed back", "control — a renewal reply landing within the grace" |
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 |
A deferred reaction's wake starts no drain pass after shutdown() resolves | Production checklist | shutdown.spec.ts → "runs no handler after shutdown resolves", "control — a wake that needs one pass also runs none" |
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" |
| An action belongs to one partial: declaring it in two partials throws at build, even when they share its schema; the same partial registered through several slices is not a duplicate | State management | partial-state.spec.ts → "should throw on duplicate action across partials", "should throw on a duplicate action even when the partials share its schema" |
| 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" |
Every action checks the state it leaves against the state schema once, before the commit: a state the schema rejects refuses the action, nothing is stored and no reaction runs; the deprecated validateFoldedState changes nothing; the check never rewrites the state; loads and replays don't check | State management | invariants.spec.ts → "refuses before the commit: nothing is stored, no reaction runs, the stream keeps working", "names the state and the action in the error", "is always on: the deprecated validateFoldedState: false changes nothing", "checks the state the action leaves, not the states between its events", "checks without replacing the state with the parsed value", "loads and replays don't check" |
A state schema refinement that throws refuses the action with ValidationError and stores nothing, as a payload's does | State management | invariants.spec.ts → "refuses with ValidationError when a refinement throws, as a payload's does" |
On a state with sensitive events, the state check ignores failures whose value is [REDACTED] or [SHREDDED], so another actor's action and every action after forget commit; any other failure, a field derived from a hidden value, and a marker string on a state without sensitive events still refuse | Sensitive data | invariants.spec.ts → "ignores a value another actor reads as [REDACTED]", "ignores a forgotten value, so the stream keeps working after forget", "still refuses a real value that breaks the schema", "still refuses a field derived from a hidden value", "checks a marker string in full on a state without sensitive events" |
A state field cleared with null in a patch loses its key, so a .nullable() field (which requires the key) refuses the clearing action and stores nothing, while .nullish() accepts it | State management | invariants.spec.ts → "refuses a .nullable() field cleared with null, because the patch deletes the key" |
An action folds each event once, before the commit; reducers see the next version and placeholders for the store-assigned id (-1) and created, and the returned snapshots carry the stored rows | State management | invariants.spec.ts → "folds each event once, with the next version and a placeholder id" |
| 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" |