ADR 0001: a session is a stream — total model, archive, structured input, turn-less Session #53

Merged
mprihoda merged 41 commits from wave-2-session-stream into main 2026-07-15 17:12:08 +00:00
mprihoda commented 2026-07-15 14:26:07 +00:00 (Migrated from github.com)

The full implementation of ADR 0001 (docs/adr/0001-session-is-a-stream-not-turns.md) and the session-contract proposal, as one consolidated PR: 29 commits, four waves, each implemented under TDD with per-wave multi-agent review and adversarial verification. Supersedes the stacked PRs #51 and #52.

Waves (in landing order)

Docstring hazard fix — the ADR's "goes first, today": all three Session.stream scaladocs now state the real contract (shared long-lived queue, out-of-turn emission, mid-turn merge, single-consumer constraint) instead of the turn-shaped promise that cost a consumer a week.

Wave 1 — total message model (core, all modules)
parseMessage is total: unknown wire types survive as UnknownMessage(type, json)rate_limit_event and the system subtypes stop vanishing. MessageId (vendor uuid), origin: Option[ResultOrigin] with key-absent ≠ null (the in-turn/out-of-turn discriminator), full result envelope (stopReason, terminalReason, permissionDenials as raw JSON, apiErrorStatus, timings), real TokenUsage (Long counts, one shared decoder, moved to core.model), parentToolUseId + model on assistant messages, control-protocol types.

Wave 4 — ConversationArchive (custody, not attribution)
Tagless port in core/log + ZIO adapter. Pure MirrorPlanner (copy/extend/recopy plan as data) and ArchivePaths (locate by encoding the known cwd — never decoding, the encoding is lossy). Mirrors the whole session tree (main jsonl + subagents + meta). Append-extension only after byte-prefix verification; per-file failures recorded, never a half-aborted mirror. Per the ADR's rejected alternatives: no result journal, no attributed store.

Wave 3 — structured UserInput
UserInput(text, context, channel) encoded as content blocks; verbatim user text is identified by structural position, never by parsing its content, so the round-trip law decode(encode(i)) == Some(i) holds against adversarial inputs (property-tested, including </interactive> and convention-mimicking text). Proven end-to-end against the real CLI: the transcript decodes back to the exact original.

Wave 2 — the turn-less ZIO Session
stream, send(String) and the "pending" sentinel are deleted (breaking, 0.5.0). The new surface: fire-and-forget send(UserInput); completion as readable state — pure SessionState.fold behind a Ref, monotone resultsSeen/notificationsSeen (origin-absent vs origin-present), awaitResultAfter with the no-window guarantee and death-race; events/stateChanges as bounded-sliding Hub fan-out (capacity is a stated contract parameter); a real control-protocol interrupt with vendor request_id correlation returning InterruptOutcome(stillQueued); info replacing the sentinel (blocks until the CLI names the session, fails on death, interruptible — preserving the 0.4.1 init-hang fix); optional best-effort archive mirroring off the reader's critical path. direct/effectful sessions untouched per the ZIO-first decision; the one-shot query path keeps its honest turn framing (ADR decision 2).

Verification

Every wave: ./mill __.compile warning-free, full unit suites, full integration suites including live-CLI E2Es (structured-input round trip; archive locate→read→mirror on a real transcript tree; probe #5 as a permanent regression test — a real interrupt mid-turn yields an error result and the session survives a follow-up turn). Final state: __.test and __.itest fully green.

Each wave also went through a four-dimension review (spec fidelity, correctness/concurrency, architecture/FCIS, test quality) with adversarial verification of every finding; 17 confirmed findings were fixed across the waves, including a real send-path deadlock against the live CLI and a stateChanges hub that never completed on session end.

🤖 Generated with Claude Code

The full implementation of ADR 0001 (docs/adr/0001-session-is-a-stream-not-turns.md) and the session-contract proposal, as one consolidated PR: 29 commits, four waves, each implemented under TDD with per-wave multi-agent review and adversarial verification. Supersedes the stacked PRs #51 and #52. ## Waves (in landing order) **Docstring hazard fix** — the ADR's "goes first, today": all three `Session.stream` scaladocs now state the real contract (shared long-lived queue, out-of-turn emission, mid-turn merge, single-consumer constraint) instead of the turn-shaped promise that cost a consumer a week. **Wave 1 — total message model (`core`, all modules)** `parseMessage` is total: unknown wire types survive as `UnknownMessage(type, json)` — `rate_limit_event` and the system subtypes stop vanishing. `MessageId` (vendor `uuid`), `origin: Option[ResultOrigin]` with key-absent ≠ null (the in-turn/out-of-turn discriminator), full result envelope (`stopReason`, `terminalReason`, `permissionDenials` as raw JSON, `apiErrorStatus`, timings), real `TokenUsage` (`Long` counts, one shared decoder, moved to `core.model`), `parentToolUseId` + `model` on assistant messages, control-protocol types. **Wave 4 — `ConversationArchive` (custody, not attribution)** Tagless port in `core/log` + ZIO adapter. Pure `MirrorPlanner` (copy/extend/recopy plan as data) and `ArchivePaths` (locate by **encoding** the known cwd — never decoding, the encoding is lossy). Mirrors the whole session **tree** (main jsonl + subagents + meta). Append-extension only after byte-prefix verification; per-file failures recorded, never a half-aborted mirror. Per the ADR's rejected alternatives: **no result journal, no attributed store**. **Wave 3 — structured `UserInput`** `UserInput(text, context, channel)` encoded as content blocks; verbatim user text is identified by structural position, never by parsing its content, so the round-trip law `decode(encode(i)) == Some(i)` holds against adversarial inputs (property-tested, including `</interactive>` and convention-mimicking text). Proven end-to-end against the real CLI: the transcript decodes back to the exact original. **Wave 2 — the turn-less ZIO `Session`** `stream`, `send(String)` and the `"pending"` sentinel are deleted (breaking, 0.5.0). The new surface: fire-and-forget `send(UserInput)`; completion as readable state — pure `SessionState.fold` behind a `Ref`, monotone `resultsSeen`/`notificationsSeen` (origin-absent vs origin-present), `awaitResultAfter` with the no-window guarantee and death-race; `events`/`stateChanges` as bounded-sliding Hub fan-out (capacity is a stated contract parameter); a **real** control-protocol `interrupt` with vendor `request_id` correlation returning `InterruptOutcome(stillQueued)`; `info` replacing the sentinel (blocks until the CLI names the session, fails on death, interruptible — preserving the 0.4.1 init-hang fix); optional best-effort archive mirroring off the reader's critical path. `direct`/`effectful` sessions untouched per the ZIO-first decision; the one-shot `query` path keeps its honest turn framing (ADR decision 2). ## Verification Every wave: `./mill __.compile` warning-free, full unit suites, full integration suites including live-CLI E2Es (structured-input round trip; archive locate→read→mirror on a real transcript tree; **probe #5 as a permanent regression test** — a real interrupt mid-turn yields an error result and the session survives a follow-up turn). Final state: `__.test` and `__.itest` fully green. Each wave also went through a four-dimension review (spec fidelity, correctness/concurrency, architecture/FCIS, test quality) with adversarial verification of every finding; 17 confirmed findings were fixed across the waves, including a real send-path deadlock against the live CLI and a `stateChanges` hub that never completed on session end. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
mprihoda commented 2026-07-15 16:03:42 +00:00 (Migrated from github.com)

Code review gate (iterative-works review-code): PASSED after fixes

Nine specialized reviewers (architecture, ZIO, Scala 3, composition, style, testing, security, error handling, port/adapter) over the full diff: 1 critical, 15 warnings, 18 suggestions → all critical+warnings fixed, plus 5 suggestions and two rulings from Michal (documented FS-in-unit-tests exception; opaque SessionId/RequestId now). Full report + resolution: project-management/reviews/review-pr53-2026-07-15.md.

Highlights:

  • Critical closed and adversarially re-verified: path traversal via sessionId into archive paths — allow-list validation centralized in ArchivePaths, fuzzed with bypass attempts (encodings, unicode lookalikes, backslashes) by an independent re-review, which also caught that the first symlink fix missed two vectors (symlinked main transcript; symlinked tree root — os.walk dereferences a symlinked root regardless of followLinks). Both closed with regression tests.
  • Mirror hardening: 0700 archive dirs (retroactive), per-file failure logging, bounded close-time mirror.
  • Session hardening: redacted stdin logs, stderr threaded into death errors, accurate clean-exit error, ReaderContext refactor (10→3 constructor params).
  • Breaking (intended, 0.5.0): opaque SessionId/RequestId across the model (40 files).
  • Test infra: deterministic subscription sync, injectable background-runner seam, ETXTBSY (JDK-8068370) retry for mock-CLI spawns.

Final gates: __.compile warning-free, __.test 526/526, __.itest 730/730 (incl. real-CLI E2Es: structured-input round-trip, archive custody flow, real mid-turn interrupt with session survival).

🤖 Generated with Claude Code

## Code review gate (iterative-works review-code): PASSED after fixes Nine specialized reviewers (architecture, ZIO, Scala 3, composition, style, testing, security, error handling, port/adapter) over the full diff: **1 critical, 15 warnings, 18 suggestions** → all critical+warnings fixed, plus 5 suggestions and two rulings from Michal (documented FS-in-unit-tests exception; opaque `SessionId`/`RequestId` now). Full report + resolution: `project-management/reviews/review-pr53-2026-07-15.md`. Highlights: - **Critical closed and adversarially re-verified**: path traversal via `sessionId` into archive paths — allow-list validation centralized in `ArchivePaths`, fuzzed with bypass attempts (encodings, unicode lookalikes, backslashes) by an independent re-review, which also caught that the first symlink fix missed two vectors (symlinked main transcript; symlinked tree root — `os.walk` dereferences a symlinked root regardless of `followLinks`). Both closed with regression tests. - Mirror hardening: 0700 archive dirs (retroactive), per-file failure logging, bounded close-time mirror. - Session hardening: redacted stdin logs, stderr threaded into death errors, accurate clean-exit error, `ReaderContext` refactor (10→3 constructor params). - Breaking (intended, 0.5.0): opaque `SessionId`/`RequestId` across the model (40 files). - Test infra: deterministic subscription sync, injectable background-runner seam, ETXTBSY (JDK-8068370) retry for mock-CLI spawns. Final gates: `__.compile` warning-free, `__.test` 526/526, `__.itest` 730/730 (incl. real-CLI E2Es: structured-input round-trip, archive custody flow, real mid-turn interrupt with session survival). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
iterative-works/claude-code-query!53
No description provided.