Files
ratatoskr/docs/contracts/worldtree_sdk_cutover.contract.md
vh ec68b1f3a5 feat(#20): worldtree-sdk cutover teardown (slice-7) + v0.22.0
The last slice of the consumer-layer cutover. Teardown only — zero
runtime-logic change; the 494-green suite is the regression gate.

- Drop `httpx-sse` from pyproject + lockfile: slice-6 deleted its last
  user, nothing imports `httpx_sse`, the SDK owns SSE parsing now.
- Module boundary (operator decision): KEEP `sessions.py` + `sse_client.py`
  as pure caller-semantic type/exception homes (no rename, no fold —
  A3 was blocked by the `AgentNotAvailable` name collision + `wt.py`
  would mis-home `endpoint_for_plane`). Docstrings updated to stop
  claiming "client"; the `AdminEvent`/`SseId`/exception homes stay put
  (resolves the slice-6 deferred-home item).
- Retire wire contracts #2 (sessions) + #15 (tier3): DEC-1 phase-2 —
  normative authority already transferred to the cutover contract; the
  code they specified is gone, so the files are deleted. #1 (SSE event
  vocab) and `first_message` stay (ratatoskr-owned, not retired).
- Final coverage-map re-anchor: tools/list_sessions re-homed to `wt.py`;
  the Last-Event-ID SSE-resume sub-gap CLOSED (folded into `stream_turn`
  auto-resume); Surface-2 SSE parsing re-anchored to the SDK.
- Stale doc-rot fix: the cli.py transport comment no longer calls
  `seed_preset_first_message` "not-yet-migrated" (it rides `wt`).
- v0.22.0 (minor, DEC-6, operator-approved): publishes the full 6-slice
  cutover milestone.
2026-07-19 13:41:34 -07:00

390 lines
29 KiB
Markdown

---
contract_version: "2.1"
module: "ratatoskr.wt"
purpose: "SDK-adapter cutover — replace ratatoskr's hand-rolled httpx consumer wrappers (sessions/sse_client/tier3) with a thin adapter over worldtree-sdk (Python) 1.0.0, deleting the old wrappers; CLI/TUI/web surfaces and caller-semantic exceptions preserved."
touches:
- src/ratatoskr/wt.py
- src/ratatoskr/cli.py
- src/ratatoskr/tui.py
- src/ratatoskr/web/server.py
scope: "Cut ratatoskr's CONSUMER client layer over from its hand-rolled httpx wrappers (sessions.py / sse_client.py / tier3.py / first_message.py) to consume worldtree-sdk (Python) 1.0.0. Replace the wrappers with a thin ratatoskr-owned adapter over the SDK; delete the old wrappers; keep the CLI / TUI / Starlette web surfaces and ratatoskr's caller-semantic exception surface. The Bifrost PROVIDER planes (memory/affect — where Worldtree dispatches INTO ratatoskr, ADR-0009) are consumer-orthogonal and untouched. Cutover decided by operator ruling 2026-07-18; design locked via vor-cross with worldtree-codex (thread 01KXWCWV3VKNR0HP74D2RCY0DC)."
depends_on:
- "worldtree-sdk (Python) == 1.0.0 # git tag python-v1.0.0; pin to the internal Gitea registry once published (DEC-5)"
used_by:
- "ratatoskr.cli"
- "ratatoskr.tui"
- "ratatoskr.web"
language: "python"
complexity: "high"
estimated_loc: 400 # net, rough — deletes (old wrappers) offset adds (adapter + call-site edits); sized per slice, not up front
confidence: 0.8
assumptions:
- "worldtree-sdk 1.0.0 is the GA Python consumer SDK ratatoskr's parity pass shaped; its resource surface (WorldtreeClient.sessions/agents/characters/me/capabilities/models/admin) covers all 21 of ratatoskr's covered routes. Verified against ~/development/worldtree-sdk @ python-v1.0.0."
- "Caller-injected transport is a first-class tested path (PY-INV-012): WorldtreeClient(base_url, auth=..., transport=my_httpx_async_client) uses ratatoskr's AsyncClient and NEVER closes it. Ratatoskr keeps base_url / bearer / User-Agent / timeout / web-app lifecycle ownership."
- "The SDK REST floor is an undiscriminated ApiError(error_code, message, status, body) (B-ERR-1/B-OPEN-3); hide-existence 404s are NOT distinguished by the SDK (B-ERR-4). Ratatoskr's caller-semantic mapping lives one layer up (DEC-2)."
---
# worldtree-sdk cutover — consumer-layer adapter contract
Single SDK-adapter contract per operator DEC-1. This is a migration contract: its
spine is a route-family slice plan, not a dense FN-block spec — each slice's
adapter FN detail is filled in at that slice's TDD (incremental migration; the
per-route SDK signatures are already mapped below).
## Locked design (operator-accepted 2026-07-18, post vor-cross)
- **DEC-1 — retire, don't repurpose (two-phase).** Normative authority for the
consumer wire transfers to THIS contract immediately (as of this file, #2 sessions
and #15 tier3 are superseded — no longer the spec of record); the contract FILES
are DELETED at slice-7 teardown, only after the code they specified is gone
(avoids both orphaning live code and leaving obsolete norms active mid-migration).
`first_message` is NOT retired — it stays a ratatoskr-owned usage contract at the
spec level (its preset / best-effort-timeout / swallow policy is local, not
SDK-owned). Repurposing the wire contracts would risk a shadow-normative copy of
SDK-owned semantics — the exact thing the cutover eliminates.
- **DEC-2 — thin semantic error adapter.** CLI/web/TUI keep ratatoskr's typed
exceptions; the adapter maps the SDK's `ApiError` floor → ratatoskr's
caller-semantic types (the SDK stays undiscriminated; hide-existence maps here).
- **DEC-3 — no shims (house rule).** Delete the old wrapper modules and update all
call-sites to the new adapter. No compatibility facade preserving old
module/function APIs (pre-1.0 no-backwards-compat rule).
- **DEC-4 — incremental + live-smoke, no soak.** Migrate one route-family per
slice, TDD each slice, and run a targeted LIVE smoke of that family against the
personal endpoint before deleting the replaced hand-rolled path. (The SDK's
fixture corpus proves the WIRE; the live smoke proves ratatoskr's
presenter/adapter/env-wiring survived the dependency inversion.)
**Smoke acceptance bar (the minimum that lets an old path be deleted):** drive
each route in the family's primary flow END-TO-END through the ratatoskr surface
(CLI/web) — not the wrapper directly — against the personal endpoint under the
real consumer key, and OBSERVE the expected success (a created session, a streamed
turn that renders, a persona write that reads back), not merely a non-crash. A
smoke that only asserts "no exception" does NOT clear the bar. **Smoke vs. soak
boundary:** a smoke is a single pass of the family's flows at deletion time; a
(forbidden) soak is a sustained dual-path parallel-run — do the former, not the
latter.
- **DEC-5 — wait for the registry.** Pin `worldtree-sdk == 1.0.0` from the internal
Gitea registry once published (verify the uv install first); use the
`python-v1.0.0` git tag only if the registry lags. Contract-drafting is NOT
gated (SDK source is inspectable); IMPLEMENTATION pin is.
- **DEC-6 — minor bump at ship.** The cutover ships as a minor (architecture-cutover
milestone; pre-1.0 breaking-internal) — requires explicit operator approval at
ship time, not now.
## Adapter design
- **`ratatoskr.wt`** — a thin adapter module owning ONE `WorldtreeClient`, built
with ratatoskr's own `httpx.AsyncClient` injected (`transport=`), ratatoskr's
`auth`/`admin_auth` providers, and the ratatoskr User-Agent. It exposes
ratatoskr-semantic call surfaces the presenters already expect, and translates
SDK errors → ratatoskr exceptions (DEC-2).
- **Transport ownership (INV-CUT-1 [hard]):** the injected `AsyncClient` is
ratatoskr-owned; `WorldtreeClient.aclose()` / `async with` exit must NOT close
it (SDK `_owns_client=False` on injection). Ratatoskr owns the client lifecycle
exactly as today.
- **Error boundary (INV-CUT-2 [hard]):** ratatoskr's caller-semantic exceptions
are raised by the adapter, mapping from the SDK where the SDK already
discriminates (`AgentNotAvailable`, `TurnLaunchUnavailable`, `SessionRetired`,
`Cancel*`, `Resume*`, `Malformed*`) and from `ApiError(status=..., error_code)`
on a KNOWN route otherwise — including the hide-existence 404 →
`AuthoredHistoryUnavailable` (labeling a call we made, NOT capability-probing).
- **Provider boundary (INV-CUT-3 [hard]):** the Bifrost provider planes stay
hand-rolled and are never routed through the SDK. The Bifrost-BOUND
`sessions.create` (consumer-side, `bifrost=` + consumer-key auth) DOES go through
the SDK and stays consumer-layer.
- **Import boundary (INV-CUT-4 [hard]):** `tests/test_no_worldtree_imports.py`
stays; the boundary becomes "no Worldtree SOURCE imports", with `worldtree_sdk`
the allowed client dependency.
## Invariants
- **INV-CUT-1 [hard]** — Ratatoskr owns the injected `httpx.AsyncClient` lifecycle;
the SDK never closes it (`_owns_client=False` on transport injection).
- **INV-CUT-2 [hard]** — The adapter raises ratatoskr's caller-semantic exceptions
per the explicit **§ Error map** table below — mapping from the SDK's discriminated
errors (passthrough) and from `ApiError` by the ROUTE that raised it (the route is
the discriminator, never the body). Every `ApiError` is mapped; the default is
`SessionApiFailed`. No route invents semantics the table doesn't list.
- **INV-CUT-3 [hard]** — The Bifrost PROVIDER planes never route through the SDK;
the Bifrost-BOUND `sessions.create` does (consumer-side).
- **INV-CUT-4 [hard]** — The no-Worldtree-SOURCE-import boundary holds
(`worldtree_sdk` allowed); `tests/test_no_worldtree_imports.py` enforces it.
- **INV-CUT-5 [hard]** — Each slice runs a LIVE smoke of its route family before the
replaced hand-rolled path is deleted (DEC-4); no old path is removed unproven.
## Route map — ratatoskr wrapper → SDK method (grounded @ python-v1.0.0)
| Ratatoskr wrapper (retiring) | SDK method | SDK return |
|---|---|---|
| `create_session` | `sessions.create(...)` | `SessionInfo` |
| `list_sessions` | `sessions.list(limit, cursor)` | page |
| `stream_turn` | `sessions.stream_turn(...)` (resilient) / `stream_turn_raw` (single) | `AsyncIterator[Event]` |
| `reconnect_turn` | folded into `sessions.stream_turn` auto-resume | — |
| `cancel_turn` | `sessions.cancel_turn(...)` | `CancelResult` |
| `get_session_messages` | `sessions.messages(id)` | open-world |
| `get_session_tools` | `sessions.tools(id)` | `SessionTools` |
| `set_persona_state` | `sessions.set_persona_state(id, PadState)` | None |
| `write_authored_history` | `sessions.write_history(...)` | authored-turn |
| `get_persona_state` | `agents.persona_state(agent_id)` | `AgentPersonaState` |
| `list_agents` | `agents.list()` | `AgentList` |
| `define_agent` | `agents.define(...)` | `DefinedAgent` |
| `patch_agent` | `agents.patch(id, changes)` | `PatchedAgent` |
| `delete_agent` | `agents.delete(id)` | None |
| `get_me` | `me.get()` | `MeInfo` |
| `get_capabilities` | `capabilities.get()` | `Capabilities` |
| `list_character_models` | `models.available_for_characters()` | `ModelsAvailable` |
| `create_character` | `characters.create(...)` | `CreatedCharacter` |
| `get_character_state` | `characters.state(id)` | `CharacterState` |
| `delete_character` | `characters.delete(id)` | `CharacterDeleteResult` |
| `get_session_bifrost` | `admin.sessions.bifrost(id)` | `BifrostInspection` |
| `stream_admin_events` | `admin.stream_events(...)` | `AsyncIterator[AdminEvent]` |
Note: SDK returns are typed `shapes.*`; per the parity pass the freeform reads
(`SessionTools`, `BifrostInspection`, capabilities) are deliberately open-world in
the Python spine. The adapter PASSES THESE THROUGH unmodified (does not normalize);
presenters read them as mappings, tolerant of wire drift. If an open-world read is
malformed the presenter degrades (never crashes) — same posture as the existing
`_format_whoami` hardening.
## Error map (INV-CUT-2 — the explicit table)
**The ROUTE is the discriminator, not the error body** (labeling a call we made, NOT
capability-probing — B-ERR-4). A 404 is mapped by which method raised it; the adapter
NEVER sniffs the body to decide whether something exists. Authored-history is
currently the ONLY hide-existence route (per Worldtree B-ERR-4); if Worldtree adds
others, they get their own row here — the default is NOT a general "any 404 → hidden".
| SDK raises (from) | Adapter maps to |
|---|---|
| SDK `AgentNotAvailable` / `TurnLaunchUnavailable` / `SessionRetired` (stream-open) | ratatoskr `AgentNotAvailable` / `TurnLaunchUnavailable` / (retired → `SessionApiFailed`) — same names, passthrough |
| SDK `ConnectionDropped` (mid-stream) | `SseConnectionDropped` |
| SDK `ResumeError` subclasses (in resilient stream) | resilient `stream_turn` absorbs; terminal → `SseConnectFailed` |
| SDK `MalformedSseId` / `MalformedSseData` / `TurnIdFlip` (stream `ProtocolError`) | ratatoskr same-named types — same-name rewrap of the discriminated stream protocol errors |
| SDK `Cancel*` (cancel_turn) — the SDK RAISES the typed races | 404 `turn_not_found` → `CancelTurnNotFound`; 409 `turn_finished` → `CancelAlreadyCompleted`; other `CancelError` → `CancelFailed`. A 200 (incl. `cancelled=False`, the B-CAN-3 late-cancel no-op) returns a `CancelResult` — never raises. The caller surface stays exception-based (DEC-2; matches the pre-cutover CLI/web handlers). |
| `ApiError(404)` on `sessions.create` | `AgentNotFound` |
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` (dual-key: status 422 AND error_code; the flat cursor body surfaces the code) |
| `ApiError(502)` on bound `sessions.create` | `BifrostHandshakeFailed` — NOT gated on error_code (unlike list's 422): INV-002, the synchronous handshake is the SOLE bound-502 cause; and the SDK's envelope parser prefers the nested `detail` (which carries `bifrost_error`, not `error_code`), so no distinguishing top-level `error_code` surfaces. The route+status IS the discriminator. |
| `ApiError(429)` on `agents.define` (slice-4) | `Tier3QuotaExceeded(retry_after=0)` — the SDK's `ApiError` floor carries no response headers, so the `Retry-After` header the hand-rolled path read is unavailable; spec §2675 pins Phase-2.0 quota to `Retry-After: 0`, so the adapter defaults to 0. A non-zero forward-compat value is unrecoverable until the SDK surfaces headers (INFORM wtsdk-dev; reference-impl posture). |
| `ApiError(403 tier3_user_id_unsupported)` on `agents.define` (slice-4) | `Tier3UserIdUnsupported` (dual-key: status 403 AND error_code) |
| `ApiError(422 layer_deferred)` on `agents.define` (slice-4) | `Tier3LayerDeferred(field)` — `field` parsed from the body (`detail.field` / flat `field`); the SDK carries `error_code` but not `field`, so the adapter body-parses it (same posture as bound-502's `bifrost_error`) |
| `ApiError(404)` on `agents.patch` / `agents.delete` (slice-4) | `Tier3AgentNotFound` (route-discriminated; agents CRUD is NOT a hide-existence route — a 404 there IS "no such agent") |
| `ApiError(422 field_not_mutable)` on `agents.patch` (slice-4) | `Tier3FieldNotMutable(field)` (dual-key status+error_code; `field` body-parsed) |
| `ApiError(404 persona_not_configured)` on `agents.persona_state` (slice-4) | `PersonaNotConfigured` (dual-key) |
| `ApiError(404 agent_not_available)` on `agents.persona_state` (slice-4) | `AgentNotAvailable` (the persona-surface `sessions.AgentNotAvailable`, distinct from the eager-turn `sse_client.AgentNotAvailable`; dual-key) |
| `ApiError(403 auth_scope_denied)` on `agents.persona_state` (slice-4) | `AuthScopeDenied(scope="persona.read")` (dual-key) |
| **`ApiError` (any other status/route, incl. `agents.list` and any unmatched agent-route code) — the default** | `SessionApiFailed(status, error_code, body)` |
The default row is load-bearing: any `ApiError` not matched above surfaces as the
generic `SessionApiFailed` carrying the raw `status`/`error_code`/`body` — the
adapter does NOT invent per-route semantics the contract doesn't list, and does NOT
leave an `ApiError` un-mapped. **This default holds on EVERY route, including the
stream and cancel** (each carries a defensive `except ApiError → SessionApiFailed`
after its discriminated branches — the SDK maps those routes to discriminated types
today, but the default guarantees INV-CUT-2 structurally, not by SDK-internal
coupling). Each slice adds/confirms its route's rows here before the old path is
deleted.
## Slice plan (incremental, DEC-4)
Each slice: wire the family through `ratatoskr.wt` → update call-sites → TDD →
LIVE smoke against the personal endpoint → delete the replaced hand-rolled path →
re-anchor its coverage-map rows.
1. **Foundation** — `ratatoskr.wt` module + injected `WorldtreeClient` + auth
providers + the error-adapter skeleton (the § Error map's default `ApiError` →
`SessionApiFailed` rule + the discriminated-error passthrough). No USER-VISIBLE
behavior change (imports/construction change; the skeleton is unit-tested but
unexercised by any surface until slice 2). Done = `ratatoskr.wt` importable, the
client constructs with an injected transport, and the error-map default is unit-
covered.
2. **Sessions/turn** — create/list/messages/tools/cancel + the turn STREAM + the
Bifrost-bound create. The turn presenter wires the resilient `sessions.stream_turn`
(auto-resume — this ABSORBS the old `reconnect_turn`, which is deleted this slice
with its call-sites); `stream_turn_raw` is available for a single-attempt caller
but is NOT the presenter default. (Highest-traffic; the SSE presenter is the
riskiest wiring.)
3. **Persona + authored-history** — set_persona_state (PadState) + write_history
(hide-existence mapping) + first_message presets (stay ratatoskr-owned OVER the
SDK method).
4. **Agents (Tier-3)** — list/get/define/patch/delete/persona_state. (Folds in the
pending `model`→`role` cutover — see [[project-tier3-agents-model-to-role-pending]].)
5. **Characters + me/capabilities/models** — the remaining consumer reads.
6. **Admin** — admin.sessions.bifrost + admin.stream_events (admin_auth).
7. **Teardown** — delete residual hand-rolled modules; drop `httpx-sse` (SDK owns
SSE parsing); retire contracts #2/#15; final coverage-map re-anchor; minor bump
(DEC-6, operator approval).
### Slice-4 notes (Agents/Tier-3 + `model`→`role` fold, decided at TDD)
- **`model`→`role` cutover folds in here (scope B).** Worldtree spec 1.2 (`v1.0.0b128`,
live on :8080/:8081) made the `/agents/define` response echo `role`, closing the
old W-4 `model` echo. The adapter returns the SDK's OPEN-WORLD `DefinedAgent` /
`PatchedAgent` dicts verbatim (parity posture); callers read `info["role"]`. The
frozen `Tier3AgentInfo` dataclass (which read `body["model"]` and would KeyError
post-b128) is DELETED — no dataclass normalization layer survives.
- **`ratatoskr.local_agents` schema bump.** `LocalAgentEntry.model` → `.role` (the
field stores what the wire now calls a role); `_SCHEMA_VERSION` 1→2 so any
pre-cutover on-disk index is discarded cleanly (no-backwards-compat, DEC-3).
- **`AgentNotAvailable` name collision.** `sessions.AgentNotAvailable` (persona-state
404 `agent_not_available`) and `sse_client.AgentNotAvailable` (eager-turn 409) are
distinct types that share a name; `wt` already imports the sse_client one for the
stream, so it imports the persona one ALIASED (`PersonaAgentNotAvailable`) and
raises it from `get_persona_state`. The web endpoint keeps importing the persona
`AgentNotAvailable` from `sessions` (same class), so its `except` is unchanged.
- **`ConnectFailed` at every rewired caller (slice-3 foot-gun).** The SDK normalizes
ANY transport failure to `ConnectFailed(status=0)` (`request.py`), not a raw httpx
error. The rewired tier3 CLI and both web endpoints (`_agents_endpoint`,
`_persona_state_endpoint`) catch `wtsdk.ConnectFailed` → their existing
network-error surface (CLI exit 21 / web 502). The web `test_network_error_returns_502`
(respx `httpx.ConnectError` side-effect) is the RED that proves this.
- **`agents.get(agent_id)`** (SDK `GET /agents/{id}`) is NOT wrapped — ratatoskr has no
`get_agent` consumer; only list/persona_state/define/patch/delete are in coverage.
- **Client-side Tier-3-id PRE on `patch_agent` / `delete_agent`.** Both assert
`":" in agent_id` pre-HTTP (a Tier-3 id is always `<user_id>:<agent_name>`, ADR-0019),
so a non-colon id fails fast with an `AssertionError` rather than reaching the SDK's
route-discriminated 404 → `Tier3AgentNotFound`. Intentional fail-fast on a
wrong-shaped id (carried over from the retired hand-rolled wrappers); documented here
per the heid-code-review slice-4 precision flag (the § Error map 404 rows assume a
well-formed Tier-3 id reaches the route).
### Slice-5 notes (Characters + me/capabilities/models, decided at TDD)
- **No new § Error map rows.** All six routes (`me.get`, `capabilities.get`,
`models.available_for_characters`, `characters.create` / `.state` / `.delete`) are
open-world reads/acks (B-OPEN-2) whose SDK ops carry NO discriminated error (no
`map_error`), so every `ApiError` maps to the default `SessionApiFailed` — exact
parity with the retiring hand-rolled path, which likewise raised only its generic
`SessionApiFailed` on any non-2xx (never discriminating a status/code on these
routes). The route-map table above already lists all six.
- **`create_character` body — omit `state` when None.** The adapter sends
`{"character": …}` plus `"state"` only when the caller supplies a non-None state
(the SDK forwards the body dict as-is via httpx `json=`). This drops the hand-rolled
path's redundant explicit `"state": null` — server-equivalent (Worldtree's
`CreateCharacterRequest.state` defaults None whether omitted or explicit-null),
SDK-idiomatic (matches the SDK's `CreateCharacterInput` `NotRequired` shape), and
invisible at the sole call-site (`--characters` never passes a state). Adopt-
canonical over byte-for-byte wire parity.
- **`delete_character` returns the SDK's open ack verbatim (`-> Mapping | None`).**
The SDK route returns an open-world ack body (not 204 — `CharacterDeleteResult`), so
the adapter passes it through rather than normalizing to the hand-rolled `None`
(parity posture: no None-normalization of an open-world read). On a 204 no-content
the SDK yields `None`, so the return type is `Mapping | None`; the sole call-site
(`--characters`) ignores the value, so the change is unobservable.
- **Open-world presenter degrade-not-crash (cumulative foot-gun).** `_format_whoami`
is already hardened (slice-4 heid bug-hunt). The rewired `_characters_probe` extracts
the created id defensively (`created.get("character_id")` + type-guard → clean abort,
never a hard-index KeyError) since the create ACK is now an open-world SDK read.
- **Container-type hardening (heid code-review + bug-hunt slice-5).** The degrade-not-
crash floor is guarded at THREE levels for the CLI presenters, not just one: (a) the
list-typed fields `scopes` / `allowed_roles` / model `items` degrade a non-list scalar
(`123`) or a bare string to empty via `_display_seq` / an `isinstance(_, list)` guard —
the older `or []` idiom only caught null/absent and would `for x in 123` `TypeError`;
(b) each element is type-guarded (`isinstance(m, dict)`); (c) the top-level open-world
reads `created` / `models` / `state` are `isinstance(_, Mapping)`-guarded before any
`.get` (a non-mapping passthrough would otherwise `AttributeError`). All three feed
`--whoami` / `--characters` only.
- **Accepted (not fixed): the `--characters` probe leaks its transient character on a
mid-lifecycle failure.** create → get-state → delete runs linearly with no `finally`,
so a state/delete failure after a successful create orphans the probe character until
its TTL. This is PRE-EXISTING (the retired hand-rolled probe had the identical
structure — the cutover did not worsen it), TTL-bounded, and `--characters` is a
one-shot diagnostic smoke; a `try/finally` cleanup would also swallow a happy-path
delete-failure (delete is both the teardown AND a tested lifecycle step). Accepted as
known-risk per the heid bug-hunt (Gróa + Heid concur accept is defensible).
- **CLI-only rewire.** `me` / `capabilities` / `characters` / `models` have NO
web-server caller — only the `--whoami` and `--characters` CLI one-shot probes. The
web surface is untouched this slice.
### Slice-6 notes (Admin: bifrost inspection + admin-events stream, decided at TDD)
- **Admin auth moves from a per-call header override to the client's `admin_auth`.**
The SDK's `admin.*` methods authenticate with the client's `admin_auth` provider
(set via `build_client(admin_key=...)`), NOT a per-request `Authorization` header. So
the two web admin endpoints build their wt client WITH `admin_key` (`_wt_client(client,
admin_key=...)`, extended this slice); the hand-rolled per-call `admin_key=` +
header-override is retired. The web already guards `if not admin_key: 400` before the
call, so the SDK's pre-HTTP `ConfigurationError` (missing admin_auth, W-5) is
unreachable from the web surface. **CLI has no admin caller** — both routes are
web-only (the coverage-map's `tui.py` rows were stale; corrected to `web/server.py`).
- **`get_session_bifrost` — no new § Error map row.** `client.admin.sessions.bifrost`
returns the open-world `BifrostInspection` dict verbatim; any `ApiError` (notably 403
`auth_scope_denied`, 404 `session_not_bifrost_bound`) → the `SessionApiFailed` default
— exact parity with the retired path (which mapped every non-200 → `SessionApiFailed`).
- **`stream_admin_events` re-wraps the SDK's `AdminEvent` → ratatoskr's `AdminEvent`
(chosen over yield-through).** The SDK's `AdminEvent` diverges from ratatoskr's:
`admin_id: int|float` (`nan` for an id-less envelope) vs ratatoskr's `id: int` (0
default), and the SDK's `type`/`data` are None-able where ratatoskr's are a dotted-str
/ a `{}`-default dict. The web filter + SSE formatter read `ev.id`/`ev.type`/`ev.data`.
The adapter re-wraps at the boundary — `id = admin_id if int else 0` (nan→0),
`type = type or ""` (None→"" so `.startswith` never crashes), `data = data or {}` —
degrading the SDK's open-world None/nan ONCE at the adapter, keeping the web endpoint +
`_admin_event_matches_web` + the ratatoskr `AdminEvent` domain type UNCHANGED (preserves
the web surface per § Out of scope). **Rejected alternative:** yield SDK `AdminEvent`s
through and rewire the web filter for `admin_id`/None/nan (the slice-2 turn-stream
precedent) — heavier web churn + scatters the None/nan hardening through the filter;
re-wrap localizes it. The ratatoskr `AdminEvent` dataclass stays in `sse_client.py` this
slice (imported by `wt` + the web); its home moves in slice-7 teardown if `sse_client.py`
is retired.
- **Admin-stream error mapping (reuses the § Error map stream rows).** The SDK admin
stream raises `ApiError("admin_stream_failed", status=…)` on a NON-200 open (NOT
`ConnectFailed` — a gotcha the web integration test caught that the unit fake could not)
→ `SseConnectFailed`; and `ConnectionDropped` on a connect-time transport failure
(cursor None) OR a mid-stream drop / the long-lived stream's resumable EOF (cursor set)
→ `SseConnectionDropped`. The SDK admin stream is best-effort (skips malformed frames —
no `Malformed*`), as was the retired hand-rolled path; the web endpoint's existing
`except (…, MalformedSseId, MalformedSseData)` stays a harmless defensive superset
(pre-existing, not introduced here).
### Slice-7 notes (Teardown — the LAST slice, decided at teardown)
- **Module boundary: KEEP `sessions.py` + `sse_client.py` as pure type/exception
homes (operator decision A1, 2026-07-19).** Post-cutover both modules hold NO
client — only ratatoskr's caller-semantic exception surface + a couple of
dataclasses (`BifrostBinding`; `SseId`, `AdminEvent`) + the `endpoint_for_plane`
provider helper. Options weighed: (A1) keep as-is + fix docstrings; (A2) rename to
honest names (`session_errors`/`stream_errors`), re-point ~7 importers; (A3)
consolidate into one `errors.py` / fold into `wt.py`. **A1 chosen** — teardown is
deletion + dep-drop, not a rename refactor; A3 is blocked by the `AgentNotAvailable`
name collision (two distinct classes: persona-404 in `sessions` vs eager-turn-409 in
`sse_client`) which would force renaming a contract-level caller-semantic type + its
§ Error-map rows + catch sites, and folding into `wt.py` mis-homes
`endpoint_for_plane` (provider-side). Naming-honesty (principle-2) addressed by the
one-line docstring note, not a rename. **Resolves the slice-6 open item** (line ~326):
the ratatoskr `AdminEvent`/`SseId` + exceptions stay in `sse_client.py`; the
session/tier3 exceptions + `BifrostBinding` stay in `sessions.py`.
- **`httpx-sse` dropped from `pyproject.toml` + lockfile.** Slice-6 deleted its last
user (`sse_client.stream_admin_events`); a tree grep confirmed nothing imports
`httpx_sse`. `uv sync` physically pruned it; suite green (494) with the module absent.
- **Wire contracts #2 (sessions) + #15 (tier3) retired (files DELETED, DEC-1
phase-2).** Their normative authority transferred to this contract at authoring;
the code they specified is gone, so the files are removed now. **#1 (SSE event
vocabulary) is NOT retired** — it stays current (amended `4bd9abd` 2026-07-18) as
ratatoskr's SSE-event-rendering reference; **`first_message` is NOT retired** (DEC-1,
ratatoskr-owned usage contract). Accepted side-effect: `issues/5.contract.md`'s
historical "amended #2/#3/#4 in-place" line now points at a deleted #2 — left as-is
(frozen issue-record of a past action; not expanding DEC-1's #2/#15 scope).
- **Final coverage-map re-anchor.** `GET /sessions/{id}/tools` → `wt.py get_session_tools`
(SDK `sessions.tools`) → `web/server.py` (the old `sessions.py`→`tui.py` row was
stale; TUI deleted). `GET /sessions` `list_sessions` re-homed to `wt.py`, still
caller-less (picker was a TUI frontier, now moot). The `Last-Event-ID` SSE-resume
sub-gap is CLOSED — `reconnect_turn` deleted, resume folded into `wt.py stream_turn`
auto-resume. Surface-2 SSE parsing re-anchored to the SDK (`_envelope_for_type` gone).
- **Ships as v0.22.0 (minor, DEC-6, operator-approved 2026-07-19).** Publishes the
full 6-slice consumer-layer cutover milestone.
## Out of scope
- Bifrost PROVIDER planes (memory/affect) — hand-rolled, ADR-0009, untouched.
- CLI / TUI / Starlette web surfaces — their EXTERNAL BEHAVIOR is preserved (their
call-sites DO change — they're updated to the new adapter per `touches:`; what's
invariant is the observable behavior, not the code).
- The four parked post-v1 SDK ergonomics items (replayed:bool, raw-dict persona
escape hatch, list_all iterator, split session types) — coordinated post-v1 lane
with wtsdk-dev, not this cutover.
## Coverage-map re-anchor
`docs/coverage-map.md` shifts from "hand-rolled route exists + live-proven" to
"SDK method wired and exercised by a ratatoskr surface"; excluded-by-design rows
stay explicit. Re-anchored per slice as each family lands.