Commit Graph

58 Commits

Author SHA1 Message Date
vh d6f9327ec1 fix(sessions): address Volva code-vs-contract drift (issue #2)
Volva's code-spec review (thread 01KS4EKVKKGF) surfaced three findings
on the TDD-passing sessions module. All three addressed; one carries
a collateral contract amendment to keep INV-002 truthful.

1) drift: archived=item.get("archived", False) returned None for an
explicit "archived": null in the response. dict.get(k, default) only
fires the default when the key is absent — it does NOT default for
explicit-null values. The dataclass type is `bool` (not `bool | None`)
and INV-002 says explicit-null → False; the .get() form silently
violated both. Fixed: archived=item.get("archived") or False
(handles absent, null, false, and true cleanly).

INV-002 wording was the source of the bug — I introduced the
mis-spelled form during the Volva amendment round. Updated to spell
out the .get(default) foot-gun explicitly so future readers (and
future paraphrase rounds) don't fall back to the broken pattern.

2) test-gap: no test exercised explicit-null archived/tags. The
_list_item() helper had its own defaulting layer (tags=None →
["work"]) so a happy path test couldn't catch the underlying drift.
Added test_explicit_null_list_defaults using a raw dict to bypass
the helper. Catches the drift directly.

3) precision: message_count=body.get("message_count") could silently
default to None while POST-003 required it non-None. INV-001 prose
literally said "body['message_count']" (bracket access) so the
STEP 5 .get() was the contract's own internal inconsistency.
Aligned the code to bracket access (matches sibling required
fields like session_id) and amended STEP 5 + INV-001 to spell out
the strict semantics explicitly.

Volva's meta-note: "modest weight" — TDD caught the main surface;
this round caught a narrow Python .get() semantics edge that no
human reading would have spotted without explicit-null priors.
Still pulls real weight: that's the kind of bug that ships and
shows up months later when a server starts emitting null where
it used to omit a field.

63 tests GREEN (42 sse_client + 20 sessions + 1 boundary).
Ruff clean. Drift check still GREEN against the pinned issue body.
2026-05-20 22:06:37 -07:00
vh 4ba143c563 feat(sessions): implement issue #2 contract via TDD
Implements docs/contracts/issues/2.contract.md. Two functions
(create_session, list_sessions), two frozen dataclasses (SessionInfo,
SessionPage), three exception types (AgentNotFound, InvalidCursor,
SessionApiFailed). 19 contract-listed tests cover every TESTS:
entry verbatim per the tracer-bullet vertical-slice ordering.

SessionInfo uses one shape across both endpoints with origin-
conditional defaults per INV-001 (create) and INV-002 (list). create-
origin always sets list-only fields to (name=None, archived=False,
tags=[]); list-origin reads them from the response item with
absent/null treated as those same defaults — keeps the dataclass
uniform without forcing callers to handle two types.

Spotted an internal-inconsistency in the contract at TDD start —
POST-003 and happy_create's test description still said "archived
is None, tags is None" while the freshly-applied Volva amendment
had moved INV-001 to (archived=False, tags=[]). Fixed in-place
before writing any tests so the spec stayed coherent.

SessionApiFailed.body truncates to <= 1024 bytes at construction,
matching the SseConnectFailed / CancelFailed precedent from issue #1.

No code shared with sse_client.py (convention-dependency only per
issue #2's dependencies: block). 62 tests GREEN total (42 sse_client
+ 19 sessions + 1 boundary smoke). Ruff clean.

No refactor pass — the two functions are ~25 LOC each with distinct
error-routing branches that don't naturally share more than they
already do.
2026-05-20 22:01:05 -07:00
vh a6e6c1bbd8 contract(issue#2): amend per Volva paraphrase — defaults, query, metadata
Volva's contract paraphrase round (thread 01KS4DTCW8CV) surfaced five
ambiguities; three are real contract-text gaps and addressed here.

1) tags/archived/name defaulting was inconsistent across prose, INV-002,
and STEP 6. open_questions said "defaulting to sensible None/empty",
INV-002 said "populated from the response item shape", STEP 6 said
`item.get("tags", []) if "tags" in item else None` (which collapses
absent and explicit-null into the same None branch while letting an
explicit [] pass through). Tightened to: tags is always list[str]
defaulting to [] for absent/null/empty in list items; archived is
always bool defaulting to False; name remains str | None (the only
field where None is a meaningful value). create_session always sets
list-only fields to their fixed defaults (name=None, archived=False,
tags=[]) instead of None to keep the dataclass shape uniform.

2) The include_archived_query test said "URL has no include_archived
param OR explicit false". STEP 2 prescribes "ADD include_archived='true'
iff include_archived" — the OR-clause weakened the test against the
prescribed behavior. Tightened to: default (include_archived=False)
asserts NO include_archived param at all, not an explicit false.

5) metadata's populated semantics: INV-001 said "populated from the
201 response", STEP 5 said body.get("metadata", {}) — two valid
readings (trust the spec vs defensive default). Aligned to the
defensive shape: INV-001 + INV-002 now explicitly state "defaults
to {} when absent" as spec-drift tolerance.

Volva flags #3 (exception .body sensitivity — truncation reduces
size not sensitivity) and #4 (assert for runtime validation — Python
-O disables) reviewed and kept as-is. Both are intentional carryovers
from issue #1's precedent: exception .body is for caller debugging
bound to 1024 bytes (caller's responsibility to not log raw); assert
chosen for fast-path validation, trading -O robustness for normal-mode
speed.

Drift check still clean — amendments don't touch the pinned issue
body, so prd: hashes remain valid.
2026-05-20 21:52:32 -07:00
vh 9df9bb8757 contract(issue#2): scaffold ratatoskr.sessions — create + list
Issue #2: ratatoskr.sessions covers the session-lifecycle endpoints
needed by --send --new (POST /sessions) and the eventual TUI startup
picker (GET /sessions). Two FN blocks (create_session, list_sessions)
plus two shared frozen dataclasses (SessionInfo, SessionPage).
complexity=low; estimated 150 LOC.

Bundles both endpoints in one contract because they share the response-
envelope shape — SessionInfo carries the union of POST-response fields
(message_count) and list-item fields (name, archived, tags), with
the origin-conditional fields defaulting to None. INV-001/002 spell
out which fields come from which source so callers can rely on the
discriminator.

INV-006 refuses out-of-range limit (< 1 or > 200) client-side: spec
§GET /sessions says the server returns 422; the client checks first
so a 422 from this endpoint indicates server-side spec drift, not a
client bug.

INV-003 codifies the opaque-cursor discipline (spec §Pagination:
"Cursors are opaque to clients — do not parse or construct them.").
list_sessions threads next_cursor verbatim; never base64-decodes.

Exception .body truncation to [:1024] inherited from issue #1's
SseConnectFailed/CancelFailed precedent.

First contract in this repo to carry a ## Out of scope H2. Future
/volva-code-review consults will auto-resolve that section instead
of needing --out-of-scope overrides. Six explicit exclusions:
Bifrost binding (Worldtree #160), ephemeral/Saga sessions, single-
session fetch, PATCH/DELETE mutation, history pagination, transparent
multi-page iteration. Server retry/backoff is caller's policy.

dependencies: lists issue #1 as a convention-dependency only — no
code import; same API-consumption posture (caller-owned httpx
client, async-native, frozen dataclasses, no Worldtree-source
imports).

prd: pinned to issue #2 body SHA-256 01fbbd52b6d90eb0 at
2026-05-21T04:45:06+00:00; scripts/contract_drift_check.py returns
clean.
2026-05-20 21:47:49 -07:00
vh 1526f0bc8e contract(issue#1): amend per Volva paraphrase — body, id range, INV-003
Volva's contract paraphrase round (thread 01KS4B3B0Y62) surfaced three
real contract-time ambiguities — addressing each here before applying
ready-for-agent.

1) reconnect_turn body was a punt. STEP 2 literally said "json={'content':
''} OR with no body (TBD per spec — confirm during implementation)".
The spec §Reconnect flow example shows POST with Content-Type:
application/json and a body shaped {"content": "..."} — the wire schema
requires content; the server identifies the resume target via the
Last-Event-ID header and does NOT re-process content (spec line 732:
"agent's tools and LLM call run exactly once regardless of disconnects/
reconnects"). reconnect_turn now takes content: str explicitly; STEP 2
sends json={"content": content}. Caller convention: pass the original
content sent to stream_turn. POST-002 added to assert byte-for-byte
body shape; new test body_threads_content covers it.

2) _parse_sse_id allowed turn_id and seq ≥ 0 — too loose. Spec §SSE id
format line 705 says seq starts at 1 (resets per turn); turn_id is
from SQLite turns.id (autoincrement, ≥1). Tightened POST-001 to require
both ≥1; STEP 5 raises ValueError on either < 1. Test happy_zero_seq
flipped to adversarial zero_seq; added zero_turn_id and negative_seq.
INV-002 tightened to reflect the same range.

3) INV-003 (TurnIdFlip) had a subtle wording gap between stream_turn
(first event ESTABLISHES turn_id; cannot be a flip) and reconnect_turn
(expected turn_id parsed FROM last_event_id BEFORE connect; first event
is already a flip-candidate). Volva noticed the reconnect test said
"first event was not yielded" while stream_turn semantics depend on
the first event being yielded unconditionally. Spelled out both entry
points in INV-003 as a numbered sub-list. reconnect_turn STEP 4 and
test turn_id_flip_on_first_event reworded to match.

Volva flags #3 (MalformedSseId-vs-ValueError split) and #5 (exactly-
one-terminal as server-assumed, not client-verified) reviewed and kept
as-is — both intentional. Drift check unchanged: amending the contract
does not touch the pinned issue body, so prd: hashes remain valid.
2026-05-20 21:08:19 -07:00
vh 999b0b4765 contract(issue#1): pin sse_client to gitea issue + seed default labels
Convert the sse_client contract into an issue-scoped contract bound to
the freshly-filed gitea issue #1. Frontmatter migrates from module-shape
(module:/purpose:) to issue-shape (target_module:/scope:/prd:) per
CONTRACT-FORMAT §2.1.I. The prd: block pins to issue #1's body SHA-256
(abcbc49467e86f1d at 2026-05-21T03:57:37+00:00); drift check verifies
the pin matches the live issue body.

scripts/contract_drift_check.py needs pyyaml; added to [dev] in
pyproject.toml. Without it the drift check (and the contract parser)
fail with ModuleNotFoundError — that's a scaffold hole I'd hit again
on a fresh checkout.

Also seed 17 default labels on gitea via tea so issue tracking has a
working vocabulary out of the gate. Five buckets: Sleipnir gating
(ready-for-agent, blocked-needs-contract, blocked-needs-dependency),
triage (needs-triage, needs-architect-decision, needs-info), type
(bug, enhancement, task, documentation), resolution (duplicate,
wontfix, invalid), Ratatoskr-specific area (sse-client, tui, cli,
observability). Labels are gitea-side state — not in this commit.

Known: contract_parser.py --validate ERRORs on the issue-scoped
frontmatter because the parser is v2.0-shape. CONTRACT-FORMAT §2.1.L
H10 explicitly marks parser kind-aware validation as a Brokkr-side
follow-up. Parser is a canonical-synced file so we do NOT patch it
locally (would drift from corviduo-project-template).
2026-05-20 20:59:46 -07:00
vh 72d477f516 contract(sse_client): first contract — SSE consumer, reconnect, cancel
The natural smallest unit to TDD against per design-brief §3. Bundles
stream_turn + reconnect_turn + cancel_turn + private _parse_sse_id into
one module because the SSE-resume flow is structurally coupled — cancel
needs the turn_id parsed from the SSE wire id:, reconnect re-uses the
same parsed SseId, and stream_turn is what produces them.

Hard invariant INV-002 forces every yielded Event to carry a parsed
SseId(turn_id, seq) lifted from the composite {turn_id}:{seq} id:
wire field. This closes the foot-gun design-brief §3 explicitly names:
hand-rolled data:-only parsing silently drops the id: line and breaks
SSE-resume invisibly.

v2.1 format used; test categories adversarial/scenario/trace flagged
warn-only by the v2.0 parser (CONTRACT-FORMAT §2.1.L H10 is a known
Brokkr-side parser follow-up). FN block list parses cleanly.

Scaffold also verified at this commit: uv pip install -e ".[dev]"
resolves clean against the lockfile (now committed), and the boundary
smoke test (tests/test_no_worldtree_imports.py) passes.
2026-05-20 20:50:26 -07:00
vh 9703eb2b6b init: seed Ratatoskr from corviduo-project-template + ship v0 scaffold
Worldtree Conversation API debug TUI. Multi-pane observability dashboard:
chat transcript + persona/Vili affect log + tool events + admin events +
Bifrost state + tool inventory + (opt-in) raw server log.

Design locked at docs/design-brief.md (originated as
brokkr-smithy/docs/ratatoskr-design-brief.md). Operator-locked decisions:

- Textual application-shell framework (multi-pane dashboard, not REPL).
- Separate repo + separate dev team (no Worldtree-source imports).
- httpx-sse for SSE consumption (reference Python SSE-resume impl).
- Triple version-skew mitigation: spec-pin in pyproject.toml + recorded
  SSE snapshot tests + conformance smoke. Initial pin: Worldtree v0.19.0
  at 55101e909abcd2219833266b6f905c5bc956e0f0.
- Persona pane: label-don't-refuse PII posture.
- Server-log pane: opt-in via --server-log <path>.
- Two-stage Ctrl-C (cancel then exit).
- Markdown rendering default-on; --raw opt-out.

In the box:

- docs/design-brief.md — the locked design with full rationale.
- docs/SPEC-PIN.md — Worldtree spec pin + bump procedure.
- docs/conversation-api-spec.md + docs/conversation_api.contract.md —
  vendored Worldtree spec snapshots at the pinned SHA.
- pyproject.toml — Python 3.12, hatchling, uv-managed, deps locked.
- src/ratatoskr/ — stub package (cli.py raises NotImplementedError).
- tests/test_no_worldtree_imports.py — boundary smoke test PASSING.
- tests/snapshots/README.md — recording convention for SSE snapshot tests.

Not in the box yet:

- Gitea remote (operator/infra-ops to register at vh/ratatoskr).
- Implementation — the dev team owns this; design brief is the spec.

Origin: althing thread 01KS3R34XD3N6HMK91VXESHGW7 (worldtree-dev →
brokkr-smithy-dev, 2026-05-20). Volva consulted via thread
01KS3VF6W33N3V5FNMGQ91YNVD.
2026-05-20 20:38:22 -07:00