Commit Graph

120 Commits

Author SHA1 Message Date
vh 75dec016eb fix(web): heid-review findings — SSE lifecycle teardown + test-shape gaps (v0.19.3)
Cross-frontier panel (Gróa/Hulda/Regin) on the v0.19.2 web surface, triaged:

- FIX (Gróa #1, drift): the turn EventSource `onerror` (raw transport drop)
  now calls hideThinkingNote() — a drop mid-reasoning no longer leaves the
  "<Agent> is pondering…" line + its setInterval running (INV-LIFECYCLE).
- FIX (Gróa #4 + Hulda #1, convergent drift): openAdminEvents now closes the
  EventSource + clears state.adminES on `stream_error` (server signalled end)
  and on a PERMANENT onerror (readyState CLOSED) — native EventSource no longer
  auto-reconnects into a retry loop; transient CONNECTING drops still reconnect.
- TEST (Gróa #2 + Hulda #3): test_routes_registered asserts the 3 new routes;
  test_state_attached asserts app.state.admin_key (create_app POST-001/002).
- TEST (Gróa #3 + Regin #3): AdminEvents stream_error-on-connect-failure test —
  upstream non-200 -> exactly one `stream_error` frame, then ends (POST-003).
- CONTRACT (Hulda #2 + Regin #2, accepted): clarified the Tools inventory
  renders NAMES only by design (descriptions live in the BifrostState pane);
  code unchanged. Also lands the web_debug_surface contract as the trail.

Accepted-no-op: 403-bifrost / non-404-tools tests (identical code path to the
tested 404). Panel found ZERO functional server-side drift; INV-004 escaping
confirmed clean across the new panes. 60 web tests pass; JS + ruff clean.
v0.19.3
2026-07-01 12:52:44 -07:00
vh a0a9d5f5e4 feat(web): debug-surface parity — BifrostState + AdminEvents + Tools panes, PAD-poll fix, reasoning indicator
Bring the browser surface to TUI parity as the primary debug surface:

- Tools inventory (GET /sessions/{id}/tools) folded into the tools pane —
  what the LLM has at turn-fire, above the live tool events.
- BifrostState pane (GET /admin/sessions/{id}/bifrost) — admin-scoped
  dispatch state; the admin key stays server-side (app.state.admin_key),
  never reaches the browser (INV-003 precedent).
- AdminEvents pane (GET /admin/events SSE) — admin lifecycle, session-
  filtered SERVER-side (heartbeats + other-session events dropped); one
  fixed "admin_event" browser event so every type renders (no drops).
- PAD refresh: poll a window (1.5/3.5/6.5/10.5s) instead of a single 2s
  shot that raced the post-turn-async affect.emit (issue #18 foot-gun).
- Reasoning indicator: ephemeral "<Agent> is pondering…" in the transcript
  on `thinking` deltas, cleared when text begins — clearly non-engine.

Admin key wired through entrypoint -> create_app. 9 new respx/route tests
(admin-bearer override, filter unit, SSE stream-filter); 59 web tests pass.
Live-proven against ratatoskr:sindra (bifrost connected, both caps; 253
thinking events -> indicator fires; affect emit lands -> PAD poll catches it).
v0.19.2
2026-07-01 12:33:11 -07:00
vh fc1e1487c7 memory: snapshot — v1 coverage-audit converged (REST 17/40, zero in-scope gaps)
Refresh the decay-prone in-flight section from the stale 2026-06-20
(#17/#18) state to the converged-audit state: REST 17/40 covered with
zero in-scope gaps, SSE 11/11, Bifrost planes 8/8; debug-observability
core complete (v0.19.0); standing pins v1.0.0b2 + bifrost 1.0.0; admin
key scopes verified. Recent-decisions log unchanged.
2026-07-01 00:08:53 -07:00
vh af07a2329a feat(#2): Tier-2 — transient characters + persona-state write; audit converges
v1 coverage-audit: the last in-scope client I/O points. The audit now
CONVERGES — REST 17/40 covered with zero in-scope gaps (23 excluded-by-
design), SSE 11/11, Bifrost planes 8/8.

- sessions.py: list_character_models / create_character / get_character_state
  / delete_character (#161, character.read/write) + set_persona_state
  (POST /sessions/{id}/persona_state — freeform body, unpinned in the
  frozen surface). 200/201 -> dict (or None on 204), off-status ->
  SessionApiFailed.
- cli.py: two one-shot probes (mirror --whoami): --characters (CRUD
  lifecycle report) + --set-persona-pad "p,a,d" (requires --session).
  New ParsedArgs.characters/set_persona_pad + probe mutual-exclusion.
- Contract #2 amended (5 FNs) + validated. TDD: 7 wrapper + 5 cli tests.
  Suite 573 green; touched code ruff-clean.
- Char read side live-proven (GET /models/available-for-characters -> 200).

Coverage-map: convergence frontier CLOSED — scope-A "done" (every frozen
I/O point classified) is met; ratatoskr cuts v1 when Worldtree tags 1.0.
v0.19.1
2026-06-30 23:57:09 -07:00
vh 5fbe353836 feat: v0.19.0 — debug-observability core complete
Milestone minor (operator-approved). Publishes the design-brief's
headline deliverable: the multi-pane debug-observability dashboard is
complete — all four observability panes are built and consuming their
real Worldtree endpoints:

- Persona      → GET /agents/{id}/persona_state
- Tools        → GET /sessions/{id}/tools
- BifrostState → GET /admin/sessions/{id}/bifrost
- AdminEvents  → GET /admin/events (SSE)

v1 client-REST coverage is 12/40; both non-REST surfaces (SSE 11/11,
Bifrost provider planes 8/8) already complete. Only Tier-2 client I/O
(transient-characters routing, persona_state-write) remains in scope;
everything else is covered or excluded-by-design in docs/coverage-map.md.

Version bump only (the feature arc landed across v0.18.5–v0.18.11).
v0.19.0
2026-06-30 23:38:46 -07:00
vh a3c92b68dc feat(#11): AdminEvents pane — GET /admin/events SSE (session-filtered)
v1 coverage-audit: the last unbuilt design-brief §5 debug pane. #11's
blocker was already satisfied (admin key carries admin.events.read).
Completes the admin/debug-observability core.

- sse_client.py: AdminEvent dataclass + stream_admin_events — a new
  long-lived SSE consumer for the admin lifecycle stream (envelope
  {id,type,timestamp,data}), admin-scoped (bearer-override), Last-Event-ID
  resume. non-200 -> SseConnectFailed; mid-drop -> SseConnectionDropped.
- tui.py: "AdminEvents" TabPane + _format_admin_event + _admin_event_matches
  (design-brief §6 filter: active-session + non-heartbeat system.*) +
  _stream_admin_events long-lived best-effort worker (unconditional
  on_mount; self-labels not-configured / unavailable / stream-ended).
- Contract-skipped for stream_admin_events (out of #1's turn-SSE scope;
  spec § Admin Event Stream is the reference). TDD: 4 sse_client + 5 tui
  tests. Suite 561 green.
- LIVE-AUTH-PROVEN on :8081 (GET /admin/events -> HTTP 200 under admin key).

Coverage: REST 12/40. Tier 1 debug-observability core complete.
v0.18.11
2026-06-30 23:25:34 -07:00
vh 9ce83d5fdc feat(#2): BifrostState pane — GET /admin/sessions/{id}/bifrost (admin-key)
v1 coverage-audit: the last unbuilt design-brief §5 debug widget. First
admin-key consumer in ratatoskr.

- sessions.py: get_session_bifrost(client, session_id, *, admin_key) —
  admin-scoped (admin.sessions.read); the request overrides Authorization
  with admin_key (distinct from the consumer bearer). 200 -> dict, non-200
  -> SessionApiFailed (403 scope-denied, 404 not-bound).
- cli.py: --admin-key flag + RATATOSKR_ADMIN_API_KEY env -> ParsedArgs.admin_key.
- tui.py: new "Bifrost" TabPane + _format_bifrost_state + _hydrate_bifrost_state
  best-effort worker (unconditional on_mount). Writes {endpoint, connected,
  caps, tools} + audits; self-labels "not configured" / "not bound" / graceful
  on 403+error, never crashes.
- Contract #2 amended (FN, incl. the bearer-override POST) + validated. TDD:
  4 wrapper tests + 1 format unit + 3 hydrate integration. Suite 552 green.
- LIVE-AUTH-PROVEN on :8081 (admin key reached resource-layer 404, not 401/403).

Ledger correction: #11 (AdminEvents) is NO LONGER BLOCKED — the admin key
was verified to carry admin.events.read; only the pane is unbuilt. Coverage:
REST 11/40.
v0.18.10
2026-06-30 23:12:29 -07:00
vh e62208d8e3 feat(#2): consume GET /sessions/{id}/tools — Tools-pane inventory hydrate
v1 coverage-audit Tier-2 quick win. The owner-scoped tool-inventory
endpoint (#183) had no caller; wire it into the TUI Tools pane.

- sessions.py: get_session_tools (GET /sessions/{id}/tools) — owner-
  scoped (consumer key, no admin scope), 200 -> parsed dict verbatim,
  non-200 -> SessionApiFailed. Mirrors get_persona_state / get_me.
- tui.py: _format_tool_inventory helper + _hydrate_session_tools
  best-effort worker (mirrors _hydrate_persona), wired unconditionally
  in on_mount. Writes the merged {agent_id, builtin_tools,
  bifrost_tools} inventory the LLM saw at turn-fire into the Tools
  pane + audits; never crashes on failure.
- Covers the design-brief 5 "Tools widget" via the reachable owner
  endpoint (the admin variant stays a gap only for cross-user debug).
- Contract #2 amended (FN) + validated. TDD: 3 wrapper tests + 1
  format-helper unit + 2 hydrate integration tests. Coverage: REST
  10/40. Suite 544 green; touched code ruff-clean.
v0.18.9
2026-06-30 22:50:12 -07:00
vh 0205b81319 memory: b1 heid-code-review panel — zero findings (cross-model-verified)
Gróa + Hulda + Regin each independently reviewed stream_turn_resilient
vs contract #1 (artifact-only) → all three zero findings. Records the
clean bill + the calibration signal (prescriptive contract + TDD =
confirmation, not discovery).
2026-06-30 22:38:40 -07:00
vh 387ac4ab2c feat(#2): consume GET /me + GET /capabilities via --whoami one-shot
v1 coverage-audit slice (capabilities+me). Both endpoints had no
caller; add them as cheap boot-time debug primitives.

- sessions.py: get_me (GET /me — identity/whoami) + get_capabilities
  (GET /capabilities — Echo ephemeral-template discovery). Mirror
  get_persona_state: 200 -> parsed dict verbatim, non-200 ->
  SessionApiFailed. Freeform dicts (frozen OpenAPI types both as
  objects).
- cli.py: new --whoami one-shot mode (mirrors --send). Fetches both,
  prints an identity + capabilities report, exits. Standalone probe:
  mutually exclusive with --send/--session/--new/--agent; opens no
  session. New ParsedArgs.whoami field + main() dispatch.
- Contract #2 amended (2 FNs) + validated. TDD: 5 wrapper tests +
  5 cli tests (validation + mode + error). Coverage map: REST 9/40.
  Suite 538 green; touched code ruff-clean.

Audit note: /capabilities is the Echo ephemeral-template discovery
endpoint, not a generic server-caps endpoint (coverage-map framing
corrected). TUI-surfacing of /me + /capabilities deferred.
v0.18.8
2026-06-30 22:21:59 -07:00
vh 5c1b9816d4 feat(#6): startup session picker for bare TUI mode
v1 coverage-audit slice b2. The audit found list_sessions had no
caller — the startup session picker (design-brief §4) was never built;
bare TUI mode was a hard usage error. Add SessionPickerApp (mirrors
AgentPickerApp) and resolve bare mode in _resolve_then_run.

- Bare TUI mode (no --session/--new) now valid → session picker.
  Resolution: 0 sessions -> [no_sessions] exit 14 (resume-only per
  §4 "no in-app creation, --new only"); exactly 1 -> auto-resume
  (§4 "picker only when >1"); >=2 -> SessionPickerApp -> resume pick
  (Esc/Ctrl-D -> exit 0).
- cli._parse: bare TUI valid; --send still requires one flag; --agent
  forbidden in bare mode. run_tui PRE-002 xor -> mutually-exclusive.
- Contract #6 amended (SessionPickerApp + bare-mode resolution) +
  validated. TDD: 3 picker pilot tests + 5 resolution tests + 3 cli
  validation tests. Suite 528 green; touched code ruff-clean.

Design note: bare + 0 sessions errors (honors §4's no-in-app-creation
clause); the friendlier auto-fall-through-to-new is deferred pending
operator preference.
v0.18.7
2026-06-30 22:05:14 -07:00
vh 2ba4244e9e feat(#1): route TUI + web presenters through stream_turn_resilient
Complete b1's design-brief §8b promise ("all presenters share the
consumer"): the TUI and web SSE consumers now resume transparently on
a mid-stream drop, same as cli --send (v0.18.5). The TUI is the primary
beneficiary — long-lived dev sessions across laptop suspend.

Name-for-name swap of stream_turn -> stream_turn_resilient at the two
remaining consumer loops (tui.py:1321, web/server.py:294) + their
imports. No behavioral change on the happy path (resilient == stream
when there is no drop); suite 518 green; touched lines ruff+mypy clean
(pre-existing tui/web lint debt left untouched per surgical rule).
v0.18.6
2026-06-30 15:46:04 -07:00
vh 0c7660791f feat(#1): shared SSE resume orchestration; wire cli --send
v1 coverage-audit slice b1. The audit found reconnect_turn had no
caller — every presenter dropped the stream on disconnect instead of
resuming, leaving the "reference SSE-resume implementation" (design-
brief §3/§8d) unreachable. Add stream_turn_resilient as the single
shared resume surface (design-brief §8b "share the consumer, branch
the presenter") and route cli --send through it.

- stream_turn_resilient wraps stream_turn + reconnect_turn: on
  SseConnectionDropped (mid-stream drop or clean EOF before terminal),
  resume from the last-seen sse_id via reconnect_turn (Last-Event-ID),
  up to max_reconnects (default 5). last_seen persists across attempts.
- Non-drop reconnect failures (412/410/400/TurnIdFlip/SseConnectFailed)
  propagate unchanged, per contract #1's "surface, not recover".
- cli.py: --send consumer now drives stream_turn_resilient (transparent
  reconnect). tui/web still consume bare stream_turn (follow-up).
- Contract #1 amended (FN stream_turn_resilient) + validated; 8 TDD
  cases (happy, resume-after-1/2-drops, clean-EOF resume, unresumable
  zero-event, max-reconnects-exhausted, zero-budget, buffer-expired-
  propagates). Suite 518 green; ruff + mypy clean on touched code.
v0.18.5
2026-06-30 15:42:33 -07:00
vh 1f289098ba memory: scope (b) Tier-1 frontier — SSE-resume + session-picker slices
Capture the contract-first plan for the two presenter-wiring gaps
(resume-orchestration wrapper per design-brief 8b; picker + CLI flags
per 4) so the next focused TDD cycle has the slice plan resident.
2026-06-30 15:32:15 -07:00
vh b798068932 pin: re-pin to Worldtree's FROZEN v1 surface (OpenAPI 2.2.0 + SSE schema)
v1 coverage-audit remediation P-1: vendor the authoritative machine-
readable artifacts and pin them for drift-checking, advancing the spec
pin from v0.35.16 (f1b59f8) to v1.0.0b2 (5810a26).

- Vendor docs/conversation-api-openapi.json (OpenAPI 2.2.0, 40 path-
  groups) + docs/conversation-api-sse-events.schema.json (11 events).
- Pin all three Conversation-API artifacts in .corviduo-canonicals.toml:
  OpenAPI + SSE schema as strict drift gates (canonical_drift.py), the
  prose markdown as tolerate_drift reference. Drift check green (10/10).
- pyproject: worldtree-spec-rev -> 5810a26, worldtree-version -> v1.0.0b2
  (was stale at v0.29.0), pinned-on -> 2026-06-30.
- SPEC-PIN.md: current-pin table + history row + vendored-artifacts list.
- coverage-map.md: P-1 marked remediated; the map now audits a frozen,
  diffable target.

The prose markdown is byte-identical to v0.35.16 (last WT edit
2026-05-31); the b2 surface lives only in the OpenAPI. No client-
facing code change (the b2 409/503 + unified error envelope were
already consumed in v0.18.3/.4) -> pin-only, no version bump.
2026-06-30 15:28:52 -07:00
vh 93e4176346 docs: author v1 coverage-map ledger; lock scope mandate A
First coverage map — every Worldtree v1-FROZEN I/O point x ratatoskr
status. Anchored on WT's frozen machine-readable artifacts (OpenAPI
2.2.0 = 40 REST path-groups + SSE schema = 11 events + bifrost wire
v0.6), not the stale vendored prose markdown.

- SSE 11/11 and Bifrost provider planes 8/8 covered + live-proven;
  client REST 7/40 live, 11 in-scope frontier, 22 excluded-by-design.
- Scope mandate A (operator): v1 done = every frozen point classified
  (covered-or-excluded-with-rationale), zero unaccounted; not a
  feature-complete client.
- Finding P-1: vendored prose markdown is byte-identical to live WT
  but frozen at v0.35.16-era content; b2's surface lives in the
  OpenAPI 2.2.0 + SSE-schema JSON we don't vendor. Pin-remediation
  pending operator nod.

No version bump (docs-only).
2026-06-30 14:41:06 -07:00
vh a358cc9150 memory: snapshot — b1→b2 409/503 adaptation + bifrost 1.0.0 + combined-bind default + admin key + regard-dead-axis finding
Worldtree on v1.0.0b2 (both demo + personal); ratatoskr v0.18.4 all pushed.
Session arc: web combined-bind default (v0.18.1), bifrost 1.0.0 repin
(v0.18.2), b1/b2 eager 409/503 status mapping (v0.18.3/.4), readonly-admin
key collected (#11 prereq cleared), and the regard-dead-axis finding
(provider-side catch -> worldtree-dev escalating to Vuong). Next substantive
effort = the v1 coverage-audit (folds in the deferred live-409 + b2 spec
re-vendor).
2026-06-30 13:57:04 -07:00
vh e4317f6a73 fix: pin the eager-503 default error_code to not_ready (Worldtree b2)
worldtree-dev finalized the #331 503 turn-launch error_code as `not_ready`
(re-pinned from internal_error; retryable, matching the /readyz 503 sense)
and re-froze the OpenAPI at 2.2.0 documenting the 409/503 statuses our
v0.18.3 mapping already handles. Tighten our fallback default from the
placeholder `turn_launch_unavailable` to the canonical `not_ready` (the
default only fires when the body omits error_code — the real code is
surfaced verbatim regardless). +1 test, suite 510 green.

NOTE: a full conversation-api-spec.md re-vendor to the b2 era is a separate,
larger pin-refresh (ratatoskr vendors the markdown spec, not the OpenAPI
JSON) — deferred, to bundle with the v1 coverage-audit / when personal is on b2.
v0.18.4
2026-06-30 13:26:14 -07:00
vh b2e4901264 feat: map Worldtree b1 eager turn-launch statuses (409/503) in stream_turn
Worldtree v1.0.0b1 (#331) decoupled turn execution from the SSE connection,
so turn-launch failures now arrive EAGERLY as an HTTP status before any
stream: 409 agent_not_available (pre-b1 was a 200 + in-stream error event)
and 503 (retryable turn-launch / infra failure). stream_turn previously
funneled both into a generic SseConnectFailed.

Map them to typed SseConnectFailed subclasses — AgentNotAvailable (409) and
TurnLaunchUnavailable (503, retryable=True) — carrying the parsed
error_code/message from the {detail:{error_code,message}} envelope.
Subclassing keeps existing `except SseConnectFailed` handlers working with
zero changes (POST-003 preserved — no synthetic event yielded; raise mirrors
reconnect_turn's 400/410/412 pattern).

worldtree-dev confirmed 409/503 are real runtime statuses; the OpenAPI 2.1.0
gap (not enumerating them) is theirs to fix (doc-completeness, not a wire
break). The 503 error_code is being re-pinned upstream (today internal_error
-> likely not_ready); our handling keys on STATUS so it's robust to the final
code — tighten the 503 default once they confirm.

Body shape live-confirmed against demo b1's 404/401 responses. Suite 509 green.
Contract docs/contracts/issues/1.contract.md updated.
v0.18.3
2026-06-30 13:11:33 -07:00
vh af67ad995c chore(deps): repin bifrost==1.0.0 (first stable; wire v0.6 frozen)
Bifrost shipped 1.0.0 — first stable release, freezing wire v0.6 (the
surface ratatoskr's combined-builder consumer #18 already adopted).
Non-breaking: byte-identical on the wire to the prior >=0.10.0 pin.
Switched floor pin -> exact pin per the stable-substrate posture.
Suite 506 green against bifrost 1.0.0.
v0.18.2
2026-06-29 11:10:01 -07:00
vh 719e4d605b feat: web SPA bind — add 'combined' (:8392) both-plane option as default
The bind dropdown offered only memory/affect single-plane binds; #18's
composite endpoint (:8392, both planes in one session) was never reachable
from the SPA. Add 'combined' as the default-selected option, keeping
memory-only / affect-only for single-plane isolation diagnostics.

- endpoint_for_plane: combined -> :8392 (sessions.py)
- web server: accept bifrost_plane="combined" (server.py)
- dropdown: combined (:8392) default-selected, single-plane retained (index.html)
- #17 contract: endpoint_for_plane FN + plane-selector spec updated to combined
- tests: endpoint_for_plane combined, server combined bind -> :8392, dropdown default

Suite 506 green. Live-verified on :8765 (current code).
v0.18.1
2026-06-20 16:47:03 -07:00
vh c5c8ecf9d5 memory: snapshot — #17 CLOSED + #18 composite final leg PROVEN end-to-end
The Worldtree-driven composite :8392 smoke ran and is proven + persisted:
one bound session drove the full both-plane lifecycle through one endpoint
(handshake both caps -> affect.fetch + memory.search -> affect.emit stored:true
-> memory.upsert_many upserted:1), both writes verified in our SQLite stores.
infra-ops allowlisted :8392 (01KVHWJGTT); #17 closed in the tracker. No open
legs remain on the composite; repo at a converged checkpoint.
2026-06-20 12:59:50 -07:00
vh 4f16ba588d memory: snapshot — #18 CLOSED end-to-end + v0.18.0 (composite both-plane binding)
#18 D1 SHIPPED: build_combined_provider_app on :8392 wraps bifrost 0.10.0's public
build_combined_app over both stores + the shared affect read route; one bound WT session
drives memory.* AND affect.* through one endpoint; op-feed plane='combined' per-path.
Shipped v0.17.15 (affect.fetch, the strong-or-absent prerequisite) -> v0.17.16 (composite)
-> v0.17.17 (#17 op-feed field-name fix) -> v0.18.0 (publishing minor). Suite 503 green.

Live-smoke PROVEN at wire+dispatch (real stores + bifrost 0.10.0 on a running :8392):
handshake grants both caps, PAD read route serves real sindra PAD, both planes dispatch at
one bound session_id. WT-driven turn gated on infra-ops adding :8392 to WT's
BIFROST_CLIENT_ALLOWED_HOSTS (requested).

New decisions: reference-impl-adopt-canonical (operator); v1-derived-from-WT-I/O-coverage
(operator). New foot-guns: memory-store check_same_thread bug (same as affect D2, exposed by
the contract-mandated search test via TestClient); :8392 infra-allowlist gate; heid-review
test-fidelity nudge cascaded into 2 latent-bug fixes.
2026-06-19 23:57:01 -07:00
vh 359dbb1436 release: v0.18.0 — composite both-plane Bifrost binding (#18 closed)
Publishing-minor for the #18 arc: ratatoskr now exposes a COMPOSITE Bifrost
endpoint (build_combined_app, :8392) so one bound Worldtree session drives BOTH
the memory.* and affect.* planes through a single endpoint — completing the
Tier-3 consumer round-trip (durable memory + live PAD from one binding).

Shipped as patches v0.17.15 (affect.fetch prerequisite) → v0.17.16 (composite) →
v0.17.17 (#17 op-feed fix); this minor publishes the milestone.

Live-smoke (against real stores + bifrost 0.10.0 on a running :8392): handshake
grants BOTH caps by store presence; the PAD read route serves real sindra PAD;
both planes dispatch through the one endpoint at a single bound session_id with
the op-feed deriving plane per path. The remaining WT-driven turn is gated on
infra-ops adding :8392 to Worldtree's BIFROST_CLIENT_ALLOWED_HOSTS (requested).
v0.18.0
2026-06-19 23:48:33 -07:00
vh d60b77d4f1 fix(#17): op-feed handshake reads the real capabilities field, not capabilities_requested
The dispatch-layer op-feed's handshake req-summary read req.get("capabilities_requested"),
a field that never exists on the wire — bifrost's handshake handler reads
request_body["capabilities"] (reference_server/_protocol.py:181). So the op-feed's
caps_requested was silently ALWAYS None on every handshake. Read the real field.

Surfaced by the heid-code-review panel (Regin) during the #18 D1 review — a latent
#17 observability bug, not D1 drift. Regression test asserts caps_requested is
populated from a handshake body's capabilities.

Suite 502 -> 503 green.
v0.17.17
2026-06-19 23:34:19 -07:00
vh 7f4ceaab2b feat(#18): composite Bifrost endpoint — build_combined_app (Deliverable 1)
One ASGI app fronting BOTH the memory.* and affect.* planes (:8392), so a single
bound Worldtree session both remembers AND shows live PAD. Closes #18 end-to-end
(D2 PAD read-endpoint shipped v0.17.14; D1 was bifrost-blocked, now unparked by
bifrost 0.10.0's public build_combined_app + FR-1 resolved — zero Worldtree change).

- provider/combined.py: build_combined_provider_app wraps bifrost.consumer.build_combined_app
  over both stores + mounts the shared affect read route. Advertises both caps by store
  presence; per-route call-time isolation is bifrost's (INV-013).
- affect_store.py: extract add_affect_read_route shared helper (the D2 INV-007 promise —
  composite + standalone mount the SAME read route over the same affect.db, INV-011).
- opfeed.py: plane='combined' derives the OpEvent plane per request path
  (memory-call->memory, affect-call->affect, handshake->combined; INV-012).
- serve_combined.py + ratatoskr-combined-provider console script on :8392 (additive —
  standalone :8390/:8391 untouched, INV-014).
- contract: 18.contract.md § Deliverable 1 (INV-009..INV-014); D1 un-deferred.

Latent bug fixed (exposed by the contract-mandated memory `search` dispatch test running
through TestClient = a worker thread): open_memory_store lacked check_same_thread=False —
the SAME sqlite thread-safety bug already fixed in the affect store (D2). The composite
serves the memory plane over HTTP, so a memory-call on uvicorn's threadpool would trip it.
Fix: check_same_thread=False + PRAGMA busy_timeout=5000 (memory contract Concurrency note).

heid-code-review panel (Groa/Hulda/Regin): ZERO drift findings; the implementation matches
INV-009..INV-014 at function-block level. Folded the genuine test-fidelity fix (memory leg
describe_store -> search per the contract TEST) + added the PRE-001/PRE-002 guard tests.
Suite 486 -> 502 green.
v0.17.16
2026-06-19 23:32:47 -07:00
vh ca6af6bdaa feat(#18): affect.fetch — adopt bifrost 0.10.0 mandatory fetch (D1 prerequisite)
bifrost 0.10.0's _supports_affect_plane (bifrost/affect.py:75-80) now requires a
callable fetch for the affect capability to advertise/dispatch at all (INV-012
strong-or-absent), so an emit-only store 400s on EVERY affect op — repinning past
the affect.fetch release (#12/#13) breaks our shipped affect plane until fetch
exists. Implement affect.fetch as a thin async wrapper over the existing get()
read seam, conformed verbatim to the reference InMemoryAffectStore.fetch:
{"found": False} or {"found": True, "snapshot": <verbatim>}, AffectInvalidArguments
on empty ids, opaque (INV-001 — never reads pad/valence).

This is the forced prerequisite for the #18 D1 composite (build_combined_app),
and a new Worldtree I/O point consumed (affect read-back over bifrost).

- Repin bifrost>=0.8.0 -> >=0.10.0 (uv lock: 0.8.0 -> 0.10.0)
- affect_store.py: add async fetch() over get()
- contract bifrost_affect_provider v1.2: fetch FN block + INV-010 (cap = supported+emit+fetch)
- tests: 3 fetch unit + parity_vs_reference_fetch through dispatch_affect_call
- suite 482 -> 486 green
v0.17.15
2026-06-19 22:59:59 -07:00
vh a0c6c73ab9 memory: snapshot — #18 D2 SHIPPED+PUSHED (v0.17.14, 39eebd1): web pane renders live PAD/valence from our :8390 store, persona-telemetry gap closed; full #17+#18 arc now on origin. D1 (composite :8392) PARKED on bifrost build_combined_app (~v0.9.0, design locked, after WT #289). FR-1 RESOLVED — composite is bifrost-only, ZERO WT change (single-endpoint caps-routed, worldtree-dev code-verified). New decisions: #18 split + Option-C canonical-surface routing; D2 TDD + heid-code-review (1 INV-001 drift + 4 test-gaps fixed). Foot-guns: rationalized-away a known INV-001 deviation that only the post-impl cross-model review caught; latent sqlite check_same_thread bug exposed by the HTTP read route. FOOT-GUN: running :8390/:8765 are PRE-#18 code — restart with new code + RATATOSKR_AFFECT_READ_URL to see D2 live. 2026-06-19 22:10:57 -07:00
vh 39eebd1a55 feat(#18): PAD read-endpoint — web pane renders live PAD/valence from our affect store (Deliverable 2)
The web persona pane now renders live PAD/valence for Tier-3 agents from our
:8390 affect store, closing the persona-telemetry gap (Worldtree persona_state
404s for Tier-3 per ADR-0009; Tier-3 emits no affect_update SSE).

- provider: non-bifrost GET /affect/state/{agent_id} on the affect-store-owning
  app (add_route — keeps /bifrost/* top-level + op-feed-skipped); explicit
  no_affect_snapshot 404 (never a zeroed PAD); busy_timeout + check_same_thread
  on the connection.
- web: GET /api/affect/{agent_id} proxy — end_user_id server-supplied (never the
  browser), colon-id round-trip, configured RATATOSKR_AFFECT_READ_URL.
- pane: honest affect render (pad + valence + emitted_at, labelled "affect", no
  fabricated Tier-1 fields); explicit empty-state; polls 2s post-turn.

Contract-first (docs/contracts/issues/18.contract.md, Deliverable-2-scoped;
Deliverable 1 / composite endpoint deferred — bifrost-blocked on a public
build_combined_app, WT dispatch confirmed single-endpoint caps-routed).
Heid-code-review panel: 1 INV-001 drift (strip fabricated "neutral") + 4
test-gaps fixed. Live-smoke PROVEN: web->provider->affect.db chain returns real
sindra/vuong PAD; Playwright DOM check confirms the pane render + the fix.

Suite 482 green.
v0.17.14
2026-06-19 21:56:15 -07:00
vh f3bac46238 memory: snapshot — persona-telemetry diagnosis sharpened + #18 split; archived the 2026-05-* build-era cluster (59 entries: 41 decisions + 18 foot-guns) to archival-memory.md. New: wire-verified Tier-3 emits ZERO affect_update SSE (both WT persona sources dead → #18 PAD-display half is the only path); PAD confirmed in our :8390 store (vuong 8 turns, familiarity 0.18→0.59); affect.emit is POST-TURN ASYNC foot-gun. persistent-memory.md trimmed 331→~190. 2026-06-18 10:17:57 -07:00
vh f15c8c6153 memory: snapshot — #17 SHIPPED end-to-end (slices 1-3c, v0.17.8-.13, suite 470 green, live-smoke PROVEN: bound CLI->sindra->op-feed captured 2 recall searches @ exact bound session_id 2c0c7482 with #297/#298 union scopes; dispatch JWT carries session_id=sub, open-q resolved). Operator session UP: web :8765 bind-configured + plane selector, providers :8390/:8391 with op-feed, althing monitor armed. Persona-pane PAD gap diagnosed (affect persists to :8390 stored:true but pane reads Tier-3-404 persona_state) -> #18 filed (composite endpoint + PAD read-endpoint, operator approved 'A', contract-first next). 2026-06-18 01:17:55 -07:00
vh 179a8dff6e feat(#17): web bind UI — plane selector + bound-state indicator (slice 3c UI)
Completes slice 3c: the browser-facing trigger for the web bind. A 'Bifrost
binding' <select> (none / memory / affect) on the setup panel; startSession
sends bifrost_plane in the create body (the consumer key stays server-held,
never sent from the browser). On a bound 201 the identity line renders the
bound-state indicator (plane + endpoint, never the key); bind failures surface
the error_code + bifrost_error in the setup error line.

Closes the persistent-memory caveat: web chat can now bind its own Tier-3
provider (memory persistence + affect telemetry), not persona+debug only.

Web suites 63 green (presentation-contract included); pure static HTML/JS.
v0.17.13
2026-06-18 01:04:20 -07:00
vh 2806abac44 feat(#17): web Bifrost-bind — server side (slice 3c, INV-008 lockstep complete)
Slice 3c of issue #17 — the web surface of the bind trigger, server side. Closes
the INV-008 lockstep (CLI + TUI + web all carry the bind now). Implements the
contract's "web bind split": the browser selects only the PLANE; the consumer key
and the Worldtree-visible host are SERVER-HELD config and never reach the browser.

- create_app gains bifrost_consumer_key + bifrost_visible_host (server-held,
  from env via the entrypoint: RATATOSKR_BIFROST_CONSUMER_KEY /
  RATATOSKR_PROVIDER_VISIBLE_HOST).
- _create_session_endpoint reads an optional `bifrost_plane` from the browser
  body, builds the BifrostBinding SERVER-SIDE via endpoint_for_plane(plane,
  visible_host), and calls create_session(bifrost=, consumer_key=). The 201
  response echoes bound-state {plane, endpoint, status: bound} for the UI
  indicator — never the key (INV-008/INV-009).
- Error routing: invalid plane / unconfigured server -> 400; BifrostHandshakeFailed
  -> 502 {bifrost_error}; BifrostConsumerKeyMissing (server misconfig) -> 400.

5 new web bind tests (server constructs binding + key-never-leaks + upstream
carries bifrost body + consumer-key bearer; unconfigured -> 400; invalid plane;
handshake 502; no-plane unbound regression). Full suite 470 green; added lines
ruff + mypy clean (pre-existing web-file backlog untouched).

Follow-on: the index.html plane selector (UI trigger) — the server capability is
complete and TDD'd; the browser-side dropdown is a thin separate change.

LIVE-SMOKE PROVEN (this session): the CLI bind drove a bound sindra session
against personal Worldtree :8081 -> handshake 200 -> the op-feed captured 2
recall searches correlated to the EXACT bound session_id (2c0c7482), with the
real #297/#298 union-recall scopes. Bind + observe proven end-to-end live.
v0.17.12
2026-06-18 01:02:14 -07:00
vh 016defcc01 feat(#17): TUI Bifrost-bind trigger (slice 3b of the INV-008 lockstep)
Slice 3b of issue #17 — the TUI surface of the bind trigger (web is 3c). The TUI
consumes the same ParsedArgs the cli already parses (--bifrost-plane / --bifrost-url
/ consumer key from RATATOSKR_BIFROST_CONSUMER_KEY), so this wires the bind into
_resolve_then_run's pre-flight create_session:

- bifrost + consumer_key threaded into create_session at the pre-alt-screen
  resolution layer, so bind failures land on the operator's REAL stderr BEFORE
  the Textual alt-screen opens (INV-002, mirrors issue #6's pre-alt-screen
  routing) — never eaten by the alt-screen teardown.
- BifrostConsumerKeyMissing -> exit 22; BifrostHandshakeFailed -> exit 23 with the
  same 401-scoping hint, keyed on bifrost_error == bifrost.auth_rejected. Exit
  codes + label vocabulary match cli._amain exactly (INV-006).
- Bound-state indicator on success (pre-alt-screen): ". bifrost: status=bound
  plane=... endpoint=...".

3 new TUI bind tests (handshake-fail / consumer-key-missing / bound-create carries
binding + indicator, run_async stubbed). Full suite 465 green; added lines ruff +
mypy clean (pre-existing tui.py lint/type backlog untouched per surgical-changes).
v0.17.11
2026-06-18 00:49:30 -07:00
vh 0bebad74ad feat(#17): CLI Bifrost-bind trigger (slice 3a of the INV-008 lockstep)
Slice 3a of issue #17 — the CLI surface of the bind trigger (TUI + web follow,
INV-008 lockstep). ratatoskr can now self-drive a bound session from the CLI:

- New flags: --bifrost-plane {memory,affect} (dev shortcut -> endpoint_for_plane
  over --bifrost-host / RATATOSKR_PROVIDER_VISIBLE_HOST) and --bifrost-url (the
  direct HTTPS/prod endpoint, bypassing the plane shortcut). Mutually exclusive;
  a binding is a session-CREATE concern (forbidden with --session).
- Consumer key resolved from RATATOSKR_BIFROST_CONSUMER_KEY only (the privileged
  handshake identity — never a CLI flag, distinct from the canary WORLDTREE_API_KEY).
- _amain threads bifrost + consumer_key into create_session and routes the bind
  failures: BifrostConsumerKeyMissing -> exit 22; BifrostHandshakeFailed -> exit
  23 with the 401-scoping hint ("use the consumer key, not WORLDTREE_API_KEY")
  keyed on bifrost_error == bifrost.auth_rejected.
- Bound-state indicator on success: ". bifrost: status=bound plane=... endpoint=..."
  — shows WHICH identity/endpoint bound, not a bare boolean.

Also fixes a pre-existing test-isolation bug: test_no_textual_import did a live
importlib.reload(ratatoskr.cli) that mutated the shared module in place, breaking
class identity (isinstance / pytest.raises) for every test after it. The real
check is the static source grep; the reload was vestigial and is removed.

9 new CLI bind tests; full suite 462 green; ruff clean (no new mypy errors).
v0.17.10
2026-06-18 00:45:48 -07:00
vh 8ebe227ae4 feat(#17): dispatch-layer op-feed for the provider (slice 2 — Observe)
Slice 2 of issue #17 — the OBSERVE half. New ratatoskr.provider.opfeed:

- OpEvent{ts, plane, op, session_id, status, req_summary, resp_summary,
  turn_id=None} — scope-only summaries, never record bodies / PAD content
- OpSink Protocol + JsonlOpSink (continuous append-only JSONL, INV-007)
- instrument_provider_app(app, *, plane, sink): an ASGI middleware over the
  built bifrost provider app. Buffers+replays the request, captures the
  response, reads session_id off the dispatch JWT's "sub" claim (INV-005 —
  present for ALL verbs incl. search/get/delete, which bifrost withholds from
  the store method), emits exactly one OpEvent per inbound bifrost-call incl.
  handshake + errors. Read-only over dispatch; store scope semantics untouched
  (INV-004). A sink/summary failure is swallowed + logged, never breaks serve
  (POST-003).
- Per-verb summaries: search {scope_all,scope_any,top_k}->{hit_count,hits};
  upsert_many {record_count,scopes}->{upserted,replayed}; get/get_many/
  delete_many {ids}->{found_count|deleted}; emit (affect, opaque)->{stored};
  handshake {caps_requested}->{caps_granted,ok}; error->{error: code}
- serve_memory/serve wired: opt-in via RATATOSKR_OPFEED_PATH (maybe_instrument)

Resolves the contract's open question: the dispatch JWT DOES carry session_id
(= the "sub" claim). Tests drive the REAL bifrost dispatch end-to-end with
minted JWTs. 11 new tests; full suite 453 green; ruff + mypy clean (opfeed.py).
v0.17.9
2026-06-18 00:36:33 -07:00
vh 7be162e84d feat(#17): create_session Bifrost-bind primitive (slice 1)
Slice 1 of issue #17 (Bifrost-binding the chat client) — the client-side
BIND primitive, TDD'd against docs/contracts/issues/17.contract.md.

- BifrostBinding{endpoint_url, scope=None} frozen dataclass (#160 shape)
- create_session(..., bifrost=, consumer_key=): carries the bifrost body
  field and OVERRIDES the bearer to the consumer key per-request (INV-001 —
  never falls back to the canary key)
- BifrostConsumerKeyMissing: raised BEFORE any HTTP when a binding lacks a
  non-empty key (PRE-001)
- BifrostHandshakeFailed: 502 on a BOUND create -> carries detail.bifrost_error
  (both-shape unwrap per the persona_state wire lesson); gated on bifrost!=None
  so an unbound 502 stays SessionApiFailed (INV-002)
- endpoint_for_plane: memory->:8391 / affect->:8390, invalid->ValueError

7 new tests; full suite 442 green; ruff clean.
v0.17.8
2026-06-18 00:21:36 -07:00
vh f533464c54 memory: snapshot — persona-pane reframe (worldtree-dev): persona_state GET is Tier-1-only by ADR-0009 (colon-404 correct-by-design, not a stub); Tier-3 affect is CLIENT-persisted — we already hold PAD/valence @ :8390 from affect.emit, so the pane is an OUR-side render via #17 affect-binding (→ affect.emit → :8390 → render), NOT a WT endpoint wait. WT #289 affect.fetch = optional mediated-read; #300 = WT client-impl guide. Expands #17 payoff: memory AND the persona pane. 2026-06-18 00:04:30 -07:00
vh 37cdef511f fix(web): de-ugly the Tier-3 persona pane — clear message instead of bare HTTP 404
persona_state hard-404s every Tier-3 (colon-id) agent by design upstream
(WT api.py:1220, "Phase 2.0 has no Tier 3 persona") — so the Persona pane
showed "persona not available (HTTP 404)" for consumer-defined characters.
loadPersona now reads error_code + renders a clear Tier-3-aware message
(she still responds in character; only the affect/OCEAN readout is gated),
with distinct text for persona_not_configured / 403 / other.

Also (snapshot): sindra switched to thoughtful-character role
(mistral-small-4-reasoning); worldtree-dev pinged re Tier-3 persona_state
roadmap (thread 01KVCR6P); #17 (bifrost-binding the chat client) teed up as
the next-context target.

v0.17.7
v0.17.7
2026-06-18 00:00:41 -07:00
vh 835375d22b memory: snapshot — FULL COVERAGE proven (verbose persona too): sindra-probe theatrical turn promoted the user fact cleanly under Stage 2/v0.36.0 + cold-recalled @0.694; :8081 confirmed on v0.36.0; closes the verbose-persona caveat end-to-end. Operator session: :8391 wiped, ratatoskr-web up :8765 (consumer key, sindra in picker) — persona+debug only, web client does NOT bind :8391 (#17 unbuilt = no memory persistence in web chat) 2026-06-17 23:45:08 -07:00
vh 7666203722 memory: snapshot — Tier-3 memory PROVEN end-to-end live (terse-probe cold recall @0.6994, fresh history-free session); #296 arc closed: Stage 1 (v0.35.19) recallability gate validated live + bisect localized residual to verbose-persona volume, Stage 2 (v0.36.0) MERGED at worldtree-codex (user-only per-turn extraction), live-validated eval fixture pair -> #305; root-cause chain v0.35.16 emit-2-meta -> v0.35.19 emit-then-reject -> v0.36.0 fix; foot-gun: :8391 store-wipe != WT promotion-dedup reset (clean promotion smoke needs a fresh agent+end_user) 2026-06-17 21:27:17 -07:00
vh 84d8c3f65f memory: snapshot — cold-recall arc PROVEN live e2e (#297/#298 union recall; WT v0.35.16 emits scope_any into our v0.17.6 store); #296 extraction quality the isolated upstream gap (triage→worldtree-dev, both symptoms localized in-code: empty _EXTRACTOR_SYSTEM + both-roles prefilter); sindra restored (DELETE+redefine, role:character→mistral-small-4, memory:{}); learnings: Tier-3 owner-scoped, define-takes-role, promotion 4-trigger hybrid, DELETE≠drain 2026-06-17 11:15:06 -07:00
vh 4eee7c89b2 pin: bump Worldtree spec to f1b59f8 (v0.35.16) — cold recall closes end-to-end
Worldtree shipped its half of the union-recall fix: #297 (client-side
per-scope-value union recall) + #298/#299 (adopt the bifrost v0.6
scope_any/scope_all wire, v0.35.16). It now emits scope_any on the recall
path, pairing with our v0.17.6 provider — cold cross-session recall is
closed end-to-end (pending a live re-smoke against a v0.35.16 instance).

Re-vendored conversation-api-spec.md + conversation_api.contract.md;
285-commit catch-up (v0.29.0 -> v0.35.16). Diff-reviewed: no client-facing
breaking changes for our consumer.

- #211 agent-slug rename (saga->echo, actor->mask) — slugs only, we pass --agent
- #245 end_user_id persistence + memory-scope resolver (additive)
- #187/#188/#219 Tier-3 define/PATCH policy (additive); error codes stable
- bifrost binding field + ephemeral_does_not_accept_bifrost 422 now documented (#17 surface)
- docs: SPEC-PIN.md pin table + history; bifrost-self-test recall status; persistent-memory

No package version bump (docs/pin-only, no ratatoskr code change).
2026-06-17 08:24:27 -07:00
vh 96d61a4bb1 feat(provider): split memory search scope_filter → scope_all + scope_any (bifrost 0.8.0/wire v0.6)
Repin bifrost 0.7.0→0.8.0 and reimplement the memory store's search scope
filter to the v0.6 split (#11): scope_all (AND/intersection) + scope_any
(OR/union over a list of conjunctive scopes), at parity with the v0.6
reference _matches_scope / _validate_scope. No-compat: scope_filter removed.

scope_any is the union-visibility primitive that resolves the #295/#297
silent-zero AND foot-gun — a subset-scoped chunk now recalls via an OR
member. End-to-end cold recall now gated only on Worldtree emitting
scope_any on its recall path (#297, upstream).

- store: search(scope_all, scope_any); _scope_subset + _matches_scope + _validate_scope
- contract v1.2: search FN sig, INV-005 recomposed, PRE-003 both fields, scope_any_union test
- tests: scope_any union, scope_all∧scope_any compose, both-empty match-all; parity vs real 0.8.0 dispatch (433 green)
- #17 contract: sync stale scope_filter/_scope_matches-AND refs to scope_all/scope_any
- runbook + persistent-memory updated; provider bounced onto 0.8.0 (fresh empty db)

v0.17.6
v0.17.6
2026-06-16 23:09:31 -07:00
vh 43f2e148ad memory: snapshot — observe brick + self-drive proven; #295 root-caused (upstream); agent_self canonical shipped both sides + 4-axis parity (v0.17.5); #17 contract reviewed, TDD next 2026-06-16 18:26:36 -07:00
vh aac4353933 feat(provider): validate scope_filter against the v0.5 4-axis lattice
bifrost 0.7.0 (wire v0.5) makes agent_self canonical: the scope lattice is
now {end_user, group, tenant, agent_self}. Our store was MORE permissive than
bifrost's reference (no _validate_scope_filter), which silently 0-zeroed the
#295 cold recall instead of a loud 400. Now matched: search rejects an
out-of-lattice axis with InvalidFilter (-> memory.invalid_filter 400), agent_self
admitted. Purely additive — everything that validated before still validates.

Closes the parity gap our own foot-gun flag opened (bifrost-dev shipped the
lattice add #10 off it). Pin bumped bifrost>=0.6.1 -> >=0.7.0. Contract
search PRE-003 + lattice_axes test; 2 new store tests; full suite 429 green.
v0.17.5
2026-06-16 01:24:01 -07:00
vh ca02c70b7c docs(#17): self-drive+observe contract, bifrost self-test runbook + snapshot
- docs/contracts/issues/17.contract.md — issue-scoped v2.1 contract for #17
  (Bifrost-binding the chat client). v1 scope = single-plane bind +
  dispatch-layer op-feed (composite endpoint + turn-pane UI parked). Design
  consulted via /heid, paraphrase-gated via /heid-contract-review panel; two
  internal inconsistencies fixed (OpEvent turn_id reservation made literal;
  session_id-for-all-verbs correction). Validates OK, prd drift-clean.
- docs/bifrost-self-test.md — reusable runbook for driving + observing the
  full Bifrost round-trip against our own provider (the manual form of #17;
  pins the consumer-key-as-bearer tripwire).
- persistent-memory.md — snapshot: observe brick shipped, self-drive proven,
  #295 root-caused (upstream, scope-axis asymmetry) -> #296/#297, agent_self
  -> canonical decided.
2026-06-16 01:15:28 -07:00
vh 2fef6e39f9 feat(provider): inbound memory-call observe log (#17 observe brick)
Structured [memory-provider] request/response logging on the memory-call
path: search REQUEST (scope_filter, top_k) + RESPONSE (chunk_ids, scores,
scope), plus concise entry lines on upsert_many/delete_many. Self-contained
stdout handler so the lines reach the provider stdout under uvicorn.

Additive observability only — no search-semantics change (AND-parity with
bifrost's reference store holds). This is the first concrete brick of #17's
observe half, and the lens that root-caused #295's cold-recall miss (the
persist/recall scope-axis asymmetry) from the provider side.
v0.17.4
2026-06-16 01:15:16 -07:00
vh 2b47dcff5a memory: snapshot — memory provider live-proven (persist/dispatch/search); recall-injection upstream; #17 filed 2026-06-15 23:44:33 -07:00
vh e57b054054 memory: snapshot — memory plane shipped (v0.17.3), canonical sync + #3/#4 refresh 2026-06-15 21:46:07 -07:00