Files
ratatoskr/docs/contracts/issues/5.contract.md
T
vh 804c2df6eb feat(sessions,cli,tui): issues #5 + #6 + worldtree-dev consumer-API follow-up
Issue #6 (TUI startup error visibility): restructure run_tui lifecycle so
pre-App.run() failures land on real stderr instead of getting eaten by
the alt-screen teardown. New _resolve_then_run async helper opens the
AsyncClient via async-with, does pre-flight session resolution, routes
AgentNotFound / SessionApiFailed / network errors to sys.stderr (verbatim
same labels + exit codes as cli._amain), then constructs RatatoskrApp
with pre-resolved state and awaits app.run_async(). RatatoskrApp.__init__
signature widens to (args, *, session_id, agent_id, client) — all three
required. on_mount narrows to identity-widget population; on_unmount
becomes a no-op (client lifetime owned by run_tui's async-with).

Issue #5 (--end-user-id for per-end-user agents): sessions.create_session
gains keyword-only end_user_id kwarg with PRE-003 non-empty assertion;
ParsedArgs.end_user_id field added (default None); --end-user-id flag
with non-empty validation; _amain + _resolve_then_run thread it to their
create_session calls. RATATOSKR_END_USER_ID env-var fallback
(flag > env > None) per the post-2026-05-23 amendment; env.sh (gitignored)
ships "ratatoskr-tui" as project-stable partition default.

Worldtree-dev consumer-API follow-up (althing 01KSBARG2B8M): User-Agent
header added (ratatoskr/<version> (vh@phasefinal.com), version pulled via
importlib.metadata) to both AsyncClient constructions so server logs can
distinguish ratatoskr traffic from other consumers.

Volva code-review (2 rounds on #6) found 8 test-precision gaps + 1 PRE
assertion drift, all Category 1 fixed: missing PRE-001 at
_resolve_then_run entry; Rule separator assertions on markdown render;
RichLog-write spy on empty submit; input-cleared + no-new-worker on
cancelling busy; worker.cancel observation on three force-exit paths;
on_unmount-no-close focused test (the prior client-lifetime test patched
run_async so on_unmount was never exercised); happy --new resolve test
verifying POST count + identity propagation.

Issues #2/#3/#4/#5 contracts amended in-place to reflect:
- create_session widened (PRE-003, body construction step, body shape POST)
- ParsedArgs description + _parse_args STEPS + _amain create_session call
  + new TESTS for end_user_id + env-var fallback
- _resolve_then_run STEPS + new TEST entries; on_mount narrowed;
  INV-007 amended for new client ownership
- Post-#6 adjustment note on issue #5 (_resolve_then_run replaces
  on_mount as the threading site since #6 moved session resolution out
  of the alt-screen)

188 tests GREEN; ruff clean. Bumps to v0.1.0 — first minor release, the
load-bearing reason is RatatoskrApp.__init__'s breaking signature change
(additive end_user_id alone wouldn't have triggered a minor pre-v1.x).

Files Gitea issues #9 (spec-pin refresh v0.19.0 → v0.22.1), #10 (track
Worldtree #196 subject:{type,id} migration), #11 (AdminEvents pane auth
prerequisite admin.events.read). Infra-ops pinged via althing for
agents.call:lofn scope add (broker pattern; they forwarded to
worldtree-dev because personal Worldtree exposes no public
scope-mutation endpoint).
2026-05-23 14:34:53 -07:00

14 KiB

contract_version, target_module, scope, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions, open_questions, prd, dependencies
contract_version target_module scope depends_on used_by language complexity estimated_loc confidence assumptions open_questions prd dependencies
2.1 ratatoskr.sessions 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.
httpx
python low 40 0.9
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.
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.
issue issue_url body_sha256_16 lock_in_comment_id lock_in_sha256_16 lock_in_at pinned_at
5 #5 03fe1fa547235f32 null null null 2026-05-22T05:03:17+00:00
issue path reason
2 src/ratatoskr/sessions.py 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 path reason
3 src/ratatoskr/cli.py 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 path reason
4 src/ratatoskr/tui.py 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_mountcreate_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:

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).