--- contract_version: "2.1" target_module: "ratatoskr.tier3" scope: "New module `ratatoskr.tier3` exposing Worldtree's Tier 3 (consumer-defined) agent lifecycle: `define_agent` (POST /agents/define), `patch_agent` (PATCH /agents/), `delete_agent` (DELETE /agents/), plus `Tier3AgentInfo` frozen dataclass. Plus a thin CLI entry point (`python -m ratatoskr.tier3 `) that mirrors `ratatoskr.cli`'s env-var posture (`WORLDTREE_API_URL`, `WORLDTREE_API_KEY`). Convention-aligned with `ratatoskr.sessions` (issue #2): caller-owned httpx.AsyncClient, no Worldtree imports, response parsing into frozen dataclass, exception `.body` truncated to `[:1024]`. Picker stays generic — agents with `:` in agent_id show in the list like any other per issue #8's out-of-scope clause. Goal: ratatoskr operators can define, mutate, and delete Tier 3 agents from the command line, then exercise the full session flow against them to observe how Tier 3 agent_ids (colon-containing) flow through the picker / session-create / SSE stream." depends_on: - "httpx" used_by: [] language: "python" complexity: "low" estimated_loc: 250 confidence: 0.9 assumptions: - "Tier 3 endpoints land at the same `WORLDTREE_API_URL` as the rest of the Conversation API — no separate hostname / port. Auth via the same bearer key. The caller's user_id is derived server-side from the API key's owner; the agent's `agent_id` is constructed as `:`. Live probe against personal Worldtree (2026-05-25) confirmed: POST with `{agent_name: 'smoke-test', ...}` and `Authorization: Bearer ` returned `agent_id=ratatoskr:smoke-test`, `user_id=ratatoskr`." - "Per Worldtree spec §2576-2750: `agent_name` is a strict slug `[a-z][a-z0-9-]{2,63}` and immutable after definition. `user_id` is derived from the auth, must be slug-safe (`[a-z][a-z0-9-]{2,63}` per Phase 2.0 gate). PATCH accepts ONLY `system_prompt` and/or `model`; any other key (including the immutable `agent_name`, `user_id`, or layer fields `persona`/`motivational`/`valence`/`memory` — even with `null` value) returns 422 `field_not_mutable` BEFORE the DB lookup." - "**Layer fields are explicitly null** on define. Phase 2.0 ships baseline addressing + ownership + lifecycle only; `persona` / `motivational` / `valence` / `memory` are schema-reserved. Non-null on these → 422 `layer_deferred`. The module's `define_agent` does NOT expose these as parameters at all — sending them would require an amendment when a future Phase enables them." - "**`model` field is a provider model ID, not a profile alias.** Live probe found: `model='default'` (an llm_profiles profile name) returns 422 `model_not_available`; `model='qwen3.6-35-a3b'` (an actual provider model ID) returns 201. The CLI / module take the string verbatim and pass through — validation is server-side. Operators discover valid IDs via the model `metadata` on existing sessions or out-of-band." - "**Quota: 50 Tier 3 agents per Heimdall key.** 51st define → 429 `agent_quota_exceeded` with `Retry-After: 0`. The module raises `Tier3QuotaExceeded(retry_after=0)` — the retry_after field captures the header value verbatim for forward-compat if Worldtree later returns a non-zero throttle." - "**Key-revocation cascade is server-side.** When an API key is revoked (`DELETE /admin/keys/{key_id}`), every Tier 3 agent with `owner_key_hash` equal to the revoked key's hash is soft-deleted in the same SQL transaction. Active sessions on those agents return 401 `auth_revoked` on next message. The ratatoskr module doesn't track or simulate this — operators discover it via runtime 401s and the admin-side audit log." - "**Picker integration is implicit** — no changes to `ratatoskr.tui.AgentPickerApp` for this issue. Tier 3 agents appear in `GET /agents` if defined and the picker's existing format `{agent_id} · {name} — {description}` renders the colon-containing agent_id without special-casing. Per issue #8 out-of-scope clause, ratatoskr does not visually distinguish Tier 1 vs Tier 3 in the picker — same UX surface." - "**Session-create with colon-containing agent_id works unchanged.** Issue #5 already routes `end_user_id` into the POST /sessions body, which Tier 3 session-create requires from Phase 2.0 (per spec §2649-2664). No `ratatoskr.sessions` change needed." - "**CLI uses argparse with subparsers** (define / patch / delete). The subparsers entry point lives at `python -m ratatoskr.tier3` via `__main__.py`. Output on success: prints a one-line summary (`defined ratatoskr:wizard (qwen3.6-35-a3b)` / `patched ratatoskr:wizard` / `deleted ratatoskr:wizard`). Output on error: `[] ` to stderr + non-zero exit. Exit codes mirror `ratatoskr.cli`: 0 happy / 10 usage / 11 auth / 20 api-failure / 21 network." - "**No `list` subcommand in v1.** A `tier3 list` operation would have to filter `GET /agents` by prefix-matching the caller's user_id, but that prefix isn't exposed in the response — only the agent_id is, and you'd have to introspect the auth's user_id. Operators discover their own Tier 3 agents by reading the `GET /agents` list (which the picker already surfaces) and looking for `:*` entries. Add `list` in a follow-up if operators report friction." - "**Module is standalone**: does NOT import or interact with `ratatoskr.sessions` / `ratatoskr.sse_client` / `ratatoskr.tui` / `ratatoskr.cli` beyond reusing the `USER_AGENT` constant from `ratatoskr.cli`. Cross-module use is one-way (cli supplies the user-agent string; tier3 does not import sessions). This keeps the module surface minimal and testable in isolation." - "**The CLI's `python -m ratatoskr.tier3` entry point uses sys.argv handling that mirrors `ratatoskr.cli`** — a top-level `main(argv: list[str] | None = None) -> int` function that argparse-dispatches to subcommand handlers. Each subcommand handler is an async coroutine wrapped by `asyncio.run(...)`. Auth resolution: `--api-key` flag > `$WORLDTREE_API_KEY` env > `_AuthError` (exit 11). Server URL: `--server` > `$WORLDTREE_API_URL` > default `http://localhost:8000` (same default as `ratatoskr.cli`)." - "**Tests use `respx` for HTTP mocking** (same pattern as `tests/test_sessions.py`). New test file: `tests/test_tier3.py`. Cover all success + error response codes per the ERROR_ROUTING matrix below. No live network in unit tests — the live smoke is in the acceptance criteria, not the unit tests." open_questions: - "Should `define_agent` accept an optional `bifrost` parameter for Bifrost-bound Tier 3 sessions? The spec §2658 shows `bifrost` as a session-create field (not define-time). Draft: no — Bifrost binding is per-session; if a Tier 3 agent needs Bifrost on every session, that's an orthogonal feature on POST /sessions, not POST /agents/define. Issue #5's `--end-user-id` already covers the session-create-side parameters." - "Should the CLI also offer `--end-user-id` for sessions created via tier3 + ratatoskr-cli composition? Draft: no — once an agent is defined, operators use the main `ratatoskr --new --agent --end-user-id ` flow; tier3 CLI is define/patch/delete only." - "Should `delete_agent` support a `--force` flag for 'really delete even if active sessions exist'? Per spec §2634-2639, `DELETE` already cancels active sessions and revokes the per-resource scope grant on the owner — there's no soft fail. Draft: no — the spec's hard-delete-with-cascade behavior is the right shape; ratatoskr doesn't need to wrap it." prd: issue: 15 issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/15" body_sha256_16: "03367d7b451ab17f" lock_in_comment_id: null lock_in_sha256_16: null lock_in_at: null pinned_at: "2026-05-25T03:21:38+00:00" dependencies: - issue: 2 path: "src/ratatoskr/sessions.py" reason: "Convention dependency, not a code dependency. Issue #2 (`ratatoskr.sessions`) is the posture template: caller-owned httpx client, async-native, no Worldtree imports, response-parsing into frozen dataclasses, exception body truncation to [:1024]. `ratatoskr.tier3` follows the same shape verbatim." - issue: 3 path: "src/ratatoskr/cli.py" reason: "Convention dependency only. `ratatoskr.tier3.__main__` mirrors `ratatoskr.cli`'s argparse + env-fallback + exit-code shape. Imports `USER_AGENT` from `ratatoskr.cli` so outbound HTTP carries the same identity string." --- # Tier 3 — Consumer-defined agent lifecycle module ## Context Worldtree's Tier 3 (Phase 2.0, spec §2576-2750) lets the consumer define their own agents at `:`. The agent's `user_id` is the auth's user identity (derived from the API key's owner); the `agent_name` is supplied at define-time. The lifecycle is owner-only — only the key that defined an agent can patch / delete it (modulo the key-revocation cascade). `ratatoskr.tier3` exposes this lifecycle as a Python module + small CLI tool. Picker integration is implicit (Tier 3 agents already appear in `GET /agents` per issue #8). Session-create works unchanged through `ratatoskr.sessions.create_session` since the colon-containing agent_id is opaque to that layer. ## Public surface ```python @dataclass(frozen=True) class Tier3AgentInfo: """Worldtree Tier 3 agent envelope returned by define / patch.""" agent_id: str # f"{user_id}:{agent_name}" user_id: str agent_name: str system_prompt: str model: str created_at: str # ISO 8601 with offset updated_at: str # ISO 8601 with offset async def define_agent( client: httpx.AsyncClient, *, agent_name: str, system_prompt: str, model: str, ) -> Tier3AgentInfo: """POST /agents/define → 201 with Tier3AgentInfo. See FN define_agent.""" async def patch_agent( client: httpx.AsyncClient, agent_id: str, *, system_prompt: str | None = None, model: str | None = None, ) -> Tier3AgentInfo: """PATCH /agents/ → 200 with updated Tier3AgentInfo. See FN patch_agent.""" async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None: """DELETE /agents/ → 204. See FN delete_agent.""" ``` ## Exception classes ```python class Tier3QuotaExceeded(Exception): """429 agent_quota_exceeded — 50-agent cap reached on the Heimdall key.""" def __init__(self, *, retry_after: int) -> None: ... retry_after: int class Tier3UserIdUnsupported(Exception): """403 tier3_user_id_unsupported — auth's user_id not slug-safe.""" class Tier3FieldNotMutable(Exception): """422 field_not_mutable — PATCH carrying an immutable key.""" def __init__(self, *, field: str | None) -> None: ... field: str | None class Tier3LayerDeferred(Exception): """422 layer_deferred — define carrying non-null layer field.""" def __init__(self, *, field: str | None) -> None: ... field: str | None class Tier3AgentNotFound(Exception): """404 — patch/delete on non-existent agent.""" def __init__(self, *, agent_id: str) -> None: ... agent_id: str # Reused from ratatoskr.sessions (one-way import — sessions doesn't depend on tier3): # SessionApiFailed(status, body) for all other non-2xx responses. ``` ## Functions ### FN define_agent ``` FN define_agent( client: httpx.AsyncClient, *, agent_name: str, system_prompt: str, model: str, ) -> Tier3AgentInfo BRIEF: POST /agents/define → 201 with Tier3AgentInfo. PRE-001: agent_name matches `[a-z][a-z0-9-]{2,63}` (slug guard — client-side assert; the server enforces too, but this prevents wire round-trip for trivially-bad input). PRE-002: system_prompt is non-empty. PRE-003: model is non-empty. STEPS: 1. assert PRE-001/002/003. 2. body = { "agent_name": agent_name, "system_prompt": system_prompt, "model": model, } 3. resp = await client.post("/agents/define", json=body) 4. ROUTE response status: 201 → parse body into Tier3AgentInfo, return. 422 → inspect error_code: layer_deferred → raise Tier3LayerDeferred(field=err.get("field")) (others) → raise SessionApiFailed(status=422, body=resp.content) 403 + tier3_user_id_unsupported → raise Tier3UserIdUnsupported 429 → raise Tier3QuotaExceeded(retry_after=int(resp.headers.get("Retry-After", 0))) other → raise SessionApiFailed(status, body) POST-001: returned Tier3AgentInfo has agent_id of shape ":". ``` ### FN patch_agent ``` FN patch_agent( client: httpx.AsyncClient, agent_id: str, *, system_prompt: str | None = None, model: str | None = None, ) -> Tier3AgentInfo BRIEF: PATCH /agents/ → 200 with updated Tier3AgentInfo. PRE-001: agent_id contains `:` (Tier 3 shape). PRE-002: at least one of system_prompt or model is non-None (no-op patches are still server-accepted but client-side assert avoids the round-trip). STEPS: 1. assert PRE-001/002. 2. body = {}; if system_prompt is not None: body["system_prompt"] = system_prompt; if model is not None: body["model"] = model. 3. resp = await client.patch(f"/agents/{agent_id}", json=body) 4. ROUTE response status: 200 → parse, return. 404 → raise Tier3AgentNotFound(agent_id=agent_id) 422 + field_not_mutable → raise Tier3FieldNotMutable(field=err.get("field")) other → raise SessionApiFailed(status, body) ``` ### FN delete_agent ``` FN delete_agent(client: httpx.AsyncClient, agent_id: str) -> None BRIEF: DELETE /agents/ → 204. PRE-001: agent_id contains `:` (Tier 3 shape). STEPS: 1. assert PRE-001. 2. resp = await client.delete(f"/agents/{agent_id}") 3. ROUTE response status: 204 → return None. 404 → raise Tier3AgentNotFound(agent_id=agent_id) other → raise SessionApiFailed(status, body) ``` ## CLI surface (`python -m ratatoskr.tier3`) ``` $ python -m ratatoskr.tier3 define --name wizard \ --system-prompt "You are a guided-elicitation wizard..." \ --model qwen3.6-35-a3b defined ratatoskr:wizard (qwen3.6-35-a3b) $ python -m ratatoskr.tier3 patch ratatoskr:wizard --system-prompt "New prompt" patched ratatoskr:wizard $ python -m ratatoskr.tier3 delete ratatoskr:wizard deleted ratatoskr:wizard ``` Auth + server URL: same env-var fallback as `ratatoskr.cli`. Exit codes: 0 / 10 (usage) / 11 (auth) / 20 (api-failure) / 21 (network). ## Invariants - **INV-001**: `define_agent` request body carries exactly `{agent_name, system_prompt, model}` — no layer fields, no `bifrost`, no `metadata`. Phase 2.0 baseline shape only. - **INV-002**: `patch_agent` request body carries ONLY `system_prompt` and/or `model` — every other key is omitted. Server-side 422 `field_not_mutable` is the safety net; client-side body-construction is the first line. - **INV-003**: `delete_agent` is fire-and-confirm — no body, no retry, no soft-delete. Cascade handling is server-side; ratatoskr doesn't track it. - **INV-004**: All exceptions carry a `[:1024]` body cap (when applicable) per the issue #2 convention. - **INV-005**: CLI auth resolution mirrors `ratatoskr.cli`: `--api-key` flag > `$WORLDTREE_API_KEY` > exit 11. - **INV-006**: CLI server URL resolution mirrors `ratatoskr.cli`: `--server` > `$WORLDTREE_API_URL` > `http://localhost:8000`. - **INV-007**: Module never imports `ratatoskr.sessions` / `ratatoskr.sse_client` / `ratatoskr.tui` (one-way: only `cli.USER_AGENT` is imported, and only by `__main__.py` for the outbound User-Agent header). - **INV-008**: All HTTP through caller-owned `httpx.AsyncClient` — module never constructs its own client. (`__main__` constructs one for the CLI entry point per ratatoskr.cli's pattern.) ## TESTS (tests/test_tier3.py — new file) ``` - test_define_happy: 201 + full response shape → Tier3AgentInfo populated. - test_define_quota_exceeded: 429 + Retry-After header → Tier3QuotaExceeded(retry_after=N). - test_define_user_id_unsupported: 403 tier3_user_id_unsupported → Tier3UserIdUnsupported. - test_define_layer_deferred_persona: 422 layer_deferred → Tier3LayerDeferred (would only fire if the body sent a layer field; the module never sends one, so this asserts server-side defense but reflecting a 422 we don't actually generate. Test exercises the response path, not the request). - test_define_bad_slug: PRE-001 assertion fires before HTTP for agent_name="X" (uppercase) or "ab" (too short). - test_define_empty_prompt: PRE-002 assertion fires for empty system_prompt. - test_define_other_5xx: 503 → SessionApiFailed(status=503). - test_patch_happy_both_fields: 200 + updated body → Tier3AgentInfo. - test_patch_happy_single_field: 200 with only system_prompt set; body omits model. - test_patch_field_not_mutable: 422 field_not_mutable → Tier3FieldNotMutable. - test_patch_404: 404 → Tier3AgentNotFound(agent_id=...). - test_patch_no_args: PRE-002 assertion fires (both None). - test_patch_non_tier3_id: PRE-001 assertion fires for agent_id without `:`. - test_delete_happy: 204 → returns None. - test_delete_404: 404 → Tier3AgentNotFound. - test_delete_non_tier3_id: PRE-001 assertion fires. - test_delete_other_5xx: 500 → SessionApiFailed. - test_cli_define_happy: argv → 201 mock → stdout="defined ratatoskr:wizard (qwen3.6-35-a3b)" + exit 0. - test_cli_patch_happy: argv → 200 mock → stdout="patched ratatoskr:wizard" + exit 0. - test_cli_delete_happy: argv → 204 mock → stdout="deleted ratatoskr:wizard" + exit 0. - test_cli_missing_auth: no API key → stderr "[auth_error]" + exit 11. - test_cli_api_failed: 500 mock → stderr "[api_failed]" + exit 20. ``` ## ERROR_ROUTING (module + CLI) | HTTP shape | error_code | Exception (module) | CLI label | Exit | |---|---|---|---|---| | 201 / 200 / 204 | — | (none — happy) | one-line confirmation on stdout | 0 | | 429 | agent_quota_exceeded | `Tier3QuotaExceeded(retry_after=N)` | `[quota_exceeded] retry_after=N` | 20 | | 403 | tier3_user_id_unsupported | `Tier3UserIdUnsupported` | `[user_id_unsupported]` | 20 | | 404 | — | `Tier3AgentNotFound(agent_id=...)` | `[agent_not_found] ` | 20 | | 422 | field_not_mutable | `Tier3FieldNotMutable(field=...)` | `[field_not_mutable] field=...` | 20 | | 422 | layer_deferred | `Tier3LayerDeferred(field=...)` | `[layer_deferred] field=...` | 20 | | any other non-2xx | — | `SessionApiFailed(status, body)` | `[api_failed] status=N body=...` | 20 | | httpx.ConnectError / ReadTimeout / TransportError | — | propagates | `[network_error] T: M` | 21 | | PRE-001/002/003 assertion violation | — | `AssertionError` | `[usage_error] ` | 10 | | no auth | — | `_AuthError` (reused from cli) | `[auth_error] no API key` | 11 | ## Layout after this module lands ``` src/ratatoskr/ __init__.py cli.py (existing, unchanged) sessions.py (existing, unchanged) sse_client.py (existing, unchanged) tui.py (existing, unchanged) tier3.py NEW __main__/ (no change — main cli still entry-point) # CLI invocation: $ python -m ratatoskr.tier3 define --name wizard ... $ python -m ratatoskr.tier3 patch ratatoskr:wizard ... $ python -m ratatoskr.tier3 delete ratatoskr:wizard ```