@zakkster/lite-stream 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/README.md +119 -63
  3. package/ROADMAP.md +679 -159
  4. package/Stream.js +316 -80
  5. package/llms.txt +101 -18
  6. package/package.json +3 -3
package/ROADMAP.md CHANGED
@@ -1,191 +1,711 @@
1
- # @zakkster/lite-stream Roadmap
1
+ # lite-stream -- enriched roadmap (post-1.1.0)
2
2
 
3
- Features deliberately deferred from 1.0 with the rationale. Each is gated
4
- on a specific triggering signal from a real consumer rather than
5
- speculative inclusion.
3
+ Blueprint: `../BLUEPRINT_ROADMAP.md`. Sibling roadmaps this one is wired to:
4
+ `../LiteQuery/ROADMAP.md` (the flagship consumer), `../LiteBakeStream/ROADMAP.md`
5
+ (the torture-spec model), `../LiteAwait/AWAIT_ROADMAP.md` (the TimeoutError
6
+ duck-pact). Ethos carried over unchanged: **reproduce by running, never infer.**
7
+ Every finding below was produced by executing code on 2026-09-01, not by
8
+ reading it. Reproductions live in `bench/findings-probes-2026-09-01.mjs`.
6
9
 
7
- ## Triggering signals
8
-
9
- This roadmap exists because of the same discipline that drove
10
- `lite-signal`'s and `lite-statechart`'s development: ship the minimum that
11
- solves the actual problem, defer everything else until a concrete consumer
12
- demonstrates the need with a real code site. Nothing in this file is
13
- "planned for 1.x" -- everything is "considered, deferred, waiting for a
14
- triggering signal."
10
+ Sessions: LS1 (v1.1.1, truth patch) -> LS2 (v1.2.0, the law's gate) ->
11
+ LS3 (v1.3.0, the consumer contract) -> LS4 (v1.4.0, Q8 enablement, GATED).
15
12
 
16
13
  ---
17
14
 
18
- ## 1.1.0 candidates
19
-
20
- ### Concurrent mapping (`mapLimit`)
15
+ ## 0. Ground truth (verified 2026-09-01)
16
+
17
+ - Registry: `@zakkster/lite-stream` 1.0.0 and 1.1.0 published; `latest` =
18
+ 1.1.0. Local HEAD `e9d2151 1.1.0;` IS the published state; tree clean.
19
+ - Surface: 4 named exports -- `fromAsyncIterable`, `pipeToSignal`,
20
+ `toAsyncIterable`, `TimeoutError`. NO `VERSION` export (LS-05). `Stream.d.ts`
21
+ and `llms.txt` agree with the export block (hand-checked; guard pending LS2).
22
+ - Tests: 79. Default `npm test` -> 71 pass, **8 skipped silently** (the entire
23
+ GC tier, LS-02). `npm run test:gc` -> 79 pass, 0 skip, 0 fail -- against
24
+ lite-signal **1.5.0** AND against the floor **1.2.2** (both run today; the
25
+ `^1.2.0` peer claim is TRUE, previously untested -- LS-09).
26
+ - Torture: none. No `test/torture.mjs`, no `@zakkster/lite-leak`, no
27
+ `@zakkster/lite-gc-profiler`, no seeded fuzz, no controls. The 8 GC tests
28
+ use coarse "retain < 2 MB" ceilings, not the law's `maxMajor: 0` gate (LS-03).
29
+ - Bench (node v26.3.1, this machine, 2026-09-01): from-latest 61,464 ops/s
30
+ (0.35 B/op retained), from-buffer 63,382 (0.46), abort-cycle 29,652 (1.49),
31
+ pipe-to-signal 42,382 (0.26), to-async-iterable 200,956 (negative retained),
32
+ overflow 204,774 (0.13). Shipped llms.txt claims different numbers with no
33
+ provenance on the 1.0 block (LS-08).
34
+ - Consumers in the suite: exactly one. `../LiteQuery/StreamQuery.js:36`
35
+ imports `pipeToSignal` (peer `^1.0.0`, optional). lite-query also vendors
36
+ a 1.0.0 copy for demos (`demo/vendor/lite-stream.js`).
37
+ - ASCII: **zero** non-ASCII bytes across Stream.js, Stream.d.ts, all docs,
38
+ test/, bench/, demo/. Already clean; LS2's guard keeps it that way.
39
+ - Hygiene: no `.gitignore` (added at roadmap-pass time: `node_modules/`,
40
+ `package-lock.json` -- same operator ruling as lite-query Q1), no lockfile
41
+ (deliberate, now recorded). `files[]` ships ROADMAP.md (LS-11).
42
+ - Runtime facts: engines `>=18`; `Symbol.asyncDispose` feature-detected
43
+ (present on node 26, its conditional test runs). devDep `^1.2.2` resolves
44
+ to lite-signal 1.5.0 at install time.
45
+
46
+ ## 1. Shared law (holds in every session)
47
+
48
+ 1. Zero runtime deps; `@zakkster/lite-signal` is the single peer. node:test
49
+ only. ASCII-only source and docs. Single main file `Stream.js`.
50
+ 2. Bounded buffering is this package's own law: "unbounded buffering is a
51
+ memory bug pretending to be a feature." Every buffer has a required,
52
+ validated ceiling; every drop is counted and observable.
53
+ 3. Fail closed on every unverified state. The LS-01 lesson is the local
54
+ phrasing: a cleanup path that depends on a peer behavior nobody ever
55
+ executed is not a cleanup path, it is a comment.
56
+ 4. **AbortSignal is the only cleanup contract.** Dispose-based teardown is
57
+ not promised anywhere until a lite-signal disposed-oracle exists (parked).
58
+ Docs, tests, and torture pin this in every direction.
59
+ 5. No silent skips (the Q-02/AW-14/LS-02 class): the default `npm test` runs
60
+ every test the repo owns. Environment-conditional skips (asyncDispose on
61
+ node < 20) must name their condition in the skip message and have a
62
+ counterpart that runs.
63
+ 6. Every module change is proven by `node --expose-gc test/torture.mjs`
64
+ (lite-leak + lite-gc-profiler) once LS2 lands. No gate output is a FAIL.
65
+ A gate that cannot fail is decorative -- controls are part of the gate.
66
+ 7. Docs parity is release-blocking: exports == llms.txt == Stream.d.ts ==
67
+ README API section; peer floors in docs == package.json; perf numbers
68
+ carry provenance (node version, date, machine class) or do not ship.
69
+ 8. Measured numbers are measured once, at the source of truth, and cited
70
+ everywhere else (the bake-stream MQ2 discipline).
71
+
72
+ ## 2. Verified findings
73
+
74
+ Severity: S1 = a shipped claim is false / correctness; S2 = a gate or
75
+ guarantee is dark; S3 = drift, hygiene, or docs debt. Every row was
76
+ reproduced by running on 2026-09-01; probe bodies live in
77
+ `bench/findings-probes-2026-09-01.mjs` (P0..P4).
78
+
79
+ | ID | Sev | Finding | Reproduce |
80
+ | --- | --- | --- | --- |
81
+ | **LS-01** | S1 | **The shipped dispose-teardown claim is false, and it has never been true.** llms.txt (lines 132-134): "If the consumer disposes the result signal ... the pump detects on next `sig.set()` and tears down silently." Reality: lite-signal's `set()` after `dispose()` is a **silent no-op** in 1.5.0 AND 1.2.2 (P0), so the pump's catch-based detection (`Stream.js:343`) is dead code born dead -- it never fired against any published peer in the range. Observed: dispose mid-pump leaves the iterator **pulling forever** (P1: 14 pulls at dispose, 64 and climbing 60ms later, `return()` never called); same for `pipeToSignal` with a disposed target (P2, `onError` never fires). For an infinite source (SSE -- the package's own headline use case) this is an unbounded background fetch loop. The `Stream.js:148-152` docstring even offers dispose as a stop mechanism ("the iterator continues until natural completion" -- an infinite source has none). Sole suite consumer verified SAFE: StreamQuery always `ac.abort()`s via `streamStop` before entry teardown (`StreamQuery.js:156`). | `node bench/findings-probes-2026-09-01.mjs` (P0/P1/P2) |
82
+ | **LS-02** | S2 | **Default `npm test` silently skips the entire GC tier.** All 8 tests in `test/06-gc.test.mjs` are `{ skip: !hasGc }` and the `test` script lacks `--expose-gc`; 71/79 green LOOKS complete. The `verify` script runs `test:gc`, but the habitual gate is `npm test`. Third suite recurrence of the green-light-over-a-hole class (Q-02, AW-14). Fix pre-verified: the `test:gc` form runs 79/79 green in 21.5s. | `npm test 2>&1 \| grep skipped` -> 8; `npm run test:gc` -> 0 |
83
+ | **LS-03** | S2 | **The pipeline law's torture gate does not exist.** No `test/torture.mjs`, no lite-leak, no lite-gc-profiler, no seeded fuzz, no zero-alloc gate (llms.txt claims "allocates zero on the steady-state hot path" -- nothing gates it), no controls. The GC tests' "retain < 2 MB" ceilings would pass a 1.9 MB leak. | `ls test/torture.mjs` -> ENOENT; `grep -c lite-leak package.json` -> 0 |
84
+ | **LS-04** | S3 | **Disposing the SOURCE under `toAsyncIterable` hangs the pending `next()` forever** (P3: still PENDING 50ms after dispose; no resolve, no reject). llms.txt already anti-patterns this as "undefined behavior", so it is documented-away rather than false -- but the observed behavior is now on record, and `timeout` is the only escape hatch. Hardening parked on a lite-signal disposed-oracle (see parking ledger). | probes P3 |
85
+ | **LS-05** | S3 | **No `VERSION` const or export.** Suite convention (lite-query, lite-await) and the /release drill's site list expect one in the main file. Current version sites: `package.json`, `Stream.js:1` header comment, CHANGELOG headings. Additive export -> lands with LS2 (1.2.0), never re-exported by consumers (suite ruling, lite-await pass). | `grep -c VERSION Stream.js` -> 0 |
86
+ | **LS-06** | S3 | **Shipped source references a document that does not exist.** `Stream.js:25` and `:560` cite `CONTRACT-toAsyncIterable-1.1.md`; no such file in the repo or the tarball. The TimeoutError comment (`Stream.js:44-45`) still narrates its own dev sessions ("Session B's territory ... Session A"). | `ls CONTRACT-toAsyncIterable-1.1.md` -> ENOENT |
87
+ | **LS-07** | S3 | **The pump comment describes code that is not there.** `Stream.js:352-353`: "Defer the next pull through the microtask queue so back-to-back synchronous yields don't blow the stack" -- `pump()` is called directly; no deferral exists at that site. Behavior is stack-safe anyway (`.then` handlers unwind): P4 drains a 1,000,000-value sync source in ~80ms with no overflow. Fix is the comment, plus a torture case pinning the law. | probes P4 |
88
+ | **LS-08** | S3 | **Shipped perf numbers lack provenance and do not match a current run.** llms.txt's 1.0 block ("178K ops/s ... 235K ... 84K") names no node version, date, or machine; today's run on node v26.3.1 gives 61K/42K/29K for the same scenarios. Not a lie -- a rotting number. Re-measure once with provenance at LS1, cite everywhere (README Benchmarks included). | `npm run bench` vs llms.txt Performance section |
89
+ | **LS-09** | S3 | **The peer floor `^1.2.0` was a claim, not a fact -- now verified, must stay verified.** Tests had only ever run against the latest resolution (1.5.0). Run today against pinned 1.2.2: 79/79 green. The floor holds; nothing keeps it holding. LS2's floor matrix (scripted `test:floor`) institutionalizes the check. | `npm i --no-save --no-package-lock @zakkster/lite-signal@1.2.2 && npm run test:gc` |
90
+ | **LS-10** | S3 | **No repo hygiene layer.** No `.gitignore` -- a plain `npm install` leaves `node_modules/` (and would leave `package-lock.json`) as untracked noise poised to be committed; no recorded lockfile policy. Mitigated at roadmap-pass time (2-line `.gitignore`, same ruling as lite-query Q1); policy recorded here: no lockfile, devDeps float within their stated ranges, the floor matrix pins what must stay true. | `git status` after `npm install` (pre-fix) |
91
+ | **LS-11** | S3 | **ROADMAP.md ships in the tarball, and two shipped docs point into its old shape.** `files[]` includes ROADMAP.md; llms.txt's Files section describes it as "deferred features"; CHANGELOG 1.1.0 cites "ROADMAP.md's 'Shipped in 1.1.0' section" (preserved below under History so the published 1.1.0 pointer stays honest). Ruling to execute at LS2: drop ROADMAP.md from `files[]` (align with lite-query/bake-stream -- the roadmap is a working doc), fix the llms.txt Files line and CHANGELOG forward-refs in the same release. | `npm pack --dry-run \| grep ROADMAP` |
92
+ | **LS-12** | S3 | **The two directions disagree about what abort means, and the flagship consumer pays for it.** `fromAsyncIterable`/`pipeToSignal` route abort through `onError` (error framing, `makeAbortError`); `toAsyncIterable` treats abort as graceful done. StreamQuery hand-filters the noise: "pipeToSignal funnels intentional aborts through onError too. Those are not failures" (`StreamQuery.js:139-142`). Each direction is documented locally; the asymmetry as a whole is undocumented and exports a workaround obligation to every consumer. LS3 owns the decision doc (pin the rationale or add an `onAbort` hook -- see the brief). | read `StreamQuery.js:139-142`; compare `Stream.js:261` vs `:815` |
93
+
94
+ ### 2b. Cross-package facts (verified against the neighbors' code, not their docs)
95
+
96
+ - **lite-signal 1.5.0 has no public disposed-oracle.** Exports: signal,
97
+ computed, signalBox, computedBox, effect, dispose, batch, createRegistry,
98
+ setDefaultRegistry, CapacityError, getOwner, runWithOwner. Nothing answers
99
+ "is this signal disposed?" -- so LS-01's behavioral fix (pump detects
100
+ dispose) is IMPOSSIBLE in-library today. Tripwire: if lite-signal ships an
101
+ oracle, un-park "dispose-aware pumps" (parking ledger).
102
+ - **StreamQuery's exact usage shape** (the contract LS2's conformance tier
103
+ freezes): `pipeToSignal(source, entry.data, { signal, transform, onError,
104
+ onDone })` where `transform` doubles as a first-value status tap and a
105
+ hand-rolled buffer window (`ring.push/shift/slice`, `StreamQuery.js:119-137`),
106
+ and `onError` filters `signal.aborted`. Three shipped triggering signals for
107
+ LS3, cited by line.
108
+ - **TimeoutError duck-pact.** Three packages now carry the shape
109
+ (`e.name === "TimeoutError"`, `.timeout` field): lite-await (origin),
110
+ lite-stream (structural copy, deliberate zero-dep), lite-query's /await
111
+ entry (re-export of lite-await's). The duck-check IS suite vocabulary;
112
+ renaming it anywhere is a breaking change everywhere. Pinned by a shape
113
+ test in LS2's conformance tier.
114
+ - **lite-bake-stream is bytes-domain** (`ReadableStream<Uint8Array>` in,
115
+ LBK1 containers out) -- no structural integration with value-domain
116
+ lite-stream. One honest recipe candidate, docs-only: RangeReader shard
117
+ walk -> async generator -> `fromAsyncIterable` for a UI-reactive lazy
118
+ view (parking ledger; needs bake-stream >= MQ1 for abortability).
119
+ - **lite-query Q8 (2.0.0, shared streams)** is the design event LS4 waits
120
+ for: leader owns one iterator, followers receive frames over lite-channel
121
+ and need "push externally-delivered values into entry signals with the
122
+ same windowed-view discipline", plus "leader hung (frames stop, channel
123
+ alive)" stall detection. Whether those primitives land in lite-stream 1.4.0
124
+ or inside lite-query is Q8's session-start spike ruling. Their spike is
125
+ our tripwire; recorded on both sides.
126
+
127
+ ## 3. The torture suite (`test/torture.mjs`) -- spec
128
+
129
+ Model: `../LiteBakeStream/ROADMAP.md` section 3 (itself ported from
130
+ `../LiteBvh/test/torture/`), tiers renamed to this package's threat model:
131
+ lifecycle leaks, protocol violations, scheduling races, and false zero-GC
132
+ claims. Gate: `node --expose-gc test/torture.mjs` prints exactly "ok",
133
+ exit 0/1. Control switch: `STREAM_TORTURE_BREAK=1` must exit non-zero.
21
134
 
22
- ```js
23
- const live = fromAsyncIterable(urls, {
24
- transform: async (url, ctx) => {
25
- return await fetch(url, { signal: ctx.signal }).then((r) => r.json());
26
- },
27
- concurrency: 4 // up to 4 in-flight transforms
28
- });
135
+ ```
136
+ test/
137
+ torture.mjs # entry: tiers in order, prints "ok", exit 0/1
138
+ torture/
139
+ harness.mjs # xorshift32 PRNG (TORTURE_SEED replay), check() with
140
+ # message thunks, runOpsGate (stabilize:'deep'),
141
+ # STREAM_TORTURE_BREAK switch, preflight: exit 2 with
142
+ # a clear message when devDeps are missing
143
+ s0-laws.mjs # metamorphic invariants
144
+ s1-degenerate.mjs # nasty values and nasty iterators
145
+ s2-protocol.mjs # async-iteration protocol conformance
146
+ s3-abuse.mjs # API misuse: every case has a decided policy
147
+ s4-fuzz.mjs # seeded schedule fuzz vs a naive oracle
148
+ s5-alloc.mjs # the zero/bounded-alloc gate (gc-profiler)
149
+ s6-soak.mjs # lite-leak retention + lifecycle churn
150
+ s7-conformance.mjs # consumer corpus + floor matrix + duck-pact
151
+ s8-controls.mjs # every tier, deliberately broken, exits non-zero
29
152
  ```
30
153
 
31
- A consumer-side pattern where each yielded value needs an async
32
- transformation, and the user wants bounded concurrency. Currently doable
33
- by composing async generators externally, but if multiple consumers (helix
34
- batch enrichment, EBS request fan-out) end up writing the same pattern,
35
- add it.
36
-
37
- **Triggering signal:** Two or more consumers in the ecosystem writing
38
- identical "transform-with-concurrency-limit" wrappers around
39
- `fromAsyncIterable`.
40
-
41
- ---
42
-
43
- ### Reverse-direction buffering modes
154
+ Harness rules (verbatim from the blueprint; all apply):
155
+ - All scratch allocated once, outside every loop; assertion messages built
156
+ only on failure.
157
+ - Seeded xorshift32; any failure prints seed + op index, replayable via
158
+ `TORTURE_SEED=... node --expose-gc test/torture.mjs`.
159
+ - lite-gc-profiler is one-measurement-at-a-time; tiers run sequentially;
160
+ read `../lite-gc-profiler/llms.txt` before wiring -- rule names from
161
+ memory are how gates rot. Unknown rule keys throw as of 1.10.0; floor the
162
+ devDep there.
163
+ - lite-leak held-value contract: neither `cleanup` nor `tag` closes over the
164
+ tracked target. Never resolve an unexpected `inconclusive` with
165
+ `allowInconclusive`.
166
+ - `test/` never enters `files[]`; `npm pack --dry-run` proves it.
167
+
168
+ ### s0 -- metamorphic laws
169
+
170
+ For any seeded op sequence: `state.count` equals values yielded (both modes);
171
+ buffer `values.length <= maxBuffer` always; `droppedCount` equals exactly
172
+ `max(0, yields - maxBuffer)` when no consumer drains; latest-mode terminal
173
+ `value` equals the last yield; toAsyncIterable delivery order equals source
174
+ order (filtered) in buffer mode; **timing invariance**: the same logical
175
+ value sequence delivered via sync iterables, microtask-resolved promises,
176
+ and macrotask-resolved promises produces the same terminal state (count,
177
+ last value/values, done, error class) -- the scheduling analog of
178
+ bake-stream's re-chunk invariance. Terminal states are frozen: after
179
+ done/error, no further mutation ever (already unit-pinned; fuzz-scale it).
180
+
181
+ ### s1 -- degenerate values and hostile iterators
182
+
183
+ Values: `undefined` yields (state.count is the oracle for "did a value
184
+ arrive", value alone is ambiguous -- pin it), null, NaN, -0, frozen objects,
185
+ functions, symbols. Sources: 1M-value sync iterable (stack law, P4 baseline
186
+ ~80ms), empty iterable (immediate done), iterator with `.next` but no
187
+ `Symbol.asyncIterator`, thenable-returning next(), next() returning
188
+ non-object (pinned TypeError), result objects with throwing getters
189
+ (`{get done() {throw}}`), `return()` that throws (swallowed -- pin),
190
+ `return()` that never settles, an iterator that resolves a pull AFTER
191
+ stop/abort landed (the `if (stopped)` guards, fuzz-reachable). Aborts:
192
+ pre-aborted (pinned), abort inside the first `queueMicrotask` window before
193
+ pull one, abort reentrantly from inside `onError`/`onDone`, abort with a
194
+ custom `reason` (pinned), double abort.
195
+
196
+ ### s2 -- protocol conformance (toAsyncIterable)
197
+
198
+ next() after done; `return(value)` passthrough vs pending waiters resolving
199
+ `{value: undefined, done: true}` (the documented split); `throw()` rejecting
200
+ both waiters and its own call site; multi-waiter FIFO under fuzz (N waiters
201
+ x M values, orders compared to a queue oracle); `Promise.all` batch pulls;
202
+ `for await` + break; `await using` on node >= 20; the emitInitial x filter x
203
+ initial-value matrix (8 cells, each named); filter throwing on the initial
204
+ synchronous fire (the late-binding unsubscribe path, `Stream.js:656-663`).
205
+
206
+ ### s3 -- API abuse (throw / documented no-op / documented undefined -- pick one per case)
207
+
208
+ Mode typos ("Latest", "newest", 5); maxBuffer 0 / -1 / 1.5 / Infinity / NaN /
209
+ "5"; `mode: "latest"` + maxBuffer (pinned TypeError); timeout 0 (DECIDE and
210
+ pin: fires on the first turn -> first pending next() rejects TimeoutError),
211
+ timeout Infinity (non-finite -> RangeError, pin), timeout -1; filter
212
+ non-function; non-signal source/target (missing peek, missing subscribe,
213
+ missing set, frozen impostor objects); `stop()` twice (pinned) and `stop()`
214
+ racing a pending pull; dispose-then-X: every LS-01/LS-04 case lands here as
215
+ a NAMED test pinning the documented behavior ("dispose does not stop the
216
+ pump; abort does" / "dispose under a consuming iterable hangs -- documented
217
+ anti-pattern, timeout is the escape").
218
+
219
+ ### s4 -- seeded schedule fuzz vs a naive oracle
220
+
221
+ A seeded generator emits op schedules over both directions: producer-set /
222
+ consumer-next / filter-toggle-outcome / abort / break / return / timeout-arm,
223
+ with micro/macro-task jitter. Oracle: a deliberately naive reference model
224
+ (unbounded array + hand bookkeeping of drops/overflow per the documented
225
+ semantics). Compare delivered sequences, terminal states, and counter values
226
+ cell-for-cell. Divergence prints seed + op index + a minimal replay line.
227
+ The multi-waiter FIFO and the ring head/wrap arithmetic are the two
228
+ mechanisms this tier exists to break.
229
+
230
+ ### s5 -- the alloc gate (the llms.txt claims, gated at last)
231
+
232
+ `runOpsGate(fn, { ops })` with `RULES = { maxMajor: 0, maxPauseMs: 4 }`,
233
+ stabilize `'deep'`. Scenarios and their DOCUMENTED budgets (the M0 doctrine:
234
+ per-scenario budgets, never a widened global rule):
235
+ - toAsyncIterable `mode:"latest"`, consumer caught up: llms.txt claims ZERO
236
+ steady-state allocation -- gate exactly that (plus the per-next Promise
237
+ cost intrinsic to the protocol, measured and documented once).
238
+ - toAsyncIterable buffer, no waiter pending: same claim, same gate.
239
+ - pipeToSignal steady pump: budget from today's 0.26 B/op retained baseline.
240
+ - fromAsyncIterable latest: one state object per yield BY DESIGN -- assert
241
+ the documented envelope (today 0.35 B/op retained steady-state), assert
242
+ nothing HIDDEN grows (retained slope flat).
243
+ - fromAsyncIterable buffer: state object + snapshot array per yield BY
244
+ DESIGN -- envelope pinned per maxBuffer tier (8 / 64 / 1024).
245
+ - waiter-heavy path (consumer faster than producer): waiter objects are
246
+ intrinsic; retained slope must be flat.
247
+
248
+ ### s6 -- soak and retention (lite-leak enters here)
249
+
250
+ 4096 cycles per surface of: construct -> pump N values -> terminate (each of
251
+ the three paths in rotation) -> drop refs; tracker with the standard kernels;
252
+ `tracker.size()` returns to 0. Abort-listener accumulation on a SHARED
253
+ AbortSignal across 4096 streams (the removeEventListener discipline at
254
+ scale). Timer discipline: 4096 timeout-armed iterables that settle before
255
+ the deadline -- `clearTimeout` called every time (the llms.txt "negative
256
+ retained" bench hints this is right; prove it). The existing 4K-cycle unit
257
+ leak tests stay in `test/07`; s6 is the deeper per-commit version.
258
+
259
+ ### s7 -- conformance: the consumer corpus, the floor, the pact
260
+
261
+ - **StreamQuery corpus**: the exact call shape from `StreamQuery.js:124-154`
262
+ (transform-as-tap + hand ring + abort-filtering onError), run against a
263
+ seeded source, asserting the semantics lite-query depends on: transform
264
+ called once per value BEFORE set, abort fires onError with
265
+ `signal.aborted === true` observable, onDone exactly once, stop idempotent.
266
+ lite-stream must never drift under its flagship consumer silently. When
267
+ LS3 ships the enriched pipeToSignal, the corpus gains the enriched shape
268
+ and KEEPS the 1.1.0 shape (both are public API).
269
+ - **Floor matrix**: `npm run test:floor` -- scripted: install the peer floor
270
+ (`@zakkster/lite-signal@1.2.2`) via `--no-save --no-package-lock`, full
271
+ `test:gc` + torture, restore latest, print both verdicts. Run per release
272
+ (release drill step), not per commit. LS-09 stays a fact, not a claim.
273
+ - **TimeoutError duck-pact**: shape assertions (`name`, `timeout` field,
274
+ `instanceof Error`, message prefix) pinning the cross-package duck-check
275
+ contract (2b). No lite-await devDep -- the pact is structural.
276
+
277
+ ### s8 -- controls
278
+
279
+ `STREAM_TORTURE_BREAK=1 node --expose-gc test/torture.mjs` must exit
280
+ non-zero via injected breakage, one per tier minimum: an allocating closure
281
+ in the s5 caught-up loop; an oracle that ignores droppedCount in s4; a
282
+ skipped abort case in s1; a waiter-order swap in s2; a retained-stream ref
283
+ in s6; a corpus assertion inverted in s7. A gate that cannot fail is
284
+ decorative.
285
+
286
+ ## 4. Session order
44
287
 
45
- `toAsyncIterable` currently only does drop-oldest on overflow. Some
46
- consumers might want:
288
+ ```
289
+ LS1 (1.1.1 truth patch)
290
+ |
291
+ v
292
+ LS2 (1.2.0 the law's gate + guards + VERSION + packaging)
293
+ |
294
+ v
295
+ LS3 (1.3.0 the consumer contract: pipeToSignal enrichment + abort decision)
296
+ |
297
+ v [tripwire: lite-query Q8 session-start spike rules the split]
298
+ LS4 (1.4.0 Q8 enablement: stall detection / push-writer / share primitives)
299
+ ```
47
300
 
48
- - `overflow: "drop-newest"` -- reject incoming when full
49
- - `overflow: "throw"` -- iterator throws on overflow
50
- - `overflow: "block"` -- producer-side signal back-channel (requires
51
- consumer cooperation; significant API change)
301
+ Serial on purpose: LS1's honest docs are the floor LS2's gates stand on;
302
+ LS2's harness is what makes LS3's surface growth safe to ship; LS4 is
303
+ design-gated on lite-query Q8 and MUST NOT jump the queue (building shared-
304
+ stream primitives before the consumer's failover matrix exists would be
305
+ speculative API -- the exact thing the 1.0 deferral discipline refused).
52
306
 
53
- **Why deferred from 1.0:** Drop-oldest is the only mode any real consumer
54
- in the roadmap actually wants. Adding alternatives speculatively grows the
55
- API surface for no measurable benefit.
307
+ ## 5. The briefs
56
308
 
57
- **Triggering signal:** A consumer demonstrating that drop-oldest causes
58
- observable correctness issues for their use case.
309
+ ===============================================================================
310
+ # LS1 -- lite-stream v1.1.1 -- the docs tell the truth and the gates run
311
+ ===============================================================================
59
312
 
313
+ ```markdown
60
314
  ---
61
-
62
- ### `mergeIterables(iterA, iterB, ...)`
63
-
64
- Merge multiple async iterators into one signal, interleaving values as
65
- they arrive. Useful for "watch both helix and pubsub for follower
66
- changes" patterns.
67
-
68
- **Why deferred from 1.0:** Doable today by composing iterators externally
69
- (an async generator that races multiple sources). Built-in support cleans
70
- up the cancellation story (one AbortSignal cancels all sources) but adds
71
- complexity.
72
-
73
- **Triggering signal:** First Twitch consumer that needs to merge >= 2
74
- async sources into a single reactive surface with shared cancellation.
75
-
315
+ package: "@zakkster/lite-stream"
316
+ version_target: 1.1.1
317
+ status: planned
318
+ tests_min: 85
319
+ skip_max: 1 # only the named asyncDispose environment skip, node < 20
320
+ findings: [LS-01, LS-02, LS-04, LS-06, LS-07, LS-08, LS-10]
321
+ depends_on: []
76
322
  ---
77
323
 
78
- ### `splitSignal(sig, predicate) -> [matchingSig, restSig]`
79
-
80
- The dual of `mergeIterables`: split a signal's value stream into two based
81
- on a predicate, exposing both halves as new signals.
82
-
83
- **Why deferred from 1.0:** No concrete consumer in the roadmap needs this.
84
- Speculative.
324
+ # lite-stream -- no shipped sentence the probes can refute
325
+
326
+ PURPOSE
327
+ A patch release with zero API change that makes every shipped claim true
328
+ and every existing test actually run. The package's core positioning is
329
+ "structural cleanup on every termination path"; today one documented path
330
+ is a leak generator. Fix the words now, the machinery in LS2/LS3.
331
+
332
+ TASKS
333
+ - LS-01 docs surgery, all three shipped docs: llms.txt lines 132-134
334
+ replaced with the truth ("disposing the result signal does NOT stop the
335
+ pump; the iterator keeps pulling. AbortSignal or natural completion are
336
+ the only stop mechanisms. See ROADMAP LS-01."); the Stream.js:148-152
337
+ docstring stops offering dispose as a stop mechanism; README cleanup
338
+ section aligned. The dead catch at Stream.js:343-351 STAYS (belt against
339
+ a future lite-signal that throws) but its comment stops claiming it is
340
+ the dispose path and starts naming what it actually is: defensive.
341
+ - LS-01/LS-04 pinned tests (new test/08-dispose-behavior.test.mjs, ~6
342
+ tests): dispose-mid-pump -> pump continues, return() not called (pin the
343
+ TRUE behavior so any future change is a conscious one); same for
344
+ pipeToSignal; dispose-source under toAsyncIterable -> pending next()
345
+ hangs (pin via a bounded race against a sentinel timer); each test cites
346
+ LS-01/LS-04 in its name.
347
+ - LS-02: default gate becomes `"test": "node --expose-gc --test
348
+ --test-reporter=spec"`; `test:gc` kept as an alias for muscle memory;
349
+ `verify` unchanged. Assert in-session: 79 -> 79 pass 0 skip (pre-verified
350
+ 2026-09-01: 21.5s).
351
+ - LS-06: delete the two `CONTRACT-toAsyncIterable-1.1.md` references (the
352
+ locked semantics live in llms.txt + this roadmap's s2/s3 tiers now);
353
+ rewrite the TimeoutError comment without the Session A/B narrative.
354
+ - LS-07: fix the pump comment to state the actual mechanism (".then
355
+ handlers unwind the stack; no explicit deferral needed") and cite the 1M
356
+ sync-source probe number.
357
+ - LS-08: re-run `npm run bench`, stamp llms.txt Performance and README
358
+ Benchmarks with node version + date + "your numbers will differ", one
359
+ source of truth (llms.txt), README cites it.
360
+ - LS-10 (partially done at roadmap-pass time): .gitignore landed; record
361
+ the no-lockfile policy line in README's Testing section or CONTRIBUTING
362
+ stub -- one sentence, not a document.
363
+ - CHANGELOG [1.1.1]: Fixed (docs corrections, each naming its finding),
364
+ Added (dispose-behavior pins, gate flag). Facts only.
365
+
366
+ HOT PATH
367
+ Untouched. `git diff -- Stream.js` shows comment-only hunks; reviewer
368
+ proves token-stream identity (the Q2 discipline).
369
+
370
+ ASSERTIONS
371
+ - npm test -> >= 85 pass, 0 fail, skip <= 1 (asyncDispose env skip only,
372
+ named).
373
+ - bench/findings-probes-2026-09-01.mjs output UNCHANGED (the probes pin
374
+ behavior; this release changes words).
375
+ - grep proves no shipped doc contains "tears down" dispose claims; grep
376
+ finds the AbortSignal-only sentence in llms.txt AND README.
377
+ - npm pack --dry-run: no test/, no bench/, no demo/.
378
+
379
+ NON-GOALS
380
+ No API change. No VERSION export (that is additive -> LS2). No torture
381
+ harness (LS2). No behavior change to the pump.
382
+
383
+ DONE WHEN
384
+ Every sentence the probes can check is true, the default gate runs
385
+ everything, and 1.1.1 is published with the corrected llms.txt.
386
+ ```
85
387
 
86
- **Triggering signal:** A consumer demonstrating a real need to fan-out a
87
- single async source into two reactive surfaces.
388
+ ===============================================================================
389
+ # LS2 -- lite-stream v1.2.0 -- the law's gate, able to fail
390
+ ===============================================================================
88
391
 
392
+ ```markdown
89
393
  ---
90
-
91
- ## 1.2.0+ candidates (smaller, lower priority)
92
-
93
- - **`fromEventTarget(target, eventName)`** -- a wrapper that turns DOM
94
- EventTarget events into an async iterable feeding a signal. Doable
95
- today via async generators, but ergonomically common. Add if the
96
- pattern recurs across browser-facing consumers (e.g. window resize
97
- observers, pointer event streams).
98
-
99
- - **`backpressure: "block"` for pipeToSignal** -- await the target
100
- signal's effects before pulling the next value. Requires effect-
101
- completion semantics from lite-signal, which don't exist today.
102
-
103
- - **`opts.equals` for diff-mode signals** -- by default the underlying
104
- signal uses lite-signal's default `Object.is`. Allow opt-in to a custom
105
- equality predicate for buffer-mode snapshot arrays (where Object.is
106
- always reports them as distinct).
107
-
108
- - **Diagnostic surface** -- `live.stats() -> { yields, drops, errors,
109
- startedAt, settledAt }` for telemetry. Useful but no consumer asking
110
- for it yet.
111
-
394
+ package: "@zakkster/lite-stream"
395
+ version_target: 1.2.0
396
+ status: planned
397
+ tests_min: 90
398
+ skip_max: 1
399
+ torture: "s0-s8 ok; STREAM_TORTURE_BREAK=1 fails every tier; floor matrix green"
400
+ findings: [LS-03, LS-05, LS-09, LS-11]
401
+ depends_on: [LS1]
112
402
  ---
113
403
 
114
- ## Deferred indefinitely
115
-
116
- ### Full reactive stream combinators
117
-
118
- `map`, `filter`, `take`, `skip`, `debounce`, `throttle`, etc. -- the
119
- ReactiveX / RxJS surface area.
120
-
121
- **Why:** Once you have async iterators, all of these are five-line async
122
- generators. Adding them to lite-stream would create two ways to do the
123
- same thing, where the second way (combinator API) hides the source code
124
- and makes the cancellation model harder to reason about. The async
125
- generator form is more inspectable and composes naturally with all of
126
- JS's existing async tooling.
127
-
128
- If a consumer wants `debounce`, they write:
129
-
130
- ```js
131
- async function* debounce(source, ms, signal) {
132
- let pending;
133
- for await (const v of source) {
134
- if (pending) clearTimeout(pending);
135
- pending = await new Promise((r) => {
136
- setTimeout(() => r(v), ms);
137
- signal?.addEventListener("abort", () => r(undefined));
138
- });
139
- if (pending !== undefined) yield pending;
140
- }
141
- }
404
+ # lite-stream -- prove the claims, then keep them proven
405
+
406
+ PURPOSE
407
+ Port the suite-law torture harness (bake-stream M0 discipline, tiers per
408
+ section 3), add the drift guards that keep docs/exports/ASCII honest
409
+ (lite-query Q2 discipline), export VERSION, and execute the LS-11
410
+ packaging ruling. After this session the package can prove its own
411
+ headline claims -- including the zero-steady-state-alloc sentence that has
412
+ been unverified since 1.0.0.
413
+
414
+ TASKS
415
+ - test/torture.mjs + test/torture/ per section 3: harness (seeded PRNG,
416
+ check thunks, runOpsGate stabilize:'deep', STREAM_TORTURE_BREAK,
417
+ preflight exit 2 on missing devDeps), tiers s0-s8, controls for every
418
+ tier. devDeps: @zakkster/lite-leak (floor from its llms.txt),
419
+ @zakkster/lite-gc-profiler ^1.10.0 (unknown-rule-keys-throw relied on).
420
+ Read both llms.txt files before wiring; rule names from memory are how
421
+ gates rot.
422
+ - package.json: "torture": "node --expose-gc test/torture.mjs";
423
+ "torture:control": "STREAM_TORTURE_BREAK=1 node --expose-gc
424
+ test/torture.mjs || echo control-failed-as-expected"; "test:floor" per
425
+ s7; "verify" gains torture.
426
+ - Guards (Q2-modeled, ~5 tests): ascii-guard (walk = files[] parsed at
427
+ runtime + test/ + bench/ + demo/; printable ASCII + LF; empty allowlist;
428
+ inline failing control) and surface-guard (exports == llms.txt == d.ts
429
+ export names; peer names in llms.txt exist in peerDependencies; failing
430
+ fixture control). Pre-verified 2026-09-01: repo is already 0 non-ASCII.
431
+ - LS-05: `const VERSION = "1.2.0"` exported from Stream.js; string-equals
432
+ package.json (release-drill site). Never re-exported by consumers
433
+ (suite ruling). Surface-guard and d.ts updated in the same commit.
434
+ - LS-11 ruling executes: ROADMAP.md out of files[]; llms.txt Files section
435
+ updated; CHANGELOG forward-references fixed (the published 1.1.0 tarball
436
+ keeps its pointer -- the History section below preserves the target).
437
+ - LS-09: test:floor scripted and run in-session (pre-verified green
438
+ 2026-09-01); result recorded in CHANGELOG with provenance. Peer floor
439
+ STAYS ^1.2.0 because it is now a tested fact.
440
+ - Expected s5 friction, decided in advance (M0 lesson): the per-next
441
+ Promise allocation in toAsyncIterable and the by-design per-yield state
442
+ objects in fromAsyncIterable get per-scenario documented budgets --
443
+ never a widened global rule. A budget that moves is not a gate.
444
+
445
+ ASSERTIONS
446
+ - npm test >= 90 pass, 0 fail, skip <= 1; torture prints "ok" exit 0;
447
+ torture:control exits non-zero FOR EVERY TIER (each control verified
448
+ individually in-session).
449
+ - The llms.txt zero-steady-state-alloc sentence now has a gate (s5) and
450
+ the gate has a control (s8).
451
+ - npm pack --dry-run: ROADMAP.md ABSENT, test/ bench/ demo/ absent,
452
+ llms.txt + CHANGELOG.md present; VERSION === package.json version.
453
+ - Fresh-clone drill: clone to scratch, npm install, npm test, npm run
454
+ torture -- all green (the lite-query G10 habit).
455
+
456
+ NON-GOALS
457
+ No surface growth beyond VERSION. No pipeToSignal enrichment (LS3). No
458
+ behavior changes -- if a tier finds a real bug, it lands as a NAMED
459
+ failing todo + ledger row and ships fixed in LS3, not silently here.
460
+
461
+ DONE WHEN
462
+ the law's command runs from the law's path, every control fails, the
463
+ floor matrix is scripted, and 1.2.0 is published with VERSION exported
142
464
  ```
143
465
 
144
- Not as pretty as `source.pipe(debounce(500))`, but inspectable, debuggable,
145
- and composes with everything else in the JS async ecosystem.
466
+ ===============================================================================
467
+ # LS3 -- lite-stream v1.3.0 -- the consumer contract session
468
+ ===============================================================================
146
469
 
470
+ ```markdown
147
471
  ---
148
-
149
- ### `propagate: "always"` mode
150
-
151
- A mode where every yielded value triggers an effect run even if
152
- `Object.is`-equal to the previous. Currently the underlying signal's
153
- default equality (`Object.is`) is sufficient for all known consumers --
154
- our wrapper state object is fresh per yield, so dedup never kicks in.
155
-
156
- **Why deferred:** No consumer wants force-propagate semantics in
157
- practice; the wrapper state shape already guarantees per-yield
158
- propagation.
159
-
472
+ package: "@zakkster/lite-stream"
473
+ version_target: 1.3.0
474
+ status: planned
475
+ tests_min: 105
476
+ skip_max: 1
477
+ torture: "ok + s7 corpus extended to the enriched shape"
478
+ findings: [LS-12]
479
+ depends_on: [LS2]
480
+ consumer_evidence: "StreamQuery.js:119-137 (hand ring), :127 (status tap), :139-142 (abort filter)"
160
481
  ---
161
482
 
162
- ## Non-goals
163
-
164
- These will never ship in `lite-stream`:
483
+ # lite-stream -- what its flagship consumer already built by hand, offered properly
484
+
485
+ PURPOSE
486
+ Every addition in this session has a shipped consumer code-site as its
487
+ triggering signal -- the 1.0 deferral discipline, satisfied for real.
488
+ lite-query's StreamQuery hand-rolls three things around pipeToSignal:
489
+ a buffer window (push/shift/slice -- O(n) shift where our ring is O(1)),
490
+ a first-value status tap smuggled through `transform`, and an
491
+ abort-filtering onError. Offer all three natively; let the consumer
492
+ delete code.
493
+
494
+ TASKS
495
+ - pipeToSignal enrichment (design settled in-session by the planner,
496
+ candidates): `mode: "latest" | "buffer"` + `maxBuffer` + a
497
+ droppedCount observable (return-object upgrade or stats getter --
498
+ decide against the "returns stop fn" 1.x compat constraint: the stop
499
+ FUNCTION must remain callable as before; additive properties on it are
500
+ the compat-safe shape), and `onValue(v)` tap firing before set (the
501
+ status-tap consumer). All additive; 1.1.0 call shapes byte-compatible
502
+ (s7 corpus keeps both shapes green).
503
+ - LS-12 decision doc, decisions/0001-abort-vocabulary.md (the lite-await
504
+ decisions/ convention): EITHER pin the asymmetry as deliberate with the
505
+ producer/consumer rationale (from-side: abort interrupts production =
506
+ error framing; to-side: abort stops listening = graceful done) and
507
+ document it in llms.txt as a contract table -- OR add `onAbort`
508
+ callbacks so consumers stop filtering. Recommendation: PIN the
509
+ asymmetry + add `onAbort` to pipeToSignal only (the direction with the
510
+ shipped workaround); do not touch toAsyncIterable's graceful-done
511
+ contract (1.0.0-locked, correct for its side).
512
+ - s7 corpus: add the enriched-shape cases; keep the 1.1.0-shape cases
513
+ forever.
514
+ - Cross-repo rider recorded (not executed here): lite-query parking entry
515
+ exists pointing at this session -- after 1.3.0 ships, StreamQuery can
516
+ collapse its ring + tap + filter into the enriched call in a lite-query
517
+ minor, and refresh its vendored demo copy (still 1.0.0).
518
+
519
+ ASSERTIONS
520
+ - Old-shape s7 corpus green UNCHANGED against the new build (no drift
521
+ under the flagship consumer).
522
+ - A StreamQuery-shaped rewrite using the enriched surface (as a TEST, in
523
+ this repo) produces identical observable semantics to the hand-rolled
524
+ original, including droppedCount parity on overflow.
525
+ - Suite >= 105, 0 fail; torture ok; guards green; VERSION bumped.
526
+
527
+ NON-GOALS
528
+ No fromAsyncIterable changes. No toAsyncIterable changes. No shared/
529
+ multicast primitives (LS4, gated). No combinators (never -- History).
530
+
531
+ DONE WHEN
532
+ lite-query COULD delete StreamQuery.js:119-137 without behavior change,
533
+ and 1.3.0 is published with the decision doc on disk
534
+ ```
165
535
 
166
- - **A standalone scheduler.** Microtask / setImmediate decisions stay
167
- inside the pump. The library does not expose a scheduling API.
168
- - **A retry/backoff policy.** Consumers compose with `lite-await`'s
169
- `withTimeout`, `withAbort`, and a plain `for` loop. Every Twitch SDK
170
- endpoint has different retry semantics; a single policy would be
171
- opinionated wrong for half of them.
172
- - **WebSocket / EventSource adapters.** Those belong in the SDK that
173
- consumes them (lite-twitch-ebs, lite-twitch-pubsub), not in lite-stream.
174
- - **Concurrent multi-iterator orchestration beyond `mergeIterables`** --
175
- use `lite-await`'s `allOf` / `anyOf` / `raceOf` for that.
536
+ ===============================================================================
537
+ # LS4 -- lite-stream v1.4.0 -- Q8 enablement (GATED, do not start on a hunch)
538
+ ===============================================================================
176
539
 
540
+ ```markdown
541
+ ---
542
+ package: "@zakkster/lite-stream"
543
+ version_target: 1.4.0
544
+ status: gated
545
+ gate: "lite-query Q8 session-start spike has ruled which primitives land here"
546
+ tests_min: 120
547
+ depends_on: [LS3]
177
548
  ---
178
549
 
179
- ## Versioning policy
180
-
181
- `@zakkster/lite-stream` follows strict semver. 1.x is a stable API; only
182
- additive changes (new exports, new opts fields, new state-shape fields)
183
- ship in minor releases. Breaking changes wait for 2.0. Patch releases are
184
- bug fixes only.
185
-
186
- Each addition will land with:
550
+ # lite-stream -- the primitives a one-connection browser needs, if Q8 wants them here
551
+
552
+ PURPOSE
553
+ lite-query 2.0 (Q8) makes one tab own an upstream iterator while N tabs
554
+ consume frames. Three primitive-shaped needs are visible from Q8's brief
555
+ today; WHICH of them belong in lite-stream (vs inside lite-query, vs
556
+ nowhere) is Q8's session-start spike ruling. This brief is a menu, not a
557
+ commitment.
558
+
559
+ CANDIDATE MENU (each with its Q8 obligation)
560
+ - Idle/stall detection: an `idleTimeout` that resets per value (today's
561
+ `timeout` is an overall deadline -- wrong tool for "leader hung, frames
562
+ stopped, channel alive"). Smallest candidate, clearest fit.
563
+ - Push-writer surface: values that arrive by callback (channel frames),
564
+ not by iterator pull, entering a signal with the same mode/maxBuffer/
565
+ droppedCount discipline (the follower side's projection). Shape TBD --
566
+ possibly just documenting the enriched pipeToSignal over a hand-made
567
+ async queue, possibly a real `createSignalWriter`.
568
+ - Share/refcount: one source iterator, N signal consumers, last-consumer
569
+ teardown. The most API-heavy candidate and the most likely to belong
570
+ INSIDE lite-query next to its cache entries instead -- do not build
571
+ speculatively.
572
+
573
+ ASSERTIONS (floor, if the gate opens)
574
+ Whatever ships: torture tiers extended same-session (a primitive without
575
+ its soak + fuzz + controls does not ship); Q8's failover matrix consumes
576
+ the new surface in the same quarter, or the surface is reverted before
577
+ 2.0 -- an enabler nobody enabled is speculative API with a version number.
578
+
579
+ DONE WHEN
580
+ the Q8 ruling is recorded here (menu -> commitments), and either 1.4.0
581
+ ships what Q8 consumes, or this session closes as "not needed in
582
+ lite-stream" with the reasoning -- both are wins
583
+ ```
187
584
 
188
- 1. A concrete triggering consumer in the `@zakkster/lite-*` ecosystem
189
- 2. Tests covering the full lifecycle (happy + all three cleanup paths)
190
- 3. A bench measuring throughput + retained heap
191
- 4. A line item in CHANGELOG.md with a hot-path-cost note
585
+ ## 6. How to run it
586
+
587
+ In order: LS1 -> LS2 -> LS3 -> LS4 (gated). Pipeline per session: planner ->
588
+ coder -> reviewer -> qa; reviewer REJECTED goes back to coder. `status:
589
+ planned -> shipped` in the brief's frontmatter on publish, with the /release
590
+ drill (fail closed; the drill owns version bumps; VERSION site list from
591
+ LS2 on: package.json, Stream.js header, VERSION const, llms.txt if it ever
592
+ gains a version string).
593
+
594
+ Habits this roadmap is built around (inherited, locally re-proven):
595
+ 1. **Reproduce by running, never infer.** Every ledger row above has a
596
+ command. LS-01 existed for two shipped versions because a comment was
597
+ trusted over a run.
598
+ 2. **A green suite that skips its GC tier is a green light over a hole.**
599
+ Fourth suite recurrence (Q-02, AR-02, AW-14, LS-02). The fix is always
600
+ the same: the default gate runs everything.
601
+ 3. **Claims about a peer are facts about a VERSION of a peer.** The dispose
602
+ assumption was never true of any version; the floor claim happened to be
603
+ true of all of them. Neither status was known until today. The floor
604
+ matrix and the conformance corpus exist so both stay known.
605
+ 4. **Budgets, not widened rules.** By-design allocations (state objects,
606
+ snapshot arrays, protocol promises) get named per-scenario budgets in
607
+ s5; the global maxMajor: 0 rule never widens.
608
+
609
+ ## Parking ledger (deferred, with reasons and tripwires)
610
+
611
+ - **Dispose-aware pumps** (the LS-01 behavioral fix): IMPOSSIBLE in-library
612
+ today -- lite-signal 1.5.0 exposes no disposed-oracle (2b). Tripwire:
613
+ lite-signal ships one -> un-park, wire into fromAsyncIterable/pipeToSignal
614
+ teardown, upgrade the LS1 pinned "pump continues" tests into "pump stops"
615
+ tests in the same change. Until then: docs + pins (LS1).
616
+ - **toAsyncIterable dispose-source hardening** (LS-04): same tripwire, same
617
+ fate (pending next() could reject instead of hang).
618
+ - **mapLimit / transform concurrency** on fromAsyncIterable (from the 1.0
619
+ ledger): trigger re-grounded -- two or more SUITE consumers writing the
620
+ same bounded-concurrency wrapper. None exist today (checked: only
621
+ StreamQuery consumes, no concurrency wrapper).
622
+ - **toAsyncIterable overflow policies** (drop-newest / throw / block):
623
+ trigger unchanged -- a consumer demonstrating drop-oldest causes
624
+ observable correctness issues. None known.
625
+ - **mergeIterables / splitSignal**: trigger re-grounded from "first Twitch
626
+ consumer" to "first suite consumer needing >= 2 async sources into one
627
+ reactive surface with shared cancellation". Q8's leader might be that
628
+ consumer -- if so it routes through the LS4 menu, not around it.
629
+ - **fromEventTarget(target, eventName)**: browser-facing sugar; trigger:
630
+ the pattern recurring across suite demos. demo/index.html is the place to
631
+ look first (unaudited against the demo-audit law -- run that skill next
632
+ time the demo is touched).
633
+ - **pipeToSignal backpressure "block"**: requires effect-completion
634
+ semantics lite-signal does not have (2b-class cross-package fact).
635
+ Tripwire: lite-signal ships them.
636
+ - **opts.equals for buffer-mode snapshot dedup**: trigger: a consumer
637
+ demonstrating redundant snapshot propagation mattering in a profile.
638
+ - **stats() diagnostic surface**: superseded in part by droppedCount/
639
+ overflowCount; remainder (yields, startedAt, settledAt) waits for the
640
+ lite-query Q7 devtools feed to define what a stream should report --
641
+ tripwire: Q7 ships and asks.
642
+ - **RangeReader recipe** (bake-stream): docs-only candidate -- lazy shard
643
+ walk -> async generator -> fromAsyncIterable; needs bake-stream >= MQ1
644
+ (abortable range reads) so the AbortSignal story is end-to-end. Tripwire:
645
+ MQ1 ships.
646
+ - **propagate: "always"** and **full combinators (map/filter/debounce/...)**:
647
+ never -- see History; the async-generator composition answer stands.
648
+
649
+ ## History (preserved from the 1.0/1.1 roadmap; the shipped 1.1.0 CHANGELOG points here)
650
+
651
+ ### Shipped in 1.1.0 -- toAsyncIterable enrichment
652
+
653
+ Six additions, each with the triggering signal that moved it out of
654
+ deferred status: `mode: "latest" | "buffer"` (vocabulary match with
655
+ lite-query's streamQuery + reactive-state consumers); `filter` (removes the
656
+ per-yield wrapper-generator allocation); `timeout` + `TimeoutError` export
657
+ (replaces external AbortController boilerplate; structurally identical to
658
+ lite-await's class, deliberately not imported -- the zero-dep story);
659
+ `Symbol.asyncDispose` on node >= 20 (`await using`); multi-waiter FIFO
660
+ queue (latent 1.0.0 bug: `Promise.all([iter.next(), iter.next()])` silently
661
+ lost the first resolver); `overflowCount` (mode-neutral alias for
662
+ `droppedCount`). Full details: CHANGELOG.md [1.1.0].
663
+
664
+ ### Non-goals carried forward verbatim
665
+
666
+ No standalone scheduler; no retry/backoff policy (compose lite-await);
667
+ no WebSocket/EventSource adapters (they belong to the consuming SDK); no
668
+ ReactiveX combinator surface (async generators are the composition story --
669
+ inspectable, debuggable, cancellation-transparent); no multi-iterator
670
+ orchestration beyond a potential mergeIterables (lite-await's allOf/anyOf/
671
+ raceOf own that).
672
+
673
+ ### Versioning policy (unchanged)
674
+
675
+ Strict semver. 1.x additive only (new exports, new opts fields, new
676
+ state-shape fields); breaking waits for 2.0; patches are fixes/docs only.
677
+ Every addition lands with: a concrete suite consumer, full-lifecycle tests
678
+ (happy + all termination paths), a bench with provenance, and a CHANGELOG
679
+ line with a hot-path-cost note.
680
+
681
+ ## Progress log
682
+
683
+ - 2026-09-01 -- Roadmap enriched to blueprint form. Ground truth verified by
684
+ running: 79 tests (71+8 dark under default gate; 79/79 under test:gc,
685
+ against lite-signal 1.5.0 and 1.2.2 both), bench re-run with provenance,
686
+ five probes written and committed (bench/findings-probes-2026-09-01.mjs).
687
+ Ledger LS-01..LS-12 opened; LS-01 (dispose forever-pump, llms.txt claim
688
+ false since 1.0.0) is the headline. .gitignore added (LS-10 mitigation,
689
+ lite-query Q1 ruling). Cross-package wiring recorded on both sides:
690
+ lite-query Q8 spike <-> LS4 gate; LS3 <-> StreamQuery hand-ring rider.
691
+ Sessions LS1..LS4 defined. Nothing else changed; the tree still builds
692
+ and tests exactly as published 1.1.0.
693
+
694
+ - 2026-09-01 -- LS1 (v1.1.1 truth patch) landed in the working tree. LS-01:
695
+ the dispose-teardown claim corrected in llms.txt, README (two sites), the
696
+ Stream.js fromAsyncIterable + pipeToSignal docstrings, and the package.json
697
+ description; the defensive set() catch and its forward comment re-framed as
698
+ peer-defense (not disposal detection), and the LS-07 pump comment corrected
699
+ to "direct call, no deferral" -- the Stream.js diff is comment-only and the
700
+ code-line stream is byte-identical (md5 unchanged, exports unchanged). LS-06:
701
+ the three phantom CONTRACT-toAsyncIterable-1.1.md references and the
702
+ Session A/B narrative removed from Stream.js and test/07. LS-08: bench
703
+ re-measured with provenance (node v26.3.1, macOS arm64, 150ms warmup /
704
+ 500ms runs) and consolidated as llms.txt Performance (the single source of
705
+ truth); README now cites it with no ops/s digits; the four unreproducible
706
+ 1.1 bench scenarios deleted from the docs (no runnable source in the repo).
707
+ LS-02: --expose-gc added to the default npm test so the GC tier gates on
708
+ every run. Added test/08-dispose-behavior.test.mjs (6 pins, LS-01/LS-04);
709
+ LS-10 no-lockfile sentence added to the README. Measured: npm test 85 pass
710
+ / 0 fail / 0 skipped; probes P0-P4 structurally unchanged. The LS1 brief's
711
+ `status:` is left at planned -- it flips at publish, not here.