---
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 <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 <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 <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": <agent>, "end_user_id": <id>}` when `--end-user-id` was passed; remains `{"agent_id": <agent>}` 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 <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).
