contract(issue#2): scaffold ratatoskr.sessions — create + list

Issue #2: ratatoskr.sessions covers the session-lifecycle endpoints
needed by --send --new (POST /sessions) and the eventual TUI startup
picker (GET /sessions). Two FN blocks (create_session, list_sessions)
plus two shared frozen dataclasses (SessionInfo, SessionPage).
complexity=low; estimated 150 LOC.

Bundles both endpoints in one contract because they share the response-
envelope shape — SessionInfo carries the union of POST-response fields
(message_count) and list-item fields (name, archived, tags), with
the origin-conditional fields defaulting to None. INV-001/002 spell
out which fields come from which source so callers can rely on the
discriminator.

INV-006 refuses out-of-range limit (< 1 or > 200) client-side: spec
§GET /sessions says the server returns 422; the client checks first
so a 422 from this endpoint indicates server-side spec drift, not a
client bug.

INV-003 codifies the opaque-cursor discipline (spec §Pagination:
"Cursors are opaque to clients — do not parse or construct them.").
list_sessions threads next_cursor verbatim; never base64-decodes.

Exception .body truncation to [:1024] inherited from issue #1's
SseConnectFailed/CancelFailed precedent.

First contract in this repo to carry a ## Out of scope H2. Future
/volva-code-review consults will auto-resolve that section instead
of needing --out-of-scope overrides. Six explicit exclusions:
Bifrost binding (Worldtree #160), ephemeral/Saga sessions, single-
session fetch, PATCH/DELETE mutation, history pagination, transparent
multi-page iteration. Server retry/backoff is caller's policy.

dependencies: lists issue #1 as a convention-dependency only — no
code import; same API-consumption posture (caller-owned httpx
client, async-native, frozen dataclasses, no Worldtree-source
imports).

prd: pinned to issue #2 body SHA-256 01fbbd52b6d90eb0 at
2026-05-21T04:45:06+00:00; scripts/contract_drift_check.py returns
clean.
This commit is contained in:
2026-05-20 21:47:49 -07:00
parent c17af18351
commit 9df9bb8757
2 changed files with 206 additions and 4 deletions
+201
View File
@@ -0,0 +1,201 @@
---
contract_version: "2.1"
target_module: "ratatoskr.sessions"
scope: "Implement the Worldtree Conversation API session-lifecycle client for Ratatoskr. Two entry points: create_session (POST /sessions) and list_sessions (GET /sessions with cursor pagination), plus two shared frozen dataclasses (SessionInfo, SessionPage). Consumed by ratatoskr.cli for --send --new (single session create) and by ratatoskr.tui for the startup session picker (list). No core.* / worldtree.* imports; caller owns httpx.AsyncClient and Authorization header lifecycle. Convention-aligned with ratatoskr.sse_client (issue #1) — same posture, no shared types."
depends_on:
- "httpx"
used_by:
- "ratatoskr.cli"
- "ratatoskr.tui"
language: "python"
complexity: "low"
estimated_loc: 150
confidence: 0.9
assumptions:
- "Worldtree spec pin (`docs/conversation-api-spec.md` at v1.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`) is the wire contract. POST /sessions response shape (§POST /sessions) and GET /sessions response shape (§GET /sessions) are read FROM the spec, not from any Worldtree source import."
- "POST /sessions returns 201 Created with a body matching the documented shape (session_id, agent_id, message_count, created_at, last_active, metadata). The created_at/last_active fields are ISO 8601 strings with +HH:MM offsets."
- "GET /sessions cursor pagination uses the `v1.<base64url>` envelope (§Pagination); the consumer treats cursors as opaque strings (does not parse or construct them)."
- "Bifrost binding (Worldtree issue #160) is NOT used. create_session does not accept a `bifrost` parameter and never sends one in the request body."
open_questions:
- "Should SessionInfo split into two dataclasses (CreatedSessionInfo with message_count vs ListedSessionInfo with archived/tags/name) since the two endpoints return different field sets? Draft uses one SessionInfo with optional fields keyed by origin; consumers can rely on `metadata` being present in both, and `message_count`/`archived`/`tags`/`name` defaulting to sensible None/empty when not present."
- "Should list_sessions transparently paginate (iterate all pages) or surface one page at a time? Draft surfaces one page (SessionPage with next_cursor). Caller decides whether to iterate. Matches Worldtree's pagination idiom and lets the TUI render lazily."
prd:
issue: 2
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/2"
body_sha256_16: "01fbbd52b6d90eb0"
lock_in_comment_id: null
lock_in_sha256_16: null
lock_in_at: null
pinned_at: "2026-05-21T04:45:06+00:00"
dependencies:
- issue: 1
path: "src/ratatoskr/sse_client.py"
reason: "Convention dependency, not a code dependency. Issue #1 establishes the API-consumption posture (caller-owns httpx client, async-native, no Worldtree imports, response-parsing into frozen dataclasses, exception body truncation to [:1024]). sessions.py follows the same shape."
---
# Sessions — Worldtree Conversation API session lifecycle
## Context
`ratatoskr.sessions` is Ratatoskr's session-lifecycle client. Two entry points (`create_session`, `list_sessions`) plus two shared frozen dataclasses (`SessionInfo`, `SessionPage`). The module is the surface that `ratatoskr.cli` calls when `--send --new` mints a fresh session against Worldtree, and that `ratatoskr.tui` calls to populate the startup picker's `DataTable` of existing sessions.
The module deliberately does NOT cover per-turn operations (those live in `ratatoskr.sse_client`), session mutation (`PATCH /sessions/{id}` is out of scope per design-brief §4 negative clauses), or session deletion (`DELETE /sessions/{id}` is admin work via `sessions_cli.py`).
Convention-aligned with issue #1: caller owns the `httpx.AsyncClient` and Authorization header; the module never imports Worldtree source; responses are parsed into typed frozen dataclasses; exception `.body` payloads are truncated to `[:1024]` at construction.
## Data flow
**Input:**
- `httpx.AsyncClient` (caller-owned, base_url + bearer auth on the client).
- `agent_id: str` — for `create_session`.
- `include_archived: bool`, `limit: int`, `cursor: str | None` — for `list_sessions`.
**Output:**
- `create_session``SessionInfo`:
- `session_id: str`
- `agent_id: str`
- `created_at: str` (ISO 8601 with offset)
- `last_active: str`
- `metadata: dict[str, Any]`
- `message_count: int | None` (present from POST response; None when SessionInfo was sourced from a list item)
- `name: str | None`, `archived: bool | None`, `tags: list[str] | None` (present from list items; None when sourced from POST response)
- `list_sessions``SessionPage`:
- `items: list[SessionInfo]`
- `next_cursor: str | None` (None on the last page; opaque string otherwise)
**Side effects:** outbound HTTP only; no disk I/O, no global state.
## Invariants
- **INV-001 [hard]**: `create_session` returns a `SessionInfo` whose `session_id`, `agent_id`, `created_at`, `last_active`, and `metadata` fields are populated from the 201 response. `message_count` is populated from the response; list-only fields (`name`, `archived`, `tags`) are `None`.
- **INV-002 [hard]**: `list_sessions` returns a `SessionPage` where every `SessionInfo` has `session_id`, `agent_id`, `created_at`, `last_active`, `metadata`, `name`, `archived`, and `tags` populated from the response item shape. `message_count` is `None` (the list endpoint does not include it — spec §GET /sessions: "`message_count` is not included in list items").
- **INV-003 [hard]**: `list_sessions` treats cursors as opaque strings. The module never parses, base64-decodes, or constructs a cursor — it threads the server-provided `next_cursor` back verbatim on the next call. Per spec §Pagination ("Cursors are opaque to clients — do not parse or construct them.").
- **INV-004 [hard]**: Both functions truncate exception `.body` payloads to `[:1024]` at construction. Matches the issue #1 precedent (`SseConnectFailed`, `CancelFailed`).
- **INV-005 [hard]**: No `core.*` or `worldtree.*` imports. Boundary verified by `tests/test_no_worldtree_imports.py`.
- **INV-006 [hard]**: `list_sessions` rejects out-of-range `limit` values (`< 1` or `> 200`) client-side before issuing any HTTP request. Spec §GET /sessions specifies the server returns 422 on out-of-range; the client refuses to send an obviously-invalid request rather than depending on the server to reject it.
## Constraints
- **[compatibility]** Module must work against the spec pin (`55101e909abcd2219833266b6f905c5bc956e0f0`, Worldtree v0.19.0).
- **[security]** Module does not log full response bodies (they may carry user-readable session names + tags). Logging limited to status code + session_id when present.
- **[style]** Async-native. No sync entry points. Consistent with `sse_client`.
## Out of scope
- **Bifrost binding** (Worldtree issue #160). `create_session` does not accept or send a `bifrost` field. Ratatoskr is not a Bifrost consumer; consumer-side tool injection is an advanced feature outside the dev TUI's purpose.
- **Ephemeral / Saga sessions.** Separate session class with TTL semantics; not needed for hands-on dev probing.
- **`GET /sessions/{id}` (single fetch), `PATCH /sessions/{id}` (mutation), `DELETE /sessions/{id}` (deletion).** Per design-brief §4 negative clauses; admin operations live outside Ratatoskr.
- **`GET /sessions/{id}/messages` (history pagination).** Deferred until the TUI needs scrollback replay; `--send` doesn't need history.
- **Transparent multi-page iteration.** `list_sessions` returns one page; caller threads `next_cursor` for the next call. Don't add an `iter_all_sessions()` until the TUI proves it needs that shape.
- **Server retry / backoff.** Caller's policy. The module does not retry on 5xx; it surfaces failure once and returns control.
---
```contract
FN create_session(client: httpx.AsyncClient, agent_id: str) -> SessionInfo
BRIEF: POST /sessions with {"agent_id": agent_id} to create a new conversation session. Returns SessionInfo populated from the 201 response.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] agent_id is a non-empty string -- assert agent_id and isinstance(agent_id, str)
POST: [POST-001 side_effect] exactly one POST to /sessions was issued with body {"agent_id": agent_id} -- assert mock_router.calls.call_count == 1 and json.loads(req.content) == {"agent_id": agent_id}
POST: [POST-002 return_value] returns SessionInfo with session_id, agent_id, created_at, last_active, metadata populated from response -- assert all 5 fields non-None
POST: [POST-003 return_value] returns SessionInfo where message_count == response["message_count"] (typically 0 for a fresh session) and list-only fields (name, archived, tags) are None -- assert info.message_count is not None and info.name is None and info.archived is None and info.tags is None
ERROR_ROUTING:
HTTP 404 unknown_agent_id:
local_handling: raise AgentNotFound(agent_id=agent_id)
flow_control: abort
state_recovery: none (caller passed an unknown agent_id; that's a user error)
HTTP 422 validation_failed:
local_handling: raise SessionApiFailed(status=422, body=resp.content[:1024])
flow_control: abort
state_recovery: none (typically client bug; surface for debugging)
httpx.HTTPStatusError (other status):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content[:1024])
flow_control: abort
state_recovery: none
STEPS:
1. [setup, flexibility=prescriptive] Validate inputs per PRE-001, PRE-002
2. [sequential, flexibility=prescriptive] CALL client.post("/sessions", json={"agent_id": agent_id})
tool: { destructive: false, idempotent: false, read_only: false, open_world: false }
3. [branch, flexibility=prescriptive] IF resp.status_code == 404: RAISE AgentNotFound
ELIF resp.status_code != 201: RAISE SessionApiFailed
4. [sequential] Parse resp.json() → body
5. [cleanup] RETURN SessionInfo(
session_id=body["session_id"],
agent_id=body["agent_id"],
created_at=body["created_at"],
last_active=body["last_active"],
metadata=body.get("metadata", {}),
message_count=body.get("message_count"),
name=None,
archived=None,
tags=None,
)
TESTS:
happy_create [happy,tracer]: mock returns 201 with full body → returns SessionInfo with all create-side fields populated; list-only fields are None
happy_create_with_metadata [happy]: response includes metadata={"model": "glm5-turbo"} → SessionInfo.metadata == {"model": "glm5-turbo"}
request_body_shape [trace]: outbound JSON body is exactly {"agent_id": <arg>} — no Bifrost field, no extra keys
unknown_agent_id [error]: mock returns 404 → raises AgentNotFound(agent_id="mimir")
validation_failed [error]: mock returns 422 → raises SessionApiFailed(status=422); body truncated to ≤1024 bytes
unexpected_status_truncates [error]: mock returns 500 with 5000-byte body → SessionApiFailed; .body is exactly the first 1024 bytes
empty_agent_id [adversarial]: agent_id="" → AssertionError; no HTTP issued
```
```contract
FN list_sessions(client: httpx.AsyncClient, *, include_archived: bool = False, limit: int = 50, cursor: str | None = None) -> SessionPage
BRIEF: GET /sessions with cursor pagination. Returns one SessionPage. Caller threads next_cursor for subsequent pages.
PRE: [PRE-001 hard] client is not None -- assert client is not None
PRE: [PRE-002 hard] limit is in [1, 200] -- assert 1 <= limit <= 200 (INV-006: refuse out-of-range client-side; do not depend on server 422)
PRE: [PRE-003 hard] cursor is None or a non-empty string -- assert cursor is None or (isinstance(cursor, str) and cursor)
POST: [POST-001 side_effect] exactly one GET to /sessions was issued -- assert mock_router.calls.call_count == 1
POST: [POST-002 side_effect] query string carries `limit=<limit>` always; `include_archived=true` iff caller passed include_archived=True; `cursor=<cursor>` iff caller passed a cursor -- assert URL params match
POST: [POST-003 return_value] returns SessionPage(items=[SessionInfo, ...], next_cursor=str|None) per response -- assert isinstance(result.items, list) and (result.next_cursor is None or isinstance(result.next_cursor, str))
POST: [POST-004 return_value] each SessionInfo in items has list-side fields (name, archived, tags) populated and message_count=None per INV-002 -- assert all(info.message_count is None for info in result.items)
ERROR_ROUTING:
HTTP 422 (cursor_invalid):
local_handling: parse body for error_code; raise InvalidCursor(raw=cursor) if error_code == "cursor_invalid"; else raise SessionApiFailed
flow_control: abort
state_recovery: caller policy — restart from page 1 (cursor=None)
HTTP 422 (other validation_failed):
local_handling: raise SessionApiFailed(status=422, body=resp.content[:1024])
flow_control: abort
state_recovery: none (PRE-002/003 should have caught client-side issues; server-side 422 means spec mismatch)
httpx.HTTPStatusError (other status):
local_handling: raise SessionApiFailed(status=resp.status_code, body=resp.content[:1024])
flow_control: abort
state_recovery: none
STEPS:
1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-003
2. [sequential, flexibility=prescriptive] Build params dict: {"limit": limit}; ADD "include_archived": "true" iff include_archived; ADD "cursor": cursor iff cursor is not None
3. [sequential, flexibility=prescriptive] CALL client.get("/sessions", params=params)
tool: { destructive: false, idempotent: true, read_only: true, open_world: false }
4. [branch, flexibility=prescriptive] IF resp.status_code == 422:
Parse body; IF body.get("error_code") == "cursor_invalid": RAISE InvalidCursor(raw=cursor)
ELSE: RAISE SessionApiFailed(status=422, body=resp.content[:1024])
ELIF resp.status_code != 200: RAISE SessionApiFailed
5. [sequential] Parse resp.json() → body
6. [loop] FOR EACH item in body["items"]: CONSTRUCT SessionInfo(
session_id=item["session_id"],
agent_id=item["agent_id"],
created_at=item["created_at"],
last_active=item["last_active"],
metadata=item.get("metadata", {}),
message_count=None, # not in list response per spec
name=item.get("name"),
archived=item.get("archived"),
tags=item.get("tags", []) if "tags" in item else None,
)
7. [cleanup] RETURN SessionPage(items=infos, next_cursor=body.get("next_cursor"))
TESTS:
happy_first_page [happy,tracer]: GET /sessions, mock returns {items: [one full session shape], next_cursor: "v1.abc..."} → SessionPage(items=[1], next_cursor="v1.abc...")
happy_last_page [happy]: mock returns {items: [...], next_cursor: null} → SessionPage with next_cursor=None
empty_results [happy]: mock returns {items: [], next_cursor: null} → SessionPage([], None)
include_archived_query [trace]: include_archived=True → URL has include_archived=true; default → URL has no include_archived param OR explicit false (asserts default behavior)
cursor_threaded [trace]: cursor="opaque-from-prev-page" → URL has cursor=opaque-from-prev-page
limit_query [trace]: limit=10 → URL has limit=10
invalid_cursor_server [error]: mock returns 422 with body {"error_code":"cursor_invalid","message":"..."} → raises InvalidCursor(raw=<the cursor passed in>)
other_validation_failed [error]: mock returns 422 with body {"error_code":"validation_failed",...} → raises SessionApiFailed(status=422); body truncated
unexpected_status_truncates [error]: mock returns 500 with 5000-byte body → SessionApiFailed; .body is exactly the first 1024 bytes
limit_below_one [adversarial]: limit=0 → AssertionError; no HTTP issued
limit_above_max [adversarial]: limit=300 → AssertionError; no HTTP issued
empty_cursor [adversarial]: cursor="" → AssertionError; no HTTP issued
```
+5 -4
View File
@@ -54,10 +54,10 @@ What's NOT in the repo yet:
**Branch:** `main`. Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git` (added 2026-05-20).
**Next natural moves:**
1. Record real SSE snapshot fixtures from a running Worldtree (per `tests/snapshots/README.md`). Current tests use respx mocks against hand-rolled SSE wire — recording against a real Worldtree exercises spec conformance and gives version-skew detection per design-brief §2.
2. Build the `--send` stdout presenter — simplest consumer of `stream_turn`, exercises the API path without TUI machinery. Useful as a tracer for `ratatoskr.cli` work.
3. Textual TUI app shell — second presenter; multi-pane observability dashboard per design-brief §5. Layout shape locked there (Horizontal split, left=chat, right=TabbedContent with persona/tools/admin/bifrost/server-log).
4. Run `/sleipnir-preflight 1` if any AFK dispatch is wanted for these follow-ups, but most of this is hands-on dev work.
1. TDD-implement `ratatoskr.sessions` per `docs/contracts/issues/2.contract.md`. Two FNs (`create_session`, `list_sessions`) + two dataclasses (`SessionInfo`, `SessionPage`). complexity=low; ~150 LOC. Tracer order: `create_session` first (unblocks `--send --new`), then `list_sessions` (for the eventual TUI picker). Optional: `/volva-contract-review docs/contracts/issues/2.contract.md` before implementing.
2. Build the `--send` stdout presenter under `ratatoskr.cli` — composes `create_session` + `stream_turn` into the non-interactive mode (design-brief §8b).
3. Record real SSE snapshot fixtures from a running Worldtree. `--send --new` is itself a recording probe — capture its outputs to `tests/snapshots/` for replay-based regression coverage.
4. Textual TUI app shell — second presenter; multi-pane observability dashboard per design-brief §5.
## Recent decisions
@@ -79,6 +79,7 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-05-21]` **Contract converted to issue-scoped (issue #1).** Moved `docs/contracts/sse_client.contract.md``docs/contracts/issues/1.contract.md`. Frontmatter shape switched from module-scoped (`module:`/`purpose:`) to issue-scoped (`target_module:`/`scope:`/`prd:`) per CONTRACT-FORMAT §2.1.I. `prd:` block pins to issue #1's body hash (`abcbc49467e86f1d`). `scripts/contract_drift_check.py` returns clean. **Known parser stale-ness**: `contract_parser.py --validate` ERRORs on issue-scoped frontmatter (missing `module:`/`purpose:`) — this is CONTRACT-FORMAT §2.1.L H10, a documented Brokkr-side follow-up. Parser is a canonical sync, so we do NOT patch it locally (would drift from canonical). Treat parser ERROR-on-issue-scoped as expected until the canonical bumps.
- `[2026-05-21]` **Default issue-tracker labels seeded** (17 total). Sleipnir gating (`ready-for-agent`, `blocked-needs-contract`, `blocked-needs-dependency`), triage (`needs-triage`, `needs-architect-decision`, `needs-info`), type (`bug`, `enhancement`, `task`, `documentation`), resolution (`duplicate`, `wontfix`, `invalid`), Ratatoskr-specific area (`sse-client`, `tui`, `cli`, `observability`).
- `[2026-05-21]` **`ratatoskr.sse_client` implemented via TDD against issue #1's contract.** 37 contract-listed tests authored + GREEN per the tracer-bullet vertical-slice ordering (`_parse_sse_id``stream_turn``reconnect_turn``cancel_turn`). Refactor pass extracted `_iter_events` helper to dedupe INV-002 + INV-003 + terminal-break logic across `stream_turn` and `reconnect_turn`; `expected_turn_id=None` vs `expected_turn_id=N` distinguishes the two entry-point semantics Volva surfaced. Notable choices made during implementation: (a) regex `^-?\d+$` pre-check in `_parse_sse_id` to reject whitespace before `int()` (Python's `int(" 3 ")` would silently strip — this kept the strict-no-whitespace test honest); (b) `_DropAfter` AsyncByteStream subclass in tests to simulate mid-stream `RemoteProtocolError`; (c) ToolResult.result and ToolStart.arguments typed as `Any` (server JSON varies); (d) ruff line-length=100 (per pyproject) forced some test docstrings to be tighter than v0 draft.
- `[2026-05-21]` **Issue #2 + contract: `ratatoskr.sessions`.** Scope is narrow — `create_session` (POST /sessions) + `list_sessions` (GET /sessions, cursor-paginated) + shared `SessionInfo` and `SessionPage` frozen dataclasses. Bundles two endpoints in one contract because they share the response envelope shape; splitting would duplicate the dataclass. Bifrost binding (Worldtree issue #160), ephemeral sessions, `GET /sessions/{id}`, `PATCH`, `DELETE`, and `GET /sessions/{id}/messages` (history) are explicitly out of scope (codified in the contract's `## Out of scope` H2 — first contract in this repo to carry that section, so future Volva consults resolve cleanly via the default path instead of needing `--out-of-scope` overrides). `prd:` pinned to issue #2 body SHA `01fbbd52b6d90eb0` at `2026-05-21T04:45:06+00:00`; drift check clean. `dependencies:` lists issue #1 as a convention-dependency (no code import; same API-consumption posture).
- `[2026-05-21]` **Volva code-vs-contract review round on `ratatoskr.sse_client`.** Volva flagged 4 findings (3 drifts + 1 test-gap), all code-side "fix it" recommendations: (1) `_iter_events` fell off cleanly on EOF before terminal, violating INV-001 ("MUST NOT raise StopAsyncIteration before a terminal event arrives unless connection drops"); fix tracks `terminal_seen` flag and raises `SseConnectionDropped` on clean-EOF-without-terminal. (2) Both `SseConnectFailed.body` and `CancelFailed.body` stored full response bytes; ERROR_ROUTING specified truncation to `[:1024]`; fix truncates in `__init__` before storing. (3) `_parse_sse_id` PRE-001 specified `assert isinstance(raw, str)`, but code called `.split(":")` directly (incidental `AttributeError` on non-str); fix adds the assert. (4) Test-gap on cancel_turn's "other status → CancelFailed" branch; fix adds a 503 test with >1024-byte body that double-covers finding #2. Meta-note: Volva said TDD caught the main happy/adversarial shape; the misses were "negative space" cases (clean EOF, exception payload truncation, untested generic cancel branch) — calibration evidence that cross-model review pulls weight on the same-model author's blind spots. 43 tests GREEN post-fix (42 sse_client + 1 boundary), ruff clean.
- `[2026-05-21]` **Volva paraphrase round on `docs/contracts/issues/1.contract.md`.** Volva flagged 5 ambiguities; operator approved amendments to 3 of them. (1) `reconnect_turn` STEP 2 punt resolved: signature now carries `content: str`; STEP 2 body is `json={"content": content}` matching spec §Reconnect flow example verbatim. Spec line 732 makes the agent's tools+LLM run "exactly once regardless of disconnects/reconnects" — the `content` is a wire-schema requirement, not re-processed server-side. (2) `_parse_sse_id` tightened: `turn_id ≥ 1` AND `seq ≥ 1` (was `≥ 0`); spec §SSE id format line 705 explicitly states `seq` starts at 1, and `turn_id` is SQLite autoincrement (≥1). Test `happy_zero_seq` flipped to `zero_seq [adversarial]`; new `zero_turn_id` + `negative_seq` adversarial tests added. (3) INV-003 clarified to spell out the two-entry-point semantics: `stream_turn` establishes `turn_id` from the first event (first event always yields); `reconnect_turn` parses the expected `turn_id` FROM `last_event_id` BEFORE the connection opens, so the first server event is already a flip-candidate and is NOT yielded on mismatch. Volva flags #3 (MalformedSseId-vs-ValueError split) and #5 (exactly-one-terminal as server-assumed) noted but kept as-is — deliberate distinctions. Drift check still clean against issue #1 (amending the contract doesn't touch the pinned issue body).