Defect
Two degenerate-input divergences where InMemoryStore disagrees with both SQL adapters. Both come from a bound being applied after the effect rather than before.
1. claim(millis = 0) records no lease holder — every subsequent ack is dropped.
libs/act/src/adapters/in-memory-store.ts:172
lease(lease: Lease, millis: number): Lease {
if (millis > 0) {
this._leased_by = lease.by;
this._leased_until = new Date(Date.now() + millis);
}
this._retry = this._retry + 1;
...
PG (postgres-store.ts:1289-1291) and SQLite (sqlite-store.ts:975-978) set leased_by/leased_until unconditionally. On InMemory the lease is granted but no holder is recorded, so ack — gated on leased_by === lease.by — is silently dropped forever and the watermark never advances.
2. limit: 0 emits one row on query_streams and query_stats.
libs/act/src/adapters/in-memory-store.ts:1098 (query_streams) — callback(...); count++; if (count >= limit) break; — and :1208 (query_stats) — out.set(...); if (limit !== undefined && out.size >= limit) break;. The bound is checked after emitting, so limit: 0 yields exactly one. PG (:1812, :1988) and SQLite (:1288, :1446) return zero rows.
Failure scenario
claim millis = 1000 (control) InMemory / PG / SQLite all agree, watermark advances
claim millis = 0 (red) InMemory { leased_by_set: false, acked_count: 0, watermark_advanced: false }
PG { leased_by_set: true, acked_count: 1, watermark_advanced: true }
SQLite { leased_by_set: true, acked_count: 1, watermark_advanced: true }
query_streams/query_stats limit = 2 (control) all three agree
query_streams/query_stats limit = 0 (red) InMemory { emitted: ["A"], count: 1 } / stats ["A"]
PG, SQLite { emitted: [], count: 0 } / stats []
At app level the first one compounds: with leaseMillis: 0, first_ran: 1, total_ran: 3 and the watermark stuck at -1 — every past event redelivered on every future drain, forever.
Reachability of #1: leaseMillis: z.number().min(0) (libs/act/src/internal/config.ts:134,156) makes 0 legal config, and drain-cycle.ts:667 (d.leaseMillis ?? options.leaseMillis ?? 10_000) passes it straight through — ?? does not coalesce 0. So an operator can configure this from fully type-checked source. It is still dev/test-only in practice, since InMemoryStore is documented dev/test-only.
Reachability of #2: no framework caller passes limit: 0; this is a raw-port divergence.
Proof (red)
Control/red verdicts above are the wave-21 TCK hunter's, from probes it ran (differential InMemory-vs-PG and InMemory-vs-SQLite) and archived before hitting its session limit. I verified both root-cause mechanisms directly in source — the if (millis > 0) guard and the emit-then-check ordering in both query_streams and query_stats — but did not re-run the probes in the main loop.
Severity
Low. Both need a degenerate input, and InMemoryStore is dev/test-only by design (the standing invariant that "some InMemory shortcuts are legitimate" applies to the dev/test-only framing, not to these two — neither is a declared best-effort behavior). #1 is the more interesting of the pair because leaseMillis: 0 is documented-legal config and the failure is a silent infinite redelivery rather than an error.
Fix direction
- Set
_leased_by unconditionally in lease(), matching both SQL adapters; millis should govern only _leased_until. (Alternatively, if a zero-length lease is meant to mean "no lease", that has to be the contract on all three adapters — it currently is not.)
- Check the bound before emitting in both
query_streams and query_stats.
Both need matching TCK cases — claim with millis: 0 and limit: 0 on the two enumerating methods are exactly the boundary arguments the store TCK never exercises, which is why these drifted.
Context
Found by debug wave 21 (TCK blind-spot lens). The same sweep closed the long-open store-differential-tck.ts:816-841 lead: a full mutator-return-value and StreamPosition-row differential (including deferred_at's exact value and leased_until presence, across defer / due-ack / block / unblock / reset) found no divergence among the three adapters. Also swept clean: a 27-case query option matrix and a 112-case query_stats matrix, both identical on all three. The one further divergence found was limit: -1 (InMemory returns 1, SQLite returns all, PG throws a raw unwrapped pg error) — recorded as awareness-only, same family as the already-filed #1199 edge-input divergences.
Defect
Two degenerate-input divergences where
InMemoryStoredisagrees with both SQL adapters. Both come from a bound being applied after the effect rather than before.1.
claim(millis = 0)records no lease holder — every subsequentackis dropped.libs/act/src/adapters/in-memory-store.ts:172PG (
postgres-store.ts:1289-1291) and SQLite (sqlite-store.ts:975-978) setleased_by/leased_untilunconditionally. On InMemory the lease is granted but no holder is recorded, soack— gated onleased_by === lease.by— is silently dropped forever and the watermark never advances.2.
limit: 0emits one row onquery_streamsandquery_stats.libs/act/src/adapters/in-memory-store.ts:1098(query_streams) —callback(...); count++; if (count >= limit) break;— and:1208(query_stats) —out.set(...); if (limit !== undefined && out.size >= limit) break;. The bound is checked after emitting, solimit: 0yields exactly one. PG (:1812,:1988) and SQLite (:1288,:1446) return zero rows.Failure scenario
At app level the first one compounds: with
leaseMillis: 0,first_ran: 1, total_ran: 3and the watermark stuck at-1— every past event redelivered on every future drain, forever.Reachability of #1:
leaseMillis: z.number().min(0)(libs/act/src/internal/config.ts:134,156) makes0legal config, anddrain-cycle.ts:667(d.leaseMillis ?? options.leaseMillis ?? 10_000) passes it straight through —??does not coalesce0. So an operator can configure this from fully type-checked source. It is still dev/test-only in practice, sinceInMemoryStoreis documented dev/test-only.Reachability of #2: no framework caller passes
limit: 0; this is a raw-port divergence.Proof (red)
Control/red verdicts above are the wave-21 TCK hunter's, from probes it ran (differential InMemory-vs-PG and InMemory-vs-SQLite) and archived before hitting its session limit. I verified both root-cause mechanisms directly in source — the
if (millis > 0)guard and the emit-then-check ordering in bothquery_streamsandquery_stats— but did not re-run the probes in the main loop.Severity
Low. Both need a degenerate input, and
InMemoryStoreis dev/test-only by design (the standing invariant that "some InMemory shortcuts are legitimate" applies to the dev/test-only framing, not to these two — neither is a declared best-effort behavior). #1 is the more interesting of the pair becauseleaseMillis: 0is documented-legal config and the failure is a silent infinite redelivery rather than an error.Fix direction
_leased_byunconditionally inlease(), matching both SQL adapters;millisshould govern only_leased_until. (Alternatively, if a zero-length lease is meant to mean "no lease", that has to be the contract on all three adapters — it currently is not.)query_streamsandquery_stats.Both need matching TCK cases —
claimwithmillis: 0andlimit: 0on the two enumerating methods are exactly the boundary arguments the store TCK never exercises, which is why these drifted.Context
Found by debug wave 21 (TCK blind-spot lens). The same sweep closed the long-open
store-differential-tck.ts:816-841lead: a full mutator-return-value andStreamPosition-row differential (includingdeferred_at's exact value andleased_untilpresence, across defer / due-ack / block / unblock / reset) found no divergence among the three adapters. Also swept clean: a 27-casequeryoption matrix and a 112-casequery_statsmatrix, both identical on all three. The one further divergence found waslimit: -1(InMemory returns 1, SQLite returns all, PG throws a raw unwrapped pg error) — recorded as awareness-only, same family as the already-filed #1199 edge-input divergences.