docs(contract): worldtree-sdk cutover — SDK-adapter contract (refs #20)
Consumer-layer cutover to worldtree-sdk (Python) 1.0.0: retire the hand-rolled httpx wrappers (sessions/sse_client/tier3) behind a thin ratatoskr.wt adapter over the SDK. Carries the 6 locked DECs (vor-cross'd with worldtree-codex), the route map (21 wrappers -> SDK methods), the Error map table (heid-panel find: route-as-discriminator, default -> SessionApiFailed), INV-CUT-1..5, and the 7-slice plan. Adapter design: caller-injected transport (never closed), thin semantic error adapter, Bifrost provider planes untouched. heid-contract-review clean after fixups (3/3 error-map convergence + 6 clarifications folded in). No version bump (.contract.md, no code). Refs #20.
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
---
|
||||
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 `Cancel*` (cancel_turn) | folded into `CancelResult`; late-cancel race (B-CAN-3) returns `cancelled=False`, never raises |
|
||||
| `ApiError(404)` on `sessions.create` | `AgentNotFound` |
|
||||
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
|
||||
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` |
|
||||
| `ApiError(502 bifrost_handshake_failed)` on bound `sessions.create` | `BifrostHandshakeFailed` |
|
||||
| **`ApiError` (any other status/route) — 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. 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).
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user