diff --git a/docs/SPEC-PIN.md b/docs/SPEC-PIN.md index f037df8..3a6056e 100644 --- a/docs/SPEC-PIN.md +++ b/docs/SPEC-PIN.md @@ -7,17 +7,17 @@ documents the pin, the vendored artifacts, and the bump procedure. | Field | Value | |---|---| -| Worldtree git SHA | `562001af28d752c3a60d449c7ddd09f44fa9dc9a` | -| Worldtree HEAD message | `feat(#201): v0.29.0 — awaiting_llm_first_token SSE heartbeat` | -| Pinned on | 2026-05-26 | -| Pinned by | ratatoskr-dev (bump for #201 awaiting_llm_first_token SSE) | -| Worldtree version at pin | `v0.29.0` | +| Worldtree git SHA | `f1b59f8cd6fe41e497d0be9dad9d3110451f0d9a` | +| Worldtree HEAD message | `Merge #299: adopt bifrost v0.6 memory scope wire (scope_any/scope_all)` | +| Pinned on | 2026-06-17 | +| Pinned by | ratatoskr-dev (bump for #297/#298 — cold recall closed end-to-end) | +| Worldtree version at pin | `v0.35.16` | ## Pin history | Date | SHA | Version | Notable deltas consumed | |---|---|---|---| -| 2026-05-26 | `562001a` | v0.29.0 | #201 — new SSE event `awaiting_llm_first_token` (heartbeat during BuildingPrompt → CallingLLM gap, default 5s interval) | +| 2026-06-17 | `f1b59f8` | v0.35.16 | **#297 + #298/#299 — Worldtree adopts the bifrost v0.6 scope wire (emits `scope_any`/`scope_all`) + client-side per-scope-value union recall. With our v0.17.6 provider this closes cold cross-session recall end-to-end.** Catch-up bump (v0.29.0→v0.35.16). Intervening client-facing deltas reviewed, none break our consumer: #211 agent rename (`saga`→`echo`, `actor`→`mask` — slugs only); #245 `end_user_id` persistence + memory-scope resolver; #187/#188/#219 Tier-3 define/PATCH policy (additive); `bifrost` binding field + `ephemeral_does_not_accept_bifrost` 422 now documented (the #17 surface). Error codes stable; no ratatoskr code change required. | | 2026-05-25 | `da93ca7` | v0.28.0 | #204 — new SSE event `affect_update` (current/scheduled), new endpoint `GET /agents/{id}/persona_state`, auth-model doc edits | | 2026-05-20 | `55101e9` | v0.19.0 | initial scaffold pin | diff --git a/docs/bifrost-self-test.md b/docs/bifrost-self-test.md index aaa844c..ab0d504 100644 --- a/docs/bifrost-self-test.md +++ b/docs/bifrost-self-test.md @@ -155,8 +155,10 @@ Branch (a), scope asymmetry — fed to #297. into `scope_all` (AND) + `scope_any` (OR/union). Worldtree can now send the visible scopes as a `scope_any` union (e.g. `[{end_user: smoke-user}, {end_user: smoke-user, agent_self: ...}]`), so the subset-scoped chunk recalls via the matching OR member. -Our store implements this at parity with the v0.6 reference; **end-to-end cold recall -now waits only on Worldtree emitting `scope_any`** on the recall path (#297). +Our store implements this at parity with the v0.6 reference; Worldtree **adopted the +v0.6 wire and now emits `scope_any`** on the recall path (#297 client-side union recall ++ #298/#299 bifrost-v0.6 adoption, v0.35.16), so cold cross-session recall is **closed +end-to-end** — pending a live re-smoke against a personal instance running v0.35.16. ## Notes / foot-guns diff --git a/docs/conversation-api-spec.md b/docs/conversation-api-spec.md index 1f9db4f..fd41ccf 100644 --- a/docs/conversation-api-spec.md +++ b/docs/conversation-api-spec.md @@ -61,7 +61,7 @@ When you call `POST /sessions` against an agent, the authorization check that fi ### Tier 1 — foundational agents (no `:` in agent_id) -Agents bundled with Worldtree: `mimir`, `lofn`, `soong`, `forseti`, `domari`, `vili`, `actor`, `saga`, `bragi`, `leif`, `troi`, `cara`, `glados`, and any future Asgardian. The agent_id is a simple slug like `mimir` — no colon. +Agents bundled with Worldtree: `mimir`, `lofn`, `forseti`, `domari`, `vili`, `mask`, `echo`, `muninn`, and any future Asgardian. The agent_id is a simple slug like `mimir` — no colon. > **About tiers:** Your `tier` is set on the `users` table row your API key resolves to, assigned at key-mint time (see `POST /admin/keys`). Tiers are `anonymous` (dev-mode unauthenticated), `user` (default for newly-issued keys), `free`/`pro` (subscription-shaped, not actively differentiated), and `admin`. The tier you have is visible via `GET /me`'s `tier` field. Tier-derived scopes come from `config/policies.yaml > tiers..scopes` — there is no per-key scope override. @@ -989,7 +989,7 @@ Create a new conversation session with an agent. **Bifrost field validation:** - `endpoint_url`: required, must be an HTTPS URL. - `scope`: optional, ≤ 256 chars, opaque string passed through to the JWT payload unchanged. -- Bifrost binding is **incompatible with ephemeral (Saga) sessions** — returns 422 `ephemeral_does_not_accept_bifrost`. +- Bifrost binding is **incompatible with ephemeral (Echo) sessions** — returns 422 `ephemeral_does_not_accept_bifrost`. - Requires the `bifrost:invoke` scope (included in the `user` tier by default). **Response:** `201 Created` @@ -1535,7 +1535,7 @@ for (const tc of items) { Ephemeral templates are a second tier of agent, distinct from foundational persistent agents (Mimir, Soong, etc.). They have no persona, no memory, no tools, and no motivational context. The consumer supplies the system prompt and (optionally) the model at session-create time; that config is frozen for the session's lifetime. -**Saga** is the first ephemeral template — Norse goddess of history and chronicle, a blank-slate actor that becomes whatever the consumer's system prompt instills. +**Echo** is the first ephemeral template — a blank-slate per-session host that becomes whatever the consumer's system prompt instills. ### Discovering available templates @@ -1547,7 +1547,7 @@ Authorization: Bearer ```json { "ephemeral_templates": { - "saga": { + "echo": { "allowed_models": ["glm5-turbo", "glm4.7", "glm4.5-air", "granite-structured", "qwen3.6-35-a3b"], "default_model": "glm5-turbo", "system_prompt_max_bytes": 32768 @@ -1556,14 +1556,14 @@ Authorization: Bearer } ``` -`GET /capabilities` does not require `instantiate:saga` scope — any authenticated caller can read what's available before deciding to instantiate. +`GET /capabilities` does not require `instantiate:echo` scope — any authenticated caller can read what's available before deciding to instantiate. ### Creating an ephemeral session ```json POST /sessions { - "agent_id": "saga", + "agent_id": "echo", "config": { "system_prompt": "You are a careful, skeptical frame-clarifier...", "model": "glm5-turbo" @@ -1580,16 +1580,16 @@ POST /sessions | `system_prompt_required` | `config.system_prompt` missing or null | | `system_prompt_empty` | `config.system_prompt` is whitespace-only | | `system_prompt_too_large` | `config.system_prompt` > 32768 bytes UTF-8 | -| `model_not_allowed` | `config.model` present but not in `saga_allowed_models` | +| `model_not_allowed` | `config.model` present but not in `echo_allowed_models` | -**`config.model` resolution:** When `config.model` is omitted (or `null`), the server resolves it to `saga.default_model` from `config/defaults.yaml`. The resolved value is always populated in the session snapshot; `model` is never left absent or null in the stored config. +**`config.model` resolution:** When `config.model` is omitted (or `null`), the server resolves it to `echo.default_model` from `config/defaults.yaml`. The resolved value is always populated in the session snapshot; `model` is never left absent or null in the stored config. **Response:** Same 201 shape as foundational sessions, with two new fields: ```json { "session_id": "...", - "agent_id": "saga", + "agent_id": "echo", "kind": "ephemeral", "config": { "system_prompt": "You are a careful, skeptical frame-clarifier...", @@ -1601,7 +1601,7 @@ POST /sessions } ``` -**`kind` field:** `"ephemeral"` for Saga sessions, `"foundational"` for all other sessions. Present on both `GET /sessions` list items and `GET /sessions/{id}`. +**`kind` field:** `"ephemeral"` for Echo sessions, `"foundational"` for all other sessions. Present on both `GET /sessions` list items and `GET /sessions/{id}`. ### Sending messages to an ephemeral session @@ -1617,9 +1617,9 @@ SSE, cancel, `persist_partial`, rate limits, and error shapes are bit-identical ### Scope -Creating a Saga session requires the `instantiate:saga` scope. This scope is bundled in the `user` tier. Tier `admin` inherits it via the wildcard. +Creating an Echo session requires the `instantiate:echo` scope. This scope is bundled in the `user` tier. Tier `admin` inherits it via the wildcard. -### What Saga does NOT do +### What Echo does NOT do - No persona injection (`PersonaRegistry.inject_context` not called) - No post-turn appraisal (`PersonaRegistry.update_after_turn` not called) @@ -2415,7 +2415,7 @@ The `POST /sessions/{session_id}/messages` endpoint also accepts an additive `mo - Override is per-call only. Stored `CharacterSchema.model` is NOT mutated. - Validated against the same `available_for_characters` allowlist that gates `CharacterSchema.model` at create time (#153 INV-091). - Override displaces the character's bound model when both are set (per-call wins). -- Override is REJECTED on ephemeral (Saga) sessions — their config is frozen at session-create per INV-161-2. +- Override is REJECTED on ephemeral (Echo) sessions — their config is frozen at session-create per INV-161-2. **Validation:** 1. Pydantic validates `model`: optional string, non-empty after stripping whitespace. @@ -2672,7 +2672,7 @@ The override client has a fresh 25-call reentrancy budget, independent of the se | Condition | HTTP | `error_code` | `bifrost_error` | |-----------|------|-------------|----------------| | `endpoint_url` is not HTTPS | 422 | `validation_failed` | — | -| Ephemeral (Saga) session | 422 | `validation_failed` | — | +| Ephemeral (Echo) session | 422 | `validation_failed` | — | | Missing `bifrost:invoke` scope | 403 | `auth_scope_denied` | — | | `consumer_id` not in Heimdall or not Bifrost-registered | 502 | `bifrost_consumer_not_found` | — | | Handshake failed (network, auth, etc.) | 502 | `bifrost_handshake_failed` | spec error code | @@ -2753,6 +2753,61 @@ Caller must: `agent_name` is a strict slug `[a-z][a-z0-9-]{2,63}` and immutable after definition. +The 201 response includes an advisory `warnings` array (#219) — see +"Model-assignment warnings" under `PATCH` below. + +##### Motivational layer (Phase 2.2, #187) + +`motivational` is **active** as of Phase 2.2 (persona + memory activated in +Phase 2.1; only `valence` still returns `layer_deferred`). It carries the +agent's goals + fears — the same substrate Tier 1 agents author in +`agents//motivation.yaml`: + +```json +"motivational": { + "goals": [ + { + "id": "successful_handoff", + "type": "achievement", // maintenance | achievement | avoidance + "salience": 0.85, // [0.0, 1.0] + "description": "You succeed when the user lands with the right specialist.", + "positive_signals": ["talk to mimir"], // optional + "negative_signals": ["stay with me"] // optional + } + ], + "fears": [ + { + "id": "specialist_displacement", + "salience": 0.90, + "description": "You fear being mistaken for the specialist the user needs.", + "trigger_signals": ["actually mimir would"] // optional (NB: fears use trigger_signals) + } + ] +} +``` + +Semantics: + +- **Per-agent, not per-(agent, end_user).** Goals/fears are an identity trait of + the agent — identical for every end-user and session. +- **Immutable post-define.** `PATCH` with `motivational` returns 422 + `field_not_mutable`. To change motivations, define a new agent. +- **Rendered into the system prompt.** The config is captured on the session's + `AgentContext` at session-create and rendered into the prompt on each turn + (only goals/fears with `salience >= 0.5` surface). Tier 3 agents bypass the + persona registry; the render reuses the Tier 1 substrate so output is + identical to an equivalent Tier 1 `motivation.yaml`. + +Validation rejects malformed payloads at define-time with these 422 codes: +`motivational_id_collision` (id duplicated across goals AND fears — case-sensitive), +`motivational_goal_invalid_type`, `motivational_salience_out_of_range`, +`motivational_description_too_short` (< 20 chars after strip), +`motivational_missing_required_field` (missing id / salience / description / +goal `type`). Unknown keys at the top level or inside a goal/fear object → +`validation_failed`. v0.1 exposes only the documented fields; advanced +`GoalConfig` knobs (`priority`, `resilient`, `completion_signal`, …) are not +consumer-settable yet. + #### `DELETE /agents/:` — `204 No Content` Owner-initiated hard-delete. Bypasses the 24h grace (distinct from the @@ -2762,11 +2817,77 @@ session bound to this agent and revokes the owner's per-resource #### `PATCH /agents/:` -Phase 2.0 minimal: only `system_prompt` and/or `model` may be patched. -Any other key (including the immutable `agent_name`, `user_id`, or -layer fields — even `null`) returns 422 `field_not_mutable` BEFORE the -DB lookup. Active sessions continue using their cached `AgentContext`; -the new values take effect at the next session-create. +**Mutable surface (Phase 2.3, #188): `system_prompt` and/or `model` only.** +PATCH re-enforces the same validation as define — the `system_prompt` +byte-cap and the `model` allowlist. Any other key returns a 422 BEFORE +the DB lookup (so an immutable-field PATCH against a missing agent still +422s, not 404s), with the error code chosen by *why* the field can't be +set: + +| Field(s) | Code | Reason | +|---|---|---| +| `agent_name`, `user_id`, `agent_id` | `field_not_mutable` | Identity — fixed at creation. | +| `persona`, `motivational` | `field_not_mutable` | Shipped traits; an agent *is* its personality/goals. Change → define a new agent. | +| `memory` | `field_not_mutable` | Rejected **wholesale** — see below. | +| `valence` | `layer_deferred` | Not a shipped layer yet (matches define-time); not a frozen trait. | + +Every immutable/deferred field is rejected even when its value is `null` — +supplying the key at all is the trigger. + +**`memory` is wholesale-immutable.** There is no sub-field carve-out: +`stm_capacity` / `stm_token_budget` are deprecated no-ops since the STM +tier was removed (#197), `allows_world_scope` is create-time-only (memory +scope policy must be fixed before any memory is written), and +`embedder_version` is library-pinned. Note the deliberate asymmetry with +define: `POST /agents/define` accept-and-ignores deprecated `stm_*` +(201 + deprecation warning), but `PATCH {"memory": {...}}` rejects the +whole field with `field_not_mutable`. When a real long-term-memory tuning +dial ships, its PATCH semantics will be specified at that time. + +**Active sessions are unaffected.** A PATCH never mutates an in-flight +session's cached `AgentContext`; new `system_prompt` / `model` values take +effect only at the next session-create. + +**Audit.** A successful PATCH emits one `agents.patch` event whose +`changes` detail records before/after per mutated field: `model` as literal +`{before, after}` values, and `system_prompt` as `{before_bytes, +after_bytes}` only — the raw prompt text is never written to the audit log +(potential PII). + +**Model-assignment warnings (#219).** A `model` swap is **not blocked** for +capability or context-window compatibility, but PATCH (and `define`) attach an +advisory `warnings` array to the response — see the shared subsection below. +Correctness for over-budget prompts remains the runtime `context_overflow` +guard; the warnings are an early, best-effort heads-up. + +##### Model-assignment warnings (`define` + PATCH) + +Both `POST /agents/define` (201) and `PATCH /agents/` (200) include a +`warnings` array in the response body (always present; `[]` when none). It is +**advisory and non-blocking** — never a rejection — and appears only on these +two mutation responses, not on `GET /agents/`. Each entry is +`{code, severity, message, details}`. The closed code set: + +| code | severity | when | +|---|---|---| +| `model_context_window_unknown` | `info` | The assigned model has no recorded context window (`0`/absent in the registry). | +| `model_context_window_smaller` | `warning` | Both prior and new model have known windows and the new one is smaller. `details: {before, after}`. | +| `model_capability_downgrade` | `warning` | The new model **explicitly** advertises fewer capabilities than the prior — drops `tools`, `vision`, or `audio`. `details: {dropped: [...]}`. | + +Semantics: + +- **`define`** has no prior model, so only `model_context_window_unknown` can + fire there. **PATCH** computes warnings only when the payload changes `model` + (a `system_prompt`-only PATCH returns `warnings: []`); the comparison is + against the resulting model. +- Capability warnings are **conditional by nature**: a Tier 3 agent row does + not record whether it uses tools/vision/audio (tools arrive per-session via + Bifrost), so the message is phrased "if your sessions rely on these…". A + downgrade is reported only when both models carry explicit registry metadata. +- Messages never claim a hard failure. The stored `system_prompt` cap is a + **byte** limit (32 KiB), independent of any model's token budget — it is not + a fit guarantee. A too-large prompt for the chosen model still surfaces at + runtime as `context_overflow`. #### `POST /sessions` — Tier 3 routing @@ -2860,8 +2981,8 @@ endpoint isn't reachable. | `agent_name_invalid` | 422 | `agent_name` violates `[a-z][a-z0-9-]{2,63}`. | | `system_prompt_too_large` | 422 | `system_prompt` > 32 KiB. | | `model_not_available` | 422 | `model` not in `providers.yaml`. | -| `layer_deferred` | 422 | One of `persona` / `motivational` / `valence` / `memory` set. | -| `field_not_mutable` | 422 | PATCH carries an immutable key (any value, even `null`). | +| `layer_deferred` | 422 | `valence` set on define OR PATCH (the only still-deferred layer; persona/motivational/memory activated in Phase 2.1/2.2). | +| `field_not_mutable` | 422 | PATCH carries an immutable key — identity (`agent_name`/`user_id`), `persona`, `motivational`, or `memory` (any value, even `null`). `valence` → `layer_deferred` instead. | | `end_user_id_required` | 422 | Tier 3 session-create without a non-empty `end_user_id`. | | `tier3_user_id_unsupported` | 403 | Caller's `ctx.user_id` not slug-safe. | | `auth_scope_denied` | 403 | Missing `agents.define` or wrong owner. | diff --git a/docs/conversation_api.contract.md b/docs/conversation_api.contract.md index 78098ee..cc725c3 100644 --- a/docs/conversation_api.contract.md +++ b/docs/conversation_api.contract.md @@ -137,6 +137,68 @@ Sessions are persistent via SQLite. On server restart, existing sessions are loadable from the store (lazy-loaded on first access). In-memory cache is rebuilt on demand, not at startup. +## Memory-partition scope (#245 / ADR-0011) + +`end_user_id` is the per-end-user memory partition key (distinct from `user_id`, +the API-key owner). It is REQUIRED at session-create for Lofn (Tier-1) and Tier-3 +agents and must survive a store reload, because "remember me next session" is by +definition a reload. Memory partition resolution flows through ONE resolver that +cannot hand an authenticated session the shared `local_dev` partition. + +- **INV-245-1 (end-user-id-durable)**: `end_user_id` is persisted as a `sessions` + table column at create and rehydrated onto the `ConversationSession` on every + cache-miss load (`get_session`). A session loaded from the store carries the + same `end_user_id` it was created with. Pre-migration rows read as `None`. +- **INV-245-2 (end-user-id-threaded-all-tiers)**: the `POST /sessions` handler + forwards `body.end_user_id` to `create_session` for EVERY agent, not only + Tier-3. (The pre-fix `if tier3_agent_context is not None else None` conditional + dropped it for Lofn despite the create gate requiring it.) +- **INV-245-3 (no-authenticated-local-dev)**: the two MEMORY partition sites — + auto-recall (read) and the ContextPromotion producer (write) — resolve via + `memory_scope_for_session`. An authenticated, memory-bearing session (one not + carrying the explicit `local_dev` sentinel) NEVER resolves to `local_dev`; a + missing `end_user_id` raises `MemoryScopeError`, and because both sites are + best-effort (recall is fire-and-forget; the producer is `_run_promotion_safe`), + the caller skips memory — it never silently writes to the shared partition. +- **INV-245-5 (persona-plane-corrected-by-persistence)**: the three PERSONA-plane + sites (`inject_context`, `get_state`, `update_after_turn` — ADR-0008 mood/PAD/ + valence) keep their `session.end_user_id or "local_dev"` form but are on the + main turn path where a raise would break the turn. They are corrected by + INV-245-1/2: once `end_user_id` is persisted + threaded, the fallback yields a + real partition for authenticated sessions and `local_dev` only for the explicit + terminal path. Unifying the persona plane under the resolver (with main-path + error semantics) is follow-up, tracked with the #246-adjacent hardening. +- **INV-245-4 (terminal-explicit-local-dev)**: the internal terminal transport + creates its sessions with `end_user_id="local_dev"` explicitly. `local_dev` is + reached only by this positive assertion, never by omission. (External API + callers passing `local_dev` are still rejected per #216.) + +```contract +FN memory_scope_for_session(session) -> MemoryScope +BRIEF: The single authority resolving a session to its memory partition scope. + Returns a typed MemoryScope(scope_type, scope_id); scope_type ∈ + {local_dev, end_user, room, tenant} (only local_dev + end_user active in + v1; room/tenant reserved for ADR-0010). Cannot yield local_dev for an + authenticated session. +PRE: [PRE-001 soft] callers have already gated ephemeral / consumer_defined + sessions out (those skip memory before resolution) +POST: [POST-001 return_value] end_user_id == "local_dev" -> MemoryScope("local_dev", "local_dev") +POST: [POST-002 return_value] end_user_id truthy and != "local_dev" -> MemoryScope("end_user", end_user_id) +POST: [POST-003 exception] end_user_id is None/empty -> raise MemoryScopeError (NEVER local_dev) +ERRORS: + MemoryScopeError -> caller skips memory (best-effort) + emits an audit/log line; turn proceeds +STEPS: + 1. [setup] read euid = session.end_user_id + 2. [branch] euid == "local_dev" -> RETURN MemoryScope("local_dev", "local_dev") (terminal sentinel) + 3. [branch] euid truthy -> RETURN MemoryScope("end_user", euid) + 4. [error_handler] else (None/empty) -> RAISE MemoryScopeError (never silently local_dev) +TESTS: + end_user_partition [happy,tracer]: session end_user_id="alice" -> MemoryScope("end_user","alice") + terminal_local_dev [boundary]: session end_user_id="local_dev" -> MemoryScope("local_dev","local_dev") + authenticated_none_raises [boundary]: foundational session end_user_id=None -> raises MemoryScopeError, NOT local_dev + isolation_roundtrip [happy]: create_session(end_user_id="alice") write + clear cache + reload + recall isolates from a "bob" session; negative-assert no local_dev write +``` + ```contract FN ConversationService.startup() -> None BRIEF: Discover agents, build per-agent contexts, initialise shared infrastructure @@ -1658,13 +1720,13 @@ Ephemeral templates are a new agent kind that bypass persona, memory, tools, and **Invariants added by issue #161:** - **INV-161-1 (ephemeral-template-bypass)**: For sessions where `session.ephemeral_config is not None`, `PersonaRegistry.inject_context` is NOT called pre-turn; `PersonaRegistry.update_after_turn` is NOT called post-turn; valence side-channel is NOT called; tool list passed to provider is `[]`. -- **INV-161-2 (frozen-session-config)**: Once a session is created with an `ephemeral_config` snapshot, subsequent mutations to `agents/saga/config.yaml`, `config/providers.yaml → saga_allowed_models`, or `config/defaults.yaml → saga.default_model` do NOT affect that session's per-turn `system_prompt` or `model`. +- **INV-161-2 (frozen-session-config)**: Once a session is created with an `ephemeral_config` snapshot, subsequent mutations to `agents/echo/config.yaml`, `config/providers.yaml → echo_allowed_models`, or `config/defaults.yaml → echo.default_model` do NOT affect that session's per-turn `system_prompt` or `model`. - **INV-161-3 (no-tools-for-ephemeral)**: Tool list passed to the provider for an ephemeral session is `[]` regardless of any `tools:` block in the template's config.yaml. - **INV-161-4 (foundational-flow-unchanged)**: For sessions where `session.ephemeral_config is None`, the per-turn path is bit-identical to pre-#161 — same system_prompt loading, same persona injection, same tool list, same audit-log shape. - **INV-161-5 (config-required-for-ephemeral-create)**: `POST /sessions` against an ephemeral template MUST reject the request with 422 if `config` is missing or fails any validation step. -- **INV-161-6 (model-allowlist-enforcement)**: `config.model`, when supplied, MUST be in `saga_allowed_models` at session-create time. When omitted, server resolves to `saga.default_model` (startup-validated to be in the allowlist). +- **INV-161-6 (model-allowlist-enforcement)**: `config.model`, when supplied, MUST be in `echo_allowed_models` at session-create time. When omitted, server resolves to `echo.default_model` (startup-validated to be in the allowlist). - **INV-161-7 (full-prompt-in-audit)**: Session-create audit entries for ephemeral sessions include `tier: 2` and `ephemeral_config` (full JSON). -- **INV-161-8 (cross-user-isolation)**: A Saga session created by user A is invisible to user B — `GET /sessions/{id}` returns 404. +- **INV-161-8 (cross-user-isolation)**: An Echo session created by user A is invisible to user B — `GET /sessions/{id}` returns 404. - **INV-161-9 (foundational-rejects-config)**: `POST /sessions { agent_id: "", config: {...} }` returns 422 with `error_code: "foundational_does_not_accept_config"`. - **INV-161-10 (capabilities-public-shape)**: `GET /capabilities` is callable by any authenticated key. The response has `ephemeral_templates` at top-level. - **INV-161-11 (template-kind-immutable-at-runtime)**: The `kind` field on a loaded `AgentContext` is set once at startup and never mutated. @@ -1673,16 +1735,16 @@ Ephemeral templates are a new agent kind that bypass persona, memory, tools, and | code | HTTP | trigger | |---|---|---| -| `ephemeral_requires_config` | 422 | saga session without `config:` | +| `ephemeral_requires_config` | 422 | echo session without `config:` | | `foundational_does_not_accept_config` | 422 | foundational agent with `config:` | | `system_prompt_required` | 422 | `config.system_prompt` missing or null | | `system_prompt_empty` | 422 | `config.system_prompt` whitespace-only | | `system_prompt_too_large` | 422 | > 32768 bytes UTF-8 | -| `model_not_allowed` | 422 | model not in `saga_allowed_models` | +| `model_not_allowed` | 422 | model not in `echo_allowed_models` | -**New `AgentContext` fields:** `kind: str = "foundational"`, `saga_allowed_models: list | None`, `saga_default_model: str | None` — populated for ephemeral templates, `None` for foundational agents. +**New `AgentContext` fields:** `kind: str = "foundational"`, `echo_allowed_models: list | None`, `echo_default_model: str | None` — populated for ephemeral templates, `None` for foundational agents. -**Startup failfast:** server refuses to start if `agents/saga/config.yaml` is missing/malformed OR `saga.default_model` is not in `saga_allowed_models`. Raises `ConfigurationError` before binding any port. +**Startup failfast:** server refuses to start if `agents/echo/config.yaml` is missing/malformed OR `echo.default_model` is not in `echo_allowed_models`. Raises `ConfigurationError` before binding any port. **Function-level contracts for issue #161** are documented in `docs/contracts/issues/161.contract.md`. @@ -1704,7 +1766,7 @@ Bifrost allows consumers to expose tools to Worldtree agents. `POST /sessions` a - **INV-160-1 (handshake-at-create)**: When `POST /sessions` carries `bifrost: {endpoint_url, ...}`, the handshake completes BEFORE the 201 response. No "create session, handshake later" path in v0.1. Verifiable via test: handshake-failing endpoint → 502; session not in store. - **INV-160-2 (one-connection-per-session)**: Each Bifrost-bound session owns exactly one MCP connection. Two sessions binding to the same `endpoint_url` open two independent connections. No pooling, no sharing. -- **INV-160-3 (saga-incompatible)**: A session cannot be both ephemeral (Saga, `kind: "ephemeral"`) AND Bifrost-bound. Session-create rejects with 422 `ephemeral_does_not_accept_bifrost`. Verifiable: `POST /sessions { agent_id: "saga", config: {...}, bifrost: {...} }` → 422. +- **INV-160-3 (echo-incompatible)**: A session cannot be both ephemeral (Echo, `kind: "ephemeral"`) AND Bifrost-bound. Session-create rejects with 422 `ephemeral_does_not_accept_bifrost`. Verifiable: `POST /sessions { agent_id: "echo", config: {...}, bifrost: {...} }` → 422. - **INV-160-4 (jwt-bound-to-session-expiry)**: JWT TTL is bound to session expiry — far-future `expires_at` for sessions without a fixed TTL. Re-mint happens only when a re-handshake fires (connection-loss recovery). No standalone JWT-staleness check. - **INV-160-5 (reentrancy-25-per-turn)**: At most 25 successful Bifrost tool invocations per agent turn. The 26th returns `bifrost.reentrancy_cap_exceeded` without contacting the consumer. Counter resets per turn via `BifrostClient.reset_turn_counter()`. Enforced inside `BifrostClient.invoke_tool`. - **INV-160-6 (tool-list-cached-per-session)**: Bifrost tools are fetched once at handshake and cached on `ConversationSession.bifrost_tools`. Per-turn dispatch reads from the cache; never re-fetches mid-session except on connection-loss recovery. @@ -1751,7 +1813,7 @@ class BifrostEndpointOverride(BaseModel): 1. HTTPS URL check — Pydantic field validator; 422 on miss. 2. `bifrost:invoke` scope check — same as session-bound path; 403 on miss. -3. Ephemeral session rejection — 422 `ephemeral_does_not_accept_bifrost` when session is Saga (extends INV-160-3). +3. Ephemeral session rejection — 422 `ephemeral_does_not_accept_bifrost` when session is Echo (extends INV-160-3). 4. Heimdall consumer lookup — 502 `bifrost_consumer_not_found` on miss or unregistered. 5. Instantiate a new `BifrostClient` with the override consumer's algorithm + key; set `_jwt_ttl_seconds = 60`. 6. `await override_client.connect()` — 502 `bifrost_handshake_failed` on failure. @@ -1789,7 +1851,7 @@ In the `finally` block, `await override_client.disconnect()` is called unconditi The conversation API grows a three-tier agent model. Tier 1 is the foundational set (Mimir, Bragi, Leif, ...) wired at startup. Tier 2 is -the ephemeral template surface (Saga). Tier 3 is the consumer-defined +the ephemeral template surface (Echo). Tier 3 is the consumer-defined class addressed by `:` and stored in Heimdall's SQLite `consumer_agents` table. @@ -1813,9 +1875,13 @@ SQLite `consumer_agents` table. - **INV-181-5 (agent-name-immutable, Phase 2.0 scope)**: PATCH rejects any payload that includes `agent_name`, returning 422 `field_not_mutable` BEFORE the DB lookup. -- **INV-181-6 (layer-immutable-in-patch, Phase 2.0 scope)**: PATCH - rejects payloads carrying any of `persona`, `motivational`, - `valence`, `memory` even when set to `null`. +- **INV-181-6 (layer-immutable-in-patch, Phase 2.0 scope; AMENDED #188)**: + PATCH rejects payloads carrying any of `persona`, `motivational`, + `memory` even when set to `null`, returning `field_not_mutable`. + **Amended by #188 (Phase 2.3):** `valence` was moved out of this + `field_not_mutable` set — it now returns `layer_deferred` (see + INV-188-1), because valence is a not-yet-shipped layer, not a frozen + trait. `memory` is rejected wholesale (see INV-188-2). - **INV-181-7 (owner-delete-hard, Phase 2.0 scope)**: `DELETE /agents/` is a hard-delete; bypasses the 24h grace. - **INV-181-8 (cascade-key-scoped, Phase 2.0 scope)**: Key revocation @@ -1881,6 +1947,79 @@ SQLite `consumer_agents` table. through `_publish`, so SSE resume / replay handles them with no special case. +## Amendment — Suspended-tier license-state gate (issue #174, INV-174-1..9) + +Adds a `suspended` tier with empty scope set to drive license-expiry +transitions without destroying user state. Endpoint +`POST /admin/users/{user_id}/tier` mutates the tier; the +`_http_exception_handler` rewrites `AUTH_SCOPE_DENIED` → +`USER_SUSPENDED` for any 403 raised against a non-anonymous caller with +an empty scope-set (the suspended-tier defining property). Ships in +v0.29.1. + +- **INV-174-1 (closed tier vocabulary)**: `POST /admin/users/{user_id}/tier` + validates `body.tier` against the hard-coded set `{anonymous, user, free, + pro, admin, suspended}`. Out-of-set values return 422 `invalid_tier`. + Vocabulary is NOT derived from `policies.yaml` at runtime — a typo in + YAML must not silently expand the accepted set. + +- **INV-174-2 (admin-only mutation)**: endpoint requires + `admin.users.write.tier_change` scope. Listed explicitly in admin + tier's scope set in `policies.yaml` for grep-discoverability (admin + also carries `*` umbrella). + +- **INV-174-3 (tier mutation primitive)**: + `UserStore.update_user_tier(user_id, new_tier) -> User` is the storage + primitive. Raises `LookupError` for unknown user_id (endpoint converts + to 404 `user_not_found`). + +- **INV-174-4 (suspended scope-set is exactly empty)**: + `policies.yaml.tiers["suspended"].scopes == []`. The empty set is what + makes the auth-denial work for free; the + `_http_exception_handler` rewrite uses + `ctx.user_id != "anonymous" and not ctx.scopes` as the + suspended-detection heuristic since `SecurityContext` deliberately + excludes `tier` (per `core/integration/types.py:64`). + +- **INV-174-5 (uniform suspended error code via exception handler)**: + The `_http_exception_handler` (registered for `StarletteHTTPException`) + intercepts every 403 with `error_code: auth_scope_denied`; if the + request's stashed `SecurityContext` has an empty scope-set (and + non-anonymous user_id), it rewrites the detail to + `{error_code: "user_suspended", message: "Account is suspended."}`. + Single seam — covers every existing and future scope-deny site + without per-endpoint refactor. The ctx is stashed by + `get_security_context` on `request.state.security_context`. + +- **INV-174-6 (/me carve-out)**: `/me` does NOT call `authorize()` and + therefore never raises `AUTH_SCOPE_DENIED`. Suspended users with + empty scopes reach the /me handler normally and see + `{user_id, tier: "suspended", scopes: [], ...}`. Adding a scope check + to /me without preserving the suspended-tier visibility would be a + contract violation — the carve-out is structural, not coded. + +- **INV-174-7 (audit emission)**: every tier-change attempt emits + `conversation_api:admin:user:tier_changed` via `_audit_admin_action` + with `actor_user_id`, `target_user_id`, `outcome ∈ + {success, denied}`, and `extra = {from_tier, to_tier, reason}` for + successes; `extra = {reason: }` for denials + (`invalid_tier`, `user_not_found`). + +- **INV-174-8 (reversibility via audit replay)**: the user record does + NOT carry a `previous_tier` column. Restoration of a suspended user + requires reading the audit log to find the most recent + `tier_changed` event with `to_tier="suspended"` and replaying its + `from_tier` as the new target. Operational responsibility of SEA's + billing integration; Worldtree provides only the read (audit log) and + write (endpoint) surfaces. + +- **INV-174-9 (no cross-tier session invalidation)**: a tier change for + a user with active SSE turns in flight does NOT cancel those turns. + The next request after the tier change picks up the new scope-set; + in-flight streams complete under the old tier. If SEA needs + immediate-cutoff semantics, that requires `disable_user`-style + hard-revoke, not a tier change. + ## Amendment — AwaitingLLMFirstToken heartbeat (issue #201, INV-201-1..7) Adds a periodic SSE heartbeat event during the gap between @@ -2027,3 +2166,160 @@ Lofn introduces zero net-new persistence surface. No table, no column, no Mimir KB collection. No new audit-event types. Existing session-create / session-revoke audit covers Lofn the same way it covers Mimir / Forseti. + +## Amendment — Tier 3 motivational layer (issue #187, Phase 2.2) + +Activates the `motivational` layer field on `POST /agents/define`, narrowing the +Phase 2.0 `layer_deferred` rejection (INV-181-3) to `valence` only. Full FN-level +spec at `docs/contracts/issues/187.contract.md`. + +- **INV-187-1 (motivational-activated)**: `POST /agents/define` accepts a non-null + `motivational` object `{goals, fears}`; `_tier3_validate_layer_fields` rejects + only `valence` now. (Persona + memory were activated in Phase 2.1 / #189.) +- **INV-187-2 (define-validation)**: `validate_motivational_define_payload` enforces + the documented 422 codes — `motivational_id_collision` (case-sensitive, across + goals AND fears), `motivational_goal_invalid_type`, + `motivational_salience_out_of_range`, `motivational_description_too_short` + (< 20 chars after strip), `motivational_missing_required_field`. Unknown top-level + OR nested (per goal/fear) keys → `validation_failed` (sub-models extra-forbid). + Stricter than the Tier 1 `validate_motivation` (which only warns on short text). +- **INV-187-3 (per-agent-scope)**: motivational is per-agent, NOT + per-(agent, end_user) — stored once on the row, identical across all end-users. +- **INV-187-4 (immutable-in-patch)**: `PATCH` with `motivational` → 422 + `field_not_mutable` (already covered by INV-181-6's `_IMMUTABLE_FIELDS` gate). +- **INV-187-5 (tier3-render-bridge)**: Tier 3 agents are NOT registered with the + `persona_registry`; the stored config rides on the per-session `AgentContext` + (`motivational_config`) and is rendered into the prompt per-turn in `stream_turn` + via `_append_motivational_context_section`, before the memory-context section. +- **INV-187-6 (fear-signal-shape)**: fears carry `trigger_signals`; goals carry + `positive_signals` + `negative_signals` (matches the `GoalConfig`/`FearConfig` + substrate). +- **INV-187-7 (tier-uniformity)**: the render reuses `core.persona.goals.load_goals` + + `render_motivational_context`, so a Tier 3 motivational config produces a + byte-identical block to an equivalent Tier 1 `motivation.yaml`. +- **INV-187-8 (storage)**: persisted in `consumer_agents.tier3_layers_json` under + the `"motivational"` key; round-trips via `ConsumerAgent.motivational`; null/omitted + → `None` (no fabricated defaults; no migration). + +### Audit + +`agents.define` audit `extra` gains `presence_motivational: bool` alongside +`presence_persona` / `presence_memory`. + +## Amendment — Tier 3 PATCH mutability policy (issue #188, Phase 2.3) + +Settles which Tier 3 agent fields are editable post-define. #197 deleted the +STM tier between this issue's filing (2026-05-19) and its implementation, so the +"mutable memory dials" the original issue envisioned no longer exist; the policy +collapses to: `system_prompt` + `model` mutable, everything else fixed, with +`valence` distinguished from the immutable traits by error code. No new +endpoint, no new storage, no new invariant philosophy — a clarification + +error-code alignment + audit enrichment over the Phase 2.0 PATCH baseline. + +- **INV-188-1 (valence-deferred-in-patch)**: `PATCH /agents/` carrying a + `valence` key (any value, including `null`) → 422 `layer_deferred` with + `field: "valence"`, matching define-time (INV-181-3). Rationale: valence is + a layer that does not exist yet, not a real-but-frozen trait; `layer_deferred` + is the truthful reason and gives consumers ONE code for "valence unavailable" + across both define and PATCH. The check precedes the DB lookup (INV-181-5/6 + ordering), so a `valence` PATCH against a missing agent still 422s, not 404s. +- **INV-188-2 (memory-wholesale-immutable-in-patch)**: `PATCH` carrying a + `memory` key → 422 `field_not_mutable` with `field: "memory"`, rejected at the + WHOLE-field level. No sub-field carve-out exists: `stm_capacity` / + `stm_token_budget` are deprecated no-ops post-#197, `allows_world_scope` is + create-time-only (toggling it after memory is written breaks scope-visibility + invariants — memory scope policy must be fixed before any memory is written), + and `embedder_version` is library-pinned. A real LTM tuning dial would warrant + a deliberate per-sub-field PATCH contract at that time; pre-splitting for dead + fields is not done. NOTE the deliberate define/PATCH asymmetry: `define` + accept-and-ignores deprecated `stm_*` (201 + DeprecationWarning per + INV-197-19), but `PATCH memory:{...}` rejects wholesale (422). Acceptable + transitional artifact; disappears when the shims are removed. +- **INV-188-3 (patch-audit-before-after)**: a successful `agents.patch` audit + event's `extra.changes` records before/after for each mutated field — + `model: {before, after}` (literal values; allowlist enum, not PII) and + `system_prompt: {before_bytes, after_bytes}` (byte-length only; raw prompt + content is excluded as potential PII, consistent with `emit_consumer_agent_event`'s + exclusion rule). `changes` contains only keys for fields actually present in + the PATCH payload. `patched_fields` (the Phase 2.0 name list) is retained. +- **INV-188-4 (mutable-surface-unchanged)**: the mutable surface stays exactly + `system_prompt` + `model` (per INV-181 Phase 2.0). PATCH re-enforces the + define-time `system_prompt` byte-cap and `model` allowlist. #188 does NOT add + model-swap capability/context-window validation — that gap (a swap to a + smaller-context or non-tool model with no re-check of the existing prompt) is + tracked as a separate follow-up (#219), not folded here. + +## Amendment — model-assignment advisory warnings (issue #219) + +`POST /agents/define` and `PATCH /agents/` attach a best-effort, **non- +blocking** `warnings` array to their 2xx response when the assigned `model` +carries metadata risk (smaller context window, unknown window, or an explicit +capability downgrade). This is advisory-only by deliberate design: hard +rejection was rejected (Heid panel + operator, 2026-05-29) because model +metadata coverage is partial (`context_window` is 0/unknown for several +allowlisted models; `supports_tools` defaults true), the stored `system_prompt` +cap is bytes not tokens, Tier 3 agent rows store no tool/modality usage (tools +arrive per-session via Bifrost, so any capability concern is inherently +conditional), and runtime already classifies the real failure as +`CONTEXT_OVERFLOW`. The warning is a receipt-note for the owner who just made a +deliberate change, not a correctness gate. + +- **INV-219-1 (advisory-not-blocking)**: neither define nor PATCH ever rejects + on context-window or capability grounds. The allowlist check + (`model_not_available`) and `system_prompt` byte-cap are the only model- + related *rejections*; everything in #219 is a warning on an otherwise-2xx + response. Correctness for over-budget prompts remains the runtime + `CONTEXT_OVERFLOW` guard. +- **INV-219-2 (bounded-warning-codes)**: the closed code set is exactly — + `model_context_window_unknown` (severity `info`): the assigned model's + registry `context_window` is `0`/absent; `model_context_window_smaller` + (severity `warning`): prior and new model both have known windows and + new < prior (`details: {before, after}`); `model_capability_downgrade` + (severity `warning`): the new model EXPLICITLY drops a capability the prior + model advertised — `supports_tools`, `vision`, or `audio` (`details: + {dropped: [...]}`). No token-aware "prompt won't fit" code — deferred until + tokenizer-aware estimation exists; messages never claim a hard fit/failure. +- **INV-219-3 (when-evaluated, resulting-pair)**: warnings are computed + whenever a model is *assigned*. At define, always (prior = None → only + `model_context_window_unknown` can apply, since the comparative codes need a + prior). At PATCH, only when the payload carries a `model` key whose value + differs from the stored model (prior = stored model); a PATCH without `model` + (e.g. `system_prompt`-only) emits no model warnings. The comparison is always + against the *resulting* model. +- **INV-219-4 (capability-downgrade)**: a `model_capability_downgrade` fires + only when BOTH prior and new models resolve to registry `ModelInfo` AND the + new model's *effective* capability flags lack one the prior advertised + (`supports_tools`, `vision`, or `audio`). The "both resolve" guard is the + false-positive defense — an unresolvable model on either side yields no + downgrade claim. Beyond that, comparison uses the registry's **effective** + flags, which is asymmetric by capability because the data model collapses + absent-to-default and does not preserve a "was this declared?" bit: + - `supports_tools` defaults **true** (`ModelInfo` / `_build_model_info`), so + a tools-drop requires the new catalog entry to set `supports_tools: false` + *explicitly* — omission never triggers it. + - `vision` / `audio` default **false** (`ModelCapabilities`), so a drop is + detected whenever the prior advertised the capability and the new model does + not carry it — whether the new entry says `false` explicitly OR omits it. + This is the deliberate conservative reading: an undeclared modality is + treated as unsupported. (A vision-capable model with sloppy metadata that + omits its `vision` flag would thus be reported as a downgrade; the remedy is + to declare the flag in the catalog, not to suppress the advisory.) + + Message phrasing is conditional ("if your sessions rely on these, e.g. Bifrost + tools, they may be rejected") — the agent row does not record whether tools or + modalities are actually used, so every capability warning is advisory by + nature. +- **INV-219-5 (inline-response-shape)**: the `warnings` array is added inline to + the define (201) and PATCH (200) response bodies — the existing flat + `ConsumerAgentResponse` dict gains a `warnings` key (always present, `[]` when + none). It is NOT added to the shared `ConsumerAgentResponse` pydantic model + nor to `GET /agents/` — only the two mutation handlers merge it into their + returned dict, keeping persisted fields and the read path unchanged. Each + entry is `{code, severity, message, details}`. +- **INV-219-6 (single-helper)**: a single pure helper + `compute_model_swap_warnings(*, prior_model: str | None, new_model: str, + registry)` is the only source of warning logic; both define and PATCH call + it. It tolerates unresolvable specs / `None` `ModelInfo` / `context_window` + `0` by treating them as "unknown" (emitting the unknown-window info code where + applicable, never raising). Metadata improvements over time sharpen the + warnings with no API or signature change. diff --git a/persistent-memory.md b/persistent-memory.md index 88fb7b6..d647144 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -43,7 +43,22 @@ model output is untrusted); upstream API key stays server-side (INV-003). _As of 2026-06-16:_ -**LATEST (2026-06-16 PM) — BIFROST REPINNED 0.7.0→0.8.0 (wire v0.5→v0.6).** The +**LATEST (2026-06-17) — COLD RECALL CLOSED END-TO-END + WORLDTREE SPEC REPINNED.** +Worldtree shipped its half: v0.35.16 adopts the bifrost v0.6 wire and emits +`scope_any`/`scope_all` on recall (#297 client-side per-scope-value union recall + +#298/#299 bifrost-v0.6 adoption). With our v0.17.6 provider, cold cross-session recall +is now CLOSED end-to-end. **Remaining: a live re-smoke against a personal instance on +v0.35.16** (the running smoke target was v0.35.2/.3 — needs infra-ops to update it). +Worldtree spec pin bumped `562001a`/v0.29.0 → `f1b59f8`/v0.35.16 (`docs/SPEC-PIN.md` + +`worldtree-spec-rev`; 285-commit catch-up). Client-facing deltas reviewed, NONE break +our consumer: #211 agent-slug rename (saga→echo, actor→mask — slugs only), #245 +`end_user_id` persistence + memory-scope resolver, #187/#188/#219 Tier-3 define/PATCH +policy (additive); the `bifrost` binding field + `ephemeral_does_not_accept_bifrost` 422 +are now in-tree (the #17 surface). No ratatoskr code change for the spec bump; shipped +as a `pin:` commit (no package bump — docs/pin only). The provider half shipped earlier +as v0.17.6 (`96d61a4`, tag v0.17.6). + +**(2026-06-16 PM) — BIFROST REPINNED 0.7.0→0.8.0 (wire v0.5→v0.6).** The memory `search` scope filter was split into `scope_all` (AND/intersection) + `scope_any` (OR/union over a LIST of conjunctive scopes) — bifrost #11, the canonical fix for the #295/#297 silent-zero AND foot-gun. Our store + contract (v1.2) + tests @@ -52,12 +67,11 @@ reimplemented to parity with the v0.6 reference `_matches_scope`/`_validate_scop test + the parity-vs-reference test through the real 0.8.0 `dispatch_memory_call`. Memory provider BOUNCED onto 0.8.0 (`:8391`, fresh empty `memory.db` — the prior 5-chunk #296 corpus was WIPED, operator confirmed "nothing of value", SUPERSEDES the -"KEEP PINNED" note below). **End-to-end cold recall now waits only on Worldtree -EMITTING `scope_any` on its recall path (#297, upstream).** Affect plane untouched -(split is memory-only); affect provider still on its 0.7.0-loaded process (bounce -optional — affect wire unchanged at 0.8.0). NOT yet committed; patch bump v0.17.6 -pending operator commit approval. **#17 contract carries stale `scope_filter` / -`_scope_matches`-AND references — fix when #17 TDD starts.** +"KEEP PINNED" note below). Shipped as v0.17.6 (`96d61a4`, tag v0.17.6); the #17 +contract's stale `scope_filter`/`_scope_matches`-AND references were synced in the same +commit. Affect plane untouched (split is memory-only); affect provider still on its +0.7.0-loaded process (bounce optional — affect wire unchanged at 0.8.0). Cold-recall +status SUPERSEDED by the 2026-06-17 block above (now CLOSED end-to-end). **Ratatoskr now has a SECOND identity: the v1 Bifrost Tier-3 consumer** — the durable persistence provider Worldtree writes Tier-3 agent affect/persona + @@ -140,8 +154,8 @@ HS256 = the API-key STRING utf-8-encoded; rotate via infra-ops. operator's call. `graphify-out/GRAPH_REPORT.md` still runs dirty (auto-regenerated artifact, not chased). -**Still standing from before:** Worldtree spec pin v0.29.0 (`562001a`) for the -conversation-API/TUI surface (untouched by the Bifrost work). Codex-first pilot +**Still standing from before:** Worldtree spec pin now v0.35.16 (`f1b59f8`) for the +conversation-API/TUI surface (bumped 2026-06-17 from v0.29.0/`562001a`). Codex-first pilot still dormant (no codex session spun up — see the 2026-05-29 decision). Open issues: #10 (subject migration, deferred), #11 (AdminEvents pane, deferred). @@ -211,6 +225,8 @@ decision. Captures rationale that won't be obvious from code alone. - `[2026-06-16]` **Repinned bifrost 0.7.0→0.8.0 + reimplemented memory `search` to the v0.6 scope split (operator-directed).** `scope_filter` → `scope_all` (AND) + `scope_any` (OR/union over a list of conjunctive scopes), bifrost #11 — the canonical resolution of the #295/#297 silent-zero. **SUPERSEDES the "Provider stores byte-faithful to AND reference / Do NOT flip `_scope_matches` to OR" entry above**: the reference itself now does OR via `scope_any` (a NEW field — `scope_all` keeps the old AND semantics; this is an additive split, not a flip of the AND predicate). Store / contract (v1.2) / tests at parity with the v0.6 reference; provider bounced onto 0.8.0 with a wiped DB (operator: "nothing of value"). Cold recall now gated only on Worldtree emitting `scope_any` (#297). **#17's contract has stale `scope_filter`/`_scope_matches`-AND references (its `assumptions`, INV-004, and the op-feed `search → req {scope_filter}` summary shape) — update those to `scope_all`/`scope_any` when #17 TDD starts; INV-004's intent (observe must not alter scope semantics) still holds.** +- `[2026-06-17]` **Worldtree spec pin bumped v0.29.0→v0.35.16 (`562001a`→`f1b59f8`); cold recall CLOSED end-to-end.** Worldtree shipped #297 (client-side per-scope-value union recall) + #298/#299 (adopt the bifrost v0.6 `scope_any`/`scope_all` wire) — it now emits `scope_any` on recall, the upstream half that pairs with our v0.17.6 provider. Re-vendored `conversation-api-spec.md` + `conversation_api.contract.md`; diff-reviewed the 285-commit catch-up — no client-breaking changes (#211 agent-slug rename saga→echo/actor→mask is slugs-only; #245 end_user_id+memory-scope; #187/#188/#219 Tier-3 define/PATCH additive; error codes stable). Shipped as a `pin:` commit, NO package bump (docs/pin-only, no ratatoskr code; per SemVer SKIP for docs-only). **Remaining proof: a live cold-recall re-smoke against a personal instance on v0.35.16** — the smoke target ran v0.35.2/.3, needs infra-ops to update it. + _For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._ ## Tried and abandoned diff --git a/pyproject.toml b/pyproject.toml index b02827c..d0a9dfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ Repository = "https://gitea.phasefinal.com/vh/ratatoskr" # Ratatoskr is built against Worldtree at this commit; the vendored # spec snapshot in docs/ reflects that SHA. [tool.ratatoskr.spec-pin] -worldtree-spec-rev = "562001af28d752c3a60d449c7ddd09f44fa9dc9a" +worldtree-spec-rev = "f1b59f8cd6fe41e497d0be9dad9d3110451f0d9a" worldtree-version = "v0.29.0" pinned-on = "2026-05-26"