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).
This commit is contained in:
vh
2026-06-17 08:24:27 -07:00
parent 96d61a4bb1
commit 4eee7c89b2
6 changed files with 487 additions and 52 deletions
+6 -6
View File
@@ -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 |
+4 -2
View File
@@ -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
+142 -21
View File
@@ -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.<tier>.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 <any valid key>
```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 <any valid key>
}
```
`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/<name>/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/<user_id>:<agent_name>` — `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/<user_id>:<agent_name>`
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/<id>` (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/<id>`. 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. |
+309 -13
View File
@@ -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: "<foundational>", 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 `<user_id>:<agent_name>` 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/<id>` 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: <reason_code>}` 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/<id>` 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/<id>` 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/<id>` — 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.