docs(canonical): add client-spec-v2 pin + sync worldtree conversation-api canonicals

Operator decision A (client-spec tracking): add a first-class pin for
docs/conversation-api-client-spec-v2.md (v2.1, sha 1ff1d73a — the
client-facing spec our #371 parity pass shaped), vendored as a consumer
copy, tolerate_drift=true (prose reference; OpenAPI+SSE stay the gates).

canonical_sync is whole-manifest (no per-pin scope), so it also brought
two independently-drifted pins current:
- OpenAPI hard-gate 36148179 (2.3.0) -> 1c801547. Additive-only within
  the FROZEN v1 wire (Worldtree #326) — no code/SDK breakage; the SDK
  targets 2.3.0 and still works against the newer additive surface.
- conversation-api-spec.md (full prose) -> f6e964ec (v1.3+).

canonical_drift now exits 0. FOLLOW-UP (non-urgent): the OpenAPI moved,
so a coverage-map re-audit against the new surface would surface any new
in-scope I/O points worth consuming (ratatoskr v1 = full WT I/O coverage).
Release-only cadence: no tag.
This commit is contained in:
2026-08-01 16:58:39 -07:00
parent 0058020272
commit 14bbc2b48e
4 changed files with 1328 additions and 38 deletions
+13 -4
View File
@@ -104,8 +104,8 @@ id = "worldtree-conversation-api-openapi-v2"
canonical_source = "Worldtree"
canonical_path = "docs/conversation-api-openapi.json"
consumer_path = "docs/conversation-api-openapi.json"
pinned_sha256_16 = "36148179601453a0"
pinned_at = "2026-07-06T16:09:05+00:00"
pinned_sha256_16 = "1c801547bbdbccc6"
pinned_at = "2026-08-01T23:56:50+00:00"
[[pins]]
id = "worldtree-conversation-api-sse-events-v1"
@@ -120,10 +120,19 @@ id = "worldtree-conversation-api-spec-v1"
canonical_source = "Worldtree"
canonical_path = "docs/conversation-api-spec.md"
consumer_path = "docs/conversation-api-spec.md"
pinned_sha256_16 = "70aabcb30af6c615"
pinned_at = "2026-07-18T18:51:00+00:00"
pinned_sha256_16 = "f6e964ecbf3deec0"
pinned_at = "2026-08-01T23:56:50+00:00"
tolerate_drift = true # prose reference; OpenAPI+SSE are the gates
[[pins]]
id = "worldtree-conversation-api-client-spec-v2"
canonical_source = "Worldtree"
canonical_path = "docs/conversation-api-client-spec-v2.md"
consumer_path = "docs/conversation-api-client-spec-v2.md"
pinned_sha256_16 = "1ff1d73a24e72d43"
pinned_at = "2026-08-01T23:56:50+00:00"
tolerate_drift = true # client-facing prose reference (our #371 parity pass shaped it); OpenAPI+SSE are the gates
# Worldtree persona render canons (d2) — the deterministic affect->NL the agent is
# context-injected. The web persona pane renders mood + relationship-directive BYTE-EXACT
# from these (via the flat src/ratatoskr/web/static/persona_render_canon.json, regenerated
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -950,7 +950,7 @@
"info": {
"description": "Multi-turn conversation interface for Worldtree agents.",
"title": "Worldtree Conversation API",
"version": "2.3.0"
"version": "2.4.0"
},
"openapi": "3.1.0",
"paths": {
+75 -33
View File
@@ -1,6 +1,6 @@
# Worldtree Conversation API — Client Interface Specification
**Version:** 1.1
**Version:** 1.3
**Date:** 2026-07-18
**Status:** Stable
**Contract:** `docs/contracts/conversation_api.contract.md`
@@ -1209,7 +1209,19 @@ Send a message and receive the agent's response as a Server-Sent Events (SSE) st
The stream emits JSON-encoded events. Each SSE `data:` line contains a JSON object with a `type` field.
**Errors:** `404` if session not found (returned before stream starts).
**Content size (normative, spec 2.4.0):** `content` is bounded on **UTF-8
byte length** by `message_content_max_bytes` (server config; **default
262144 = 256 KiB — the normative spec number**). Over-cap content is
rejected with an eager `422` carrying `error_code: content_too_long`
*before* any SSE bytes are emitted (plain JSON error envelope, the
pre-stream error path). Content that is not valid UTF-8 (lone surrogates)
is a deterministic `422 validation_failed`. This cap is the number
client-side read bounds derive from: a max-page transcript body is
structurally ≤ 200 items × (cap + envelope overhead), and no single
message-bearing SSE frame can exceed cap + envelope overhead.
**Errors:** `404` if session not found (returned before stream starts);
`422 content_too_long` for over-cap content (see above).
---
@@ -1634,7 +1646,7 @@ Creating an Echo session requires the `instantiate:echo` scope. This scope is bu
Public primitive for downstream consumers (Skaldsong, RPG/game engines, dialog tools) that need to spin up dozens of distinct OCEAN-driven personas per session. Worldtree owns no durable state — the consumer ships character JSON, gets a `character_id`, and runs sessions against it. Persists in-memory only; process restart drops everything.
The character displaces the **persona + model** layer of the bound session. The `agent_id` (typically `actor`) still resolves system prompt, tools, and the LLM provider unless the character carries a `model` override.
The character displaces the **persona + model-role** layer of the bound session. The `agent_id` (typically `mask`, renamed from `actor` at v0.29.9/#211) still resolves system prompt, tools, and the LLM provider unless the character carries a `role` override (a configured model-role per ADR-0012 — the raw `model` selector left this surface at the role cutover).
### `POST /characters`
@@ -1655,7 +1667,7 @@ Create a transient character. Requires `character.write` scope.
"description": "A melancholy prince.",
"narrative": "He speaks to himself when alone, weighs every choice three ways…",
"voice_profile_block": "Formal English; iambic pentameter under pressure; weights every word.",
"model": "fast"
"role": "fast"
},
"state": null
}
@@ -1691,7 +1703,7 @@ Remove the character. Requires `character.write` scope. Sessions bound to this c
### `GET /models/available-for-characters`
Return the model profiles the actor (and the `model` field on `CharacterSchema`) can reference. Requires `character.read` scope.
Return the catalog bindings the `character` model-role resolves to (what the `role` field on `CharacterSchema` can reach). Requires `character.read` scope.
```json
{
@@ -1725,7 +1737,7 @@ When supplied, the session binds the character. The character's persona (OCEAN,
- `410 character_not_found` — character was deleted/expired (returned at the session-bind path on next turn)
- `422 ttl_too_large``ttl_seconds` over the configured ceiling
- `422 state_schema_outdated` — schema_version mismatch; detail includes `accepted_versions`
- `422 model_not_available_for_characters``model` field references a profile not in the allowlist
- `404 model_not_available``role` field names an unconfigured model-role (resolved through the `character` role gate; the pre-cutover `model_not_available_for_characters` code is retired)
- `422 validation_failed` — OCEAN out of `[-1, 1]`, voice_profile_block too long, etc.
- `429 quota_exceeded` — per-user character cap hit
- `403 auth_scope_denied` — missing `character.write` or `character.read`
@@ -2404,24 +2416,24 @@ The `POST /sessions/{session_id}/messages` endpoint accepts an additive `upload_
2. Each upload existence/ownership/expiry check — first failure short-circuits with `410 upload_expired` or `404`
3. Agent capability gate — if `upload_ids` non-empty and `agent.capabilities` does not include `accepts_uploads` → `422 agent_lacks_upload_support`
### Per-call model override (issue #157)
### Per-call role override (issue #157, role-keyed since #261/#344)
The `POST /sessions/{session_id}/messages` endpoint also accepts an additive `model` field that overrides the character / agent default for this turn only:
The `POST /sessions/{session_id}/messages` endpoint also accepts an additive `role` field that overrides the character / agent default model-role for this turn only:
```json
{"content": "Reformat this YAML.", "model": "granite-structured"}
{"content": "Reformat this YAML.", "role": "assistant"}
```
**Semantics:**
- 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 per-call only. Stored `CharacterSchema.role` is NOT mutated.
- Resolved through the same `character` model-role gate that validates `CharacterSchema.role` at create time (ADR-0012).
- Override displaces the character's bound role when both are set (per-call wins).
- 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.
2. If the session is ephemeral (`ephemeral_config != None`): `422 validation_failed` ("per-call 'model' override is not permitted on ephemeral sessions").
3. If `model` is not in the `available_for_characters` allowlist: `422 model_not_available_for_characters`.
1. Pydantic validates `role`: optional string, non-empty after stripping whitespace.
2. If the session is ephemeral (`ephemeral_config != None`): `422 validation_failed` ("per-call 'role' override is not permitted on ephemeral sessions").
3. If `role` names an unconfigured model-role: `404 model_not_available`.
**Audit:** the `turn.started` admin event carries the effective `model` plus a `model_override_applied: bool` flag so cross-system audit trails can see what model actually ran on a per-turn basis.
@@ -2714,7 +2726,7 @@ below document the active layers and their exact validated shapes.
| -------- | --------------------------------- | ------------------------------------------ |
| `POST` | `/agents/define` | Create a Tier 3 agent. |
| `DELETE` | `/agents/<user_id>:<agent_name>` | Owner-initiated hard-delete. |
| `PATCH` | `/agents/<user_id>:<agent_name>` | Mutate `system_prompt` and/or `model`. |
| `PATCH` | `/agents/<user_id>:<agent_name>` | Mutate `system_prompt` and/or `role`. |
| `POST` | `/sessions` | Tier 3 routing when `agent_id` has a `:`. |
| `GET` | `/sessions/<session_id>/tools` | Owner-scoped session-tools introspection (#183, Phase 2.0.1). |
@@ -2724,7 +2736,7 @@ below document the active layers and their exact validated shapes.
{
"agent_name": "wizard",
"system_prompt": "You are a guided-elicitation wizard...",
"role": "gen-reasoning", // REQUIRED — a configured model-role (#344), not a raw model id
"role": "thoughtful-assistant", // REQUIRED — a configured model-role (#344), not a raw model id
"persona": { // active (Phase 2.1) — single-letter OCEAN keys; see "Persona layer"
"ocean": {"O": 0.4, "C": 0.6, "E": -0.3, "A": 0.2, "N": 0.5}
},
@@ -2742,12 +2754,19 @@ Response (`201 Created`):
"user_id": "alice",
"agent_name": "wizard",
"system_prompt": "...",
"model": "glm5-turbo",
"role": "thoughtful-assistant",
"created_at": "2026-05-19T12:00:00+00:00",
"updated_at": "2026-05-19T12:00:00+00:00"
}
```
The response `role` echoes the requested role — symmetric with the request
(W-4 closed, spec 1.2; the example above would echo
`"role": "thoughtful-assistant"`). The resolved engine catalog id is
privileged and never surfaces (#344 INV-344-3). A legacy pre-cutover row
defined with a raw model (null stored role) surfaces that stored model
string in this field.
Caller must:
- hold `agents.define` scope (default for `user` tier);
@@ -2762,6 +2781,19 @@ after definition.
The 201 response includes an advisory `warnings` array (#219) — see
"Model-assignment warnings" under `PATCH` below.
**Duplicate name (normative, spec 2.4.0):** re-defining an existing
`agent_name` returns `409` with `error_code: agent_name_taken` AND a
structured `agent_id` field naming the existing agent
(`{"detail": {"error_code": "agent_name_taken", "message": "...",
"agent_id": "alice:wizard"}}`). Define-then-catch-409 is therefore the
sanctioned, race-safe **define-or-reuse** shape: one round trip, no
client-side id composition, no lookup-then-define TOCTOU window. (The
`user_id` field on `GET /me` is also normative — guaranteed present on
every response shape including anonymous and Heimdall-degraded — so
client-side `<user_id>:<agent_name>` composition remains sanctioned for
addressing, per the id shape documented above; the 409 field simply makes
it unnecessary for the reuse flow.)
> **Vendoring note (OpenAPI 2.3.0).** In the frozen OpenAPI 2.3.0 document
> the `persona` / `motivational` / `memory` / `valence` request fields
> serialize as **untyped/freeform** — the `POST /agents/define` request
@@ -2969,16 +3001,18 @@ session bound to this agent and revokes the owner's per-resource
#### `PATCH /agents/<user_id>:<agent_name>`
**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:
**Mutable surface (Phase 2.3, #188; role-keyed since #344): `system_prompt`
and/or `role` only.** PATCH re-enforces the same validation as define — the
`system_prompt` byte-cap, and `role` must name a configured model-role
(unknown role → 404 `model_not_available`). 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. |
| `model` | `field_not_mutable` | Engine catalog ids left the surface at the role cutover (#344) — select via `role`. |
| `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. |
@@ -2997,18 +3031,23 @@ 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
session's cached `AgentContext`; new `system_prompt` / `role` 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,
`changes` detail records before/after per mutated field: a `role` change is
recorded as its **derived catalog** `model` `{before, after}` pair (the
audit log is a privileged surface; #344 INV-344-6 updates role + derived
catalog id in one write), 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.
**Model-assignment warnings (#219).** A role swap is **not blocked** for
capability or context-window compatibility. PATCH (and `define`) attach an
advisory `warnings` array to the response — but a **role-defined agent
always receives `[]`** (#344 INV-344-3: the warning texts name catalog-level
engine details the role consumer didn't pick and can't act on); only legacy
null-role rows can surface them. 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.
@@ -3028,9 +3067,12 @@ two mutation responses, not on `GET /agents/<id>`. Each entry is
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
- **Role-defined agents never receive these warnings** — `warnings` is
always `[]` for them (#344 INV-344-3); the codes below can only surface on
legacy null-role rows. **`define`** has no prior model, so only
`model_context_window_unknown` can fire there. **PATCH** computes warnings
only when the payload actually changes the underlying 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