Files
ratatoskr/docs/contracts/worldtree_sdk_cutover.contract.md
T
vh 4e20030229 fix(#20): heid-bug-hunt fixups — CLI open-world container-type hardening (slice-5)
Panel (Gróa + Hulda + Regin, source-verified by Heid): adapter/route-map/
ConnectFailed-at-call-sites sound against the declared invariants; 4 real
robustness findings, all in the CLI open-world presenter/probe paths — the
container-type layer BELOW the null/element holes the code-review already fixed.

Fixed (findings 1-3):
- `_format_whoami` (`cli.py`): a non-iterable `scopes`/`allowed_roles` scalar
  (`{"scopes": 123}`) made `x or []` yield `123` → `for s in 123` TypeError. New
  `_display_seq` helper degrades any non-list (scalar / bare string / null / absent)
  to empty; applied to both `scopes` and `allowed_roles`.
- `_characters_probe` (`cli.py`): same class on the model catalog `items` (`{"items":
  123}`) — now guards `models` is a Mapping and `items` is a list before iterating.
- `_characters_probe`: the top-level open-world reads `created` / `state` are now
  `isinstance(_, Mapping)`-guarded before any `.get` — a non-mapping SDK passthrough
  (`created=[...]`) aborts cleanly (exit 20) / renders `pad=None` instead of an
  AttributeError.

Accepted (finding 4, documented in contract § slice-5 notes): the `--characters`
probe leaks its transient character on a mid-lifecycle failure. PRE-EXISTING (the
retired probe had the identical linear no-`finally` structure — cutover did not
worsen it), TTL-bounded, one-shot diagnostic; a `try/finally` would swallow a
happy-path delete-failure (delete is both teardown and a tested step). Gróa + Heid
concur accept is defensible.

Dismissed (finding 5): Hulda flagged `sessions.py` dropping `get_me`/etc. as a
caller-contract break — it is the intended DEC-3 no-backwards-compat migration (all
in-repo callers rewired same-diff); Heid labels it intended-surface-change.

Added CLI tests for the three hardened paths (scalar scopes/roles; scalar items +
non-mapping state; non-mapping create abort). Suite 488 green; ruff clean; live
smoke re-run clean (identical happy-path output). Patch bump 0.21.17 → 0.21.18.
2026-07-19 11:29:10 -07:00

23 KiB

contract_version, module, purpose, touches, scope, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions
contract_version module purpose touches scope depends_on used_by language complexity estimated_loc confidence assumptions
2.1 ratatoskr.wt 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.
src/ratatoskr/wt.py
src/ratatoskr/cli.py
src/ratatoskr/tui.py
src/ratatoskr/web/server.py
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).
worldtree-sdk (Python) == 1.0.0 # git tag python-v1.0.0; pin to the internal Gitea registry once published (DEC-5)
ratatoskr.cli
ratatoskr.tui
ratatoskr.web
python high 400 0.8
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_foundCancelTurnNotFound; 409 turn_finishedCancelAlreadyCompleted; other CancelErrorCancelFailed. 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. Foundationratatoskr.wt module + injected WorldtreeClient + auth providers + the error-adapter skeleton (the § Error map's default ApiErrorSessionApiFailed 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 modelrole 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 + modelrole fold, decided at TDD)

  • modelrole 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.

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.