--- contract_version: "2.1" target_module: "ratatoskr.sessions" scope: "Add `end_user_id` support to the POST /sessions flow so per-end-user agents (lofn confirmed; presumably the Persona/Vili family) can be smoked. Small surface change distributed across three existing modules via in-place contract amendments: `ratatoskr.sessions.create_session` gains a keyword-only `end_user_id: str | None = None` parameter that is threaded into the POST body when non-None; `ratatoskr.cli` adds an `--end-user-id ` flag + corresponding `ParsedArgs.end_user_id: str | None` field + threading through `_amain`; `ratatoskr.tui.on_mount` threads `args.end_user_id` into its `create_session` call. No new modules, no new files (apart from this contract). Default-omitted preserves backwards compatibility: existing `mimir` smoke flows that don't pass `--end-user-id` continue to work unchanged." depends_on: - "httpx" used_by: [] language: "python" complexity: "low" estimated_loc: 40 confidence: 0.9 assumptions: - "Worldtree spec pin (`docs/conversation-api-spec.md` at v1.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`, v0.19.0) declares `end_user_id` as a field on POST /sessions — required by some agents (lofn confirmed via 422 `end_user_id_required` on 2026-05-21 smoke), optional/ignored by others (mimir doesn't reject when omitted)." - "The 422 response shape is `{\"detail\":{\"error_code\":\"end_user_id_required\",\"message\":\"...\"}}` per the smoke evidence. The existing `SessionApiFailed` exception handler in `_amain` / `on_mount` already maps 422s to exit 20 / `[session_api_failed]` label, so missing `--end-user-id` for a per-user agent surfaces as that label rather than a more specific hint. Defer 422-→-hint translation to a follow-up issue." - "`end_user_id` is a free-form string from the operator's perspective. Per worldtree-dev (althing 01KSBARG2B8M, 2026-05-23): it's a *runtime partition key* — same value → same long-term-memory + persona/valence partition; different values → fully isolated partitions. The server validates it as non-empty; ratatoskr also rejects empty client-side (INV-001)." - "**Env-var fallback amended 2026-05-23**: `$RATATOSKR_END_USER_ID` populates the field when the flag is omitted. Resolution order: `--end-user-id` flag > `$RATATOSKR_END_USER_ID` > None. `env.sh` ships `RATATOSKR_END_USER_ID=\"ratatoskr-tui\"` as the project-stable default. Original posture rejected env-var fallback as 'papering over isolation'; revised after worldtree-dev's guidance that the realistic use case (single-operator debugging) wants partition continuity. The override path preserves isolation when needed." - "**Forthcoming breaking change (Worldtree #196)**: the `end_user_id` field is being replaced by polymorphic `subject: {type, id}` at a future v0.22.x / v0.23.0. Spec is LOCKED, substrate not yet shipped. Don't pre-implement; migrate when the substrate change lands (deprecation warnings will fire per call as heads-up). Tracked as a separate ratatoskr issue." open_questions: - "Should the 422 `end_user_id_required` error_code trigger a user-friendly hint suggesting `--end-user-id ` rather than the raw body? Draft: no for v1 — the raw `[session_api_failed]` label is honest about what came back. Add the hint in a follow-up if the bare label proves empirically confusing." - "Should `ratatoskr.sessions.list_sessions` also accept `end_user_id` to filter by end-user-id? Spec allows it. Draft: no for this issue (out of scope; list_sessions has no consumer yet in the CLI/TUI — `--send` and the TUI shell only call create). File if the startup-session-picker issue needs it." prd: issue: 5 issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/5" body_sha256_16: "03fe1fa547235f32" lock_in_comment_id: null lock_in_sha256_16: null lock_in_at: null pinned_at: "2026-05-22T05:03:17+00:00" dependencies: - issue: 2 path: "src/ratatoskr/sessions.py" reason: "In-place contract amendment: `create_session` signature widens to accept `end_user_id`; POST body construction gains a conditional field; new TESTS entries for present + omitted cases." - issue: 3 path: "src/ratatoskr/cli.py" reason: "In-place contract amendment: `_parse_args` adds `--end-user-id` flag; `ParsedArgs` gains `end_user_id: str | None`; `_amain` threads it into the `create_session(...)` call when `args.new`." - issue: 4 path: "src/ratatoskr/tui.py" reason: "In-place contract amendment: `RatatoskrApp.on_mount` threads `self.args.end_user_id` into its `create_session(...)` call when `args.new`." --- # end_user_id support — POST /sessions parameter for per-user agents ## Context Manual smoke against personal Worldtree on 2026-05-21 (`ratatoskr --send "test" --new --agent lofn`) returned a 422 from POST /sessions: ``` [session_api_failed] status=422 body=b'{"detail":{"error_code":"end_user_id_required","message":"end_user_id is required (non-empty string) for Lofn sessions"}}' ``` The `lofn` agent (and presumably others in the Persona/Vili family) requires an `end_user_id` field in the create-session body to scope state per end-user. Ratatoskr's current `create_session(client, agent_id)` only sends `{"agent_id": agent_id}`, so per-user agents are unreachable. Mimir, the agent used for prior smoke validation, doesn't require `end_user_id` and continues to work unchanged. This issue threads `end_user_id` through the small chain: CLI flag → `ParsedArgs` → `_amain` / `on_mount` → `create_session` → POST body. The change is small (a keyword-only parameter widening + one new CLI flag + arg-passing in two places) but touches three existing contracts (#2, #3, #4) in-place. This issue's own contract is mostly a coordinating record + the new test additions. ## Data flow **Input:** - Operator passes `--end-user-id ` on the CLI when invoking against a per-user agent. - Worldtree's POST /sessions endpoint accepts `end_user_id: str` as an optional field; rejects with 422 `end_user_id_required` when omitted for an agent that requires it. **Output:** - POST /sessions body becomes `{"agent_id": , "end_user_id": }` when `--end-user-id` was passed; remains `{"agent_id": }` when omitted. - Returned `SessionInfo` is unchanged shape (the server response doesn't change; only the request body widens). **Side effects:** outbound HTTP only (no new state). No persistence — operator passes `--end-user-id` on each invocation; ratatoskr doesn't remember it. ## Invariants - **INV-001 [hard]**: `end_user_id`, when passed via `--end-user-id`, MUST be a non-empty string. Empty-string `--end-user-id ""` raises `UsageError` BEFORE any HTTP call (mirrors the existing `--send ""` empty-check in `_parse_args`). Server-side validation also rejects empty, so client-side rejection is friendlier. - **INV-002 [hard]**: `create_session(...)` MUST omit the `end_user_id` field from the POST body when the kwarg is `None`. This preserves the existing 2-field body shape for agents that don't require `end_user_id` (mimir today; other agents in the future). Sending an empty-string `end_user_id` is NOT equivalent to omitting it (server rejects empty; INV-001 catches empty before HTTP). - **INV-003 [hard]**: Backwards compatibility: all existing `mimir` smoke flows that don't pass `--end-user-id` continue to work unchanged. The CLI's `--end-user-id` flag is OPTIONAL (no default required); `_parse_args` succeeds without it; `_amain` / `on_mount` call `create_session(client, agent_id)` (no end_user_id kwarg) when the flag wasn't passed, identical to today's behavior. - **INV-004 [hard]**: No `core.*` / `worldtree.*` imports (existing boundary; this issue doesn't change it). ## Out of scope - **422 `end_user_id_required` → user-friendly hint.** The raw `[session_api_failed] status=422 body=...` label is honest; the message in the body (`"end_user_id is required (non-empty string) for Lofn sessions"`) is reasonably clear. Hint translation deferred to a follow-up if the bare label proves empirically confusing. - **`list_sessions` filter by `end_user_id`.** Spec allows it; no consumer needs it yet (the startup-session-picker issue is a separate ticket). - **Environment-variable fallback for `end_user_id`.** Auto-defaulting (e.g., `$USER`, `$RATATOSKR_END_USER_ID`) would paper over the per-user-isolation intent. Explicit flag only. - **TUI startup error visibility** — when `--end-user-id` is missing for a per-user agent, the TUI's `[session_api_failed]` line still gets eaten by the alt-screen teardown. That's issue #6's scope, not this one. - **Empty-data SSE crash** — separate issue #7; mid-stream JSONDecodeError on empty `sse.data` is independent of `end_user_id`. - **Other per-agent-required fields.** If Worldtree later adds another required-by-some-agents field, file a sibling issue; don't generalize this one prematurely. ## Constraints - **[compatibility]** Spec pin unchanged. The `end_user_id` field is already in the v0.19.0 spec; we're just starting to use it. - **[performance]** No new round-trips; no new state. The change is one extra optional field in an existing POST body. - **[security]** `end_user_id` is logged as part of `[create_session]` (alongside `session_id`, `agent_id`) — operator's choice of identifier may carry semantic meaning, but it's not auth-bearing. Don't redact. - **[style]** Keyword-only parameter for `end_user_id` (matches `persist_partial` on `cancel_turn`). Ruff line-length=100. --- ## In-place amendments (the work) This issue's contract is small because the real work is amendments to issues #2, #3, #4. The amendments are pinned here so reviewers see the whole change in one place; the actual contract files at `docs/contracts/issues/2.contract.md`, `3.contract.md`, `4.contract.md` are amended in-place as part of this issue's commit. ### Issue #2 (`ratatoskr.sessions`) amendments **`create_session` signature widens:** ```contract FN create_session(client: httpx.AsyncClient, agent_id: str, *, end_user_id: str | None = None) -> SessionInfo BRIEF: POST /sessions with {"agent_id": agent_id} and optionally {"end_user_id": end_user_id} when non-None. Returns SessionInfo populated from the 201 response. PRE: [PRE-001 hard] client is not None PRE: [PRE-002 hard] agent_id is a non-empty string PRE: [PRE-003 hard] end_user_id is None OR a non-empty string -- assert end_user_id is None or (isinstance(end_user_id, str) and end_user_id) POST: [POST-001 side_effect] exactly one POST to /sessions was issued; body is {"agent_id": agent_id} when end_user_id is None, OR {"agent_id": agent_id, "end_user_id": end_user_id} when non-None ... (other POST/ERROR_ROUTING unchanged) ... STEPS: ... 2. [sequential, flexibility=prescriptive] Build body: body = {"agent_id": agent_id}; IF end_user_id is not None: body["end_user_id"] = end_user_id 3. [sequential, flexibility=prescriptive] CALL client.post("/sessions", json=body) ... ``` **New TESTS entries:** - `happy_create_with_end_user_id [happy]`: pass `end_user_id="alice"` → outbound JSON body == `{"agent_id": "mimir", "end_user_id": "alice"}` byte-for-byte; SessionInfo populated as today. - `default_omits_end_user_id [trace]`: omit `end_user_id` kwarg → outbound JSON body == `{"agent_id": "mimir"}` (no end_user_id key); preserves the issue #2 baseline. - `empty_end_user_id [adversarial]`: `end_user_id=""` → AssertionError before HTTP (PRE-003). ### Issue #3 (`ratatoskr.cli`) amendments **`_parse_args` STEPS gain a new flag + env-var fallback (amended 2026-05-23):** ``` 1. [setup] Construct argparse.ArgumentParser: ... (existing flags) ... --end-user-id (str, optional — non-empty if passed; validated in step 2b) 2a. [branch] IF ns.send is not None AND not ns.send: UsageError("--send content must be non-empty") (unchanged) 2b. [branch, NEW] IF ns.end_user_id is not None AND not ns.end_user_id: UsageError("--end-user-id must be non-empty when passed") ... (rest unchanged) ... 5b. [sequential, AMENDED 2026-05-23] Resolve end_user_id with env-var fallback: end_user_id = ns.end_user_id or os.environ.get("RATATOSKR_END_USER_ID") or None (Flag wins; env fallback active when flag omitted; None when neither set.) 6. [cleanup] RETURN ParsedArgs( ... existing fields ..., end_user_id=end_user_id, ) ``` **`ParsedArgs` gains `end_user_id: str | None`** (default semantics handled by argparse default=None). **`_amain` threads through to `create_session`:** ``` IF args.new: TRY: info = await create_session(client, args.agent_id, end_user_id=args.end_user_id) ... existing error handling unchanged ... ``` **New TESTS:** - `happy_new_with_end_user_id [happy]`: argv includes `--end-user-id alice` → ParsedArgs.end_user_id == "alice". - `end_user_id_default_none [trace]`: argv omits `--end-user-id` AND env unset → ParsedArgs.end_user_id is None. - `empty_end_user_id [adversarial]`: argv has `--end-user-id ""` → UsageError. - `end_user_id_from_env [trace, amended 2026-05-23]`: env `RATATOSKR_END_USER_ID="ratatoskr-tui"`, flag omitted → ParsedArgs.end_user_id == "ratatoskr-tui". - `end_user_id_flag_beats_env [trace, amended 2026-05-23]`: env set + flag passed → flag wins. - Update `_amain`'s `happy_new_session_then_stream` test: respx assertion on the POST /sessions body now confirms `end_user_id` is OR isn't present per the test variant. ### Issue #4 (`ratatoskr.tui`) amendments **Post-#6 adjustment:** issue #6 (landed 2026-05-23, after this contract was drafted) moved session resolution OUT of `on_mount` (alt-screen) into `_resolve_then_run` (pre-`App.run_async()`). The equivalent end_user_id threading site is therefore `_resolve_then_run`'s `create_session` call, not `on_mount`'s. `on_mount` no longer calls `create_session` at all. **`_resolve_then_run` STEPS gain end_user_id threading (was on_mount pre-#6):** ``` 2. [branch] IF args.new: TRY: info = await create_session(client, args.agent_id, end_user_id=args.end_user_id) ... existing error handling unchanged ... ``` **New TEST (renamed _mount → _resolve per #6):** - `happy_new_with_end_user_id_resolve [happy]`: `_args_new(end_user_id="alice")`; respx mocks POST /sessions; assert outbound body has `end_user_id: alice`. --- ## Acceptance - All three amended contracts (#2, #3, #4) drift-check clean. - All existing tests + new `end_user_id` coverage GREEN under `uv run pytest tests/`. - `uv run ruff check src/ tests/` clean. - Boundary smoke `tests/test_no_worldtree_imports.py` still passes. - Manual smoke: `source env.sh && uv run ratatoskr --send "test" --new --agent lofn --end-user-id ratatoskr-dev` succeeds against personal Worldtree (session creates, stream consumes to `[done]`). - The pre-existing `mimir` smoke flow without `--end-user-id` STILL works (regression check).