Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c595b862d | |||
| e3a10ad80e | |||
| b907a7b8a5 | |||
| bb158ae47d | |||
| 12cd8642fa | |||
| b7f4bc5e7c | |||
| 29c4fda12e | |||
| 57fdc48e20 | |||
| e45640c4da | |||
| b1fbadde3c | |||
| c7016f23a6 | |||
| 5d06a274bf | |||
| 80c8d58b83 | |||
| 7bfd4ddc95 | |||
| 26482ba2e9 | |||
| 8f3e9dd896 | |||
| 4bd9abdebc | |||
| 13efdf43a5 | |||
| ff1f9ca1e8 | |||
| 29481fbcf0 | |||
| 860e0d56bb | |||
| 36aad58ce9 | |||
| 21625d3c89 | |||
| 87857eb44f | |||
| daf6dbfc46 | |||
| c06fea412f | |||
| 4ce2e0e8b8 | |||
| bc2ce176d4 | |||
| 911f78bcb3 | |||
| 669316c254 | |||
| 3f3a9f7b0f | |||
| 60cafee68a | |||
| c6788539ac | |||
| 021fb5265f | |||
| 5a1883a023 | |||
| bf421f1203 | |||
| 0e7a04331a | |||
| ab8c1ee781 | |||
| eef4d01c15 | |||
| be2c577884 | |||
| c0d00ccd18 | |||
| 722d6c76de | |||
| 56f4895881 | |||
| ca249e4986 | |||
| faf605fdf1 | |||
| 7c8644dc45 | |||
| 195292156f | |||
| abe1b52002 | |||
| e365b24339 | |||
| 5e3e88d26d | |||
| ce6907bd73 | |||
| e2b2f51364 | |||
| 0f2b28919a | |||
| b154bb3885 | |||
| 46d6efa962 | |||
| f46ccbae1c | |||
| 772fad18b4 | |||
| 8199774405 | |||
| 66ba06875e | |||
| 25ccb5c75b | |||
| 22e7a1b0e7 | |||
| a9c521a48a | |||
| 8fc757aa61 | |||
| 39050c333f | |||
| 19e5182228 | |||
| 5f321b968a | |||
| 5e28919b39 | |||
| 7bca76e7b6 |
@@ -87,8 +87,8 @@ id = "contract-drift-check-v1"
|
||||
canonical_source = "corviduo-project-template"
|
||||
canonical_path = "scripts/contract_drift_check.py"
|
||||
consumer_path = "scripts/contract_drift_check.py"
|
||||
pinned_sha256_16 = "23271287ac488da4"
|
||||
pinned_at = "2026-05-17T05:30:00+00:00"
|
||||
pinned_sha256_16 = "2659a17a65704b66"
|
||||
pinned_at = "2026-07-12T08:39:35+00:00"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Worldtree Conversation-API surface (vendored from ~/development/Worldtree).
|
||||
@@ -120,8 +120,8 @@ 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 = "c656a789caceef14"
|
||||
pinned_at = "2026-07-06T16:51:09+00:00"
|
||||
pinned_sha256_16 = "70aabcb30af6c615"
|
||||
pinned_at = "2026-07-18T18:51:00+00:00"
|
||||
tolerate_drift = true # prose reference; OpenAPI+SSE are the gates
|
||||
|
||||
# Worldtree persona render canons (d2) — the deterministic affect->NL the agent is
|
||||
@@ -155,6 +155,52 @@ id = "worldtree-affect-egress-consumer-reference-v1"
|
||||
canonical_source = "Worldtree"
|
||||
canonical_path = "docs/affect-egress-consumer-reference.md"
|
||||
consumer_path = "docs/vendor/worldtree-persona-canon/affect-egress-consumer-reference.md"
|
||||
pinned_sha256_16 = "d959134037efae83"
|
||||
pinned_at = "2026-07-07T06:09:24+00:00"
|
||||
pinned_sha256_16 = "35eb71ba9090ad63"
|
||||
pinned_at = "2026-07-18T18:51:00+00:00"
|
||||
tolerate_drift = true # prose reference; the d2 render-canon JSONs are the gates
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Brokkr R34/R35 persona-prompt-framing reference (the character-self-report
|
||||
# reframe ratatoskr consumes: the authored psychological_profile is the prose
|
||||
# lens the Worldtree self-report producer reads for affect + memory salience).
|
||||
# Vendored for reference alongside the Worldtree affect/memory surfaces.
|
||||
# tolerate_drift: prose reference, not a machine gate — brokkr-smithy-dev owns
|
||||
# it and pings ratatoskr-dev on canonical changes. The authoring-spec GOVERNS on
|
||||
# any conflict with the parameter distillation.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
[[pins]]
|
||||
id = "brokkr-psych-profile-authoring-spec-v1"
|
||||
canonical_source = "brokkr-smithy"
|
||||
canonical_path = "research/R34-persona-prompt-framing/deliverables/psych-profile-authoring-spec.md"
|
||||
consumer_path = "docs/vendor/brokkr-r34-psych-profile/psych-profile-authoring-spec.md"
|
||||
pinned_sha256_16 = "4545a108d9fb6cc3"
|
||||
pinned_at = "2026-07-13T00:00:00+00:00"
|
||||
tolerate_drift = true # prose reference; brokkr-smithy-dev owns + pings on change
|
||||
|
||||
[[pins]]
|
||||
id = "brokkr-psych-profile-parameters-v1"
|
||||
canonical_source = "brokkr-smithy"
|
||||
canonical_path = "research/R34-persona-prompt-framing/deliverables/psych-profile-parameters.md"
|
||||
consumer_path = "docs/vendor/brokkr-r34-psych-profile/psych-profile-parameters.md"
|
||||
pinned_sha256_16 = "17157c82771aeeee"
|
||||
pinned_at = "2026-07-13T00:00:00+00:00"
|
||||
tolerate_drift = true # parameter distillation; authoring-spec governs on conflict
|
||||
|
||||
[[pins]]
|
||||
id = "soong-lab-export-contract-v1"
|
||||
canonical_source = "soong-lab"
|
||||
canonical_path = "docs/contracts/export.contract.md"
|
||||
consumer_path = "docs/vendor/soong-lab-bundle/export.contract.md"
|
||||
pinned_sha256_16 = "2f5efa72dd469d47"
|
||||
pinned_at = "2026-07-18T09:14:59+00:00"
|
||||
tolerate_drift = true # soong-lab-dev owns the bundle format + pings ratatoskr-dev on change
|
||||
|
||||
[[pins]]
|
||||
id = "soong-lab-importer-contract-v1"
|
||||
canonical_source = "soong-lab"
|
||||
canonical_path = "docs/contracts/importer.contract.md"
|
||||
consumer_path = "docs/vendor/soong-lab-bundle/importer.contract.md"
|
||||
pinned_sha256_16 = "edbd306596f26a9e"
|
||||
pinned_at = "2026-07-18T09:14:59+00:00"
|
||||
tolerate_drift = true # soong-lab-dev owns the bundle format + pings ratatoskr-dev on change
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# Ratatoskr
|
||||
|
||||
A Worldtree Conversation API debug TUI. Runs up and down Worldtree's
|
||||
A Worldtree Conversation API debug console. Runs up and down Worldtree's
|
||||
API surface — sessions, turns, persona, tools, admin events, Bifrost
|
||||
state — carrying messages between layers. Like the squirrel.
|
||||
|
||||
The product **is** the observability surface; chat is the input mechanism.
|
||||
Devs run Ratatoskr against a local Worldtree to watch a turn flow through
|
||||
every layer of the system, side-by-side, in one terminal.
|
||||
every layer of the system, side-by-side. The interactive surface is the
|
||||
web console (`ratatoskr-web`); a headless `--send` CLI drives scripted smokes.
|
||||
|
||||
## Status
|
||||
|
||||
@@ -42,9 +43,11 @@ cat docs/SPEC-PIN.md # documented Worldtree SHA + bump procedure
|
||||
# 3. Tests (none yet; scaffold only)
|
||||
uv run pytest
|
||||
|
||||
# 4. Run against a local Worldtree (once implementation lands)
|
||||
# Worldtree must be running: python -m core.conversation_api
|
||||
ratatoskr --agent mimir
|
||||
# 4. Run against a local Worldtree (Worldtree must be running)
|
||||
# Interactive web console:
|
||||
ratatoskr-web --host 0.0.0.0 --port 8765
|
||||
# Headless CLI (scripted smoke):
|
||||
ratatoskr --send "hello" --new --agent mimir --api-key "$WORLDTREE_API_KEY"
|
||||
```
|
||||
|
||||
## What this repo is NOT
|
||||
@@ -58,10 +61,10 @@ The full negative-clause list lives in `docs/design-brief.md` §6.
|
||||
|
||||
## Boundary rule
|
||||
|
||||
Ratatoskr depends on three things only:
|
||||
Ratatoskr depends on a small, fixed surface:
|
||||
|
||||
- `httpx` + `httpx-sse` (network layer)
|
||||
- `textual` (TUI framework)
|
||||
- `starlette` + `uvicorn` (the web console; the `web` extra)
|
||||
- Worldtree's **published Conversation API spec** at the pinned SHA
|
||||
|
||||
Hard rule: **no imports from a Worldtree checkout.** No `core.*` imports,
|
||||
|
||||
@@ -48,6 +48,100 @@ The 2026-05-20 → 2026-05-29 cluster: the original debug-TUI/web build era, bef
|
||||
- `[2026-05-29]` **v0.17.0 frontend redesign — aurora telemetry instrument.** `/frontend-design` pass on the web companion: all-monospace technical-instrument aesthetic with the Australis dark palette + aurora-borealis accent band. Top command bar with live connection dot (idle/streaming/error states), inline persona summary with P/A/D micro-bars, animated awaiting-token, terminal-event status chips. **Live Markdown rendering in transcript + thinking panes** via a hand-rolled `markdownSafe()` (escape-first, whitelist subset of headings/bold/italic/inline-code/fenced/lists/quote/links; link-scheme whitelist; XSS-verified under a node harness). Thinking pane now has per-turn labeled dividers + a fresh MD-rendered block per turn. **Tools / Debug / Persona panes stay literal monospace** by deliberate choice — they carry structured audit lines + JSON, where MD would corrupt readability (underscores in tool names, JSON braces). Single-file vanilla HTML/CSS/JS, no build, no CDN, no node_modules.
|
||||
- `[2026-05-29]` **Codex-first discipline pilot — Ratatoskr selected.** brokkr-smithy-dev pushed `AGENTS.md` (commit `bbeaa23`) and declared the `ratatoskr-codex` handle per `brokkr-smithy/docs/codex-first-discipline.md` v0.1 (brokkr-smithy commit `5dd061c`, tag `v0.5.3`). Per-dispatch opt-in model: default Sleipnir Claude-implementer path remains available; Codex used only when operator routes via `/codex-dispatch <N>`. Bootstrap handshake when operator spins up a codex session: codex sends `codex-online` → ratatoskr-dev replies with active branches + WIP state. Galdrabok was rejected as pilot (Codex authoring Claude skills is a category error); Skaldsong was the other candidate. (Still dormant as of 2026-06-18 — no codex session spun up.)
|
||||
|
||||
- `[2026-06-14]` **Ratatoskr becomes the v1 Bifrost Tier-3 consumer.** A second identity beyond the debug TUI: the durable persistence Worldtree writes Tier-3 agent affect (persona) + memory into. Pin `bifrost>=0.6.1` in a `provider` optional-extra (gitea PyPI index, auth via `~/.netrc`; 0.6.0 was yanked for a circular import). Implement bifrost's OWN `MemoryDataStore`/affect Protocols (NOT worldtree-memory's); `describe_store` is SYNC; affect is conduit-opaque. New module `src/ratatoskr/provider/`. Authoritative how-to: `~/development/bifrost/docs/implementing-a-consumer.md`. (commits `1a73d77` pin, `d90a58d` affect store v0.17.1, `bcdcd71` serve entrypoint v0.17.2)
|
||||
_Archived 2026-07-17._
|
||||
|
||||
- `[2026-06-14]` **Backend = SQLite + sqlite-vec; affect-first then memory; separate DB per plane** (operator-chosen). Affect = blind conduit (reads only `agent_id`+`end_user_id`); memory = structural index (reads vector/scope/id/origin to serve search). Conformance for both = #195 parity vs bifrost's `InMemory*Store` through the real `dispatch_*_call`.
|
||||
_Archived 2026-07-17._
|
||||
|
||||
- `[2026-06-14]` **The affect contract's idempotency model was WRONG; real-lib TDD caught it.** First draft modeled same-idempotency-key-different-payload as an LWW overwrite; bifrost actually raises a CONFLICT (`AffectIdempotencyConflict`), actor-scoped. The artifact-only `/heid-contract-review` STRUCTURALLY cannot catch this class (it never sees bifrost's source) — TDD against the shipped library is the gate; the executable reference store + #195 parity are the backstop. Filed the guide §6 gap to bifrost-dev, who fixed it (bifrost `c0d0a11`).
|
||||
_Archived 2026-07-17._
|
||||
|
||||
- `[2026-06-15]` **Memory v1 = the bifrost BASIC plane only** (search/get/upsert/delete + describe_store/health) per worldtree-dev re-scope (#294) — the only surface Tier-3's live path touches; gated verbs (edges/scan/atomic_supersede/mark/patch/maintenance) deferred + advertised-unsupported. Worldtree v0.35.3 already requests+maps it — no Worldtree-side blocker. Memory contract committed v1.0 (`eebab46`) → v1.1 Heid-reviewed (`1f94e5f`).
|
||||
_Archived 2026-07-17._
|
||||
|
||||
- `[2026-06-15]` **Providers run as dev-box BACKGROUND SHELLS, not infra-ops/systemd** (operator call — it's a dev box). `ratatoskr-provider` (affect) + `ratatoskr-memory-provider` as background processes; no productionization track.
|
||||
_Archived 2026-07-17._
|
||||
|
||||
|
||||
The 2026-06-14 → 2026-06-18 cluster: the Bifrost-provider second-identity build era (#17/#18 self-drive+observe, #295/#296 cold-recall diagnosis, agent_self lattice). Archived 2026-07-18.
|
||||
|
||||
`[2026-06-15]` **Affect plane shipped (v0.17.2) + LIVE-PROVEN end-to-end against real Worldtree v0.35.2.** Personal handshake 200 + `affect.emit` 200 from `10.250.50.152` → durable row persisted (opacity held). HS256 key = the consumer's Heimdall API-key STRING utf-8-encoded (NOT base64/raw — the tripwire); cross-subnet route + `BIFROST_CLIENT_ALLOWED_HOSTS` allowlist all held (infra-ops-owned). worldtree-dev confirmed ADR-0009 holding as designed.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-16]` **#295 cold-recall miss root-caused — UPSTREAM, scope-axis asymmetry.** A self-driven bound cold-recall probe captured the inbound pair via the observe log: Worldtree's recall filter carries `{end_user, agent_self}`; our chunks were `{end_user}`-only; AND-matching dropped everything on `agent_self` → 0 hits. Our store + search are SOUND; fix is Worldtree-side. F2 (question-promotion) → **#296**; F1 (recall-miss) → **#297**.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-16]` **agent_self → make it CANONICAL (operator decided A).** bifrost's reference lattice was `{end_user, group, tenant}` only (agent_self → `invalid_filter` 400); Worldtree emits agent_self (#248). Operator chose canonical-not-re-expressed; worldtree-dev filed the lattice-addition with bifrost-dev. Implication: our store's permissive axis-acceptance becomes CORRECT once bifrost adds agent_self.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-16]` **Self-drive auth identity: bound session-create uses the CONSUMER Heimdall key as bearer, NOT `WORLDTREE_API_KEY`.** Worldtree signs the Bifrost handshake JWT with the session-create bearer (canary key → handshake 401; consumer key → 200). Two keys, two identities. Proven by hand; documented in `docs/bifrost-self-test.md`; load-bearing for #17's Bind half.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-16]` **Issue #17 v1 scope locked (operator 1A/2A): single-plane bind + dispatch-layer op-feed.** `BifrostBindingRequest` is one `endpoint_url` (one plane per session); composite-both-planes endpoint PARKED (→ now #18). Observe = structured op-feed at the DISPATCH layer (bifrost passes ctx to upsert_many but NOT search/get/delete — `memory.py:244`), session-level correlation; turn-correlated pane UI PARKED. Contract `docs/contracts/issues/17.contract.md` written + `/heid`-reviewed.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-16]` **agent_self lattice SHIPPED both sides → our axis-validation gap CLOSED (v0.17.5).** bifrost 0.7.0 / wire v0.5 adds agent_self to `{end_user,group,tenant,agent_self}` (#10, driven by our foot-gun flag); Worldtree pinned 0.7.0 (v0.35.11). We DID add `_validate_scope_filter` (4-axis) to match the reference (purely additive; out-of-lattice → InvalidFilter).
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[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. The reference now does OR via `scope_any` (a NEW field — additive split, not a flip of AND). Store / contract (v1.2) / tests at parity with the v0.6 reference; provider bounced onto 0.8.0 with a wiped DB. Shipped v0.17.6 (`96d61a4`). **(SUPERSEDED the earlier "do NOT flip `_scope_matches` to OR" note.)**
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-17]` **Worldtree spec pin bumped v0.29.0→v0.35.16 (`562001a`→`f1b59f8`); cold recall closed on the WIRE.** Worldtree shipped #297 (client-side per-scope-value union recall) + #298/#299 (adopt the bifrost v0.6 `scope_any`/`scope_all` wire) — emits `scope_any` on recall, pairing with our v0.17.6 provider. Re-vendored the spec; diff-reviewed the 285-commit catch-up — no client-breaking changes. `pin:`-only commit, no bump.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-17]` **End-to-end cold-recall proof RAN — our stack proven, #296 isolated.** Against personal WT v0.35.16 with restored `ratatoskr:sindra`: #297/#298 union recall, write path, and cold read ALL proven. Lone gap = upstream #296 extraction quality (the WIRE closed; fact-recall was #296-blocked).
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-17]` **DELETE+redefine `ratatoskr:sindra` (operator-authorized; pre-v1 debug surface).** She SURVIVED the rebuild but was STALE (dead model + no memory block); memory is immutable post-define, so DELETE+redefine was the only path. v0.35.16 define takes **`role`** (capability), NOT `model`: `role:"character"` → first-healthy bind `mistral-small-4`; `memory:{}` trips the promotion gate (GET does NOT echo `memory_config`). Our `tier3.py` define is Phase-2.0-stale — untracked modernization follow-up.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-17]` **Promotion = 4-trigger hybrid (worldtree-dev, code-grounded):** salience (regex, 90s rate-limit) / `turn_count≥6` / context_pressure / **idle `≥10min` (unconditional on quality)**; per-turn `plan_promotion_run` for consumer_defined. **DELETE does NOT drain/promote** (delete-is-delete, #276) — idle `≥10min` is the deterministic flush.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-18]` **#296 Stages 1+2 closed.** Stage 1 (v0.35.19, recallability admission gate) validated live for normal turns; bisect localized the residual to verbose-persona VOLUME crowd-out. Stage 2 (v0.36.0, MERGED at worldtree-codex) = user-only one-call-per-turn extraction, the STRUCTURAL fix; hard-linguistic layer → Worldtree #305 (we handed over a live-validated eval fixture PAIR). Full-coverage re-smoke: verbose `sindra-probe` promoted the fact cleanly + cold-recalled @ 0.694 under v0.36.0.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-18]` **#17 implemented end-to-end via direct in-session TDD** (6 patch bumps `v0.17.8`→`v0.17.13`, suite 470 green). Slice order: bind primitive → op-feed → CLI → TUI → web(server) → web(UI). Tests drive the REAL bifrost dispatch via minted JWTs (`bifrost.core.dispatch_jwt.mint_dispatch_jwt`) — the "test against the shipped lib" posture, not hand-mocked envelopes. Op-feed reads `session_id` off the dispatch JWT `sub` claim (the contract open-q, resolved YES at the ASGI layer where the JWT is always present — `bifrost.reference_server._dispatch_auth.DispatchContext.session_id = payload["sub"]`). bifrost wire facts captured in-code: memory envelope `{operation, args}`→`memory_result(**payload)`={success,...}; verbs bare (search/upsert_many/get/get_many/delete_many); affect `{operation:"affect.emit"}`→`{success,stored}`; error envelope `{code, message}`; scopes `memory:read|write`.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-18]` **#17 live-smoke PROVEN — the whole thesis validated.** A self-driven bound CLI session showed, from the PROVIDER side, exactly which memory ops a turn produced (2 recall searches, exact bound session_id, real union-recall scopes). Negative (canary→auth_rejected) NOT live-constructible (Tier-1 agents aren't memory-bindable; a wrong key for an owner-scoped agent fails at agent-auth before the handshake) — covered by the unit test + prior hand-proof.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-18]` **Fixed a pre-existing test-isolation bug exposed by the #17 CLI tests** (`0bebad7`): `test_no_textual_import` did a live `importlib.reload(ratatoskr.cli)` that mutated the shared module in place, breaking class identity (`isinstance`/`pytest.raises`) for every test ordered after it. The real check is the static source-grep; the reload was vestigial → removed. Lesson: never `importlib.reload` a shared module in a test without restoring it.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-18]` **#18 filed (composite endpoint + PAD read-endpoint) — DEFERRED, tracked at Gitea #18.** Two pieces: (1) a composite Bifrost facade (new port e.g. `:8392`) fronting BOTH `:8390`+`:8391` advertising both caps at handshake → one session binds both planes (un-parks the #17 open-q; bifrost reference_server already mounts both planes in one app → thin combined builder; needs per-plane failure-status + the op-feed deriving plane PER-REQUEST from the path instead of its fixed `plane` param). (2) a non-bifrost PAD read-endpoint on the affect provider (recommended over web-reads-`affect.db`-directly) → web persona pane renders PAD/valence from OUR `:8390` store. **Composite half APPROVED by operator ("A is correct"); contract-first next.** **Persona-telemetry diagnosis (verified):** affect bind persists PAD (vuong: pleasure +0.146, familiarity 0.18→0.59 over 8 turns) but the pane reads Tier-3-404 `persona_state` AND Tier-3 emits ZERO `affect_update` SSE (wire-verified) — both WT sources dead, so #18's PAD-display half is the only path. `affect.fetch` over bifrost is RESERVED/blocked but irrelevant (we own the store). Proposed: fast-track the PAD-display half now (awaiting operator go), keep composite contract-first.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-18]` **#18 SPLIT; Deliverable 1 (composite) routed to bifrost — Option C (operator).** D2 (PAD read-endpoint, our-side only) fast-tracked; D1 (composite `:8392` endpoint) routed to bifrost-dev to add a PUBLIC `build_combined_app` rather than hand-roll one from bifrost privates — because ratatoskr is a debug surface that must exercise the CANONICAL surface ("don't go off the reservation"). The Heid framing-panel had unanimously recommended hand-rolling (Option B) — DISCARDED as wrong-grounded (the panel lacked the canonical-surface principle; their own finding that B reaches external/underscore-private names actually vindicated C). bifrost-dev confirmed: clean additive minor (~`v0.9.0`), design locked (advertise-by-store-PRESENCE handshake — no health probe; per-route call-time isolation within a shared ASGI process), slotted after WT #289. [principle → auto-memory `feedback-debug-surface-uses-canonical-surface-only`]
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-18]` **FR-1 RESOLVED — the composite premise was unverified, now wire-proven: single-endpoint, caps-routed.** The Heid panel's sharpest catch (Regin): "advertise both caps → Worldtree dispatches both planes to one endpoint" was an ASSUMPTION about WT dispatch, stated as fact. worldtree-dev verified IN CODE: one `BifrostClient` per session (single `_endpoint_url`), handshake `capabilities_granted` parsed INDEPENDENTLY into memory+affect sets, both stores attach off the SAME endpoint iff their cap was granted (`service.py:2597/2703-2713/2745-2751`, `bifrost_client.py ~357-369`; tests `test_tier3_bifrost_{memory,affect}_routing.py`). So D1 is **bifrost-only, ZERO Worldtree change** — #18's "no WT change needed" assumption was correct.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-18]` **#18 D2 implemented via direct in-session TDD (suite 470→482).** Provider read route `GET /affect/state/{agent_id}` added via `app.add_route` (NOT an outer `Mount` — keeps `/bifrost/*` top-level so the existing route test + the op-feed path-check stay valid); web `GET /api/affect/{agent_id}` proxy (server-supplied `end_user_id`, colon-id `quote()`'d, `RATATOSKR_AFFECT_READ_URL`); pane renders the affect-emit shape honestly. Contract `docs/contracts/issues/18.contract.md` (D2-scoped; D1 deferred). **heid-code-review panel (Gróa 5 / Hulda 3 / Regin 0): 1 real INV-001 drift + 4 test-gaps, all fixed.** No contract amendments (code was wrong, contract was right).
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
## Tried and abandoned (archived)
|
||||
|
||||
The 2026-05-20 → 2026-05-28 cluster: original-build-era foot-guns. Archived 2026-06-18.
|
||||
@@ -70,3 +164,69 @@ The 2026-05-20 → 2026-05-28 cluster: original-build-era foot-guns. Archived 20
|
||||
- `[2026-05-27]` **`get_persona_state` mocked flat error envelope; real Worldtree wraps in `detail`.** v0.12.0 tests used `{"error_code": "auth_scope_denied"}` but real wire (FastAPI default) returns `{"detail": {"error_code": "auth_scope_denied", "message": "…"}}`. The parser only checked top-level so the typed exception was never raised; calls fell through to `SessionApiFailed(403)`, which the web persona endpoint surfaced as HTTP 500. v0.15.1 patches both shapes. **Lesson: test-side mock envelopes must match the REAL wire shape; live smoke is load-bearing for envelope-shape verification, not just happy paths.**
|
||||
- `[2026-05-27]` **Mid-session `system_prompt` mutation: universal omission across surveyed mature systems.** brokkr-smithy R13 panel (3-arm, strong convergence) confirmed: no surveyed system ships live PATCH-on-active-session (OpenAI Assistants/Responses, Anthropic Messages, Vertex AI, MCP, LangChain, LlamaIndex, Ollama, vLLM). The omission IS the answer. 12 additional threat vectors beyond ratatoskr's initial 7. **Don't re-propose this for ratatoskr;** if a future production conversational shell wants iterative-prompt-tuning ergonomics, the consensus shape is fork-via-client (PATCH agent → new session → replay context).
|
||||
- `[2026-05-28]` **Browser-local turn_id used for upstream cancel URL — old cancel tests ENCODED the bug.** Web companion v0.15.x cancel paths posted to `/sessions/{sid}/turns/{LOCAL_ID}/cancel`. Tests mocked the local-id URL so they encoded the bug rather than detecting it. Hulda caught it in Heid pass 1. Fix in v0.16.0: capture upstream_turn_id from the first SSE event's `sse_id.turn_id`; all cancel paths use it; cancel before first event is `{"cancelled": false, "reason": "not_started"}`. **Rule: when designing cancel/match paths against an external service, test fixtures must mock what would actually be hit upstream — mocking your own derived id encodes the bug instead of catching it.**
|
||||
|
||||
The 2026-06-14 → 2026-06-18 cluster (foot-guns from the same era). Archived 2026-07-18.
|
||||
|
||||
`[2026-06-14]` **Artifact-only contract review can't validate against a dependency's ACTUAL behavior.** `/heid-contract-review` sees only the contract, never the external library (bifrost) — so "the consumer under-built against bifrost's real semantics" is invisible to it by construction (the affect idempotency model shipped wrong because of this). Real-lib TDD against the shipped library + the executable reference store + the #195 parity test are the gate. Don't treat a clean contract review as evidence the code matches the dependency.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-15]` **"Sindra hasn't been registered" was an under-verified inference — WRONG.** Concluded it from grepping ratatoskr's CODE (`sindra` absent from `src/`), but Tier-3 registration is SERVER-SIDE (`POST /agents/define`) — a code grep structurally can't see it. **Rule: to check whether a Tier-3 agent exists, query the Worldtree instance, never the consumer repo's code.** (Extended 2026-06-17: even `GET /agents` can't see consumer agents; only `GET /agents/<owner>:<name>` with the owner key does.)
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-15]` **"byte-equal" round-trip slip propagated affect→memory via copy-paste.** The affect contract's byte-identical→semantic fix reappeared in the memory contract's INV-001 (sibling copy). Only an INDEPENDENT `/heid-contract-review` of the memory contract re-caught it. **Paraphrase every sibling contract fresh — don't amortize one review across a family; copies carry the parent's slips.** (also a feedback auto-memory)
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-15]` **Memory plane TDD'd + shipped** (commit `cd12951`, v0.17.3). Impl decisions worth keeping: vec0 `distance_metric=cosine` at table creation (`score = 1 − distance`); `search` over-fetches ALL candidates by cosine then scope-filters in Python so `top_k` counts IN-SCOPE hits; idempotency_id = reference 4-tuple `("default",verb,_ctx_actor(ctx),key)` pipe-joined as the SQLite PK, digest = sha256 canonical-JSON; `_ctx_actor` = `job_id|jwt_sub|session_id`. heid-code-review returned zero true drift; optimistic-lock semantics pinned to the reference via an `expected_revisions` parity test.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-15]` **Memory provider LIVE-PROVEN against personal v0.35.3; recall-injection is upstream.** worldtree-dev's Tier-3 promotion recipe: memory-call fires from Tier-3 PROMOTION, gated at `service.py:2623` on `ctx.kind=="consumer_defined"` AND `ctx.memory_config is not None` (agent DEFINED WITH a `memory` block, dim 1024) AND handshake-granted memory caps AND `embedding_dim==1024`. Binding = `POST /sessions BifrostBindingRequest{endpoint_url}`, handshake `caps=["affect","memory"]`, **`binding.scope` null** (per-op scopes auto-minted). A `BIFROST_CLIENT_ALLOWED_HOSTS` allowlist gates the endpoint (infra-ops added `:8391`). HTTP + HS256 both work in dev.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-15]` **Diagnostic: our recall-search is SOUND — the cross-session recall gap is UPSTREAM, and it caught an upstream bug.** Embedded the recall query via gateway `qwen3-embedding` + searched our live store directly → the fact recalls at cosine 0.60, correctly ranked. So the cold-session recall failure is Worldtree's recall-assembly/injection, NOT our search. ALSO found a latent UPSTREAM bug: a recall QUESTION got promoted as a durable chunk and ranked #1. **This is exactly #17's thesis — ratatoskr-as-provider caught an upstream bug invisible from the chat side.**
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-15]` **"Wire 200 ≠ recall works" — prove recall efficacy at the model's answer in a COLD (history-free) session, not on the wire.** A `search`/memory-call returns 200 whether or not its results are injected, and same-session "recall" can be plain session history. Don't call cross-session recall proven from a clean wire.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-16]` **My #295 coupling hypothesis (the promoted question crowds out the fact at small top_k) was REFUTED** — worldtree-dev's recall over-fetches `top_k=128`, so the question can't crowd the fact out at search level. The real cause was the scope-axis asymmetry. Lesson: offer provider-side hypotheses, let the upstream owner check them against their code.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-16]` **#17 contract drifted from its own design in two spots, caught only by `/heid-contract-review` (not same-author paraphrase):** the `OpEvent` dataclass omitted the `turn_id` INV-005 promised; a `session_id` comment contradicted the dispatch-layer design. Cross-model paraphrase is load-bearing for catching an author's own contract-vs-intent drift.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-16]` **"No promotion" was checked TOO EARLY — Tier-3 promotion is ASYNC (lands AFTER the SSE turn-end).** Don't trust an immediate post-turn fixture snapshot to judge promotion; it lands after the turn completes. (The reason #17's contract pins a post-turn grace window + fixture before/after assertion.)
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-17]` **"sindra is GONE" (infra-ops, from `GET /agents` + admin token) was a FALSE NEGATIVE.** Consumer-defined Tier-3 agents are OWNER-SCOPED (separate `consumer_agents` table) — invisible to the foundational `GET /agents` roster even with an admin token. To check, `GET /agents/<owner>:<name>` with the OWNER key.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-17]` **"Promotion didn't fire → #296" was PREMATURE — twice over.** (1) Polled the op-feed only ~2min, but the upsert landed at ~4min — promotion is async + multi-trigger; watch a longer window. (2) It DID fire; the real bug is extraction QUALITY, not non-firing. "No upsert while a session is live and `<10min` idle" is WAD.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-18]` **Wiping our `:8391` store does NOT reset Worldtree's promotion-side dedup** — a same-agent re-smoke returned `reason_code=noop_duplicate` / `candidate_count=0`: the extractor NEVER RE-RAN, dedup short-circuited against an earlier promotion. **For a clean promotion smoke, use a BRAND-NEW agent + end_user (never-used names).** (Also: `llm_calls_used=0` is NOT the "did the extractor run" tell — `noop_duplicate` is.)
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-18]` **`affect.emit` is POST-TURN ASYNC — checking the op-feed immediately after a turn MISSES it.** The Tier-3 affect appraise→emit→rehydrate loop runs AFTER the SSE `[done]`; the emit lands in our `:8390` store seconds later (op-feed grep right after `[done]` showed only the handshake; the `emit stored:true` appeared on a later read). Same family as the async-promotion timing trap. Watch a few-second window post-turn before concluding "no affect emitted." Also wire-verified the same turn: Tier-3 sindra emits ZERO `affect_update` SSE (the persona-strip SSE path never populates for consumer agents) — see the #18 PAD-display decision.
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-18]` **Rationalized away a KNOWN contract-invariant deviation during TDD — only the cross-model code-review caught it.** #18 D2's `loadAffect` called `setPersonaStrip(snap)`, which renders `dominant_emotion || "neutral"`; the affect snapshot has no `dominant_emotion`, so it fabricated a "neutral" emotion — violating the very INV-001 ("no synthesized Tier-1 fields") I had WRITTEN. I knew the strip did this and talked myself into it as acceptable. Neither the design panel nor TDD caught it (unit tests don't exercise the JS render); the post-implementation `/heid-code-review` did (Gróa + Hulda both). **Lesson: a known deviation from a contract invariant is drift even when you've rationalized it — flag it, don't argue yourself past it; the post-implementation cross-model review is the backstop for author-rationalized drift, distinct from the design-stage panel.**
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
`[2026-06-18]` **Latent SQLite thread-safety bug in the affect store, surfaced ONLY by the new HTTP read route.** `open_affect_store` created the connection without `check_same_thread=False`; the bifrost emit path never tripped it (uvicorn's loop ran on the connection's creating thread), but the `TestClient`-driven read route runs handlers off a worker thread → `sqlite3.ProgrammingError`. Fix: `check_same_thread=False` (safe — the event loop serializes access) + explicit `PRAGMA busy_timeout=5000` (don't rely on sqlite3's `timeout=5.0` default). **Lesson: a sqlite-backed ASGI app needs `check_same_thread=False`; the HTTP-layer test exposed what the direct-store-method tests structurally couldn't.**
|
||||
|
||||
_Archived 2026-07-18._
|
||||
|
||||
@@ -116,9 +116,17 @@ interpreted.
|
||||
to the reference `_matches_scope`. (`scope_any` is the union-visibility primitive that
|
||||
resolves the #295/#297 silent-zero — a subset-scoped chunk now recalls via an OR member.)
|
||||
- **INV-006** [hard]: **Capabilities match implementation** (advertise-⇒-implement).
|
||||
`describe_store` advertises ONLY what v1 implements: `relational_edges_supported=False`,
|
||||
`describe_store` advertises ONLY what is implemented: `relational_edges_supported=False`,
|
||||
`atomic_supersede_supported=False`, `transaction_supported=False`,
|
||||
`optimistic_locking_supported=True`, `filterable_metadata_fields=[]`.
|
||||
`optimistic_locking_supported=True`, `filterable_metadata_fields=[]`,
|
||||
**`sortable_chunk_fields=[{"name": "updated_at", "type": "timestamp"}]`** (the ONLY
|
||||
globally-sortable field; gates `scan`'s sort at the bifrost dispatch `_validate_scan_sort`
|
||||
AND Worldtree's #349 person-prime Branch-A `"updated_at" in caps.sort_fields_supported` —
|
||||
advertising it is what lights up turn-1 durable-fact injection). Both `name` AND `type`
|
||||
are REQUIRED by the bifrost `handshake_response` `SortableChunkField` schema
|
||||
(`additionalProperties:false`) — omitting `type` fails wire-schema validation and breaks
|
||||
the ENTIRE handshake (memory + affect bind), not just the sort; `type` is advisory-only
|
||||
(the wire never interprets it).
|
||||
(`transaction_supported` is the bifrost **wire-level** multi-op transaction
|
||||
capability — NOT our internal SQLite transactions, which we use for atomic
|
||||
batches.) The client gates the gated verbs off these.
|
||||
@@ -127,6 +135,41 @@ interpreted.
|
||||
`InvalidArguments` (mirrors the reference).
|
||||
- **INV-008** [hard]: The store is REQUIRED (`build_memory_app(store=None)` raises);
|
||||
identity/scope/actor come from `ctx`, never call args.
|
||||
- **INV-009** [hard]: **`scan` is LIVE-only.** `scan` returns ONLY live chunks —
|
||||
superseded / tombstoned / any non-live governance state is EXCLUDED server-side. This
|
||||
is load-bearing because Worldtree's person-prime requests `lifecycle_state="live"` but
|
||||
that filter does NOT ride the scan wire today and the client does not re-check it
|
||||
(worldtree-dev flagged the adapter gap); server-side live-only is authoritative, so a
|
||||
dead fact can never inject. The additive `lifecycle_state` scan arg, when present, is
|
||||
honored but never relied upon.
|
||||
- **INV-010** [hard]: **`scan` is globally ordered before pagination.** The FULL
|
||||
scope-filtered live set is ordered by `(sort.field, direction)` GLOBALLY before the
|
||||
`limit` page is taken — never page-local. Missing sort value sorts LAST; ties broken by
|
||||
`chunk_id` (stable). A single `limit`-page returns the N globally-newest (for
|
||||
`updated_at desc`), matching bifrost's cross-pagination conformance negative. The sort
|
||||
field is indexed (`json_extract(record_json, '$.updated_at')`) so the read stays within
|
||||
person-prime's 500 ms fail-open budget.
|
||||
- **Cursor is v1-provisional (KNOWN DEVIATION — offset, not snapshot).** The cursor is a
|
||||
bare integer offset into the re-derived global order. This is CORRECT and conformant for
|
||||
the **single-page** person-prime call (`cursor=None`), which is the only shipped consumer.
|
||||
It **diverges from bifrost's protocol snapshot-cursor contract on multi-page continuation**:
|
||||
the dispatch engine (`bifrost.memory` scan branch) drops the `sort` arg on a cursor
|
||||
continuation because "the cursor's snapshotted order is authoritative", and maps
|
||||
`ScanCursorExpired → 410`. Our offset cursor (a) does NOT snapshot the order — a page taken
|
||||
after a concurrent write can duplicate/drop rows relative to the first page (heid-bug-hunt
|
||||
2026-07-15, all 3 arms), and (b) never raises `ScanCursorExpired`. The `global_before_paginate`
|
||||
/ cursor test asserts **static-store** behavior only. The durable/conformant fix is to adopt
|
||||
the reference `InMemoryMemoryStore`'s snapshot-cursor semantics (opaque token + frozen ordered
|
||||
id-list + TTL + `ScanCursorExpired`); DEFERRED pending bifrost-dev's ruling on the conformance
|
||||
gap (scan/cursor has NO conformance coverage today, so a non-snapshot cursor passes). Routed
|
||||
to bifrost-dev 2026-07-15.
|
||||
- **INV-011** [hard]: **`mark_superseded` retires via a top-level `superseded` flag; `_is_live`
|
||||
recognizes it.** `mark_superseded` sets top-level `superseded=True` (+ `superseded_by`) on the
|
||||
record, mirroring the reference `_mark_lifecycle` (NOT a `verbatim.governance_state` change). So
|
||||
`_is_live` MUST short-circuit on `record.get("superseded") is True` (in addition to its existing
|
||||
`lifecycle_state` / `verbatim.governance_state` checks) — else a #364-retired chunk would still
|
||||
scan live. Retirement is NON-destructive: `get`/`get_many` still return superseded chunks
|
||||
(recoverable). `search` is NOT filtered (matches the reference; WT re-checks liveness client-side).
|
||||
|
||||
## Concurrency
|
||||
|
||||
@@ -163,7 +206,7 @@ negotiation, routes). **This contract** owns the store (the basic verbs + SQLite
|
||||
|
||||
## Out of scope (deferred — do NOT flag as drift)
|
||||
|
||||
- **Gated/maintenance verbs:** `upsert_edges`/`get_edges_for`, `scan`, `mark_invalid`/`mark_superseded`, `patch_many`, `atomic_supersede`, lease/checkpoint. Absent + advertised-unsupported.
|
||||
- **Gated/maintenance verbs:** `upsert_edges`/`get_edges_for`, `mark_invalid`, `patch_many`, `atomic_supersede`, lease/checkpoint. Absent (no describe_store cap; hasattr-gated at dispatch as of bifrost 1.1.4 → `unsupported_capability` 400). (`scan` and `mark_superseded` are NO LONGER deferred — `scan` implements #349 person-prime; `mark_superseded` implements Worldtree #364's contradiction retirement, the SOLE supersession verb #364 uses. See their FN specs + INV-009/INV-011.)
|
||||
- **metadata_filter beyond scope:** advertise `filterable_metadata_fields=[]`; a non-empty `metadata_filter` is unsupported in v1 (rejected — see search PRE).
|
||||
- **The combined two-plane server** (guide §7) — separate memory + affect apps in v1.
|
||||
- **Deployment** — dev-box background shell (`ratatoskr-memory-provider`), no systemd/infra.
|
||||
@@ -270,6 +313,47 @@ TESTS:
|
||||
delete_absent [boundary]: unknown id → {"deleted":0}
|
||||
```
|
||||
|
||||
```contract
|
||||
FN mark_superseded(self, ids: list[str], *, superseded_by: str | None = None, reason: str | None = None) -> dict
|
||||
BRIEF: Worldtree #364 retirement — mark chunks superseded so scan (live-only) excludes them. Mirrors the reference _mark_lifecycle: sets TOP-LEVEL fields on the record; NON-destructive (get still returns them, recoverable). The SOLE supersession verb #364 uses (dispatch: bifrost/memory.py mark_superseded branch; args {ids:[...], superseded_by, reason}).
|
||||
PRE: [PRE-001 hard] ids is a list of chunk ids (WT sends singletons, one call per retired chunk)
|
||||
POST: [POST-001 return_value] {"marked": N} where N = ids that existed (unknown ids skipped, never error) -- assert
|
||||
POST: [POST-002 state_change] each existing chunk gets top-level `superseded=True` + `superseded_by` (when not None) + `superseded_reason` (when not None); revision incremented; mirrors reference _mark_lifecycle (only non-None fields written) -- assert
|
||||
POST: [POST-003 return_value] a superseded chunk is EXCLUDED from `scan` (INV-009 via _is_live's top-level `superseded` check, INV-011) but STILL returned by `get`/`get_many` (non-destructive) -- assert
|
||||
STEPS:
|
||||
1. [sequential, flexibility=indicative] FOR each id present: load record_json, set superseded=True (+ superseded_by / superseded_reason when not None), UPDATE record_json + revision+1; count
|
||||
2. [cleanup] RETURN {"marked": count}
|
||||
TESTS:
|
||||
mark_retires_from_scan [happy,tracer]: upsert 3 live; mark_superseded([id2], superseded_by="x"); scan → the 2 non-superseded only (id2 excluded); id2 record has superseded=True + superseded_by="x"
|
||||
mark_get_still_returns [scenario]: a superseded chunk is STILL returned by get (non-destructive/recoverable)
|
||||
mark_unknown_id_noop [boundary]: mark_superseded(["nope"]) → {"marked":0}
|
||||
mark_no_superseded_by [boundary]: mark_superseded([id], superseded_by=None) → superseded=True set, no superseded_by key written (only non-None fields)
|
||||
mark_parity_vs_reference [scenario]: identical mark_superseded envelope vs InMemoryMemoryStore → same top-level superseded/superseded_by field shape (#195)
|
||||
```
|
||||
|
||||
```contract
|
||||
FN scan(self, *, scope_all: dict | None = None, scope_any: list | None = None, cursor: str | None = None, limit: int, sort: dict | None = None, lifecycle_state=None) -> dict
|
||||
BRIEF: Query-LESS paginated LIVE-chunk scan, globally ordered by an advertised sort field (updated_at) — the #349 person-prime turn-1 durable-fact injection primitive (no query vector, unlike search). Returns {records, cursor}.
|
||||
PRE: [PRE-001 hard] limit is a positive int -- else InvalidArguments
|
||||
PRE: [PRE-002 hard] scope_all/scope_any shape + lattice-validated via _validate_scope (identical to search PRE-003) -- else InvalidArguments / InvalidFilter
|
||||
PRE: [PRE-003 hard] sort, when present, is {field, direction}: field ∈ the advertised sortable_chunk_fields names ("updated_at"), direction ∈ {asc,desc}. The bifrost dispatch layer (_validate_scan_sort) is the enforcement gate; an unadvertised/malformed sort → InvalidArguments — NEVER a silent unsorted fallback
|
||||
POST: [POST-001 return_value] {records: [<verbatim chunk wire records, same shape as a search hit's chunk>], cursor: <opaque next-page str | None>}; ≤ limit records; each record carries updated_at + agent_id + subject{type,id} + worldtree_scope (the fields person-prime's client _scan_filter_matches keys on — a record missing any is silently dropped client-side) -- assert
|
||||
POST: [POST-002 return_value] LIVE-only — returns ONLY live chunks; superseded/tombstoned excluded server-side (INV-009)
|
||||
POST: [POST-003 return_value] GLOBAL-order — the FULL scope-filtered live set is ordered by (sort.field, direction) GLOBALLY before the limit page; missing value LAST; chunk_id tiebreak (INV-010)
|
||||
STEPS:
|
||||
1. [setup] validate limit (>0) + scope (as search); sort ← the dispatch-validated {field,direction}
|
||||
2. [sequential, flexibility=indicative] SELECT scope-filtered LIVE chunks ordered by the indexed sort field (json_extract(record_json,'$.updated_at')) in `direction`, missing-last, chunk_id tiebreak, GLOBALLY; apply cursor offset; take limit
|
||||
3. [cleanup] RETURN {records: verbatim chunks, cursor: next-page-or-None}
|
||||
TESTS:
|
||||
scan_recency [happy,tracer]: upsert 4 live chunks w/ distinct updated_at; scan(scope_all={end_user}, limit=3, sort={field:updated_at,direction:desc}) → the 3 newest, newest-first
|
||||
global_before_paginate [scenario]: 5 chunks, limit=2 → page-1 = the 2 globally-newest; the cursor page continues the GLOBAL order, not a page-local re-sort (INV-010; bifrost cross-pagination conformance)
|
||||
live_only [adversarial]: a superseded/tombstoned chunk is NEVER returned even if it is the newest (INV-009)
|
||||
scope_isolation [adversarial]: scope_all one end_user → never returns another partition's chunk (INV-005 applies to scan)
|
||||
unadvertised_sort [adversarial]: sort.field ∉ sortable_chunk_fields → InvalidArguments at dispatch (never silent unsorted)
|
||||
person_prime_record_shape [scenario]: each record carries agent_id + subject{type,id} + worldtree_scope + updated_at + verbatim/distillate — the _scan_filter_matches keys (else the client silently drops it)
|
||||
parity_vs_reference [scenario]: identical scan envelopes vs InMemoryMemoryStore → same ordered chunk_ids/shape (#195)
|
||||
```
|
||||
|
||||
```contract
|
||||
FN build_memory_provider_app(store: RatatoskrMemoryStore, heimdall_key: bytes, consumer_id: str = "ratatoskr") -> Starlette
|
||||
BRIEF: Wire JwtVerifier + registration; hand the store to bifrost's build_memory_app.
|
||||
|
||||
@@ -345,3 +345,48 @@ TESTS:
|
||||
buffer_expired_propagates [error]: attempt 1 drops after 42:1; reconnect returns 412 → ResumeBufferExpired propagates (not retried as a transient drop).
|
||||
zero_budget_no_resume [adversarial]: max_reconnects=0; attempt 1 drops after 42:1 → SseConnectionDropped propagates immediately (no reconnect attempted).
|
||||
```
|
||||
|
||||
## Amendment 2026-07-18 — event-vocab catch-up: `awaiting_llm_first_token` + `affect_update` (contract-vs-code drift)
|
||||
|
||||
The §Data flow Output union and the `full_event_vocab` TEST (FN `stream_turn`)
|
||||
were frozen at the v0.19.0 baseline's 9-variant `Event` union and 8-event
|
||||
happy-path vocab. Two SSE events were added to the wire AFTER that baseline and
|
||||
are parsed by `_envelope_for_type` today, but their TESTS landed in the
|
||||
*consuming* contracts (cli #3, tui #4, affect web-proxy #18) and in
|
||||
`tests/test_sse_client.py` — never in this contract, so #1 under-described its own
|
||||
module. Surfaced during the Worldtree #371 SDK parity-matrix pass (worldtree-dev
|
||||
confirmed the gap: `full_event_vocab` stops at the 8-event set). This amendment
|
||||
re-canonicalizes #1 against the code. It is **documentation-only** — the code and
|
||||
its `test_sse_client.py` coverage already exist and pass; no code change, no
|
||||
version bump.
|
||||
|
||||
**Corrected `Event` union** (supersedes the §Data flow Output list — 9 → 11):
|
||||
|
||||
WorkerPhase | Thinking | Text | TextBoundary | ToolStart | ToolResult
|
||||
| Done | Error | Cancelled | AwaitingLlmFirstToken | AffectUpdate
|
||||
|
||||
The two additions (both **non-terminal** — they do NOT satisfy INV-001's terminal
|
||||
requirement; the stream still ends at exactly one `Done`/`Error`/`Cancelled`):
|
||||
|
||||
- `AwaitingLlmFirstToken(sse_id, turn_id, elapsed_ms_since_building_prompt: float)`
|
||||
— SSE `awaiting_llm_first_token` (Worldtree #201, v0.29.0). Heartbeat during the
|
||||
BuildingPrompt→CallingLLM gap (default 5 s); `elapsed_ms_since_building_prompt`
|
||||
is server-authoritative monotonic. First-gap only (INV-201-5) — tool round-trips
|
||||
do not re-fire.
|
||||
- `AffectUpdate(sse_id, status: str, turn_id, snapshot: dict | None)` — SSE
|
||||
`affect_update` (Worldtree #204, v0.28.0). `status="current"` at turn start
|
||||
carries the full persona snapshot; `status="scheduled"` after post-turn appraisal
|
||||
kickoff omits it (`snapshot is None`). Suppressed for persona-disabled agents,
|
||||
Tier-3 consumer-defined agents, and ephemeral sessions.
|
||||
|
||||
Unknown event `type` still raises `ValueError` in `_envelope_for_type` (the
|
||||
base-contract behavior). NOTE for the eventual repin: the #371 SDK pins a
|
||||
deliberate divergence here (D-1 — skip-with-diagnostics-hook instead of raising),
|
||||
which Ratatoskr adopts only when it repins onto the SDK, not before.
|
||||
|
||||
TESTS (already present in `tests/test_sse_client.py` — `TestAwaitingLlmFirstToken`
|
||||
+ `TestAffectUpdate`; mirrored here for contract completeness):
|
||||
single_heartbeat_parsed [tracer]: mock emits one `awaiting_llm_first_token` (turn_id, elapsed_ms_since_building_prompt=5012.3) → consumer yields AwaitingLlmFirstToken carrying both fields + parsed sse_id
|
||||
heartbeat_sequence_monotonic [trace]: mock emits a heartbeat sequence → each yields AwaitingLlmFirstToken; elapsed_ms_since_building_prompt is monotonically non-decreasing across the sequence
|
||||
current_status_parsed_with_snapshot [tracer]: mock emits `affect_update` status="current" with a full snapshot dict → consumer yields AffectUpdate(status="current", snapshot=<verbatim dict>)
|
||||
scheduled_status_parsed_no_snapshot [trace]: mock emits `affect_update` status="scheduled" (no snapshot) → consumer yields AffectUpdate(status="scheduled", snapshot is None)
|
||||
|
||||
@@ -470,3 +470,156 @@ TESTS:
|
||||
not_found_404 [error]: 404 → SessionApiFailed(status=404)
|
||||
empty_session_id [adversarial]: "" → AssertionError; no HTTP issued
|
||||
```
|
||||
|
||||
## Amendment 2026-07-18 — ephemeral-template (Echo) session creation
|
||||
|
||||
**Motivation.** `create_session` could only mint *foundational* sessions
|
||||
(`{"agent_id": <persistent-agent>}`). Attempting to start an **ephemeral
|
||||
template** session — e.g. `agent_id="echo"` — returned `422
|
||||
ephemeral_requires_config` because the request carried no `config`. Ephemeral
|
||||
templates (issue #161: Echo, a blank-slate per-session host) require the consumer
|
||||
to supply a `config` object with the session's `system_prompt` at create time;
|
||||
that config is frozen for the session's lifetime. This amendment threads a
|
||||
`config` passthrough through `create_session`, captures the two new response
|
||||
fields (`kind`, `config`) on `SessionInfo`, and corrects the `get_capabilities`
|
||||
metadata shape.
|
||||
|
||||
**Canonical grounding (role, NOT model).** worldtree-dev confirmed on althing
|
||||
(thread `01KXT976NN91DRBZBPXNZ2BVZR`, 2026-07-18) that the model→role cutover
|
||||
(commit `bb4d551`, "Complete model role cutover", ADR-0012 role-based model
|
||||
access) is canonical NOW on both surfaces:
|
||||
- `GET /capabilities` ephemeral-template metadata keys are **`allowed_roles` /
|
||||
`default_role`** (NOT `allowed_models` / `default_model`).
|
||||
- The create-time selector is **`config.role`** (NOT `config.model`). A non-empty
|
||||
`config.model` **hard-rejects** with `model_not_allowed` (the error code was
|
||||
repurposed to mean "the `model` field itself is not permitted here"). Omitted /
|
||||
null `role` resolves server-side to the template's `default_role` (`"echo"`).
|
||||
- The stored/echoed config snapshot is `{"system_prompt": <str>, "role": <str>}`.
|
||||
|
||||
Ratatoskr therefore stays **canonical-agnostic at the wrapper** (`config` is an
|
||||
opaque passthrough dict) and **role-correct at the CLI** (builds
|
||||
`{"system_prompt": ...}`; never emits `model`). The pinned
|
||||
`docs/conversation-api-spec.md` was re-synced to **v1.1** (worldtree commit
|
||||
`b4a278c`): its echo section now documents `allowed_roles`/`default_role`,
|
||||
`config.role` (omitted → `default_role` "echo"), the repurposed
|
||||
`model_not_allowed` (any non-empty `config.model` hard-rejects), and the new
|
||||
`role_required` error; the frozen OpenAPI is untouched. Empirically
|
||||
confirmed against the live v0.16.2 target: `POST /sessions
|
||||
{"agent_id":"echo","config":{"system_prompt":"..."}}` → `201` with
|
||||
`{"kind":"ephemeral","config":{"system_prompt":"...","role":"echo"}}`.
|
||||
|
||||
### SessionInfo — two new response fields
|
||||
|
||||
`SessionInfo` gains two optional fields, defaulted so every existing
|
||||
construction site and caller is unaffected (both `create_session` and
|
||||
`list_sessions` build `SessionInfo` with keyword args; no positional callers
|
||||
exist):
|
||||
|
||||
- `kind: str | None = None` — `"ephemeral"` for Echo sessions, `"foundational"`
|
||||
for all others. Present on both the create 201 and `GET /sessions` list items
|
||||
(spec §Ephemeral Templates). Captured defensively via `.get("kind")` (None when
|
||||
a pre-cutover server omits it).
|
||||
- `config: dict[str, Any] | None = None` — the frozen ephemeral config
|
||||
(`{"system_prompt", "role"}`) on the create 201; `None` for foundational
|
||||
sessions and (typically) list items. Captured via `.get("config")`.
|
||||
|
||||
- **INV-001 amendment [hard]**: `create_session` additionally populates
|
||||
`kind = body.get("kind")` and `config = body.get("config")` from the 201 body.
|
||||
The five original create-side fields and their fixed list-only defaults
|
||||
(`name=None, archived=False, tags=[]`) are unchanged.
|
||||
- **INV-002 amendment [hard]**: `list_sessions` additionally populates
|
||||
`kind = item.get("kind")` and `config = item.get("config")`. In practice the
|
||||
list endpoint does NOT echo the frozen config, so `config` is `None` for list
|
||||
items today; the `.get("config")` form is deliberate forward-compat — if a
|
||||
future server includes it on list items, it passes through unmodified rather
|
||||
than being force-nulled. (Heid panel 2026-07-18: earlier "stays None" wording
|
||||
over-claimed against the passthrough; corrected here.)
|
||||
|
||||
### create_session — `config` passthrough (supersedes the FN block above)
|
||||
|
||||
```contract
|
||||
FN create_session(client: httpx.AsyncClient, agent_id: str, *, end_user_id: str | None = None, bifrost: BifrostBinding | None = None, consumer_key: str | None = None, config: Mapping[str, Any] | None = None) -> SessionInfo
|
||||
BRIEF: POST /sessions to create a session. Foundational: {"agent_id": agent_id} (+ end_user_id / bifrost per issues #5/#17). Ephemeral (issue #161): when `config` is non-None it is passed through verbatim as the request body's "config" key — the caller (CLI) builds {"system_prompt": <str>} for Echo; the wrapper is role/model-agnostic and NEVER injects a selector. Returns SessionInfo populated from the 201, now including kind + config. (bifrost / consumer_key params + their PRE-001/POST-002 semantics are specified in issue #17's contract; shown here only to keep the signature honest.)
|
||||
PRE: [PRE-001 hard] client is not None -- assert client is not None
|
||||
PRE: [PRE-002 hard] agent_id is a non-empty string -- assert agent_id and isinstance(agent_id, str)
|
||||
PRE: [PRE-003 hard, issue #5] end_user_id is None OR a non-empty string
|
||||
PRE: [PRE-004 hard, issue #161] config is None OR a Mapping -- assert config is None or isinstance(config, Mapping)
|
||||
PRE: [PRE-005 hard, issue #161] config and bifrost are not BOTH set — ephemeral sessions do not accept a Bifrost binding (server would 422 ephemeral_does_not_accept_bifrost); the CLI enforces this at arg-parse, this assert is defense-in-depth -- assert not (config is not None and bifrost is not None)
|
||||
POST: [POST-001 side_effect] exactly one POST to /sessions; body carries "agent_id" always, "end_user_id"/"bifrost" per issues #5/#17, and "config": config iff config is not None. No "config" key when config is None (foundational baseline byte-identical to pre-#161).
|
||||
POST: [POST-002 return_value] returns SessionInfo with session_id, agent_id, created_at, last_active, metadata, message_count populated per INV-001, PLUS kind = body.get("kind") and config = body.get("config").
|
||||
ERROR_ROUTING:
|
||||
HTTP 404 unknown_agent_id: raise AgentNotFound(agent_id=agent_id); abort
|
||||
HTTP 422 (ephemeral validation, issue #161): raise SessionApiFailed(status=422, body=resp.content). The body's error_code names the fault; recognized ephemeral codes: ephemeral_requires_config (config absent for an ephemeral template), foundational_does_not_accept_config (config sent to a foundational agent), system_prompt_required / system_prompt_empty / system_prompt_too_large (config.system_prompt missing / whitespace / >32768 bytes), model_not_allowed (config.model present — forbidden post-cutover), ephemeral_does_not_accept_bifrost. NOT mapped to per-code typed exceptions — the raw code in .body is honest + debuggable (mirrors the #5 end_user_id_required posture). abort.
|
||||
HTTP 422 (other validation_failed) / other non-201: raise SessionApiFailed(status=resp.status_code, body=resp.content); abort. (bifrost 502 → BifrostHandshakeFailed per #17.)
|
||||
STEPS:
|
||||
1. [setup] Validate PRE-001..PRE-005
|
||||
2. [sequential] body = {"agent_id": agent_id}; IF end_user_id is not None: body["end_user_id"] = end_user_id; IF bifrost is not None: body["bifrost"] = {...} (per #17); IF config is not None: body["config"] = config
|
||||
3. [sequential] headers per #17 (bound create uses consumer_key); CALL client.post("/sessions", json=body, headers=headers)
|
||||
4. [branch] IF 404 → AgentNotFound; ELIF bifrost and 502 → BifrostHandshakeFailed (#17); ELIF != 201 → SessionApiFailed
|
||||
5. [sequential] body = resp.json()
|
||||
6. [cleanup] RETURN SessionInfo(... unchanged create-side fields ..., kind=body.get("kind"), config=body.get("config"))
|
||||
TESTS:
|
||||
happy_ephemeral_create [happy,tracer]: config={"system_prompt":"You are X."}, agent_id="echo" → outbound body == {"agent_id":"echo","config":{"system_prompt":"You are X."}} byte-for-byte; 201 {"kind":"ephemeral","config":{"system_prompt":"You are X.","role":"echo"},...} → SessionInfo.kind=="ephemeral" and .config=={"system_prompt":"You are X.","role":"echo"}
|
||||
foundational_omits_config [trace]: config omitted, agent_id="mimir" → outbound body has NO "config" key (byte-identical to pre-#161 baseline); 201 without kind/config → SessionInfo.kind is None and .config is None
|
||||
foundational_captures_kind [happy]: 201 {"kind":"foundational",...} for a normal agent → SessionInfo.kind=="foundational", .config is None
|
||||
ephemeral_requires_config_422 [error]: agent_id="echo", config omitted → 422 {"error_code":"ephemeral_requires_config"} → SessionApiFailed(status=422); .body contains the code
|
||||
model_not_allowed_422 [error]: config={"system_prompt":"x","model":"glm5-turbo"} → 422 {"error_code":"model_not_allowed"} → SessionApiFailed(status=422) (regression guard: the CLI never sends model, but the wrapper passes config through verbatim, so a caller that injects model gets the honest server rejection)
|
||||
config_and_bifrost_conflict [adversarial]: config={...} AND bifrost=BifrostBinding(...) → AssertionError (PRE-005); no HTTP issued
|
||||
config_not_a_mapping [adversarial]: config="not-a-dict" → AssertionError (PRE-004); no HTTP issued
|
||||
```
|
||||
|
||||
### get_capabilities — corrected ephemeral-template metadata shape
|
||||
|
||||
The 2026-06-30 amendment's `get_capabilities` BRIEF documented the pre-cutover
|
||||
`{allowed_models, default_model}` shape. Canonical (per the althing grounding
|
||||
above) is **`{allowed_roles, default_role, system_prompt_max_bytes}}`**. The
|
||||
wrapper is unaffected (returns the parsed dict verbatim, no field access), but
|
||||
its BRIEF is corrected for honesty, and the **`--whoami` renderer
|
||||
(`ratatoskr.cli`) is fixed** to read `allowed_roles` / `default_role` (it
|
||||
currently reads the dead `allowed_models` / `default_model` keys and renders
|
||||
`default=? models=[]` against a live server).
|
||||
|
||||
- get_capabilities BRIEF now reads: `GET /capabilities → {ephemeral_templates:
|
||||
{echo: {allowed_roles, default_role, system_prompt_max_bytes}}}`. Behavior,
|
||||
PRE, POST, ERROR_ROUTING, STEPS unchanged (verbatim dict passthrough).
|
||||
|
||||
### CLI surface (ratatoskr.cli — consumer glue, TDD'd in test_cli)
|
||||
|
||||
- New `--system-prompt <str>` flag → builds `config={"system_prompt": <str>}` for
|
||||
the `--new` create. `ParsedArgs.system_prompt: str | None = None`.
|
||||
- Validation: `--system-prompt`, when passed, must be non-empty, requires `--new`
|
||||
+ `--agent`, and is **mutually exclusive with the bifrost flags**
|
||||
(`--bifrost-url` / `--bifrost-plane`) — ephemeral sessions reject a binding.
|
||||
- `_amain` passes `config` to `create_session`; the demoted create line surfaces
|
||||
`kind=<kind>` when present.
|
||||
- No `--role` / `--model` flag in this amendment: Echo's only `allowed_role` is
|
||||
`"echo"` and omitted role defaults server-side, so a selector flag is premature
|
||||
(add `--role` if/when a template advertises multiple roles).
|
||||
|
||||
### Supersession + Heid panel triage (2026-07-18)
|
||||
|
||||
- **Supersedes the "Bifrost binding out of scope" out-of-scope bullet** (the
|
||||
base "create_session does not accept or send a `bifrost` field" line). That
|
||||
bullet is stale: issue #17 made bifrost an accepted create parameter, and this
|
||||
amendment's FN block reflects the current signature (`bifrost` / `consumer_key`
|
||||
present, semantics owned by #17). Read the base out-of-scope bifrost line as
|
||||
historical.
|
||||
- **Error-body truncation (INV-004).** INV-004 [hard] specifies exception `.body`
|
||||
truncated to `[:1024]`. The implemented module dropped that truncation
|
||||
module-wide (every `SessionApiFailed` raise passes `resp.content`), so INV-004
|
||||
is stale against the code independent of this amendment. This amendment's
|
||||
create_session error routing follows the module's actual practice
|
||||
(`resp.content`) for consistency with its sibling endpoints; reconciling
|
||||
INV-004 vs the code across the whole module is a separate cleanup, flagged not
|
||||
fixed here. (Heid panel convergent finding, all three arms.)
|
||||
- **CLI section is documentation, not module-acceptance.** This contract's
|
||||
`target_module` is `ratatoskr.sessions`; the `--system-prompt` flag +
|
||||
`--whoami` renderer changes live in `ratatoskr.cli` and are verified in
|
||||
`test_cli`, not by this module contract's acceptance. They are documented here
|
||||
only so the sessions-surface change and its single consumer read as one unit.
|
||||
- **Deferred (pre-existing #2 coherence items, not this amendment's scope):**
|
||||
frontmatter "two entry points" scope line is stale vs the ~15 amended FNs;
|
||||
`item.get("metadata", {})` does not defend against an explicit-null `metadata`
|
||||
(unlike the `or` idiom on `archived`/`tags`); and the panel's recurring
|
||||
structural rec — a "current effective surface" map for this 7-amendment
|
||||
contract. Surfaced to the operator as separate cleanup candidates.
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
module: "ratatoskr.wt"
|
||||
purpose: "SDK-adapter cutover — replace ratatoskr's hand-rolled httpx consumer wrappers (sessions/sse_client/tier3) with a thin adapter over worldtree-sdk (Python) 1.0.0, deleting the old wrappers; CLI/TUI/web surfaces and caller-semantic exceptions preserved."
|
||||
touches:
|
||||
- src/ratatoskr/wt.py
|
||||
- src/ratatoskr/cli.py
|
||||
- src/ratatoskr/tui.py
|
||||
- src/ratatoskr/web/server.py
|
||||
scope: "Cut ratatoskr's CONSUMER client layer over from its hand-rolled httpx wrappers (sessions.py / sse_client.py / tier3.py / first_message.py) to consume worldtree-sdk (Python) 1.0.0. Replace the wrappers with a thin ratatoskr-owned adapter over the SDK; delete the old wrappers; keep the CLI / TUI / Starlette web surfaces and ratatoskr's caller-semantic exception surface. The Bifrost PROVIDER planes (memory/affect — where Worldtree dispatches INTO ratatoskr, ADR-0009) are consumer-orthogonal and untouched. Cutover decided by operator ruling 2026-07-18; design locked via vor-cross with worldtree-codex (thread 01KXWCWV3VKNR0HP74D2RCY0DC)."
|
||||
depends_on:
|
||||
- "worldtree-sdk (Python) == 1.0.0 # git tag python-v1.0.0; pin to the internal Gitea registry once published (DEC-5)"
|
||||
used_by:
|
||||
- "ratatoskr.cli"
|
||||
- "ratatoskr.tui"
|
||||
- "ratatoskr.web"
|
||||
language: "python"
|
||||
complexity: "high"
|
||||
estimated_loc: 400 # net, rough — deletes (old wrappers) offset adds (adapter + call-site edits); sized per slice, not up front
|
||||
confidence: 0.8
|
||||
assumptions:
|
||||
- "worldtree-sdk 1.0.0 is the GA Python consumer SDK ratatoskr's parity pass shaped; its resource surface (WorldtreeClient.sessions/agents/characters/me/capabilities/models/admin) covers all 21 of ratatoskr's covered routes. Verified against ~/development/worldtree-sdk @ python-v1.0.0."
|
||||
- "Caller-injected transport is a first-class tested path (PY-INV-012): WorldtreeClient(base_url, auth=..., transport=my_httpx_async_client) uses ratatoskr's AsyncClient and NEVER closes it. Ratatoskr keeps base_url / bearer / User-Agent / timeout / web-app lifecycle ownership."
|
||||
- "The SDK REST floor is an undiscriminated ApiError(error_code, message, status, body) (B-ERR-1/B-OPEN-3); hide-existence 404s are NOT distinguished by the SDK (B-ERR-4). Ratatoskr's caller-semantic mapping lives one layer up (DEC-2)."
|
||||
---
|
||||
|
||||
# worldtree-sdk cutover — consumer-layer adapter contract
|
||||
|
||||
Single SDK-adapter contract per operator DEC-1. This is a migration contract: its
|
||||
spine is a route-family slice plan, not a dense FN-block spec — each slice's
|
||||
adapter FN detail is filled in at that slice's TDD (incremental migration; the
|
||||
per-route SDK signatures are already mapped below).
|
||||
|
||||
## Locked design (operator-accepted 2026-07-18, post vor-cross)
|
||||
|
||||
- **DEC-1 — retire, don't repurpose (two-phase).** Normative authority for the
|
||||
consumer wire transfers to THIS contract immediately (as of this file, #2 sessions
|
||||
and #15 tier3 are superseded — no longer the spec of record); the contract FILES
|
||||
are DELETED at slice-7 teardown, only after the code they specified is gone
|
||||
(avoids both orphaning live code and leaving obsolete norms active mid-migration).
|
||||
`first_message` is NOT retired — it stays a ratatoskr-owned usage contract at the
|
||||
spec level (its preset / best-effort-timeout / swallow policy is local, not
|
||||
SDK-owned). Repurposing the wire contracts would risk a shadow-normative copy of
|
||||
SDK-owned semantics — the exact thing the cutover eliminates.
|
||||
- **DEC-2 — thin semantic error adapter.** CLI/web/TUI keep ratatoskr's typed
|
||||
exceptions; the adapter maps the SDK's `ApiError` floor → ratatoskr's
|
||||
caller-semantic types (the SDK stays undiscriminated; hide-existence maps here).
|
||||
- **DEC-3 — no shims (house rule).** Delete the old wrapper modules and update all
|
||||
call-sites to the new adapter. No compatibility facade preserving old
|
||||
module/function APIs (pre-1.0 no-backwards-compat rule).
|
||||
- **DEC-4 — incremental + live-smoke, no soak.** Migrate one route-family per
|
||||
slice, TDD each slice, and run a targeted LIVE smoke of that family against the
|
||||
personal endpoint before deleting the replaced hand-rolled path. (The SDK's
|
||||
fixture corpus proves the WIRE; the live smoke proves ratatoskr's
|
||||
presenter/adapter/env-wiring survived the dependency inversion.)
|
||||
**Smoke acceptance bar (the minimum that lets an old path be deleted):** drive
|
||||
each route in the family's primary flow END-TO-END through the ratatoskr surface
|
||||
(CLI/web) — not the wrapper directly — against the personal endpoint under the
|
||||
real consumer key, and OBSERVE the expected success (a created session, a streamed
|
||||
turn that renders, a persona write that reads back), not merely a non-crash. A
|
||||
smoke that only asserts "no exception" does NOT clear the bar. **Smoke vs. soak
|
||||
boundary:** a smoke is a single pass of the family's flows at deletion time; a
|
||||
(forbidden) soak is a sustained dual-path parallel-run — do the former, not the
|
||||
latter.
|
||||
- **DEC-5 — wait for the registry.** Pin `worldtree-sdk == 1.0.0` from the internal
|
||||
Gitea registry once published (verify the uv install first); use the
|
||||
`python-v1.0.0` git tag only if the registry lags. Contract-drafting is NOT
|
||||
gated (SDK source is inspectable); IMPLEMENTATION pin is.
|
||||
- **DEC-6 — minor bump at ship.** The cutover ships as a minor (architecture-cutover
|
||||
milestone; pre-1.0 breaking-internal) — requires explicit operator approval at
|
||||
ship time, not now.
|
||||
|
||||
## Adapter design
|
||||
|
||||
- **`ratatoskr.wt`** — a thin adapter module owning ONE `WorldtreeClient`, built
|
||||
with ratatoskr's own `httpx.AsyncClient` injected (`transport=`), ratatoskr's
|
||||
`auth`/`admin_auth` providers, and the ratatoskr User-Agent. It exposes
|
||||
ratatoskr-semantic call surfaces the presenters already expect, and translates
|
||||
SDK errors → ratatoskr exceptions (DEC-2).
|
||||
- **Transport ownership (INV-CUT-1 [hard]):** the injected `AsyncClient` is
|
||||
ratatoskr-owned; `WorldtreeClient.aclose()` / `async with` exit must NOT close
|
||||
it (SDK `_owns_client=False` on injection). Ratatoskr owns the client lifecycle
|
||||
exactly as today.
|
||||
- **Error boundary (INV-CUT-2 [hard]):** ratatoskr's caller-semantic exceptions
|
||||
are raised by the adapter, mapping from the SDK where the SDK already
|
||||
discriminates (`AgentNotAvailable`, `TurnLaunchUnavailable`, `SessionRetired`,
|
||||
`Cancel*`, `Resume*`, `Malformed*`) and from `ApiError(status=..., error_code)`
|
||||
on a KNOWN route otherwise — including the hide-existence 404 →
|
||||
`AuthoredHistoryUnavailable` (labeling a call we made, NOT capability-probing).
|
||||
- **Provider boundary (INV-CUT-3 [hard]):** the Bifrost provider planes stay
|
||||
hand-rolled and are never routed through the SDK. The Bifrost-BOUND
|
||||
`sessions.create` (consumer-side, `bifrost=` + consumer-key auth) DOES go through
|
||||
the SDK and stays consumer-layer.
|
||||
- **Import boundary (INV-CUT-4 [hard]):** `tests/test_no_worldtree_imports.py`
|
||||
stays; the boundary becomes "no Worldtree SOURCE imports", with `worldtree_sdk`
|
||||
the allowed client dependency.
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-CUT-1 [hard]** — Ratatoskr owns the injected `httpx.AsyncClient` lifecycle;
|
||||
the SDK never closes it (`_owns_client=False` on transport injection).
|
||||
- **INV-CUT-2 [hard]** — The adapter raises ratatoskr's caller-semantic exceptions
|
||||
per the explicit **§ Error map** table below — mapping from the SDK's discriminated
|
||||
errors (passthrough) and from `ApiError` by the ROUTE that raised it (the route is
|
||||
the discriminator, never the body). Every `ApiError` is mapped; the default is
|
||||
`SessionApiFailed`. No route invents semantics the table doesn't list.
|
||||
- **INV-CUT-3 [hard]** — The Bifrost PROVIDER planes never route through the SDK;
|
||||
the Bifrost-BOUND `sessions.create` does (consumer-side).
|
||||
- **INV-CUT-4 [hard]** — The no-Worldtree-SOURCE-import boundary holds
|
||||
(`worldtree_sdk` allowed); `tests/test_no_worldtree_imports.py` enforces it.
|
||||
- **INV-CUT-5 [hard]** — Each slice runs a LIVE smoke of its route family before the
|
||||
replaced hand-rolled path is deleted (DEC-4); no old path is removed unproven.
|
||||
|
||||
## Route map — ratatoskr wrapper → SDK method (grounded @ python-v1.0.0)
|
||||
|
||||
| Ratatoskr wrapper (retiring) | SDK method | SDK return |
|
||||
|---|---|---|
|
||||
| `create_session` | `sessions.create(...)` | `SessionInfo` |
|
||||
| `list_sessions` | `sessions.list(limit, cursor)` | page |
|
||||
| `stream_turn` | `sessions.stream_turn(...)` (resilient) / `stream_turn_raw` (single) | `AsyncIterator[Event]` |
|
||||
| `reconnect_turn` | folded into `sessions.stream_turn` auto-resume | — |
|
||||
| `cancel_turn` | `sessions.cancel_turn(...)` | `CancelResult` |
|
||||
| `get_session_messages` | `sessions.messages(id)` | open-world |
|
||||
| `get_session_tools` | `sessions.tools(id)` | `SessionTools` |
|
||||
| `set_persona_state` | `sessions.set_persona_state(id, PadState)` | None |
|
||||
| `write_authored_history` | `sessions.write_history(...)` | authored-turn |
|
||||
| `get_persona_state` | `agents.persona_state(agent_id)` | `AgentPersonaState` |
|
||||
| `list_agents` | `agents.list()` | `AgentList` |
|
||||
| `define_agent` | `agents.define(...)` | `DefinedAgent` |
|
||||
| `patch_agent` | `agents.patch(id, changes)` | `PatchedAgent` |
|
||||
| `delete_agent` | `agents.delete(id)` | None |
|
||||
| `get_me` | `me.get()` | `MeInfo` |
|
||||
| `get_capabilities` | `capabilities.get()` | `Capabilities` |
|
||||
| `list_character_models` | `models.available_for_characters()` | `ModelsAvailable` |
|
||||
| `create_character` | `characters.create(...)` | `CreatedCharacter` |
|
||||
| `get_character_state` | `characters.state(id)` | `CharacterState` |
|
||||
| `delete_character` | `characters.delete(id)` | `CharacterDeleteResult` |
|
||||
| `get_session_bifrost` | `admin.sessions.bifrost(id)` | `BifrostInspection` |
|
||||
| `stream_admin_events` | `admin.stream_events(...)` | `AsyncIterator[AdminEvent]` |
|
||||
|
||||
Note: SDK returns are typed `shapes.*`; per the parity pass the freeform reads
|
||||
(`SessionTools`, `BifrostInspection`, capabilities) are deliberately open-world in
|
||||
the Python spine. The adapter PASSES THESE THROUGH unmodified (does not normalize);
|
||||
presenters read them as mappings, tolerant of wire drift. If an open-world read is
|
||||
malformed the presenter degrades (never crashes) — same posture as the existing
|
||||
`_format_whoami` hardening.
|
||||
|
||||
## Error map (INV-CUT-2 — the explicit table)
|
||||
|
||||
**The ROUTE is the discriminator, not the error body** (labeling a call we made, NOT
|
||||
capability-probing — B-ERR-4). A 404 is mapped by which method raised it; the adapter
|
||||
NEVER sniffs the body to decide whether something exists. Authored-history is
|
||||
currently the ONLY hide-existence route (per Worldtree B-ERR-4); if Worldtree adds
|
||||
others, they get their own row here — the default is NOT a general "any 404 → hidden".
|
||||
|
||||
| SDK raises (from) | Adapter maps to |
|
||||
|---|---|
|
||||
| SDK `AgentNotAvailable` / `TurnLaunchUnavailable` / `SessionRetired` (stream-open) | ratatoskr `AgentNotAvailable` / `TurnLaunchUnavailable` / (retired → `SessionApiFailed`) — same names, passthrough |
|
||||
| SDK `ConnectionDropped` (mid-stream) | `SseConnectionDropped` |
|
||||
| SDK `ResumeError` subclasses (in resilient stream) | resilient `stream_turn` absorbs; terminal → `SseConnectFailed` |
|
||||
| SDK `Cancel*` (cancel_turn) | folded into `CancelResult`; late-cancel race (B-CAN-3) returns `cancelled=False`, never raises |
|
||||
| `ApiError(404)` on `sessions.create` | `AgentNotFound` |
|
||||
| `ApiError(404)` on `sessions.write_history` | `AuthoredHistoryUnavailable` (hide-existence) |
|
||||
| `ApiError(422 cursor_invalid)` on `sessions.list` | `InvalidCursor` |
|
||||
| `ApiError(502 bifrost_handshake_failed)` on bound `sessions.create` | `BifrostHandshakeFailed` |
|
||||
| **`ApiError` (any other status/route) — the default** | `SessionApiFailed(status, error_code, body)` |
|
||||
|
||||
The default row is load-bearing: any `ApiError` not matched above surfaces as the
|
||||
generic `SessionApiFailed` carrying the raw `status`/`error_code`/`body` — the
|
||||
adapter does NOT invent per-route semantics the contract doesn't list, and does NOT
|
||||
leave an `ApiError` un-mapped. Each slice adds/confirms its route's rows here before
|
||||
the old path is deleted.
|
||||
|
||||
## Slice plan (incremental, DEC-4)
|
||||
|
||||
Each slice: wire the family through `ratatoskr.wt` → update call-sites → TDD →
|
||||
LIVE smoke against the personal endpoint → delete the replaced hand-rolled path →
|
||||
re-anchor its coverage-map rows.
|
||||
|
||||
1. **Foundation** — `ratatoskr.wt` module + injected `WorldtreeClient` + auth
|
||||
providers + the error-adapter skeleton (the § Error map's default `ApiError` →
|
||||
`SessionApiFailed` rule + the discriminated-error passthrough). No USER-VISIBLE
|
||||
behavior change (imports/construction change; the skeleton is unit-tested but
|
||||
unexercised by any surface until slice 2). Done = `ratatoskr.wt` importable, the
|
||||
client constructs with an injected transport, and the error-map default is unit-
|
||||
covered.
|
||||
2. **Sessions/turn** — create/list/messages/tools/cancel + the turn STREAM + the
|
||||
Bifrost-bound create. The turn presenter wires the resilient `sessions.stream_turn`
|
||||
(auto-resume — this ABSORBS the old `reconnect_turn`, which is deleted this slice
|
||||
with its call-sites); `stream_turn_raw` is available for a single-attempt caller
|
||||
but is NOT the presenter default. (Highest-traffic; the SSE presenter is the
|
||||
riskiest wiring.)
|
||||
3. **Persona + authored-history** — set_persona_state (PadState) + write_history
|
||||
(hide-existence mapping) + first_message presets (stay ratatoskr-owned OVER the
|
||||
SDK method).
|
||||
4. **Agents (Tier-3)** — list/get/define/patch/delete/persona_state. (Folds in the
|
||||
pending `model`→`role` cutover — see [[project-tier3-agents-model-to-role-pending]].)
|
||||
5. **Characters + me/capabilities/models** — the remaining consumer reads.
|
||||
6. **Admin** — admin.sessions.bifrost + admin.stream_events (admin_auth).
|
||||
7. **Teardown** — delete residual hand-rolled modules; drop `httpx-sse` (SDK owns
|
||||
SSE parsing); retire contracts #2/#15; final coverage-map re-anchor; minor bump
|
||||
(DEC-6, operator approval).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Bifrost PROVIDER planes (memory/affect) — hand-rolled, ADR-0009, untouched.
|
||||
- CLI / TUI / Starlette web surfaces — their EXTERNAL BEHAVIOR is preserved (their
|
||||
call-sites DO change — they're updated to the new adapter per `touches:`; what's
|
||||
invariant is the observable behavior, not the code).
|
||||
- The four parked post-v1 SDK ergonomics items (replayed:bool, raw-dict persona
|
||||
escape hatch, list_all iterator, split session types) — coordinated post-v1 lane
|
||||
with wtsdk-dev, not this cutover.
|
||||
|
||||
## Coverage-map re-anchor
|
||||
|
||||
`docs/coverage-map.md` shifts from "hand-rolled route exists + live-proven" to
|
||||
"SDK method wired and exercised by a ratatoskr surface"; excluded-by-design rows
|
||||
stay explicit. Re-anchored per slice as each family lands.
|
||||
@@ -1,8 +1,8 @@
|
||||
# Worldtree Conversation API — Client Interface Specification
|
||||
|
||||
**Version:** 1.0
|
||||
**Date:** 2026-04-15
|
||||
**Status:** Stable (pre-auth)
|
||||
**Version:** 1.1
|
||||
**Date:** 2026-07-18
|
||||
**Status:** Stable
|
||||
**Contract:** `docs/contracts/conversation_api.contract.md`
|
||||
|
||||
This document specifies the external interface for clients integrating with the Worldtree Conversation API. It is the reference for building web apps, TUI clients, bridges, or any system that needs to converse with Worldtree agents.
|
||||
@@ -1533,7 +1533,7 @@ for (const tc of items) {
|
||||
|
||||
## Ephemeral Templates (issue #161)
|
||||
|
||||
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.
|
||||
Ephemeral templates are a second tier of agent, distinct from foundational persistent agents (Mimir, Lofn, etc.). They have no persona, no memory, no tools, and no motivational context. The consumer supplies the system prompt and (optionally) a **role** — a purpose-named model role per ADR-0012, not a raw model id — at session-create time; that config is frozen for the session's lifetime. (The raw `model` selector was retired at the model-role cutover, 2026-06-09.)
|
||||
|
||||
**Echo** is the first ephemeral template — a blank-slate per-session host that becomes whatever the consumer's system prompt instills.
|
||||
|
||||
@@ -1548,8 +1548,8 @@ Authorization: Bearer <any valid key>
|
||||
{
|
||||
"ephemeral_templates": {
|
||||
"echo": {
|
||||
"allowed_models": ["glm5-turbo", "glm4.7", "glm4.5-air", "granite-structured", "qwen3.6-35-a3b"],
|
||||
"default_model": "glm5-turbo",
|
||||
"allowed_roles": ["echo"],
|
||||
"default_role": "echo",
|
||||
"system_prompt_max_bytes": 32768
|
||||
}
|
||||
}
|
||||
@@ -1566,7 +1566,7 @@ POST /sessions
|
||||
"agent_id": "echo",
|
||||
"config": {
|
||||
"system_prompt": "You are a careful, skeptical frame-clarifier...",
|
||||
"model": "glm5-turbo"
|
||||
"role": "echo"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1580,9 +1580,10 @@ 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 `echo_allowed_models` |
|
||||
| `model_not_allowed` | `config.model` present and non-empty — the raw-model selector is retired; select via `config.role` |
|
||||
| `role_required` | `config.role` present but empty or not a string |
|
||||
|
||||
**`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.
|
||||
**`config.role` resolution:** When `config.role` is omitted (or `null`), the server resolves it to `default_role` (`"echo"`). The resolved value is always populated in the session snapshot; `role` is never left absent or null in the stored config. Role→model mapping happens server-side at turn time per ADR-0012 (`config/model_roles.yaml`); create-time validation does not enumerate models.
|
||||
|
||||
**Response:** Same 201 shape as foundational sessions, with two new fields:
|
||||
|
||||
@@ -1593,7 +1594,7 @@ POST /sessions
|
||||
"kind": "ephemeral",
|
||||
"config": {
|
||||
"system_prompt": "You are a careful, skeptical frame-clarifier...",
|
||||
"model": "glm5-turbo"
|
||||
"role": "echo"
|
||||
},
|
||||
"message_count": 0,
|
||||
"created_at": "...",
|
||||
@@ -1612,7 +1613,7 @@ POST /sessions/{id}/messages
|
||||
SSE, cancel, `persist_partial`, rate limits, and error shapes are bit-identical to foundational sessions. The only differences are pre-turn:
|
||||
|
||||
- **System prompt:** `session.config.system_prompt` verbatim — not extended with registry text, persona, or motivational context.
|
||||
- **Provider:** resolved by `session.config.model`.
|
||||
- **Provider:** resolved from `session.config.role` (role→model per ADR-0012).
|
||||
- **Tools:** empty `[]` — no tools loaded, no tool schemas declared.
|
||||
|
||||
### Scope
|
||||
@@ -2858,6 +2859,8 @@ Semantics:
|
||||
which must carry all three of `pleasure` / `arousal` / `dominance`, each a
|
||||
float in `[-1.0, 1.0]`. Any other top-level key → 422 `validation_failed`;
|
||||
a missing or malformed `pad` → 422 `persona_seed_invalid`.
|
||||
|
||||
> **✓ R32-1B (landed, v1.0.0b29):** The PAD range `[-1.0, 1.0]` relaxes to an **unbounded latent `z`** with a finite wire sanity bound (`~±10`) as of R32 Slice-1B. The JSON shape/fields/types are UNCHANGED — only the declared range/semantics change (the value becomes a latent that renders to a bounded display value). Consumers that merely store-and-return PAD need no change; consumers that validate/clamp PAD to `[-1,1]` must relax that bound. Source of truth: `docs/contracts/persona_envelope.contract.md` rev 1.7 (INV-ENV-16).
|
||||
- **Seeds the current mood POINT, not the setpoint.** The OCEAN persona
|
||||
(above) fixes the setpoint the mood relaxes toward; this endpoint sets
|
||||
where the mood *starts*. It does not alter the persona.
|
||||
|
||||
+19
-2
@@ -23,6 +23,23 @@ anchors against the frozen machine-readable artifacts, NOT the prose markdown:
|
||||
| Conversation SSE events | `conversation-api-sse-events.schema.json` (sha `9deeebf4…`) — **11 discriminated event types** | **client** |
|
||||
| Bifrost wire (consumer protocol) | wire **v0.6** STABLE/FROZEN (`bifrost==1.0.0`) — memory + affect planes | **provider** (Worldtree dispatches into us) |
|
||||
|
||||
> **External ratification — worldtree-sdk v1.0.0 (wtsdk-dev, 2026-07-18, althing
|
||||
> thread `01KXVF24WQD2T5ZCS49KKFCCMH`).** The TypeScript v0 consumer SDK wires all
|
||||
> **41 ops as typed methods** from the **same OpenAPI 2.3.0** (sha
|
||||
> `36148179601453a0` — matches our vendored pin exactly), so it is the independent
|
||||
> parity authority for the surface this ledger tracks. Ratatoskr does **not** adopt
|
||||
> the SDK — we are the parallel **Python/httpx** reference-consumer of the identical
|
||||
> surface (no TS/npm surface exists to pin it into). Cross-check corroboration: the
|
||||
> SDK's **W-4** (send `config.role`, response echoes `model` unremapped) matches what
|
||||
> ratatoskr shipped in **v0.21.2** for ephemeral-Echo create.
|
||||
>
|
||||
> **Future consumable — worldtree-sdk Python spine (wtsdk-dev, next milestone).** The
|
||||
> TS v1 surface is our parity *checklist*; the forthcoming **Python spine** (same
|
||||
> behavioral-spec + fixture corpus, TS decisions kept Python-portable per their
|
||||
> surface-design §6) is what ratatoskr would actually *consume* when it lands.
|
||||
> wtsdk-dev slots ratatoskr as **THE reference consumer** whose coverage column
|
||||
> drives that spine — a future repin candidate, not yet available.
|
||||
|
||||
> **Pin drift (finding P-1) — REMEDIATED 2026-06-30.** Ratatoskr formerly
|
||||
> vendored only the **prose markdown** (`docs/conversation-api-spec.md`), which
|
||||
> is byte-identical to live Worldtree's but frozen at v0.35.16-era content (last
|
||||
@@ -67,7 +84,7 @@ sub-gap).
|
||||
|
||||
| Endpoint | Status | Where consumed | Note |
|
||||
|---|---|---|---|
|
||||
| `POST /sessions` | ✅ | `sessions.py:307` → `cli.py:482`,`tui.py:1508`,`web/server.py:155` | + `end_user_id`, `bifrost` binding; 404→AgentNotFound, 502→BifrostHandshakeFailed |
|
||||
| `POST /sessions` | ✅ | `sessions.py` `create_session` → `cli.py`,`tui.py`,`web/server.py` | + `end_user_id`, `bifrost` binding; 404→AgentNotFound, 502→BifrostHandshakeFailed. **v0.21.2 (#19): ephemeral-template (Echo) create** — `config` passthrough (`--system-prompt`), `role` not `model` (W-4), `kind`/`config` captured; 422 ephemeral_requires_config now reachable-and-handled. Depth enhancement to an already-covered route — count unchanged |
|
||||
| `POST /sessions/{id}/messages` (turn stream, SSE) | ✅ | `sse_client.py:484` `stream_turn` → cli/tui/web | the primary surface; 409→AgentNotAvailable, 503→TurnLaunchUnavailable (b2 #331) |
|
||||
| `POST /sessions/{id}/history` (authored-history-write, #347) | ✅ | `sessions.py:583` `write_authored_history` → `cli.py:758` `--seed-first-message` | v1: author=assistant, effects=none, per-session idempotency; 404→AuthoredHistoryUnavailable (hide-existence: feature-absent, never probe); 409/422 mapped. **LIVE-PROVEN 2026-07-06** on personal :8081 (grant applied via a rule-based Heimdall allow, worldtree-dev): create mimir session → seed → **201** (seq=0, phase=seeded, turn_id=1798) → GET /messages reads it back as a plain role=assistant turn (model-invisible provenance confirmed). Hide-404 for ungranted is unit+probe covered |
|
||||
| `GET /sessions/{id}/messages` (history) | ✅ | `sessions.py:635` `get_session_messages` → `cli.py:758` `--seed-first-message` read-back | un-deferred as the #347 seed read-back — confirms model-invisible provenance (a seed reads back as a normal `role=assistant` turn) |
|
||||
@@ -78,7 +95,7 @@ sub-gap).
|
||||
| `PATCH /agents/{id}` | ✅ | `tier3.py:219` → `_run_patch` | Tier-3 mutate (system_prompt/model) |
|
||||
| `DELETE /agents/{id}` | ✅ | `tier3.py:242` → `_run_delete` | Tier-3 hard-delete |
|
||||
| `GET /me` | ✅ | `sessions.py:411` `get_me` → `cli.py` `--whoami` | identity/whoami probe; 401→SessionApiFailed |
|
||||
| `GET /capabilities` | ✅ | `sessions.py:428` `get_capabilities` → `cli.py` `--whoami` | Echo ephemeral-template discovery |
|
||||
| `GET /capabilities` | ✅ | `sessions.py` `get_capabilities` → `cli.py` `--whoami` | Echo ephemeral-template discovery. **v0.21.2: `--whoami` renderer reads `allowed_roles`/`default_role`** (was the dead `allowed_models`/`default_model`) + tolerates malformed caps; matches conversation-api-spec **v1.1** (`b4a278c`) |
|
||||
| `GET /sessions/{id}/tools` | ✅ | `sessions.py:411` `get_session_tools` → `tui.py` `_hydrate_session_tools` | owner-scoped tool inventory in the TUI Tools pane (#183) |
|
||||
| `GET /admin/sessions/{id}/bifrost` | ✅ | `sessions.py:428` `get_session_bifrost` → `tui.py` `_hydrate_bifrost_state` | admin-scoped BifrostState pane (#176); admin key (`RATATOSKR_ADMIN_API_KEY`); live-auth-proven |
|
||||
| `GET /admin/events` (SSE) | ✅ | `sse_client.py` `stream_admin_events` → `tui.py` `_stream_admin_events` | admin lifecycle SSE stream (#11), session-filtered AdminEvents pane; admin key; live-auth-proven |
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# Psychological Profile Authoring Spec — canonical
|
||||
|
||||
**Status:** canonical (v1). **Owner:** brokkr-smithy-dev (R34/R35 self-report reframe).
|
||||
**Audience:** anyone authoring a character's `psychological_profile` — Worldtree
|
||||
foundational characters (soong-dev) and consumer characters created via the
|
||||
Conversation API (ratatoskr and other external consumers).
|
||||
**For:** the Worldtree agent-definition schema; intended to live in the Worldtree
|
||||
client-app documentation.
|
||||
|
||||
This spec governs the **content** of the psychological profile (what to write and
|
||||
what never to write). The **physical wire shape** of the field (single string vs a
|
||||
small keyed dict) is Worldtree's schema call — see § Wire shape.
|
||||
|
||||
---
|
||||
|
||||
## 1. What it is
|
||||
|
||||
A dedicated **authored prose section** of a character definition that carries the
|
||||
character's **psychological bent and formative experience**. It is the source the
|
||||
self-report producer maps from when it decides, on each turn:
|
||||
|
||||
- **what the character feels** (affect self-report), and
|
||||
- **what the character notices and keeps** (character-voiced memory salience).
|
||||
|
||||
The profile is a *lens*, not a script. It never states per-turn emotions; it
|
||||
describes the standing disposition, history, values, and attention that — combined
|
||||
with the actual event — *produce* the emotion and the salience.
|
||||
|
||||
It sits **alongside the numeric OCEAN** values (a separate, deterministic input).
|
||||
The prose gives the *qualitative* bent; the OCEAN numbers give the *magnitude dial*
|
||||
(see § OCEAN interaction).
|
||||
|
||||
---
|
||||
|
||||
## 2. What it carries — the four dimensions
|
||||
|
||||
1. **Disposition / appraisal bent** — how the character characteristically
|
||||
*interprets* situations: attribution style, what they hold weighty, how they
|
||||
respond to being challenged. NOT per-event emotions.
|
||||
2. **Attention / salience focus** — the kinds of things this character
|
||||
characteristically *notices* (and therefore tends to remember).
|
||||
3. **Values / what a good day looks like** — the yardstick that drives what they
|
||||
find worth keeping.
|
||||
4. **Formative experience (history)** — the background that shapes both appraisal
|
||||
*and* salience. A character betrayed before appraises betrayal differently, and
|
||||
remembers different things.
|
||||
|
||||
You may write these as four short labelled sections or as one integrated paragraph
|
||||
— both are supported (see § Length & format).
|
||||
|
||||
---
|
||||
|
||||
## 3. Authoring rules (load-bearing)
|
||||
|
||||
These are the rules the whole reframe depends on. Rule 1 is the one that most often
|
||||
gets violated.
|
||||
|
||||
1. **Never name a per-event output emotion.** Do NOT write "is anxious", "gets
|
||||
angry at X", "feels hurt when criticized", "joyful". Naming an emotion **primes**
|
||||
it — the "pink ball" effect — so the producer will report that emotion regardless
|
||||
of what actually happens in the scene. Describe *disposition, history, values,
|
||||
attention*; let the emotion come from the event appraisal.
|
||||
- ✅ "Registers quickly when authority is substituted for craft." (an appraisal
|
||||
trigger — sets up how she reads an event, names no feeling)
|
||||
- ❌ "Feels contempt when someone pulls rank." (names the output emotion)
|
||||
|
||||
2. **Magnitude lives in the numeric OCEAN, not the prose.** *How strongly / how
|
||||
long* a character reacts (Neuroticism) is the deterministic OCEAN dial, rendered
|
||||
valence-neutral by the producer. Do not narrate reaction dynamics in the prose
|
||||
("comes apart", "takes it hard", "rich inner life") — that double-encodes what the
|
||||
number already carries. The prose gives the *qualitative bent*; the number gives
|
||||
the *gain*.
|
||||
|
||||
3. **Appraisal-style is allowed; output-emotion is not.** "Interprets others'
|
||||
actions charitably until she can't" (a style) is fine; "feels betrayed easily"
|
||||
(an output) is not. The style plus the event produce the output.
|
||||
|
||||
4. **Salience is character-relative; facts are not.** The profile shapes what the
|
||||
character *cares to remember*. It must never license rewriting *what happened* —
|
||||
when the character does remember something, it stays grounded in the transcript.
|
||||
|
||||
---
|
||||
|
||||
## 4. Wire shape & field placement
|
||||
|
||||
- **Content is prose** covering the four dimensions, authored as **one coherent prose
|
||||
string** — the four dimensions are authoring *structure* inside that single string,
|
||||
not separate wire fields.
|
||||
- **Wire shape (LOCKED, b53):** a single dedicated prose string, field
|
||||
**`psychological_profile`** (type `str`) on the persona layer — foundational
|
||||
`persona.psychological_profile`, Tier-3 `ValidatedPersona.psychological_profile`. It
|
||||
nests under the existing `Any`-typed persona field, so it is the shipped b53 shape —
|
||||
no schema change. **Not** a dict-of-four.
|
||||
- **Hard constraint (non-negotiable):** the profile is a **dedicated field the lens
|
||||
reads ONLY** (`resolve_psych_profile` reads only this field — no `behavioral_notes`
|
||||
or other general-field remap). Non-lens content leaking into the lens produces the
|
||||
"executive-assistant" failure (the producer reads response-format / tone / tool
|
||||
instructions as if they were the character's psychology).
|
||||
|
||||
---
|
||||
|
||||
## 5. The non-priming banned set
|
||||
|
||||
The non-priming rule (Rule 1) is **semantic, not a fixed wordlist** — it bans naming
|
||||
any per-event output emotion, which is broader than any specific vocabulary
|
||||
("anxious", "worried", "hurt" all prime even though they are not in the producer's
|
||||
fixed emotion roster).
|
||||
|
||||
- **The gate is human review:** does the prose describe disposition / appraisal-style
|
||||
/ history / values / attention, and never what the character *feels*?
|
||||
- **A mechanical lint is a backstop, not the gate.** If you build one, scan the
|
||||
fixed-15 OCC roster plus `synonym_map.json` (which already folds common affect
|
||||
synonyms) as the core set, optionally extended with a general affect lexicon. Treat
|
||||
a lint hit as a prompt to re-read, not an automatic reject.
|
||||
|
||||
---
|
||||
|
||||
## 6. Required vs optional dimensions
|
||||
|
||||
- **Required** (they *are* the lens): **disposition**, **attention / salience focus**,
|
||||
**values**.
|
||||
- **Strongly recommended:** **formative history** — it is the single biggest lever on
|
||||
richness (validated in P03: richer history → sharper, more character-appropriate
|
||||
salience). It may be brief for a deliberately thin character, but omitting it leaves
|
||||
salience under-grounded.
|
||||
|
||||
---
|
||||
|
||||
## 7. Length & format
|
||||
|
||||
- A focused paragraph, or four short labelled sections — **a lens, not a biography.**
|
||||
- Target **~150–300 words.** The producer reads this on **every** turn, so keep it
|
||||
tight; bloat is a latency and dilution cost.
|
||||
- **Prose only — never typed emotion fields.** The four dimensions are a coverage
|
||||
checklist for the author, not a schema of feelings to fill in.
|
||||
|
||||
---
|
||||
|
||||
## 8. Exemplars
|
||||
|
||||
These three were the validated P03 stimuli — integrated-paragraph form, each faithful
|
||||
to its OCEAN, none naming an output emotion. (OCEAN shown in **[−1, 1] storage units**;
|
||||
validated in P03 at the equivalent [0, 1] values.)
|
||||
|
||||
**Perrin — court scribe** (OCEAN: O0.0 C0.2 E−0.2 A0.1 N0.7)
|
||||
> Perrin keeps the court's records and has done so through two changes of regime. He
|
||||
> learned early that small errors compound — a misfiled writ once cost a man his
|
||||
> lands, and Perrin found the mistake too late to undo it. Since then he double-checks
|
||||
> everything and watches situations closely for what is out of place. He forms
|
||||
> attachments slowly and holds a given trust as a considerable thing. He measures
|
||||
> himself by whether he was useful and careful. He notices discrepancies, unspoken
|
||||
> tensions, and anything that threatens the order he keeps.
|
||||
|
||||
**Vared — veteran caravan guard** (OCEAN: O−0.2 C0.4 E−0.5 A−0.2 N−0.7)
|
||||
> Vared has guarded caravans across the northern routes for twenty years and buried
|
||||
> more traveling companions than he cares to count. He speaks little and shows less.
|
||||
> Danger he treats as weather — a thing to be handled. He judges people by what they
|
||||
> do under pressure and remembers who held the line. What reaches him reaches him
|
||||
> quietly and privately. He notices terrain, exits, who is armed, and shifts in a
|
||||
> group that might precede trouble.
|
||||
|
||||
**Sella — village healer** (OCEAN: O0.2 C0.2 E0.0 A0.8 N0.0)
|
||||
> Sella has tended the sick since she was old enough to carry water for her
|
||||
> grandmother, the healer before her. She reads people's pain quickly and carries some
|
||||
> of it with her. She interprets others' actions charitably until she cannot, and
|
||||
> prioritizes keeping the peace between people. She measures a day by whether she eased
|
||||
> someone's burden. She notices who is unwell, who is troubled, and what is left
|
||||
> unsaid.
|
||||
|
||||
Note how each closes on **attention** ("he notices…", "she notices…") — the salience
|
||||
focus stated plainly, no emotion named.
|
||||
|
||||
---
|
||||
|
||||
## 9. OCEAN interaction & the scaffold fallback
|
||||
|
||||
OCEAN values are stored on **[−1, 1]** (0 = average) — a **separate deterministic
|
||||
input** and the **magnitude dial** the prose must not duplicate (Rule 2). The producer
|
||||
renders **off-average** bands as valence-neutral disposition cues. It maps storage to
|
||||
[0, 1] first (`c = (v + 1) / 2`, `render_disposition` in b53) and then applies the
|
||||
canonical [0, 1] band cutoffs (`c < 0.33` low / `c > 0.66` high). In **storage units**
|
||||
that is:
|
||||
|
||||
| trait | low (v < −0.34) | high (v > +0.32) |
|
||||
|---|---|---|
|
||||
| **N** (reactivity only) | reactions are milder than most people's | reactions are more intense than most people's |
|
||||
| **E** (expression; may be excluded from affect elicitation) | socially reserved; expression less outwardly amplified | socially expressive; reactions more externally visible |
|
||||
| **O** | prefers the familiar, the concrete, established ways | curious, drawn to novelty, ideas, the unfamiliar |
|
||||
| **C** | less plan-bound; less weight on order, detail, obligation | attends closely to order, detail, and obligations |
|
||||
| **A** | less inclined to assume cooperative intent; direct, self-protective | more inclined to preserve rapport and weigh others' needs |
|
||||
|
||||
The **mid** band (−0.34 ≤ v ≤ +0.32, i.e. `c` in [0.33, 0.66]) renders nothing — an
|
||||
average trait is silent, **not** "low." (Boundaries are slightly asymmetric because
|
||||
the canonical 0.33/0.66 cutoffs are not symmetric about 0.5. Canonical rendering
|
||||
strings live in the reframe language catalog §4; persistence/recovery dynamics live in
|
||||
the deterministic mood decay, not the profile.)
|
||||
|
||||
**Scaffold fallback:** a character with **no** authored profile falls back to this
|
||||
band-rendering from the OCEAN numbers alone. That still functions — but the authored
|
||||
profile is what turns generic band cues into *this specific character's* appraisal and
|
||||
salience. Authoring the profile is how the reframe's value actually reaches a
|
||||
character.
|
||||
|
||||
---
|
||||
|
||||
## 10. Authoring divergent characters (contrast design)
|
||||
|
||||
When you want two characters to remember **noticeably different things** (e.g. for an
|
||||
eval contrast pair, or simply a varied cast), design the divergence on the **attention
|
||||
and values** dimensions first, and set the OCEAN numbers to *serve* that prose — not
|
||||
the reverse.
|
||||
|
||||
- **The sharpest contrast is a salience *drop*, not just a different flavor.** One
|
||||
character for whom relational/emotional content is genuinely non-salient (an
|
||||
operational, task-focused character in the Vared mold — notices terrain, logistics,
|
||||
who is armed) versus one who weights it highest (a caretaker who tracks who is
|
||||
troubled and what went unsaid). "Different notes, same facts" has real teeth only
|
||||
when one character *legitimately forgets* what the other keeps.
|
||||
- **High-yield axes for salience divergence:** O (what patterns they attend to), A
|
||||
(relational vs operational/self-protective focus), C (procedural/detail salience).
|
||||
- **Low-yield for salience:** E — it is expression-oriented (shapes how a reaction is
|
||||
*rendered*, not what is *noticed*), and may even be excluded from the affect
|
||||
elicitation. Don't lean on flipping E to create divergence.
|
||||
- **Watch the direction, not just the distance:** flipping every OCEAN axis to its
|
||||
opposite does not guarantee a strong contrast. If your reference character already
|
||||
*keeps* relational content, an even-more-agreeable opposite keeps it harder and the
|
||||
most intuitive contrast collapses. Aim the contrast at *dropping* what the reference
|
||||
*keeps*.
|
||||
|
||||
---
|
||||
|
||||
## Provenance & validation
|
||||
|
||||
Grounded in R34/R35 (self-report reframe), probes P02–P05: character-voiced memory
|
||||
salience validated on two model classes (P02/P03); the "Psychological Profile and
|
||||
Experience" section mapping validated as the lens source (P03); non-priming and
|
||||
magnitude-in-OCEAN corrections are operator rulings (2026-07-10). The affect half is
|
||||
live in production (Worldtree b53) and fired a contextually-apt self-report on a
|
||||
non-frontier seat. A powered efficacy eval (salience divergence / floor recall /
|
||||
salience≠facts firewall / graded model-slot response + the authored-vs-scaffold delta)
|
||||
is preregistering to quantify the memory half; findings will refine this spec, not
|
||||
overturn its authoring rules.
|
||||
@@ -0,0 +1,123 @@
|
||||
# Psychological Profile Parameters — for AI generation (canonical)
|
||||
|
||||
**Status:** canonical (v1). **Owner:** brokkr-smithy-dev (R34/R35 self-report reframe).
|
||||
**Audience:** **soong-dev** (Soong's Lab / Soong's AI — the immediate builder that
|
||||
generates the profile from these parameters); **Worldtree** + **ratatoskr** (vendoring
|
||||
for reference alongside the authoring spec).
|
||||
**Relationship:** this is the **parameter distillation** of
|
||||
`psych-profile-authoring-spec.md` for the model where **Soong's AI writes the
|
||||
`psychological_profile` prose from parameters** (rather than a human hand-authoring it).
|
||||
The authoring spec carries the full reasoning + provenance and **governs on any
|
||||
conflict**; this file is the builder-facing input schema + generation guardrails + few-shot.
|
||||
|
||||
The profile is the prose **lens** the Worldtree self-report producer reads each turn to
|
||||
decide what the character **feels** (affect self-report) and what it **notices / keeps**
|
||||
(character-voiced memory salience). Soong's AI generates the prose; these are its inputs
|
||||
and the constraints its output must satisfy.
|
||||
|
||||
---
|
||||
|
||||
## 1. Input parameters (what the Lab collects / Soong's AI takes)
|
||||
|
||||
1. **role / vocation** — a short anchor ("court scribe", "veteran caravan guard",
|
||||
"village healer").
|
||||
2. **OCEAN values** — O, C, E, A, N each on **[−1, 1]** (0 = average). A **separate
|
||||
deterministic input** the producer uses directly (the "magnitude dial"); Soong's AI
|
||||
should see them to keep the qualitative bent *consistent* with the numbers, but must
|
||||
**not re-encode their magnitude** in the prose (constraint 2).
|
||||
3. **formative-history seed** — 1–2 key background facts/events that shape appraisal AND
|
||||
salience. **Single biggest lever on richness** (validated P03: richer history →
|
||||
sharper, more character-appropriate salience).
|
||||
4. **appraisal-bent seed** — how the character characteristically **interprets**
|
||||
situations (attribution style, what they hold weighty, how they respond to challenge).
|
||||
A *style*, NOT an emotion.
|
||||
5. **attention / salience-focus seed** — the kinds of things this character
|
||||
characteristically **notices** (and therefore keeps). Load-bearing for the memory half.
|
||||
6. **values / yardstick seed** — what "a good day" looks like; the yardstick driving what
|
||||
they find worth keeping.
|
||||
|
||||
## 2. Output (what Soong's AI emits)
|
||||
|
||||
A single coherent **prose string** (~150–300 words), field **`psychological_profile`**
|
||||
(type `str`) — the four dimensions (disposition / attention / values / formative-history)
|
||||
integrated as one paragraph. **Prose only — never typed emotion fields.** The producer
|
||||
reads it every turn, so keep it tight.
|
||||
|
||||
## 3. Generation constraints (the guardrails the output MUST obey — these ARE the reframe)
|
||||
|
||||
1. ★ **Never name a per-event output emotion.** Do NOT write "is anxious", "gets angry at
|
||||
X", "feels hurt when criticized", "joyful". Naming an emotion **primes** it (the
|
||||
"pink-ball" effect) so the producer reports it regardless of what actually happens.
|
||||
Describe disposition / history / values / attention; let the emotion come from the
|
||||
event appraisal.
|
||||
- ✅ "Registers quickly when authority is substituted for craft." (appraisal trigger)
|
||||
- ❌ "Feels contempt when someone pulls rank." (names the output emotion)
|
||||
2. **Magnitude lives in OCEAN, not prose.** Don't narrate reaction dynamics ("comes
|
||||
apart", "takes it hard", "rich inner life") — that double-encodes what the number
|
||||
already carries.
|
||||
3. **Appraisal-style yes; output-emotion no.** "Interprets others' actions charitably
|
||||
until she can't" (style) = fine; "feels betrayed easily" (output) = not.
|
||||
4. **Salience is character-relative; facts are not.** The profile shapes what the
|
||||
character *cares to remember*; it must never license rewriting *what happened* —
|
||||
remembered content stays grounded in the transcript.
|
||||
5. **Close on attention** ("...notices who is unwell, who is troubled, what is left
|
||||
unsaid") — state the salience focus plainly.
|
||||
|
||||
## 4. Few-shot exemplars (validated P03 — OCEAN in [−1, 1] storage units → emitted prose)
|
||||
|
||||
**Perrin, court scribe** (O0.0 C0.2 E−0.2 A0.1 N0.7)
|
||||
> Perrin keeps the court's records and has done so through two changes of regime. He
|
||||
> learned early that small errors compound — a misfiled writ once cost a man his lands,
|
||||
> and Perrin found the mistake too late to undo it. Since then he double-checks
|
||||
> everything and watches situations closely for what is out of place. He forms
|
||||
> attachments slowly and holds a given trust as a considerable thing. He measures himself
|
||||
> by whether he was useful and careful. He notices discrepancies, unspoken tensions, and
|
||||
> anything that threatens the order he keeps.
|
||||
|
||||
**Vared, veteran caravan guard** (O−0.2 C0.4 E−0.5 A−0.2 N−0.7)
|
||||
> Vared has guarded caravans across the northern routes for twenty years and buried more
|
||||
> traveling companions than he cares to count. He speaks little and shows less. Danger he
|
||||
> treats as weather — a thing to be handled. He judges people by what they do under
|
||||
> pressure and remembers who held the line. What reaches him reaches him quietly and
|
||||
> privately. He notices terrain, exits, who is armed, and shifts in a group that might
|
||||
> precede trouble.
|
||||
|
||||
**Sella, village healer** (O0.2 C0.2 E0.0 A0.8 N0.0)
|
||||
> Sella has tended the sick since she was old enough to carry water for her grandmother,
|
||||
> the healer before her. She reads people's pain quickly and carries some of it with her.
|
||||
> She interprets others' actions charitably until she cannot, and prioritizes keeping the
|
||||
> peace between people. She measures a day by whether she eased someone's burden. She
|
||||
> notices who is unwell, who is troubled, and what is left unsaid.
|
||||
|
||||
## 5. Validation
|
||||
|
||||
The gate is: **does the prose describe disposition / appraisal-style / history / values /
|
||||
attention, and NEVER what the character feels?** A mechanical lint (scan the fixed-15 OCC
|
||||
emotion roster + Worldtree's `synonym_map.json`) is a **backstop, not the gate** — treat a
|
||||
hit as a prompt to re-read, not an auto-reject.
|
||||
|
||||
## 6. Designing a varied cast / contrast (optional)
|
||||
|
||||
When two characters should remember **noticeably different things**: design the divergence
|
||||
on **attention + values first**, then set OCEAN to **serve** that prose (not the reverse).
|
||||
The sharpest contrast is a salience **drop** — one character for whom relational content is
|
||||
genuinely non-salient (a Vared-mold operational type: notices terrain, logistics, who is
|
||||
armed) vs one who weights it highest (a caretaker: tracks who is troubled, what went
|
||||
unsaid). *"Different notes, same facts" only has teeth when one character legitimately
|
||||
forgets what the other keeps.* High-yield axes: **O** (patterns attended), **A** (relational
|
||||
vs operational), **C** (procedural/detail). Low-yield: **E** (expression, not attention).
|
||||
Watch **direction, not just distance** — flipping every axis doesn't guarantee contrast (an
|
||||
even-more-agreeable opposite keeps relational content *harder*).
|
||||
|
||||
## 7. No-profile fallback
|
||||
|
||||
A character with **no** authored profile falls back to deterministic **OCEAN-band
|
||||
rendering** from the numbers alone — it still functions, but the authored profile is what
|
||||
turns generic band cues into *this* character's appraisal and salience.
|
||||
|
||||
---
|
||||
|
||||
**Provenance:** derived from `psych-profile-authoring-spec.md` (R34/R35 self-report
|
||||
reframe, probes P02–P05; non-priming + magnitude-in-OCEAN are operator rulings 2026-07-10).
|
||||
The affect half is live in Worldtree b53. A powered efficacy eval (memory half) is
|
||||
preregistering; findings will refine the parameters, not overturn the constraints.
|
||||
+372
@@ -0,0 +1,372 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
module: "soong_lab.export"
|
||||
purpose: "Assemble a versioned export BUNDLE from a DesignObject — the native agents.define payload (Frame Invariant 1, emitted unchanged) + the soong-lab sidecar (portrait ref · Bifrost tool manifest · first_message) + the resume half (the full editable design state), under a versioned schema tolerant of unknown future metadata. Pure + deterministic: no I/O, no persistence, no network (library persistence + import are separate downstream epics)."
|
||||
depends_on:
|
||||
- "soong_lab.design" # validate_ocean + ROLE_CHOICES/validate_role (the role enum canon) + the DesignObject model + serialize_design (relocated here — see Integration points R1)
|
||||
used_by:
|
||||
- "soong_lab.bifrost" # the export design-tool handler (_make_export) builds the bundle for the session's design
|
||||
- "soong_lab.web" # the /api/export endpoint + the browser 'Export Asset' modal render the bundle
|
||||
- "soong_lab.importer" # FUTURE (import epic) — round-trips the resume half back into a DesignObject
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 200
|
||||
confidence: 0.82
|
||||
assumptions:
|
||||
- "The DesignObject handed to export is already mutated to its final state by E3 (the Soong convo loop). Export READS it; it never mutates the design (INV-E5-4)."
|
||||
- "design_id is CALLER-SUPPLIED (a param), not generated here. Its generation + lifecycle (the durable library key, ≠ Worldtree session_id) is the per-design-sessions epic; export only needs the value to stamp the bundle. This keeps E5-export self-contained + forward-compatible with per-design-sessions landing before OR after it (agent-discretion, see open_question C)."
|
||||
- "exported_at is CALLER-SUPPLIED (a param, default None). Pure builders can't read the clock; the caller (tool handler / endpoint) stamps the timestamp so build_export_bundle stays deterministic + testable (byte-identical output for identical inputs)."
|
||||
- "role is a FIRST-CLASS design field (operator ruling 2026-07-13), set by the E3a set_role tool from a CURATED 4-value enum ROLE_CHOICES = {assistant, thoughtful-assistant, character, thoughtful-character} — mirroring the D2 curated-style-modes pattern (a fixed semantic set, NOT the target deployment's arbitrary model-role registry). Export EMITS design.role into ship.native.role, so the native payload is directly valid (Frame Invariant 1 now holds literally — no 'modulo role' caveat; only tools still bind separately at session-create). The 4 enum values are canonical soong-lab labels that map to WT model-role slugs. open_question B RESOLVED (2026-07-14, worldtree-dev via ratatoskr-dev): the 4 labels ARE WT slugs by EXACT name (1:1, no remap) — assistant/thoughtful-assistant under the `foundational` grant (gen / gen-reasoning seats), character/thoughtful-character under the `character` grant (char-rp / char-rp-reasoning seats). So ship.native.role is directly define-valid; the only deploy concern is that the CONSUMER's key must hold the matching grant (define 403s otherwise — same model.use requirement as Soong's own 'agent_architect', ADR-0012). Not a contract blocker."
|
||||
- "psych_profile exports to the NATIVE persona layer at persona.psychological_profile. RESOLVED: the vendored canonical spec (docs/psych-profile-authoring-spec.md §4) states the wire shape is LOCKED (b53) — a single prose str field, Tier-3 ValidatedPersona.psychological_profile, nesting under the existing Any-typed persona field (no schema change). Corroborated by worldtree-codex (vor-cross) + brokkr-smithy-dev (althing 01KXD34ZTF…). The open worldtree-dev thread (01KXD1PZR7…) closes as a formality."
|
||||
open_questions:
|
||||
- "[B — deploy grounding, not a blocker] The 4 ROLE_CHOICES values (assistant / thoughtful-assistant / character / thoughtful-character) must be registered + grantable model-role slugs on the TARGET Worldtree (like Soong's own agent_architect role). Confirm with worldtree-dev/infra that these exact slugs exist on the deploy target before shipping; a missing slug fails the designed agent's session-create, not export. Export emits whatever role the design holds; validity of the slug on a given deployment is a deploy concern."
|
||||
- "[C — agent-discretion, notable] design_id as a caller-supplied param (drafted) vs E5-export generating it. Drafted as an input so E5-export doesn't force per-design-sessions to land first. If the operator re-sequences the epics so per-design-sessions lands first, no change needed here (the param source just moves)."
|
||||
- "[D — scope] E5-export = the PURE builders + validators + bundle schema (this contract). The /api/export endpoint + replacing the web/api.js exportBundle shim = a thin web-surface follow-up (amends web_surface.contract.md), NOT this contract. The Bifrost export-tool wiring IS in scope (Integration points) because the tool already exists as a stub. The set_role tool + DesignObject.role field are a companion prerequisite slice (Integration points) whose contract updates land in THIS pass (design_object + bifrost_server)."
|
||||
- "schema_version starts at '1.0'. The version bump policy on future bundle-shape changes (add-only vs breaking) is deferred to when the second version actually exists — v1 only needs the field present + readers to tolerate unknown metadata (INV-E5-6)."
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
E5-export is the FOUNDATION half of the operator-accepted (2026-07-13)
|
||||
export/import/library design — the block that expands the locked single-agent
|
||||
frame into a multi-pass tuning loop (design → export → reopen → tune → keep a
|
||||
library). This contract owns exactly ONE thing: turning a finished
|
||||
`DesignObject` into a **versioned export bundle**. Persistence (the library JSON
|
||||
dir), the recent-designs picker, and import round-tripping are separate
|
||||
downstream epics; export is pure and deterministic so those epics — and the
|
||||
tests — can build on a stable, side-effect-free core.
|
||||
|
||||
**The bundle is ONE artifact with two halves** (settled decision #4):
|
||||
|
||||
- **ship** — what you hand to a deployment: the native `agents.define` payload
|
||||
(Frame Invariant 1, emitted unchanged) + the soong-lab **sidecar** (persona
|
||||
portrait ref, the Bifrost tool manifest, the D3 first_message).
|
||||
- **resume** — what you reopen to keep tuning: the full editable design state
|
||||
(the §6 DesignObject serialization), so a future import reconstructs the
|
||||
DesignObject exactly.
|
||||
|
||||
Plus a stable **`design_id`** (the durable library key, ≠ Worldtree
|
||||
`session_id`) and a **`schema_version`**, both at the top level.
|
||||
|
||||
**Frame Invariant 1 is preserved — and now holds literally.** `ship.native` is a
|
||||
valid Worldtree Tier-3 `agents.define` payload assembled from `agent_name` + the
|
||||
designed agent's **`role`** (the model-role, resolved below) + the AUTHORED
|
||||
`system_prompt` (INV-E2-2 — never `composed_preview`) + `persona.ocean`
|
||||
(Worldtree renders affect at runtime) + `persona.psychological_profile` (the
|
||||
native home, LOCKED b53 per the vendored spec §4) + `motivational` (from
|
||||
goals_fears). The image and tools are NOT in the native schema — they ride the
|
||||
sidecar (tools bind via Bifrost at session-create, exactly as grounded).
|
||||
|
||||
**The `role` resolution (operator ruling 2026-07-13).** The blast-radius pass
|
||||
caught that `agents.define` requires `role` (a model-role slug, ADR-0012) but the
|
||||
design had no source for it. Resolution: **role is a first-class design field**,
|
||||
set by a new E3a **`set_role`** tool from a **curated 4-value enum** —
|
||||
`assistant` (general LLM), `thoughtful-assistant` (CoT general),
|
||||
`character` (RP/writing-tuned), `thoughtful-character` (CoT RP). This mirrors the
|
||||
D2 curated-style-modes decision: a fixed semantic set the operator picks from,
|
||||
NOT a coupling to any one deployment's arbitrary role registry. Export emits
|
||||
`design.role`, so the native payload is directly POST-valid (modulo the tool
|
||||
binding every consumer already supplies at session-create). The one deploy-time
|
||||
caveat: the 4 slugs must be granted on the target Worldtree (open_question B).
|
||||
|
||||
**The psych field is RESOLVED (no longer quarantined).** Vendored spec §4 locks
|
||||
`persona.psychological_profile` (prose `str`, ~150–300 words, read every turn),
|
||||
nesting under the `Any`-typed persona layer. Export maps `design.psych_profile`
|
||||
there and NOWHERE else — spec §4's hard constraint is that the self-report lens
|
||||
reads ONLY this field (leaking psych prose into `behavioral_notes`/`system_prompt`
|
||||
causes the "executive-assistant" failure).
|
||||
|
||||
## Data flow
|
||||
|
||||
**In:** a `DesignObject` (final, from E3) + a caller-supplied `design_id` (str)
|
||||
+ an optional caller-supplied `exported_at` (str | None). **Out:** a plain
|
||||
JSON-ready `dict` — the versioned bundle. **On disk / network:** NONE. Export is
|
||||
pure: the OCEAN parity gate (`validate_ocean`), the role-enum gate
|
||||
(`validate_role`), and the export-critical validators are in-memory; timestamps +
|
||||
ids come in as params; no clock, no randomness, no file, no HTTP. (Library
|
||||
persistence writes the returned dict to the JSON dir — that is the library epic,
|
||||
not this module.)
|
||||
|
||||
### Export bundle schema (v1.0)
|
||||
|
||||
```
|
||||
{
|
||||
"schema_version": "1.0", # ALWAYS EXPORT_SCHEMA_VERSION — not a caller param
|
||||
"design_id": "<caller-supplied durable library key, ≠ WT session_id>",
|
||||
"exported_at": <caller-supplied OPAQUE str | null — conventionally ISO-8601, NOT validated by export>,
|
||||
"ship": {
|
||||
"native": { # a valid agents.define payload (Frame Invariant 1)
|
||||
"agent_name": <str, non-blank, ≤128>,
|
||||
"role": <one of ROLE_CHOICES: assistant|thoughtful-assistant|character|thoughtful-character>,
|
||||
"system_prompt": <str, non-blank, ≤32768 — the AUTHORED block, INV-E2-2>,
|
||||
"persona": {
|
||||
"ocean": {O,C,E,A,N}, # each a real number in [-1,1] (validate_ocean parity)
|
||||
"psychological_profile": <str> # persona.psychological_profile (LOCKED b53); included iff non-blank
|
||||
},
|
||||
"motivational": { # WT #187 OBJECTS, not strings (ratatoskr-dev bug 2026-07-16); iff goals_fears present + non-empty
|
||||
"goals": [{"id": "goal-N", "type": <maintenance|achievement|avoidance>, "salience": <0..1>, "description": <str>=20 chars>}],
|
||||
"fears": [{"id": "fear-N", "salience": <0..1>, "description": <str >=20 chars>}]
|
||||
} # `id` synthesized at export (goal-N/fear-N, unique across both); validate_exportable gates description>=20 / type∈GOAL_TYPES / salience∈[0,1]
|
||||
},
|
||||
"sidecar": {
|
||||
"portrait": <image ref str | null>, # only when portrait.status == "ready"; E4 owns generation
|
||||
"tools": [{"id","name","description"}],# the Bifrost tool manifest (bind at session-create)
|
||||
"first_message": <str> # the D3 opening turn (issue #347 seed)
|
||||
}
|
||||
},
|
||||
"resume": { <the §6 camelCase editable state — key set inlined below> }
|
||||
}
|
||||
```
|
||||
|
||||
**The `resume` key set (inlined — heid-review fold Gróa #9).** The resume half IS
|
||||
`serialize_design(design)` (relocated to `soong_lab.design`, R1), but its key set is
|
||||
pinned HERE so this contract is self-contained and an implementer knows the exact
|
||||
round-trip surface without reading the external, being-relocated function:
|
||||
|
||||
```
|
||||
resume = {
|
||||
"agentName", "role", "systemPrompt", "composedPreview", "firstMessage",
|
||||
"ocean" {O,C,E,A,N}, "dispositionPhrase", "psychProfile",
|
||||
"tools" [{id,name,description}], "portrait" {status, styleMode, imageUrl?, jobId?},
|
||||
"goalsFears" {goals,fears} | null
|
||||
}
|
||||
```
|
||||
|
||||
Import reconstructs a DesignObject from exactly these keys. `role` (new, R1) MUST be
|
||||
present so a reopened design carries its model-role. (`composedPreview` +
|
||||
`dispositionPhrase` are design-time-derived and re-derivable, but they ride the resume
|
||||
so a reopen renders instantly before the first recompute.)
|
||||
|
||||
**Divergences from the imported web mock (settled here, they were UI-comp
|
||||
shortcuts):**
|
||||
|
||||
| Field | Mock (web/*.js) | Real export (this contract) |
|
||||
|---|---|---|
|
||||
| native shape | `{name, tier, system_prompt, personality:{model,values}}` | real `agents.define` (`agent_name`/`role`/`persona.ocean`/`motivational`) |
|
||||
| role | absent | `design.role` ∈ ROLE_CHOICES |
|
||||
| system_prompt | `composedPreview` (mockApi) | authored `system_prompt` (INV-E2-2) |
|
||||
| psychProfile | omitted ("open backend decision") | `persona.psychological_profile` (LOCKED b53) |
|
||||
| bundle identity | none | `design_id` + `schema_version` |
|
||||
| resume half | none | full `serialize_design` state |
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-E5-1** [hard]: `ship.native` is a valid Worldtree `agents.define` payload
|
||||
MODULO the tool binding — it carries every required field (`agent_name`,
|
||||
`role`, `system_prompt`) + `persona.ocean`, and OMITS only the tools (they bind
|
||||
via Bifrost at session-create, as they already do). Any `persona.ocean` export
|
||||
emits passes `validate_ocean`; `role` is always one of ROLE_CHOICES.
|
||||
`persona.psychological_profile` + `motivational` are OPTIONAL native fields
|
||||
(grounded) — omitting them when blank/empty keeps the payload fully valid, not
|
||||
merely "valid enough" (heid-review fold, Gróa #1).
|
||||
- **INV-E5-2** [hard]: The exported `system_prompt` is the AUTHORED
|
||||
`design.system_prompt`, NEVER `composed_preview` (binds with INV-E2-2). The
|
||||
**disposition line** — the `"Disposition: <name> is <phrase>."` sentence that
|
||||
E2 `recompute` appends to `composed_preview` (design_object.contract.md POST-E2-5)
|
||||
— is design-time-only and never ships.
|
||||
- **INV-E5-3** [hard]: Export is pure + deterministic — identical
|
||||
`(design, design_id, exported_at)` inputs yield a byte-identical serialized
|
||||
bundle. No clock, no randomness, no I/O. The determinism is WITHIN the module:
|
||||
the returned dict has a fixed key insertion order (schema_version, design_id,
|
||||
exported_at, ship, resume; native + sidecar likewise), so any consistent
|
||||
`json.dumps` settings produce byte-identical output — the invariant does NOT
|
||||
claim cross-implementation byte-identity (heid-review fold, Regin #6).
|
||||
- **INV-E5-4** [hard]: Export NEVER mutates the input `DesignObject` (read-only);
|
||||
the bundle holds copies, not aliases, of every mutable sub-structure (ocean
|
||||
dict, tool list, goals/fears lists) so a later design mutation can't change an
|
||||
already-built bundle.
|
||||
- **INV-E5-5** [hard]: `validate_exportable` is the strict export-critical gate
|
||||
(decision #6): OCEAN (via `validate_ocean`), role (∈ ROLE_CHOICES via
|
||||
`validate_role`), agent_name (non-blank, ≤128), system_prompt (non-blank,
|
||||
≤32768), tool-refs (id/name non-blank + bounded). A design that fails ANY of
|
||||
these raises `ExportError` and NO bundle is produced — a built bundle is always
|
||||
well-formed enough to round-trip on import.
|
||||
- **INV-E5-6** [hard]: The bundle carries `schema_version` at the top level, and
|
||||
readers (import, future) MUST tolerate unknown extra keys (lenient on unknown
|
||||
metadata, decision #6) — the schema is add-only-friendly.
|
||||
- **INV-E5-7** [hard]: `psych_profile` maps to `persona.psychological_profile`
|
||||
and NOWHERE else — it never leaks into `behavioral_notes`, `system_prompt`, or
|
||||
any other native field (vendored spec §4 hard constraint — the lens reads only
|
||||
this dedicated field).
|
||||
|
||||
## Constraints
|
||||
|
||||
- **[correctness]** `validate_exportable`'s OCEAN check IS `validate_ocean` and
|
||||
its role check IS `validate_role` (both E2) — no re-implementation, no drift.
|
||||
The LENGTH bounds (name, prompt, tool id/name/desc, psych_profile, first_message)
|
||||
MUST equal the E3a tool-schema caps — now shared constants in `soong_lab.design`
|
||||
(`AGENT_NAME_MAX`, `SYSTEM_PROMPT_MAX`, `PSYCH_PROFILE_MAX`, `FIRST_MESSAGE_MAX`,
|
||||
`TOOL_*_MAX`), imported by BOTH bifrost/tools.py and export — so a design's field
|
||||
LENGTHS never drift. Import the shared constants; do not re-declare the numbers.
|
||||
(Export is stricter only on whitespace-blankness of the required fields — the one
|
||||
intentional one-way difference from the tools' minLength:1.)
|
||||
- **[style]** Pure — NO I/O (no clock, no file, no HTTP, no randomness). Every
|
||||
time-varying value (`design_id`, `exported_at`) is a param.
|
||||
- **[explicit]** The one deploy-time caveat (the 4 role slugs must be granted on
|
||||
the target WT) is documented in THIS contract (open_question B) + the library /
|
||||
README when it lands — NOT promised as a bundle/sidecar field (heid-review fold:
|
||||
the bundle is machine-consumed; a human deploy-note is not bundle data). The
|
||||
bundle carries the `role` value; slug-grant validity is a deploy concern.
|
||||
- **[explicit]** `build_export_bundle` is the PUBLIC entrypoint — it runs the
|
||||
validate→assemble ordering. `build_native_payload` / `build_sidecar` are exposed
|
||||
for testing + reuse but ASSUME an already-validated design (PRE-E5-2 / PRE-E5-4);
|
||||
a direct caller that skips `validate_exportable` owns that gate (heid-review fold,
|
||||
Hulda #5).
|
||||
|
||||
```contract
|
||||
FN validate_exportable(design: DesignObject) -> None
|
||||
BRIEF: The strict export-critical gate (settled decision #6) — refuse to build a bundle from a design that would fail on re-import or at the designed agent's define/session-create. Checks OCEAN (validate_ocean), role (validate_role), agent_name, system_prompt, every tool-ref, and the psych_profile/first_message LENGTH — against the SAME length caps the E3a tools enforce (shared constants). NO-DRIFT is one-directional: export's LENGTH bounds equal the tool caps, but export is deliberately STRICTER on whitespace — a whitespace-only required field (name/prompt/tool id/name) passes the tools' minLength:1 yet is rejected here (a " " name must not ship). Raises ExportError with the offending field; never mutates the design.
|
||||
PRE: [PRE-E5-1 hard] design is a DesignObject
|
||||
POST: [POST-E5-1 exception] raises ExportError(field, detail) unless ALL hold: design.ocean passes validate_ocean; design.role passes validate_role (∈ ROLE_CHOICES); agent_name is a non-blank str of len ≤ _AGENT_NAME_MAX; system_prompt is a non-blank str of len ≤ _SYSTEM_PROMPT_MAX; every tool has non-blank str id (≤_TOOL_ID_MAX) + non-blank str name (≤_TOOL_NAME_MAX) + str description (≤_TOOL_DESC_MAX); psych_profile is a str of len ≤ _PSYCH_PROFILE_MAX (blank OK); first_message is a str of len ≤ _FIRST_MESSAGE_MAX (blank OK). The id/name-required vs description/psych/first_message-may-be-blank asymmetry is INTENTIONAL — description defaults to "" via attach_tool; psych_profile/first_message are optional prose so only their LENGTH is bounded, not blankness (heid-review Gróa #8 + correctness-finder folds)
|
||||
POST: [POST-E5-2 state_change] design is unchanged — no mutation (INV-E5-4)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] TRY validate_ocean(design.ocean) — on OceanError, RAISE ExportError("persona.ocean", str(exc)) (reuse E2, no re-impl)
|
||||
2. [sequential, flexibility=prescriptive] TRY validate_role(design.role) — on RoleError, RAISE ExportError("role", str(exc)) (reuse E2 role canon)
|
||||
3. [branch] IF agent_name is not a non-blank str OR len > _AGENT_NAME_MAX: RAISE ExportError("agent_name", ...)
|
||||
4. [branch] IF system_prompt is not a non-blank str OR len > _SYSTEM_PROMPT_MAX: RAISE ExportError("system_prompt", ...) # the AUTHORED block, INV-E5-2
|
||||
5. [loop] FOR EACH tool in design.tools: IF id/name blank or over max, or description non-str/over max: RAISE ExportError(f"tools[{i}]", ...)
|
||||
6. [branch] IF psych_profile is non-str OR len > _PSYCH_PROFILE_MAX: RAISE ExportError("psych_profile", ...) # length only — blank OK (optional prose)
|
||||
7. [branch] IF first_message is non-str OR len > _FIRST_MESSAGE_MAX: RAISE ExportError("first_message", ...) # length only — blank OK
|
||||
8. [cleanup] RETURN None
|
||||
TESTS:
|
||||
minimal_ok [happy,tracer]: agent_name+system_prompt set, role="character", neutral OCEAN, no tools → no raise
|
||||
blank_name [adversarial]: agent_name="" → ExportError("agent_name")
|
||||
blank_prompt [adversarial]: system_prompt=" " → ExportError("system_prompt")
|
||||
prompt_too_long [boundary]: system_prompt of len _SYSTEM_PROMPT_MAX+1 → ExportError; len _SYSTEM_PROMPT_MAX → ok
|
||||
bad_ocean [adversarial]: ocean missing a key → ExportError("persona.ocean") (via validate_ocean)
|
||||
bad_role [adversarial]: role="wizard" (not in ROLE_CHOICES) → ExportError("role") (via validate_role)
|
||||
blank_role [adversarial]: role="" → ExportError("role")
|
||||
bad_tool_ref [adversarial]: a tool with id="" → ExportError("tools[0]")
|
||||
no_mutation [property]: a rejected design is byte-identical before/after the raise (INV-E5-4)
|
||||
psych_profile_length [boundary]: psych_profile="" → ok; len _PSYCH_PROFILE_MAX+1 → ExportError("psych_profile")
|
||||
first_message_length [boundary]: first_message len _FIRST_MESSAGE_MAX+1 → ExportError("first_message"); blank → ok
|
||||
whitespace_name_rejected [adversarial]: agent_name=" " → ExportError("agent_name") — deliberately stricter than the tool's minLength:1 (a whitespace-only name must not ship)
|
||||
length_bounds_parity [property]: any (name, prompt, tool, psych, first_message) LENGTH the E3a tool schema accepts is ≤ export's caps (shared constants); export is stricter ONLY on whitespace-blankness of required fields, never looser on length
|
||||
```
|
||||
|
||||
```contract
|
||||
FN build_native_payload(design: DesignObject) -> dict[str, Any]
|
||||
BRIEF: Map a DesignObject to a valid native agents.define payload (Frame Invariant 1). Emits agent_name + role + the AUTHORED system_prompt + persona{ocean, psychological_profile?} + motivational?. Copies mutable sub-structures (INV-E5-4). Assumes validate_exportable already passed (called by build_export_bundle).
|
||||
PRE: [PRE-E5-2 hard] design passed validate_exportable (OCEAN valid, role valid, name/prompt present) — build_export_bundle enforces this ordering
|
||||
POST: [POST-E5-3 return_value] result has agent_name == design.agent_name, role == design.role (∈ ROLE_CHOICES), and system_prompt == design.system_prompt (the AUTHORED block, INV-E5-2), and result["persona"]["ocean"] == a COPY of design.ocean
|
||||
POST: [POST-E5-4 return_value] result["role"] == design.role — the designed agent's model-role (one of the 4 ROLE_CHOICES); a valid agents.define required field
|
||||
POST: [POST-E5-5 return_value] persona.psychological_profile == design.psych_profile when psych_profile is non-blank, else the key is absent; it appears under persona and NOWHERE else (INV-E5-7)
|
||||
POST: [POST-E5-6 return_value] motivational == {"goals": copy, "fears": copy} when design.goals_fears is present AND at least one list is non-empty; else the key is absent (never an empty motivational block)
|
||||
STEPS:
|
||||
1. [setup] payload = {"agent_name": design.agent_name, "role": design.role, "system_prompt": design.system_prompt} # role emitted; system_prompt is the authored block (INV-E5-2)
|
||||
2. [sequential] persona = {"ocean": dict(design.ocean)} # COPY, not alias (INV-E5-4)
|
||||
3. [branch] IF design.psych_profile is a non-blank str: persona["psychological_profile"] = design.psych_profile # LOCKED b53 field; ONLY here (INV-E5-7)
|
||||
4. [sequential] payload["persona"] = persona
|
||||
5. [branch] IF design.goals_fears is not None AND (goals or fears non-empty): payload["motivational"] = {"goals": list(gf.goals), "fears": list(gf.fears)}
|
||||
6. [cleanup] RETURN payload # tools NOT here — they ride the sidecar / bind via Bifrost at session-create
|
||||
TESTS:
|
||||
authored_prompt [happy,tracer]: system_prompt authored + composed_preview differs → payload.system_prompt == authored, NOT composed_preview (INV-E5-2)
|
||||
role_emitted [happy]: role="thoughtful-character" → payload.role == "thoughtful-character" (POST-E5-4)
|
||||
ocean_copied [property]: mutate design.ocean after build → payload's ocean unchanged (INV-E5-4)
|
||||
psych_present [happy]: psych_profile set → persona.psychological_profile == it; it is the ONLY field carrying it (INV-E5-7)
|
||||
psych_absent [boundary]: psych_profile="" → no psychological_profile key
|
||||
motivational_present [happy]: goals_fears with goals=["x"] → motivational.goals == ["x"]
|
||||
motivational_absent [boundary]: goals_fears None → no motivational key; goals_fears with both lists empty → no motivational key
|
||||
no_tools_no_image [trace]: payload has no "tools" and no image field (they ride the sidecar / bind separately)
|
||||
```
|
||||
|
||||
```contract
|
||||
FN build_sidecar(design: DesignObject) -> dict[str, Any]
|
||||
BRIEF: Assemble the soong-lab sidecar — the three artifacts the native schema has no home for: the persona portrait ref, the Bifrost tool manifest, and the D3 first_message. Copies the tool list (INV-E5-4).
|
||||
PRE: [PRE-E5-4 hard] design is a DesignObject (its portrait/tools/first_message fields are read as-is; no validation here — validate_exportable is the gate, called by build_export_bundle before this)
|
||||
POST: [POST-E5-7 return_value] result == {"portrait": <str|None>, "tools": [{"id","name","description"} per tool, copied], "first_message": design.first_message}; portrait == design.portrait.image_url IFF design.portrait.status == "ready", else None (a "ready" status with a None image_url therefore yields None — no crash; any non-"ready" status → None — heid-review fold Gróa #4)
|
||||
STEPS:
|
||||
1. [setup] portrait = design.portrait.image_url if design.portrait.status == "ready" else None
|
||||
2. [sequential] tools = [t.to_dict() for t in design.tools] # ToolRef.to_dict() — the shared {id,name,description} projection (dedups with serialize_design); it MUST emit exactly id/name/description, so if to_dict ever grows keys the sidecar spec must be revisited (heid-code-review fold)
|
||||
3. [cleanup] RETURN {"portrait": portrait, "tools": tools, "first_message": design.first_message}
|
||||
TESTS:
|
||||
ready_portrait [happy]: portrait.status="ready", image_url set → sidecar.portrait == the url
|
||||
unready_portrait [boundary]: portrait.status="generating" (url set) → sidecar.portrait is None (only ready ships)
|
||||
none_portrait [boundary]: portrait.status="none" → sidecar.portrait is None
|
||||
tools_manifest [happy,tracer]: two tools → sidecar.tools has both {id,name,description}
|
||||
tools_copied [property]: mutate design.tools after build → sidecar.tools unchanged (INV-E5-4)
|
||||
first_message [happy]: first_message set → sidecar.first_message == it
|
||||
```
|
||||
|
||||
```contract
|
||||
FN build_export_bundle(design: DesignObject, *, design_id: str, exported_at: str | None = None) -> dict[str, Any]
|
||||
BRIEF: The top-level export entrypoint — validate (strict, INV-E5-5), then assemble the versioned bundle: {schema_version, design_id, exported_at, ship:{native, sidecar}, resume}. Pure + deterministic (INV-E5-3); the caller supplies design_id + exported_at (no clock here). The resume half reuses serialize_design (the §6 state) so import round-trips. schema_version is NOT a caller param (heid-review fold) — it is ALWAYS EXPORT_SCHEMA_VERSION, so a bundle's version is never caller-forgeable; a future migration bumps the module constant. exported_at is an OPAQUE caller-supplied string (conventionally ISO-8601) — export does NOT parse or validate it (purity; the caller owns timestamp correctness).
|
||||
PRE: [PRE-E5-3 hard] design_id is a non-blank str (the durable library key) — a blank id RAISES ExportError("design_id", ...) (a bundle with no library key is unusable)
|
||||
POST: [POST-E5-8 exception] IF the design fails validate_exportable, the ExportError propagates and NO bundle is returned (INV-E5-5) — validation is BEFORE assembly
|
||||
POST: [POST-E5-9 return_value] returns {schema_version: EXPORT_SCHEMA_VERSION (always), design_id, exported_at, ship:{native: build_native_payload(design), sidecar: build_sidecar(design)}, resume: serialize_design(design)}; exported_at is the param verbatim (None → JSON null), unvalidated
|
||||
POST: [POST-E5-10 return_value] deterministic — identical (design, design_id, exported_at) → byte-identical json.dumps(result) given fixed dumps settings; the returned dict has a FIXED key insertion order (schema_version, design_id, exported_at, ship, resume), so a caller's json.dumps is stable (INV-E5-3); design unchanged (INV-E5-4)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] IF design_id is not a non-blank str: RAISE ExportError("design_id", "a non-blank design_id is required")
|
||||
2. [sequential] CALL validate_exportable(design) # strict gate BEFORE assembly (INV-E5-5) — raises propagate
|
||||
3. [sequential] native = build_native_payload(design); sidecar = build_sidecar(design); resume = serialize_design(design)
|
||||
4. [cleanup] RETURN {"schema_version": EXPORT_SCHEMA_VERSION, "design_id": design_id, "exported_at": exported_at, "ship": {"native": native, "sidecar": sidecar}, "resume": resume}
|
||||
TESTS:
|
||||
full_bundle [happy,tracer]: a complete design + design_id="d-1" → bundle has schema_version, design_id=="d-1", ship.native.agent_name, ship.native.role, ship.sidecar.first_message, resume.systemPrompt
|
||||
blank_design_id [adversarial]: design_id="" → ExportError("design_id") before any assembly
|
||||
invalid_design_no_bundle [adversarial]: a design with blank agent_name → ExportError propagates, no dict returned (POST-E5-8)
|
||||
deterministic [property]: build twice with the same (design, design_id, exported_at) → byte-identical json.dumps (INV-E5-3)
|
||||
exported_at_passthrough [trace]: exported_at="2026-07-13T00:00:00Z" → bundle.exported_at == it verbatim; None → null; a non-ISO "banana" is passed through unvalidated
|
||||
schema_version_not_a_param [trace]: build_export_bundle(..., schema_version="banana") raises TypeError — schema_version is fixed, never caller-supplied (heid-review fold)
|
||||
resume_roundtrips [property]: resume half == serialize_design(design) — every editable field present for import (incl. role)
|
||||
no_mutation [property]: design byte-identical before/after build (INV-E5-4)
|
||||
schema_version_present [trace]: bundle.schema_version == EXPORT_SCHEMA_VERSION (INV-E5-6)
|
||||
```
|
||||
|
||||
## Integration points
|
||||
|
||||
**R1 — relocate `serialize_design` out of `web.py` (agent-discretion refactor,
|
||||
no public-surface change).** The resume half reuses the §6 DesignObject
|
||||
serialization, but `serialize_design` currently lives in `soong_lab.web`
|
||||
(Starlette-coupled). Importing `web.py` into `export` would drag Starlette +
|
||||
the orchestrator into a pure module. Fix: **move `serialize_design` to
|
||||
`soong_lab.design`** (it is a pure `DesignObject → dict` mapping with no web
|
||||
dependency — it belongs with the model; add `role` to its output), and update the
|
||||
two consumers to import it from there. Blast radius (confirmed via grep):
|
||||
`web.py` (define → import; 3 call-sites unchanged), `tests/test_web.py:23`
|
||||
(import path), and the new `export` consumer. Behavior-identical;
|
||||
`web_surface.contract.md` gets a one-line note. No-backwards-compat: the old
|
||||
location is deleted, all refs updated in the same commit.
|
||||
|
||||
**Companion prerequisite slice — the `role` field + `set_role` tool (contracts
|
||||
updated in THIS pass).** Export emits `design.role`, so the field + its tool must
|
||||
exist. This slice (governed by the sibling contracts, amended alongside this one):
|
||||
- `soong_lab.design` (design_object.contract.md): a `role` field on
|
||||
`DesignObject` (default `"character"`); a `ROLE_CHOICES` enum canon +
|
||||
`validate_role`, held as an in-code module constant (mirroring the OCEAN
|
||||
adjective canon); `new_design()` sets `role="character"`; `serialize_design`
|
||||
adds `role`.
|
||||
- `soong_lab.bifrost` (bifrost_server.contract.md): a new `set_role(_ctx, role)`
|
||||
design tool (the 9th), `input_schema` an `enum` of the 4 values; the handler
|
||||
sets `design.role` after membership validation.
|
||||
The behavioral CODE for this slice lands in the TDD phase after
|
||||
`/heid-contract-review`, alongside `soong_lab.export`.
|
||||
|
||||
**Bifrost export tool (`_make_export` in bifrost/tools.py) — in scope.** Replace
|
||||
the deferred stub with: get the session's design from the store, then
|
||||
`build_export_bundle(design, design_id=<source>, exported_at=<stamp>)` and
|
||||
return the bundle (or a compact confirmation carrying it). The `design_id`
|
||||
source is the per-design-sessions seam (open_question C) — until it lands, the
|
||||
tool may pass the session_id as a provisional design_id (a documented
|
||||
placeholder, NOT a silent default). The tool handler is the impure boundary that
|
||||
stamps `exported_at` (clock) and supplies `design_id`, keeping
|
||||
`soong_lab.export` pure.
|
||||
|
||||
**`/api/export` endpoint + web/api.js shim — NOT in this contract (open_question
|
||||
D).** The browser 'Export Asset' button calls `api.export()`, today a
|
||||
client-side shim assembling a NON-native mock bundle. The real path is a thin
|
||||
`GET /api/export` on `web.py` → `build_export_bundle(orchestrator.get_design(),
|
||||
…)` → JSON → the modal's native/sidecar panes render it. That amends
|
||||
`web_surface.contract.md`; it is a follow-up slice in the same epic, specified
|
||||
here only so the seam is visible.
|
||||
|
||||
## Downstream epics (NOT this contract)
|
||||
|
||||
- **Library persistence** (decision #5) — writing the returned bundle to the
|
||||
server-local single-user JSON dir on corviduo-dev, keyed by `design_id`; the
|
||||
minimal recent-designs picker.
|
||||
- **Import** (decision #6) — reading a bundle: lenient on unknown metadata
|
||||
(INV-E5-6), STRICT re-validation of the export-critical fields (the import-side
|
||||
mirror of `validate_exportable`), reconstructing a DesignObject from the
|
||||
`resume` half.
|
||||
- **Per-design-sessions** (decision #2) — the `design_id` generator + the
|
||||
fresh-WT-session-per-open lifecycle (also caps the #355 accumulation).
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
module: "soong_lab.importer"
|
||||
purpose: "Reconstruct a DesignObject from an export bundle's `resume` half — the inverse of soong_lab.export. HYBRID validation (settled decision #6): LENIENT on unknown metadata (unknown top-level bundle keys, unknown keys inside resume, any schema_version), STRICT re-validation of the export-critical fields (OCEAN, role ∈ ROLE_CHOICES, agent_name, system_prompt length, tool-refs, psych/first_message length) surfaced ON IMPORT so a truncated or tampered bundle fails EARLY, not after more tuning. Pure + deterministic: no I/O, no persistence, no network, no clock (library read + the /api/import endpoint + the reopen lifecycle are separate downstream epics)."
|
||||
depends_on:
|
||||
- "soong_lab.design" # DesignObject/ToolRef/Portrait/GoalsFears + serialize_design (the round-trip partner) + ROLE_CHOICES/UNSET_ROLE + the shared field-bound constants
|
||||
- "soong_lab.export" # validate_exportable + ExportError — the strict export-critical gate is REUSED, not re-implemented (no-drift, INV-I-1)
|
||||
used_by:
|
||||
- "soong_lab.web" # FUTURE (import epic) — the POST /api/import endpoint parses the uploaded bundle JSON → import_bundle → seed a session (out of scope here, open_question D)
|
||||
- "soong_lab.soong" # FUTURE (per-design-sessions) — the reopen lifecycle imports a stored bundle, opens a fresh WT session, seeds the design-state summary (out of scope, decision #2)
|
||||
language: "python"
|
||||
complexity: "medium"
|
||||
estimated_loc: 170
|
||||
confidence: 0.83
|
||||
assumptions:
|
||||
- "Import consumes a Python dict (a Mapping), NOT raw bytes/JSON text. The JSON parse (json.loads at the /api/import endpoint or the library-read layer) happens UPSTREAM; import operates on the already-parsed structure, exactly as export RETURNS a Python dict the caller json.dumps'es. So the round-trip contract is over Python dicts: import_bundle(build_export_bundle(d, design_id=…)) == d, with no JSON layer in between (the JSON boundary — float/int coercion, encoding — is the endpoint/library epic's concern, INV-I-5 note)."
|
||||
- "The `resume` half is the ONLY source of truth on import (settled decision #4 — resume is 'what you reopen to keep tuning'). The `ship` half is a re-derivable deployment artifact; import IGNORES it. The reopen path re-exports from the reconstructed design, regenerating ship, so a ship↔resume mismatch is harmless — resume wins (INV-I-5). No cross-check in v1."
|
||||
- "The export-critical gate on import IS soong_lab.export.validate_exportable, imported and reused verbatim — NOT a re-implemented import-side validator. This guarantees import can never drift looser than export: the exact fields export refuses to ship are the exact fields import refuses to accept (INV-I-1). ExportError is caught and re-raised as BundleImportError so callers get an import-shaped error while the validation authority stays single-sourced."
|
||||
- "role is a first-class DesignObject field (operator ruling 2026-07-13), one of the curated ROLE_CHOICES, set by the E3a set_role tool. A resume carries `role`; import restores it and validate_role (via validate_exportable) rejects UNSET_ROLE ('') or any non-member — you cannot re-import an unclassified design, same as you cannot export one."
|
||||
- "composed_preview + disposition_phrase ride the resume so a reopen renders instantly (export.contract §resume). Import TRUSTS these verbatim (INV-I-8) — it does NOT call recompute. Re-derivation from ocean+prompt is the reopen lifecycle's concern (per-design-sessions), not import's. For a legitimately-exported bundle they are already self-consistent; a hand-tampered preview is design-time-only and is overwritten on the next set_ocean/edit_prompt recompute."
|
||||
open_questions:
|
||||
- "[A — RESOLVED, operator 2026-07-13] Module name is `soong_lab.importer` (operator chose it over `soong_lab.ingest`; keyword-safe agent-noun mirroring `export`). The export contract's forward-reference `used_by: soong_lab.import` — an unusable Python-keyword path (`import soong_lab.import` is a SyntaxError) — is corrected to `soong_lab.importer` in the same commit (done). SETTLED: the Constraints hard-require reflects the decision, not a still-open recommendation (heid-review Gróa#1 reconcile open-vs-locked)."
|
||||
- "[B — SETTLED, agent-discretion] Error type is `BundleImportError(field, detail)`, mirroring export's `ExportError(field, detail)`. Deliberately NOT `ImportError` — that shadows the Python builtin, a foot-gun for an import module. The Constraints hard-require reflects the decision, not a still-open recommendation (heid-review Gróa#1)."
|
||||
- "[C — presence vs default, agent-discretion, notable] For the export-critical resume keys (agentName, role, systemPrompt, ocean) a MISSING key is a hard reject (INV-I-7), NOT a silent default. Rationale: a missing `ocean` would default to a VALID neutral OCEAN and pass validate_exportable — silently masking trait loss from a truncated bundle. Rejecting on absence fails loudly + consistently (the 'fail early on import' the decision wants). Rejected alternative: reconstruct-with-defaults-then-validate (inconsistent — ocean slips through while name/role are caught by validation)."
|
||||
- "[D — scope] This contract = the PURE reconstruction (deserialize_design) + the strict entrypoint (import_bundle) + BundleImportError. The POST /api/import endpoint (amends web_surface.contract.md), the reopen Bifrost tool / session-open wiring (per-design-sessions), and reading a bundle off the library JSON dir (library epic) are ALL downstream — specified here only as the integration seam so it is visible. Nothing in this contract does I/O."
|
||||
- "[E — schema_version tolerance] `schema_version` is read at the bundle TOP LEVEL only (where export stamps EXPORT_SCHEMA_VERSION) — import does not look for it inside `resume`. v1 tolerates ANY top-level value (present or absent) and reads the v1 resume key set regardless (INV-I-2, INV-E5-6 add-only-friendly). 'Tolerate any version' means forward-compat with ADD-ONLY future changes — NOT a promise of semantic compatibility with a bundle whose meaning changed (heid-review Gróa#5/Hulda). A future policy — reject an incompatible MAJOR version, or dispatch to a version-specific deserializer — is deferred to when a second schema version actually exists. v1 has exactly one shape."
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Import is the SECOND half of the operator-accepted (2026-07-13) export/import/library
|
||||
design — the block that expands the locked single-agent frame into a multi-pass
|
||||
tuning loop (design → export → **reopen → tune** → keep a library). Where
|
||||
`soong_lab.export` turns a finished `DesignObject` into a versioned bundle, this
|
||||
module does the inverse: it takes a bundle's **`resume`** half and reconstructs an
|
||||
editable `DesignObject` you can drop back into a session and keep tuning.
|
||||
|
||||
The reconstruction is **HYBRID-validated** (settled decision #6 — the load-bearing
|
||||
import decision):
|
||||
|
||||
- **LENIENT on unknown metadata.** Unknown top-level bundle keys, unknown keys
|
||||
inside `resume`, and any `schema_version` (present or absent) are tolerated —
|
||||
import reads only the keys it knows (INV-I-2, mirroring the export bundle's
|
||||
add-only-friendly `INV-E5-6`). A bundle from a future soong-lab that added
|
||||
fields still imports.
|
||||
- **STRICT on the export-critical fields.** OCEAN, `role`, `agent_name`,
|
||||
`system_prompt`, tool-refs, and the psych/first_message length are re-validated
|
||||
**on import** by REUSING `soong_lab.export.validate_exportable` verbatim (INV-I-1)
|
||||
— so the exact fields export refuses to *ship* are the exact fields import
|
||||
refuses to *accept*, and import can never drift looser than export. A bad field
|
||||
is surfaced immediately (fail EARLY), not after the operator has tuned for
|
||||
another ten minutes against a design that was never valid.
|
||||
|
||||
**The round-trip is the load-bearing contract between the two modules** (INV-I-3):
|
||||
for any exportable design `d`,
|
||||
|
||||
```
|
||||
import_bundle(build_export_bundle(d, design_id="…")) == d
|
||||
deserialize_design(serialize_design(d)) == d
|
||||
```
|
||||
|
||||
This is what makes "export then reopen" lossless. `serialize_design`
|
||||
(relocated to `soong_lab.design` in the export pass, R1) is the forward half;
|
||||
`deserialize_design` here is its exact inverse.
|
||||
|
||||
**Import reads the `resume` half ONLY.** The `ship` half (native `agents.define`
|
||||
payload + sidecar) is a re-derivable deployment artifact — the reopen path
|
||||
re-exports from the reconstructed design, regenerating `ship`. So import ignores
|
||||
`ship` entirely (INV-I-5); a tampered `ship` that disagrees with `resume` is
|
||||
harmless (resume wins, ship regenerated). No cross-check in v1.
|
||||
|
||||
**What this contract does NOT do** (open_question D): no file read, no HTTP, no
|
||||
session seeding. The `POST /api/import` endpoint, the reopen Bifrost tool /
|
||||
session-open wiring, and reading a bundle off the library JSON dir are downstream
|
||||
epics. This module is the pure, side-effect-free reconstruction core those epics
|
||||
build on — exactly as `soong_lab.export` is the pure builder its endpoint wraps.
|
||||
|
||||
## Data flow
|
||||
|
||||
**In:** a bundle `dict` (a Mapping — already `json.loads`'d upstream). **Out:** a
|
||||
validated, ready-to-reopen `DesignObject`. **On disk / network:** NONE. Import is
|
||||
pure: the structural gate (bundle/resume/ocean are dicts, tools a list-of-dicts),
|
||||
the tolerant reconstruction, and the strict `validate_exportable` re-check are all
|
||||
in-memory; no clock, no randomness, no file, no HTTP.
|
||||
|
||||
### The resume key set consumed (v1.0)
|
||||
|
||||
Import reconstructs from exactly the `serialize_design` output (the §6 camelCase
|
||||
state — pinned in export.contract §resume, restated here so this contract is
|
||||
self-contained):
|
||||
|
||||
```
|
||||
resume = {
|
||||
"agentName": <str>, # EXPORT-CRITICAL — presence required (INV-I-7)
|
||||
"role": <str ∈ ROLE_CHOICES>, # EXPORT-CRITICAL — presence required; validate_role gates value
|
||||
"systemPrompt": <str>, # EXPORT-CRITICAL — presence required; the AUTHORED block
|
||||
"ocean": {O,C,E,A,N}, # EXPORT-CRITICAL — presence required; validate_ocean gates value
|
||||
"tools": [{id,name,description}], # optional (absent → []); each ref value-gated by validate_exportable
|
||||
"composedPreview": <str>, # design-time-derived — TRUSTED verbatim, re-derivable (INV-I-8)
|
||||
"dispositionPhrase": <str>, # design-time-derived — TRUSTED verbatim, re-derivable (INV-I-8)
|
||||
"firstMessage": <str>, # optional prose — length-gated only (blank OK)
|
||||
"psychProfile": <str>, # optional prose — length-gated only (blank OK)
|
||||
"portrait": {status, styleMode, imageUrl?, jobId?}, # optional (absent → default Portrait())
|
||||
"goalsFears": {goals,fears} | null # optional (absent/null → None)
|
||||
}
|
||||
```
|
||||
|
||||
**Critical vs optional (the presence rule, INV-I-7).** Read the two functions as a
|
||||
boundary (all three review arms flagged that the prose blurs it): the INNER
|
||||
`deserialize_design` is total and DEFAULTS every missing key (a missing `ocean` →
|
||||
neutral) — it NEVER rejects; the OUTER, public `import_bundle` PRESENCE-CHECKS the
|
||||
export-critical keys and REJECTS a missing one BEFORE it ever calls deserialize. So
|
||||
"import defaults a missing ocean to neutral" is FALSE for the public path
|
||||
(`import_bundle` rejects it, INV-I-7) — the neutral default lives ONLY inside the
|
||||
never-directly-shipped inner function (heid-review 3/3: POST-I-3 vs INV-I-7 read as
|
||||
contradictory in isolation). `agentName`, `role`, `systemPrompt`, `ocean` are
|
||||
**presence-required** — a missing one is a truncated / corrupt bundle and raises
|
||||
`BundleImportError`, because defaulting them would either be caught inconsistently
|
||||
(name/role/prompt default to values `validate_exportable` rejects) or silently
|
||||
masked (`ocean` defaults to a VALID neutral OCEAN — silent trait loss). Every other
|
||||
key is optional and defaults to the `DesignObject` default when absent. `tools`/`portrait`/`goalsFears`, when present, must be well-formed SHAPES —
|
||||
`tools` a list-of-objects, `ocean`/`portrait` an object, `goalsFears` null or an
|
||||
object whose present `goals`/`fears` are lists — structural mismatches raise a clean
|
||||
`BundleImportError`, never a leaked builtin `TypeError`/`ValueError` (INV-I-6
|
||||
robustness). These SHAPE gates all exist to prevent SILENT DATA LOSS (heid-bug-hunt
|
||||
Gróa#1/#2: a malformed portrait/goalsFears would otherwise coerce to a default in
|
||||
`deserialize_design` and slip PAST `validate_exportable`, since both are
|
||||
non-export-critical — the same loss the `tools` gate was added to close). Import does
|
||||
NOT validate their VALUE contents — portrait `status`/`styleMode` enums or goals/fears
|
||||
item contents are not export-critical (E4 / the UI own portrait validity); those
|
||||
round-trip as-is (heid-review Gróa#6). A goalsFears item is COERCED into a Goal/Fear by
|
||||
`_coerce_goal`/`_coerce_fear` (totality, INV-I-6): a dict → its {description, type?,
|
||||
salience?} fields (missing/mistyped → the model default), a LEGACY bare string (a
|
||||
pre-2026-07-16 design, when goals were strings) → its `description`, anything else → a
|
||||
blank Goal/Fear (which `validate_exportable`'s ≥20-char/type/salience gate then rejects on
|
||||
the strict re-validate — no silent loss).
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-I-1** [hard]: The strict export-critical re-validation IS
|
||||
`soong_lab.export.validate_exportable`, imported and reused verbatim — NO
|
||||
re-implementation, no parallel import-side validator. Import therefore can NEVER
|
||||
be looser than export: OCEAN (`validate_ocean`), role (`validate_role`, ∈
|
||||
ROLE_CHOICES), `agent_name` (non-blank, ≤`AGENT_NAME_MAX`), `system_prompt`
|
||||
(non-blank, ≤`SYSTEM_PROMPT_MAX`), every tool-ref (id/name non-blank + bounded,
|
||||
description bounded), and the psych/first_message LENGTH are all gated by the
|
||||
same code export uses. An `ExportError` from that gate is caught and re-raised
|
||||
as `BundleImportError(same field, same detail)` — same field granularity,
|
||||
import-shaped type.
|
||||
- **INV-I-2** [hard]: LENIENT on unknown metadata (settled decision #6, mirrors
|
||||
INV-E5-6). Unknown top-level bundle keys, unknown keys inside `resume`, and any
|
||||
`schema_version` value (present or absent) are tolerated — import reads only the
|
||||
keys it knows and ignores the rest. A future-schema bundle that ADDED fields
|
||||
still imports.
|
||||
- **INV-I-3** [hard]: ROUND-TRIP — for any `DesignObject` `d` that passes
|
||||
`validate_exportable`, `deserialize_design(serialize_design(d))` reconstructs an
|
||||
EQUAL `DesignObject` (dataclass `==` over every field), and
|
||||
`import_bundle(build_export_bundle(d, design_id=…))` `== d`. This is the lossless
|
||||
export↔import contract. (Equality is over Python structures; the JSON encode/decode
|
||||
boundary is the endpoint/library epic's concern, not this module's.)
|
||||
- **INV-I-4** [hard]: NO-ALIAS — the reconstructed `DesignObject` holds COPIES of
|
||||
every mutable sub-structure (the ocean dict, the tools list, the goals/fears
|
||||
lists) drawn from the bundle, never aliases. A later mutation of the input bundle
|
||||
cannot change an already-imported design (the mirror of export's INV-E5-4). The
|
||||
copies are SHALLOW (the CONTAINERS) — sufficient because legit export values are
|
||||
scalars (strings/floats), and a hostile NESTED mutable (a list-valued tool id, a
|
||||
dict-valued goal) is rejected by `validate_exportable` before any successful import
|
||||
(heid-bug-hunt Gróa#5/Hulda#1: the invariant's letter holds; deep-copy is deferred
|
||||
unless nested mutables ever become in-contract).
|
||||
- **INV-I-5** [hard]: Import reads the `resume` half and NOWHERE else — `ship`
|
||||
(native + sidecar) is ignored (it is re-derivable; the reopen path re-exports).
|
||||
No ship↔resume consistency check in v1; on any disagreement, resume is
|
||||
authoritative.
|
||||
- **INV-I-6** [hard]: `deserialize_design` is TOTAL — it never raises on any input
|
||||
Mapping. Hostile shapes (a string `ocean`, an int `tools`, a list `portrait`, a
|
||||
string `goalsFears`, or a dict `goalsFears` whose `goals`/`fears` is a non-list)
|
||||
are coerced/defaulted, not crashed — in particular EVERY `list(...)`/`dict(...)`
|
||||
coercion is type-GUARDED first: a non-list `goals` becomes `[]` (never
|
||||
`list(7)`→TypeError nor `list("ab")`→`["a","b"]`), a non-dict `ocean` is held
|
||||
verbatim (never `dict("nope")`→ValueError). ALL rejection happens in
|
||||
`import_bundle` (its structural gate + `validate_exportable`). Non-export-critical
|
||||
fields that are missing or mistyped default to the `DesignObject` default;
|
||||
export-critical VALUES are held AS-READ (no silent type-coercion) so
|
||||
`validate_exportable` judges them — with ONE structural exception: `import_bundle`
|
||||
pre-checks that `ocean` is a dict (so `deserialize_design`'s `dict()` copy is
|
||||
safe), so `ocean` has a structural judge (`import_bundle`) AND a value judge
|
||||
(`validate_ocean`), while `agent_name`/`role`/`system_prompt` are judged by value
|
||||
alone — "single judge" is exact for those three, not for `ocean` (heid-review
|
||||
Gróa#2/#4, Hulda, Regin#3). (Mirrors `recompute`'s hostile-input tolerance in derive.py.)
|
||||
- **INV-I-7** [hard]: PRESENCE — `import_bundle` requires the export-critical
|
||||
resume keys `agentName`, `role`, `systemPrompt`, `ocean` to be PRESENT; a missing
|
||||
one raises `BundleImportError(f"resume.{key}", …)` (a truncated bundle fails
|
||||
loudly, not by silently defaulting — especially `ocean`, whose neutral default
|
||||
would mask trait loss). `tools` absent → `[]` (an empty toolset is a valid
|
||||
design). This is the explicit-over-implicit choice: reject a missing critical key
|
||||
rather than accept a silently-defaulted one.
|
||||
- **INV-I-8** [hard]: Import does NOT re-derive `composed_preview` /
|
||||
`disposition_phrase` — it TRUSTS the resume values verbatim (they ride the resume
|
||||
for instant reopen-render, per export.contract). `recompute` is the reopen
|
||||
lifecycle's concern (per-design-sessions), not import's. For a legit bundle these
|
||||
are already self-consistent; a tampered preview is design-time-only and is
|
||||
overwritten on the next `set_ocean`/`edit_prompt`. Import makes NO consistency
|
||||
guarantee between the trusted preview and `ocean`+`system_prompt`: for a
|
||||
hand-edited resume the two may diverge until the first recompute self-heals them —
|
||||
round-trip equality (INV-I-3) is "== the DesignObject the bundle encodes," NOT
|
||||
"the preview matches a fresh recompute" (heid-review Gróa#8).
|
||||
|
||||
## Constraints
|
||||
|
||||
- **[correctness]** The export-critical re-validation reuses
|
||||
`soong_lab.export.validate_exportable` (INV-I-1) — import declares no length
|
||||
numbers, no role list, no OCEAN shape of its own. The shared field-bound
|
||||
constants + `ROLE_CHOICES` live in `soong_lab.design`; the strict gate lives in
|
||||
`soong_lab.export`; import imports both. Zero duplicated validation logic → zero
|
||||
drift.
|
||||
- **[style]** Pure — NO I/O (no clock, no file, no HTTP, no randomness). Import is
|
||||
a total function of its input Mapping.
|
||||
- **[explicit]** `BundleImportError` does NOT shadow the builtin `ImportError`
|
||||
(open_question B). The module is `soong_lab.importer`, NOT `soong_lab.import` —
|
||||
`import` is a Python keyword and unusable as a module path (open_question A).
|
||||
- **[robustness]** `deserialize_design` guards types BEFORE any `dict()` /
|
||||
iteration: a non-dict `ocean` is held as-read (never `dict("nope")`, which raises
|
||||
a raw `ValueError`); a non-list `tools` yields `[]`; a non-dict `portrait` /
|
||||
`goalsFears` falls back to the default (`import_bundle`'s structural gates reject a
|
||||
present-but-malformed portrait/goalsFears BEFORE this, so the default-fallback is
|
||||
reachable only for a MISSING field). This keeps every rejection path flowing
|
||||
through `BundleImportError` — a caller never sees a leaked builtin exception.
|
||||
- **[robustness]** The "no builtin ever leaks from the public entrypoint" guarantee
|
||||
for hostile export-critical SCALAR types (a non-str `agent_name`/`role`/
|
||||
`system_prompt`/`psych_profile`/`first_message`, or a `None`) is provided JOINTLY by
|
||||
(a) holding them as-read + (b) `validate_exportable` being TOTAL over hostile scalar
|
||||
types — every check `isinstance`-guards BEFORE any `.strip()`/`len()`, and the `or`
|
||||
short-circuits, so a hostile scalar yields a clean `ExportError` (→ `BundleImportError`),
|
||||
never a raw `TypeError`/`AttributeError`. This is an EXPLICIT cross-module coupling
|
||||
(`soong_lab.export` guarantees the totality): import does NOT blanket-catch
|
||||
non-`ExportError` (that would mask real programming errors); the coupling is instead
|
||||
PINNED by a hostile-scalar test through `import_bundle` (heid-bug-hunt 3/3 —
|
||||
Gróa#3/Hulda#2/Regin#1). If `validate_exportable` ever did an unguarded string op, that
|
||||
test fails.
|
||||
- **[explicit]** `import_bundle` is the PUBLIC entrypoint that runs the full gate
|
||||
(structure → presence → reconstruct → `validate_exportable`). `deserialize_design`
|
||||
is exposed for the round-trip test + direct reuse but PERFORMS NO validation
|
||||
(PRE-I-1) — a direct caller that skips `import_bundle` owns re-validation (the
|
||||
mirror of export's build_native_payload/build_sidecar assuming a validated design).
|
||||
- **[explicit]** Two-LAYER error-field convention (heid-review Regin#6): a
|
||||
STRUCTURAL / PRESENCE rejection raised BY `import_bundle` names the offending
|
||||
BUNDLE key in camelCase with a `resume.` prefix (`resume.agentName` missing,
|
||||
`resume.ocean` not-an-object) — it reports the bundle's JSON shape. A VALUE
|
||||
rejection from the reused `validate_exportable` names the `DesignObject` field in
|
||||
snake_case with no prefix (`agent_name` blank, `persona.ocean` out of range) — it
|
||||
reports the design's validity. Same logical field, two deliberate `.field` forms
|
||||
encoding WHICH LAYER failed (bundle-structure vs design-value); a caller switching
|
||||
on `err.field` MUST handle both, and `err.detail` disambiguates. Tests assert on
|
||||
`.field` (pinned); `.detail` wording is human-facing and NOT pinned (heid-review
|
||||
Hulda) — intentional, not drift.
|
||||
|
||||
```contract
|
||||
FN deserialize_design(resume: Mapping[str, Any]) -> DesignObject
|
||||
BRIEF: The pure, TOTAL inverse of serialize_design — reconstruct a DesignObject from the §6 camelCase resume half. Reads each known key with a type-guard; missing/mistyped NON-export-critical fields default to the DesignObject default; export-critical fields are held AS-READ (no coercion) for validate_exportable to judge later; unknown keys are ignored (INV-I-2). Copies every mutable sub-structure (INV-I-4). NEVER raises (INV-I-6) — it performs NO validation (that is import_bundle's job). deserialize_design(serialize_design(d)) == d for any exportable d (INV-I-3).
|
||||
PRE: [PRE-I-1 hard] resume is a Mapping (import_bundle guarantees a dict before calling; a direct caller passes any Mapping — a non-Mapping is a caller error, but the function still must not crash on a Mapping of hostile VALUES)
|
||||
POST: [POST-I-1 return_value] returns a DesignObject whose fields map 1:1 from the camelCase keys: agentName→agent_name, role→role, systemPrompt→system_prompt, composedPreview→composed_preview, firstMessage→first_message, ocean→ocean (COPY), dispositionPhrase→disposition_phrase, psychProfile→psych_profile, tools→[ToolRef,…] (COPY of the list, each ref rebuilt), portrait→Portrait(...), goalsFears→GoalsFears(...) | None
|
||||
POST: [POST-I-2 return_value] ocean, tools, goals, and fears are COPIES of the resume values — mutating resume after the call never changes the returned design (INV-I-4)
|
||||
POST: [POST-I-3 return_value] export-critical fields (agentName, role, systemPrompt, ocean) are held AS-READ (missing → the DesignObject default; present-but-mistyped → the value verbatim, so validate_exportable is the single judge); design-time-derived composedPreview/dispositionPhrase coerce a non-str to "" (re-derivable, keep the object clean); firstMessage/psychProfile are held as-read (validate_exportable length-gates them). ocean is copied IFF it is a dict, else held verbatim (NEVER dict("nope"))
|
||||
POST: [POST-I-4 state_change] performs NO validation and NEVER raises on a Mapping input (INV-I-6)
|
||||
STEPS:
|
||||
1. [setup] agent_name = resume.get("agentName", ""); role = resume.get("role", UNSET_ROLE); system_prompt = resume.get("systemPrompt", "") # export-critical — held as-read, no coercion
|
||||
2. [sequential] composed_preview = resume["composedPreview"] if it is a str else ""; disposition_phrase = resume["dispositionPhrase"] if it is a str else "" # design-time-derived, re-derivable → coerce clean
|
||||
3. [sequential] first_message = resume.get("firstMessage", ""); psych_profile = resume.get("psychProfile", "") # optional prose — held as-read, length-gated by validate_exportable
|
||||
4. [branch] raw_ocean = resume.get("ocean"); ocean = dict(raw_ocean) if isinstance(raw_ocean, dict) else (raw_ocean if raw_ocean is not None else _neutral_ocean()) # COPY iff dict; else held verbatim for validate_ocean to reject (guard BEFORE dict(), INV-I-6 robustness)
|
||||
5. [loop] raw_tools = resume.get("tools"); tools = [(ToolRef(id=t.get("id",""), name=t.get("name",""), description=t.get("description","")) if isinstance(t, dict) else ToolRef(id="", name="", description="")) for t in raw_tools] IF isinstance(raw_tools, list) else [] # non-list → []; a non-dict item maps to a BLANK ToolRef (NOT skipped) so a direct caller who re-validates fails loud on the blank id rather than silently losing a tool (heid-review Gróa#7); import_bundle structurally rejects both cases upstream
|
||||
6. [branch] raw_portrait = resume.get("portrait"); portrait = Portrait(status=raw_portrait.get("status","none"), style_mode=raw_portrait.get("styleMode","cartoon"), image_url=raw_portrait.get("imageUrl"), job_id=raw_portrait.get("jobId")) IF isinstance(raw_portrait, dict) else Portrait() # use raw_portrait (heid-review Regin#2 — the `rp` working-name was unbound); imageUrl/jobId absent → None (round-trips serialize's None-omission)
|
||||
7. [branch] raw_gf = resume.get("goalsFears"); IF isinstance(raw_gf, dict): g = raw_gf.get("goals"); f = raw_gf.get("fears"); goals_fears = GoalsFears(goals=(list(g) if isinstance(g, list) else []), fears=(list(f) if isinstance(f, list) else [])) ELSE: goals_fears = None # use raw_gf (heid-review Regin#2 — `gf` was unbound); a non-LIST goals/fears → [], NEVER list(7)→TypeError (totality, INV-I-6) and NEVER list("ab")→["a","b"] (silent char-split, heid-review Gróa#2/Hulda); null/absent → None; COPY the lists (INV-I-4)
|
||||
8. [cleanup] RETURN DesignObject(agent_name, role, system_prompt, composed_preview, ocean, disposition_phrase, tools, portrait, first_message, psych_profile, goals_fears)
|
||||
TESTS:
|
||||
roundtrip_full [property,tracer]: a fully-populated exportable design d (name, role, prompt, non-neutral ocean, 2 tools, ready portrait w/ url+job, first_message, psych, goalsFears) → deserialize_design(serialize_design(d)) == d
|
||||
roundtrip_minimal [property]: minimal design (name+prompt+role, neutral ocean, no tools/portrait-url/gf) → round-trips == d
|
||||
copies_not_aliases [property]: deserialize, then mutate resume["ocean"]["O"] and append to resume["tools"] → the returned design's ocean + tools are unchanged (INV-I-4)
|
||||
total_on_hostile [property]: deserialize_design({"ocean":"nope","tools":7,"portrait":[],"goalsFears":"x","agentName":123}) does NOT raise; returns a DesignObject (ocean=="nope" held verbatim, tools==[], portrait==Portrait(), goals_fears is None, agent_name==123) — INV-I-6
|
||||
total_on_hostile_goalsfears [property]: deserialize_design({"goalsFears":{"goals":7,"fears":"abc"}}) does NOT raise (the totality-breaking case heid-review Gróa#2/Hulda caught) → goals_fears==GoalsFears([],[]) (non-list goals→[] not list(7)→TypeError; non-list fears→[] not list("abc")→["a","b","c"]) — INV-I-6
|
||||
tools_nondict_item_blank [boundary]: deserialize_design({"tools":[{"id":"a","name":"n"},7]}) → tools==[ToolRef("a","n",""), ToolRef("","","")] — the non-dict item maps to a BLANK ToolRef, NOT skipped (heid-review Gróa#7), so a direct caller re-validating fails loud on the blank id
|
||||
empty_resume [boundary]: deserialize_design({}) → DesignObject() all-defaults (role==UNSET_ROLE, neutral ocean, no tools) — total, no raise
|
||||
portrait_none_fields [boundary]: resume.portrait without imageUrl/jobId → Portrait.image_url is None, Portrait.job_id is None
|
||||
goalsfears_null [boundary]: resume.goalsFears is None → design.goals_fears is None; goalsFears={} → GoalsFears([],[])
|
||||
roundtrip_goalsfears_empty [property]: a design with goals_fears==GoalsFears([],[]) → deserialize_design(serialize_design(d)).goals_fears == GoalsFears([],[]) (empty, NOT None) — locks the null-vs-{} distinction (heid-review Regin#4)
|
||||
preview_trusted [trace]: resume.composedPreview="CUSTOM", dispositionPhrase="odd" → design.composed_preview=="CUSTOM", disposition_phrase=="odd" (NOT re-derived, INV-I-8)
|
||||
unknown_keys_ignored [trace]: resume with an extra "futureField":123 → deserialize ignores it, no crash (INV-I-2)
|
||||
```
|
||||
|
||||
```contract
|
||||
FN import_bundle(bundle: Mapping[str, Any]) -> DesignObject
|
||||
BRIEF: The public entrypoint — the mirror of build_export_bundle. Runs the full gate: STRUCTURE (bundle/resume are dicts, ocean is a dict, tools is a list-of-dicts) → PRESENCE (the export-critical resume keys, INV-I-7) → reconstruct (deserialize_design) → STRICT re-validate (validate_exportable, reused verbatim, INV-I-1). LENIENT on unknown metadata + any schema_version (INV-I-2). Reads ONLY resume; ignores ship (INV-I-5). Returns a DesignObject that PASSES validate_exportable — ready to reopen. Every rejection is a BundleImportError(field, detail); no builtin exception ever leaks.
|
||||
PRE: [PRE-I-2 hard] bundle is a Mapping (a non-Mapping raises BundleImportError("bundle", …), never a bare TypeError)
|
||||
POST: [POST-I-5 exception] raises BundleImportError(field, detail) — with NO DesignObject returned — if ANY: bundle is not a Mapping ("bundle"); bundle["resume"] is missing or not a Mapping ("resume"); any of agentName/role/systemPrompt/ocean is absent from resume ("resume.<key>", INV-I-7); resume["ocean"] is present-but-not-a-dict ("resume.ocean"); resume["tools"] is present-but-not-a-list or contains a non-dict item ("resume.tools"); resume["portrait"] is present-but-not-a-dict ("resume.portrait"); resume["goalsFears"] is present-but-not (null OR a dict whose present goals/fears are lists) ("resume.goalsFears"); OR the reconstructed design fails validate_exportable (the ExportError's field+detail, re-raised as BundleImportError — INV-I-1)
|
||||
POST: [POST-I-6 return_value] on success returns a DesignObject that PASSES validate_exportable (name/role/prompt/ocean/tools/psych/first_message all valid), holds COPIES of every mutable sub-structure (INV-I-4), with composed_preview/disposition_phrase trusted from resume (INV-I-8); ship is never read (INV-I-5)
|
||||
POST: [POST-I-7 return_value] LENIENT — unknown top-level bundle keys, unknown resume keys, and any schema_version (present, absent, or unrecognized) do not affect the result (INV-I-2)
|
||||
STEPS:
|
||||
1. [setup, flexibility=prescriptive] IF bundle is not a Mapping: RAISE BundleImportError("bundle", "bundle must be an object")
|
||||
2. [sequential] resume = bundle.get("resume"); IF resume is not a Mapping: RAISE BundleImportError("resume", "the bundle has no readable 'resume' half") # ship + schema_version read leniently — schema_version is NOT gated (INV-I-2, open_question E)
|
||||
3. [loop] FOR key IN ("agentName", "role", "systemPrompt", "ocean"): IF key not in resume: RAISE BundleImportError(f"resume.{key}", "required export-critical field is missing") # presence, INV-I-7
|
||||
4. [branch] IF resume["ocean"] is not a dict: RAISE BundleImportError("resume.ocean", "ocean must be an object") # structural — keeps deserialize's dict() safe + gives a clean field error
|
||||
5. [branch] IF "tools" in resume AND (resume["tools"] is not a list OR any item is not a dict): RAISE BundleImportError("resume.tools", "tools must be a list of objects") # structural — prevents silent tool loss
|
||||
5b. [branch] IF "portrait" in resume AND resume["portrait"] is not a dict: RAISE BundleImportError("resume.portrait", "portrait must be an object") # SAME no-silent-loss gate as tools (heid-bug-hunt Gróa#2) — else a non-dict portrait silently coerces to Portrait() (wiping status/imageUrl/jobId) and slips past validate_exportable (portrait is non-export-critical)
|
||||
5c. [branch] IF "goalsFears" in resume AND resume["goalsFears"] is not None: IF it is not a dict RAISE BundleImportError("resume.goalsFears", "must be an object or null"); ELSE FOR k IN (goals, fears): IF k in gf AND gf[k] is not a list: RAISE BundleImportError("resume.goalsFears", f"{k} must be a list") # no-silent-loss gate (heid-bug-hunt Gróa#1) — else a non-list goals/fears silently coerces to [] (dropping the operator's data) and slips past validate_exportable (goals_fears is non-export-critical)
|
||||
6. [sequential] design = deserialize_design(resume) # total; the structural gates above guarantee a plausible shape
|
||||
7. [sequential, flexibility=prescriptive] TRY validate_exportable(design) EXCEPT ExportError AS exc: RAISE BundleImportError(exc.field, exc.detail) FROM exc # the STRICT export-critical gate, REUSED (INV-I-1) — same field granularity, import-shaped type
|
||||
8. [cleanup] RETURN design
|
||||
TESTS:
|
||||
roundtrip_full [property,tracer]: import_bundle(build_export_bundle(d, design_id="d-1")) == d for a fully-populated exportable d (INV-I-3)
|
||||
roundtrip_minimal [property]: import_bundle(build_export_bundle(d_minimal, design_id="d-1")) == d_minimal (a minimal exportable design through the FULL gate — symmetry with deserialize_design, heid-code-review Regin#4)
|
||||
roundtrip_after_export [property]: build a bundle, import it, re-export the result → the two bundles' resume halves are equal (idempotent reopen)
|
||||
lenient_unknown_metadata [happy]: a valid bundle + extra top-level "x":1, extra resume "futureField":2, schema_version="99.0" → imports fine; result == the same design without the extras (INV-I-2)
|
||||
missing_resume [adversarial]: bundle == {"schema_version":"1.0","ship":{…}} (no resume) → BundleImportError("resume")
|
||||
bundle_not_mapping [adversarial]: import_bundle("not a bundle") → BundleImportError("bundle") — no bare TypeError
|
||||
missing_ocean [adversarial]: resume without "ocean" → BundleImportError("resume.ocean") via presence (INV-I-7) — NOT silently neutral
|
||||
missing_role [adversarial]: resume without "role" → BundleImportError("resume.role")
|
||||
missing_name [adversarial]: resume without "agentName" → BundleImportError("resume.agentName")
|
||||
missing_systemprompt [adversarial]: resume without "systemPrompt" → BundleImportError("resume.systemPrompt") — the 4th critical key, completes the presence coverage (heid-code-review Hulda/Regin)
|
||||
non_dict_ocean [adversarial]: resume.ocean="nope" (present) → BundleImportError("resume.ocean", must be object) — clean error, never a raw ValueError from dict()
|
||||
non_list_tools [adversarial]: resume.tools={} → BundleImportError("resume.tools"); resume.tools=[7] (non-dict item) → BundleImportError("resume.tools")
|
||||
non_dict_portrait [adversarial]: resume.portrait=[] / "x" / 7 → BundleImportError("resume.portrait") — the no-silent-loss gate (heid-bug-hunt Gróa#2)
|
||||
malformed_goalsfears [adversarial]: resume.goalsFears={"goals":["survive"],"fears":"exposure"} (fears non-list) → BundleImportError("resume.goalsFears") — the headline silent-loss case; goalsFears=7 → BundleImportError; goalsFears=None and goalsFears={} → ok (round-trip shapes) (heid-bug-hunt Gróa#1)
|
||||
hostile_scalars_no_builtin_leak [adversarial]: resume.agentName=123 / systemPrompt=null / psychProfile=0 → each a clean BundleImportError (agent_name / system_prompt / psych_profile), NEVER a raw builtin — pins the validate_exportable-totality coupling (heid-bug-hunt 3/3)
|
||||
blank_name_rejected [adversarial]: resume.agentName=" " → BundleImportError("agent_name") via validate_exportable (whitespace stricter, INV-I-1)
|
||||
bad_role_rejected [adversarial]: resume.role="wizard" → BundleImportError("role") via validate_role
|
||||
unset_role_rejected [adversarial]: resume.role="" → BundleImportError("role") — an unclassified design is not importable, same as not exportable
|
||||
bad_ocean_value [adversarial]: resume.ocean.O=2.0 → BundleImportError("persona.ocean") via validate_ocean
|
||||
bad_tool_ref [adversarial]: resume.tools=[{"id":"","name":"x"}] → BundleImportError("tools[0]") via validate_exportable
|
||||
prompt_too_long [boundary]: resume.systemPrompt of len SYSTEM_PROMPT_MAX+1 → BundleImportError("system_prompt"); len SYSTEM_PROMPT_MAX → ok
|
||||
psych_too_long [boundary]: resume.psychProfile of len PSYCH_PROFILE_MAX+1 → BundleImportError("psych_profile"); blank → ok
|
||||
first_message_too_long [boundary]: resume.firstMessage of len FIRST_MESSAGE_MAX+1 → BundleImportError("first_message"); blank → ok (same length-gate as psych, via the reused validate_exportable — heid-code-review Hulda/Regin)
|
||||
ship_ignored [trace]: a valid bundle whose ship.native.agent_name disagrees with resume.agentName → the imported design uses resume.agentName; ship is not read (INV-I-5)
|
||||
no_alias [property]: import, then mutate the source bundle's resume["ocean"] + resume["tools"] + resume["goalsFears"]["goals"]/["fears"] → the returned design is unchanged, incl. the goals/fears lists (INV-I-4, heid-code-review Hulda)
|
||||
error_is_not_builtin [trace]: BundleImportError is not the builtin ImportError (isinstance check) — the module never shadows it (open_question B)
|
||||
error_field_layer_convention [trace]: a MISSING agentName → BundleImportError field "resume.agentName" (structural/camelCase); a BLANK agentName → BundleImportError field "agent_name" (value/snake_case via validate_exportable) — the intentional two-layer convention (heid-review Regin#6)
|
||||
```
|
||||
|
||||
## Integration points
|
||||
|
||||
**Reuse of `soong_lab.export` (the no-drift anchor).** Import imports
|
||||
`validate_exportable` + `ExportError` from `soong_lab.export`. This is the single
|
||||
most important structural decision in the contract: the strict export-critical
|
||||
gate is authored ONCE (in export) and reused on import, so the two directions can
|
||||
never diverge. Import adds no length numbers, no role membership list, no OCEAN
|
||||
shape — those all live upstream (`soong_lab.design` constants + `soong_lab.export`
|
||||
gate). The dependency direction is clean: `importer → export → design`, all three
|
||||
pure.
|
||||
|
||||
**`serialize_design` is the round-trip partner (no code change).** The forward
|
||||
half already lives in `soong_lab.design` (relocated there in the export pass, R1).
|
||||
This contract adds no change to it; `deserialize_design` is written to be its exact
|
||||
inverse, and the round-trip tests pin the pair together. If a future field is
|
||||
added to the DesignObject, BOTH `serialize_design` and `deserialize_design` must
|
||||
gain it in the same commit (the round-trip test enforces this — a field added to
|
||||
serialize but not deserialize breaks `roundtrip_full`). The round-trip also locks
|
||||
the `goalsFears` null-vs-`{}` distinction (`None`→`null`, empty→`{"goals":[],"fears":[]}`);
|
||||
the tests exercise BOTH so a future `serialize_design` change that collapsed the two
|
||||
cases is caught, not silently round-trip-broken (heid-review Regin#4).
|
||||
|
||||
**Export contract `used_by` reference (one-line canon fix, same commit as code).**
|
||||
`export.contract.md`'s `used_by:` block names `soong_lab.import` — an unusable
|
||||
Python-keyword module path. On acceptance of open_question A, that line updates to
|
||||
`soong_lab.importer` (or the chosen name). No-backwards-compat: the stale reference
|
||||
is corrected, not left as a second name for the same module.
|
||||
|
||||
**`POST /api/import` endpoint + web upload — NOT in this contract (open_question
|
||||
D).** The browser 'Import Asset' / reopen flow uploads a bundle JSON; the endpoint
|
||||
`json.loads` the body → `import_bundle(bundle)` → seed a session with the
|
||||
reconstructed design (and, per per-design-sessions, open a fresh WT session +
|
||||
build the design-state summary). A `BundleImportError` becomes a 4xx with the
|
||||
`field`/`detail` surfaced to the operator ("fail early on import"). That amends
|
||||
`web_surface.contract.md`; it is a follow-up slice in the same epic, specified here
|
||||
only so the seam is visible. This module does no HTTP.
|
||||
|
||||
**Reopen Bifrost tool / session-open — NOT in this contract (per-design-sessions,
|
||||
decision #2).** Reopening a design mid-conversation (vs. at session boot) may want
|
||||
a Bifrost tool that swaps the session's stored DesignObject for an imported one. If
|
||||
so, its handler calls `import_bundle` and replaces the store entry — the impure
|
||||
boundary, keeping `soong_lab.importer` pure. Out of scope here.
|
||||
|
||||
## Downstream epics (NOT this contract)
|
||||
|
||||
- **Library read** (decision #5) — reading a stored bundle off the server-local
|
||||
single-user JSON dir on corviduo-dev, keyed by `design_id`, then handing it to
|
||||
`import_bundle`. The minimal recent-designs picker lists what is importable.
|
||||
- **Per-design-sessions** (decision #2) — the reopen lifecycle: `import_bundle` →
|
||||
fresh WT session → the compact design-state SUMMARY seeded as context (also caps
|
||||
the #355 accumulation). `import_bundle` is the reconstruction primitive it calls.
|
||||
- **`POST /api/import` + the browser upload/reopen UI** (open_question D) — the web
|
||||
surface that turns an uploaded/selected bundle into a live, reopened session.
|
||||
@@ -7,8 +7,8 @@ available to consumers) versus what stays **hidden** (the rendered natural-
|
||||
language strings injected into the agent's system prompt, never emitted).
|
||||
**Source of truth:** the render code (`core/persona/renderer.py`,
|
||||
`core/persona/stance_render.py`) and the two vendored canon files
|
||||
(`core/persona/canon/d2-mood-render-canon-v1.json` = mood/PAD;
|
||||
`d2-render-canon-v1.json` = relationship). Owner of the canon strings:
|
||||
(`core/persona/canon/d2-mood-render-canon-v2.json` = mood/PAD — the renderer
|
||||
loads v2; `d2-render-canon-v1.json` = relationship). Owner of the canon strings:
|
||||
`brokkr-smithy-dev` (R22/R24 relational + mood render).
|
||||
|
||||
---
|
||||
@@ -42,6 +42,8 @@ display.**
|
||||
| `schema_version` | `"relation_edge/1"` | versions the `relations` payload only |
|
||||
| `emitted_at` | ISO8601 | |
|
||||
|
||||
> **✓ R32-1B (landed, v1.0.0b29):** The PAD range `[-1.0, 1.0]` relaxes to an **unbounded latent `z`** with a finite wire sanity bound (`~±10`) as of R32 Slice-1B. The JSON shape/fields/types are UNCHANGED — only the declared range/semantics change (the value becomes a latent that renders to a bounded display value). Consumers that merely store-and-return PAD need no change; consumers that validate/clamp PAD to `[-1,1]` must relax that bound. Source of truth: `docs/contracts/persona_envelope.contract.md` rev 1.7 (INV-ENV-16).
|
||||
|
||||
**Not on `affect.emit`:** the full active-emotions list, `baseline_pad`,
|
||||
`mood_drift`, `last_updated_at`, and every rendered string.
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-19]` **#18 D1 SHIPPED — composite `build_combined_app` on `:8392` (`v0.17.16`, `7f4ceaa`); #18 CLOSED; published `v0.18.0` (`359dbb1`).** `build_combined_provider_app` wraps bifrost's public builder over both stores + the shared read route; op-feed `plane='combined'` per-path. Direct in-session TDD; heid-code-review panel (Gróa/Hulda/Regin) returned ZERO drift. Live-proven at wire+dispatch; WT-turn gated on infra-ops `:8392` allowlist.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-19]` **#18 D2 SHIPPED (`v0.17.14`, `39eebd1`) and the full #17+#18 arc PUSHED to origin.** Live-smoke PROVEN against real data (throwaway `:8393`/`:8766` vs the real `affect.db` → real sindra/vuong PAD through the full web→provider chain; Playwright DOM check confirmed the pane render + the F1 fix — no fabricated "neutral"). The push carried 9 previously-held commits incl. the deliberately-unpushed #17 (`v0.17.8`→`v0.17.13`); origin/main now == `39eebd1`, tag `v0.17.14`.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-19]` **bifrost repinned 0.8.0→0.10.0; `affect.fetch` became MANDATORY (strong-or-absent).** 0.10.0's `_supports_affect_plane` requires `affect_supported`+`emit`+`fetch` and gates EVERY affect op — an emit-only store 400s. Implemented `affect.fetch` (`v0.17.15`, `ca6af6b`) conformed to bifrost's reference `InMemoryAffectStore.fetch` (`{found, snapshot?}`): the forced D1 prerequisite + a new Worldtree I/O point consumed. Flagged the now-stale consumer-guide line to bifrost-dev (fixed `a2e6d62`).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-19]` **Full WT-driven `:8392` live-smoke is infra-gated — `:8392` not in WT's `BIFROST_CLIENT_ALLOWED_HOSTS` (bind 422s).** New provider ports are NOT auto-allowlisted (only `:8390/:8391` are). Self-driven dispatch (minted consumer-key JWTs → `:8392`) is the wire-proof; the WT-turn needs infra-ops to add `:8392` (requested `01KVHWJGTT…`).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-19]` **heid-code-review pulled MORE weight than its own "marginal" self-assessment.** The panel returned zero drift, but its single test-fidelity finding CASCADED into 2 real latent-bug fixes when applied (the memory `check_same_thread` bug + Regin's op-feed field-name bug). **Lesson: a contract-fidelity nudge can transitively expose bugs the test never reached — don't dismiss a "marginal" finding by its count.**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-19]` **op-feed handshake field-name fix (#17, `v0.17.17` `d60b77d`): `capabilities_requested`→`capabilities`.** The summary read a field that never exists on the wire (bifrost reads `capabilities`, `_protocol.py:181`) → caps_requested was always null. Surfaced by the heid panel (Regin) during the D1 review — a latent #17 bug, not D1 drift.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-19]` **Ratatoskr is a REFERENCE implementation of the Worldtree/Bifrost standard (operator).** Adopt the dep's canonical way (even if ours works); INFORM of drift/gaps; ADVISE a different approach only when ours is genuinely better (dep owner decides), never unilaterally fork. [auto-memory `feedback-ratatoskr-is-a-reference-impl-adopt-canonical`]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-19]` **Ratatoskr v1 is DERIVED from Worldtree I/O coverage (operator) — no self-defined feature ROADMAP.** v1 = consume all of Worldtree's I/O points, reached when Worldtree hits 1.0; the convergence target is a coverage map, not a 3–7 capability list. [auto-memory `project-ratatoskr-v1-derived-from-worldtree-io-coverage`]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-19]` **The SAME `check_same_thread` sqlite bug recurred in the MEMORY store — exposed by the contract-mandated `search` dispatch test (TestClient = worker thread).** Heid's test-fidelity finding (the D1 dispatch test used `describe_store` where the contract says `search`) → fixing it to `search` tripped `sqlite3.ProgrammingError` because `open_memory_store` also lacked `check_same_thread=False`. Fixed (mirrors affect INV-006). **Lesson: this bug is PER-STORE — every sqlite-backed ASGI store needs `check_same_thread=False`; an HTTP-layer (TestClient) test exposes what direct-store tests can't, and the composite serving memory over HTTP makes it bite.**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-20]` **#18's final leg PROVEN — composite `:8392` WT-driven smoke ran end-to-end + persisted.** infra-ops allowlisted `10.100.10.50:8392` on the personal WT (`01KVHWJGTT…`); a real WT turn (session `b83a66b6`, `ratatoskr:sindra`, fresh end_user `resmoke-choco-1`) dispatched the full both-plane lifecycle through ONE endpoint — handshake (both caps) → `affect.fetch` + `memory.search` → `affect.emit` (`stored:true`) → `memory.upsert_many` (`upserted:1`) — both writes verified in our SQLite (`affect_snapshots` PAD row + `memory_chunks` chunk `2df1b79…`). First attempt blocked by a `model_unavailable` outage on the personal WT (both agents' models down), operator-fixed mid-session, then clean. The composite has no open legs.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-20]` **Sindra has real PAD but ~empty memory — the affect/memory persistence asymmetry, confirmed on real sessions.** affect EMITS every turn (persona always accumulates: vuong 8→14 interactions across the session); memory only writes on a PROMOTION trigger (salience / turn_count≥6 / idle-≥10min flush). Two real vuong sessions through the combined bind (`04d6414c`, `433541fe`) drove affect emits + memory SEARCHES but ZERO promotion upserts → `memory.db` holds only the smoke fixture, zero vuong chunks. Operator: acceptable (server-takedown = "Sindra bonked on the head"; transient memory loss WAD). Operational catch: combined-as-default web bind saves persona reliably but silently LOSES memory if a session closes before a promotion trigger fires.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-20]` **The post-turn-async timing trap bit AGAIN — even a 35s post-`[done]` read missed the promotion `upsert_many` by ~2s** (it landed `19:48:58`; the read was ~`19:48:56`). A 15s-interval background poll caught it on the first tick. Same family as the affect.emit / async-promotion traps already logged — re-confirmed that "wait once then read" is fragile for post-turn writes; **poll a window, don't snapshot once.** (The affect.emit write, by contrast, DID land inside the 35s window — promotion is the slower of the two post-turn writes.)
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-29]` **bifrost repinned 1.0.0 (`v0.18.2`, `af67ad9`).** bifrost-dev shipped its first stable release; wire v0.6 now STABLE/FROZEN. Non-breaking (byte-identical to 0.10.0); switched the floor pin → exact `==1.0.0` per the stable-substrate posture. Post-1.0 breaking changes ride a bifrost MAJOR + new wire (v0.7+); a v0.6-pinned consumer is stable indefinitely. (Also this session: althing migrated to v0.15.0+ lean-bus / schema v4 — moderation retired, chamber/redis ripped; our tooling auto-updated to 0.17.4.)
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-29]` **Web SPA combined-bind default (`v0.18.1`, `719e4d6`) — operator-caught gap.** #18 shipped the composite `:8392` provider but never exposed it in the web bind dropdown (only memory/affect single-plane). Added `combined (:8392)` as the DEFAULT option (both planes in one session), kept single-plane for isolation diagnostics; wired `endpoint_for_plane` combined→8392 + server validation + the dropdown. Direct TDD; #17 contract updated (the governing spec for the web bind). Restarted `:8765` on current code (env.sh + provider.env + `RATATOSKR_AFFECT_READ_URL=:8392`).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-30]` **(b) Tier-1 frontier SCOPED, ready for a contract-first TDD cycle (next focused work).** The primitives already exist + are contracted + tested; the gap is PRESENTER-level wiring. Two slices: **(b1) SSE-resume** — contract #1 (`ratatoskr.sse_client`) DELIBERATELY makes resume caller-owned ("on `SseConnectionDropped`, the caller MAY invoke `reconnect_turn`"); `reconnect_turn` (sse_client.py:524) has NO caller. Gap = a SHARED resume-orchestration wrapper (catch `SseConnectionDropped` → track last-seen `sse_id` → `reconnect_turn` → continue), consumed by all 3 presenters per design-brief §8b "share the consumer, branch the presenter" (NOT per-presenter — that forks the consumer). New function block → **amend contract #1** (additive FN, e.g. `stream_turn_resilient`) then TDD (RED: drop-mid-stream→resume continuity; GREEN: wrapper; wire `cli --send` first as the tracer). Resume design pre-locked: in-process Last-Event-ID only, cross-process deferred to v2 (design-brief §8d). **(b2) session-picker** — `list_sessions` (sessions.py:198) has NO caller; add a Textual DataTable startup picker (>1 session) + `--session <id>`/`--new` CLI flags (design-brief §4, decisions pre-locked). Both pre-locked → heid-contract-review likely skippable as ceremony (small additive amendments to mature specs); heid-code-review still valuable. **#11 AdminEvents stays BLOCKED** on `admin.events.read` scope (infra-ops).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-30]` **(b1) SSE-resume SHIPPED (`v0.18.5`) — `stream_turn_resilient` (sse_client.py).** The shared resume-orchestration surface (design-brief §8b): wraps `stream_turn`+`reconnect_turn`, catches `SseConnectionDropped` (mid-stream drop OR clean-EOF-before-terminal) → resumes from last-seen `sse_id` via `reconnect_turn` (Last-Event-ID), up to `max_reconnects` (default 5); non-drop reconnect failures (412/410/400/TurnIdFlip/SseConnectFailed) PROPAGATE per contract #1's "surface, not recover". `last_seen` persists ACROSS attempts (a zero-event reconnect drop falls back to the prior attempt's id). Direct in-session TDD against a contract-#1 amendment (8 cases incl. two-drops, max-reconnects-exhausted, zero-budget, buffer-expired-propagates, unresumable-zero-event). Wired ALL THREE presenters through it (`v0.18.6`): `cli --send` (`cli.py:396`), TUI (`tui.py:1321`), web (`web/server.py:294`) — each a name-for-name `stream_turn`→`stream_turn_resilient` swap (the §8b "all presenters share the consumer" promise, fully kept; the TUI is the primary resume beneficiary — long-lived sessions / laptop-suspend). Suite 518 green; ruff+mypy clean on touched code (pre-existing cli.py:400/543 mypy warts left untouched per surgical rule); contract #1 validates OK. **heid-code-review NOT run** (small additive well-TDD'd wrapper; offered to operator). **b2 (session-picker + `--session`/`--new` flags) still pending.**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-30]` **(b2) session-picker SHIPPED (`v0.18.7`) — bare TUI mode → startup picker (design-brief §4).** `list_sessions` had NO caller; now bare TUI mode (no `--session`/`--new`) resolves via `list_sessions` in `_resolve_then_run`: **0 sessions → `[no_sessions]` error, exit 14** (resume-only, honors §4 "no in-app session creation — `--new` flag only"); **exactly 1 → auto-resume** (§4 "picker only when >1"); **≥2 → new `SessionPickerApp`** (Textual `App[str|None]`, mirrors `AgentPickerApp`; ListView of sessions) → resume the pick (Esc/Ctrl-D → exit 0). cli `_parse` relaxed: bare TUI now VALID (was "pass exactly one" error); `--send` still requires one flag (non-interactive, no picker); `--agent` forbidden in bare mode; `run_tui` PRE-002 XOR→"not both". Direct in-session TDD (contract #6 amendment, validated OK): 3 widget pilot tests + 5 `_resolve_then_run` resolution tests + 3 cli validation tests. Suite **528 green**; touched code ruff-clean (mypy: only the `BINDINGS` list-invariance warning every App in tui.py already carries — consistent). **DESIGN NOTE — bare+0-sessions → error (clause-consistent). The friendlier auto-fall-through-to-new alternative is DEFERRED pending operator preference (it would create a session without `--new`, against the §4 negative clause).** **Frontier now: `GET /capabilities`+`GET /me` → BifrostState/Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key) → #11 AdminEvents (BLOCKED on `admin.events.read`).** heid-code-review NOT run on b1 or b2 (offered).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-30]` **capabilities+me slice SHIPPED (`v0.18.8`) — `GET /me` + `GET /capabilities` consumed via a new `--whoami` one-shot.** `get_me`/`get_capabilities` added to sessions.py (mirror `get_persona_state`: 200→dict verbatim, non-200→`SessionApiFailed`; freeform dicts per the frozen OpenAPI). New `ratatoskr --whoami` CLI mode (mirrors `--send`'s non-interactive shape) fetches both + prints an identity+capabilities report; standalone probe (mutually exclusive with `--send`/`--session`/`--new`/`--agent`, opens no session; new `ParsedArgs.whoami` field + main() dispatch). **`/capabilities` is the Echo EPHEMERAL-TEMPLATE discovery endpoint** (`{ephemeral_templates:{echo:{allowed_models,default_model,system_prompt_max_bytes}}}`), NOT a generic server-caps endpoint (audit finding — the coverage-map's earlier "server capability discovery" framing was imprecise). `/me` = whoami (`{user_id,scopes,tier,key_id?,...}`, optionals omitted-not-null). Contract-skip privilege invoked (low-effort GET wrappers) but contract #2 amended (2 FNs, validated OK) to keep the sessions spec canonical + honest test citations. TDD: 5 wrapper tests + 5 cli tests (validation + mode + error). Suite **538 green**; touched code ruff-clean (mypy: only `no-any-return` on `resp.json()`→dict, identical to the pre-existing `get_persona_state`). **Coverage: REST 9/40 ✅ (up from 7).** TUI-surfacing of /me (footer identity line) + /capabilities DEFERRED — the one-shot is the minimal tracer. **Frontier now: BifrostState + Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key-gated) → #11 AdminEvents (BLOCKED on `admin.events.read`).**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-30]` **Finding P-1 (pin drift) + pin-remediation PENDING.** We vendor the PROSE markdown (`docs/conversation-api-spec.md`), which is **byte-identical to live WT's** but frozen at v0.35.16-era content (last WT edit 2026-05-31) — it does NOT capture b2: 7 new endpoints (admin/keys/bulk, admin/persona/{archive,erase}, admin/usage, embed, judgments, me/usage), the 409/503 on messages-POST (#331), the unified error envelope (#328), or the SSE schema. **WT's authoritative v1 truth is now the FROZEN OpenAPI 2.2.0 + SSE-schema JSON** (`Worldtree/docs/v1-schema-freeze-manifest.md`). So the previously-deferred "re-vendor markdown to b2" is a **near-no-op** (markdown content identical). **Pending operator nod:** re-pin to the machine-readable artifacts (recommended — drift-checkable via `canonical_drift.py`, makes the coverage map reproducible vs a frozen diffable target) vs markdown-only. Deferred (not auto-applied) because it adds vendored artifacts + a canonical-sync pin = substrate change with CI-gating reach. **→ RESOLVED 2026-06-30 (operator: "a then b").** Vendored `conversation-api-openapi.json` (2.2.0) + `conversation-api-sse-events.schema.json` + re-copied the prose markdown; pinned all three in `.corviduo-canonicals.toml` (OpenAPI+SSE = strict drift gates, markdown = `tolerate_drift` reference); advanced `worldtree-spec-rev` f1b59f8→5810a26 + `worldtree-version` v0.29.0(STALE, never bumped from the v0.35.16 pin)→v1.0.0b2 + `pinned-on`→2026-06-30; SPEC-PIN.md history row added. `canonical_drift.py` green (10/10). `pin:`-only, no version bump (no client-facing code change; the b2 409/503 + error-envelope were already consumed in v0.18.3/.4).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-30]` **Heimdall keys are PER-INSTANCE — a key minted on one Worldtree 401s on another.** Our Conversation-API key works on personal `:8081` but 401s `auth_invalid` on demo `:8080` (per-instance Heimdall user store + pepper; fresh deploys start with an EMPTY key store). Same as the admin key (personal-only). **To live-drive a given instance you need a key minted FOR that instance** (request via infra-ops). Couldn't live-prove the b2 409 on demo for this reason → deferred to personal-b2 where we have access.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-30]` **regard is a DEAD AXIS in Worldtree's emitted affect (caught provider-side; worldtree-dev confirmed + escalated to Vuong).** Across all our affect snapshots, `valence[].regard` is EXACTLY 0.15 regardless of agent/end_user/interaction_count, while familiarity accumulates (vuong 0.18→0.69 over 14 turns). Root cause (worldtree-dev, code-grounded): 0.15 = `base_regard = agreeableness*0.3` (sindra A=0.5); regard's only human-writer `update_regard` early-returns unless an emotion is `about="other"`, but the Vili appraiser's `ViliResponse` schema has NO directedness axis (everything hardcoded `about="situation"`) — producer side lost in the #265 Vili rework; consumer machinery intact. NOT WAD; the fix (reintroduce other-directed classification) is an affect-model change touching every agent + a directedness-classification design call → worldtree-dev filing an issue to Vuong. [the consumer/provider thesis paying off again]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-30]` **Scope mandate A locked (operator): v1 "done" = every frozen I/O point CLASSIFIED (covered-or-excluded-with-rationale), zero unaccounted — NOT a feature-complete client.** The coverage map is a LEDGER, not a build-everything mandate. Reconciles the 2026-06-19 "consume all I/O" reframe with the 2026-05-20 design-brief's "NOT an admin tool" + deferral negative clauses (which predate both the provider identity and the reframe). Resolved the 11 design-brief-vs-reframe ❓ rows via the debug-observability test (*does a turn flow through it?*): 🚫 search / uploads / pending / embed / judgments (consumer-product + eval utilities); ⬜ transient-characters routing (4) + persona_state-write (Tier-2 frontier). **Frontier Tier 1 (all unblocked except #11):** session-picker + SSE-resume (wrappers `list_sessions`/`reconnect_turn` exist with NO caller — presenter-wiring only) → `GET /capabilities` + `GET /me` → BifrostState/Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key) → **#11 AdminEvents BLOCKED on `admin.events.read` scope**. The 3 admin-observability widgets + picker + resume were design-brief §5/§4/§8d v1 items that **were never built**.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-30]` **`tea comment <N>` hangs on Gitea** (the whole compound bash auto-backgrounded + stuck on the open `tea` call). The #11 prereq comment hung; killed it + posted via the Gitea HTTP API directly (`POST /api/v1/repos/vh/ratatoskr/issues/<N>/comments`, token from `~/.config/tea/config.yml`). **For issue comments, prefer the Gitea API over `tea comment` when `tea` is flaky** (CLAUDE.md already says use HTTP for comment-EDITS; this extends it to ADD when tea hangs). Verify-then-post (check the comment didn't already land) to avoid a double-post after a kill.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-30]` **v1 coverage-audit kicked off; coverage ledger written (`docs/coverage-map.md`) — the first one.** Every Worldtree v1-FROZEN I/O point × ratatoskr status. Anchored on WT's frozen machine-readable artifacts (OpenAPI **2.2.0** `conversation-api-openapi.json` = 40 REST path-groups + SSE schema = 11 events + bifrost wire v0.6), NOT the stale vendored prose markdown. Result: **SSE 11/11 ✅; Bifrost provider planes 8/8 ✅ live-proven** (covers the full `bifrost.memory.MemoryDataStore` protocol = describe_store/get/get_many/search/upsert_many + delete_many, and affect emit/fetch; **`health` is extended-reference-store-only, NOT in the base protocol → correctly deferred, NOT a gap** — settles the prior "health" ambiguity); **client REST 7/40 ✅ live, 11 ⬜ in-scope, 22 🚫 excluded.**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-30]` **Verify-against-the-real-spec-before-committing caught a real upstream gap.** Holding the v0.18.3 commit to verify against demo's OpenAPI surfaced that the FROZEN OpenAPI 2.1.0 didn't document the 409/503 the heads-up described (`agent_not_available` was in the ErrorCode enum, but NO 503/turn-launch code). worldtree-dev confirmed it was THEIR gap (#331 added the statuses without extending the #328 `openapi()` override), shipped the fix in **v1.0.0b2 / OpenAPI 2.2.0** (409/503 now enumerated, 503 code finalized as `not_ready`). "The consumer-oracle earning its keep." Lesson: a provider's prose heads-up can diverge from its frozen machine-readable spec — verify the actual spec before committing a consumer adaptation.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-06-30]` **Worldtree v1.0.0b1→b2 consumer adaptation: eager turn-launch statuses (`v0.18.3` `b2e4901`, `v0.18.4` `e4317f6`).** Worldtree #331 decoupled turn execution from the SSE connection → turn-launch failures now arrive EAGERLY as a status before any stream: 409 `agent_not_available` (pre-b1 a 200 + in-stream error event), 503 retryable. Mapped both in `stream_turn` to typed `SseConnectFailed` subclasses keyed on STATUS, parsing the `{detail:{error_code,message}}` envelope — POST-003 preserved (no synthetic event yielded), existing handlers still catch (the design fork vs yield-an-Error-event was decided by POST-003). **DEFERRED follow-ups** (tracked here; bundle with the v1 coverage-audit): (1) live-prove the 409/503 end-to-end on personal-b2 (now unblocked — personal on b2, my key works there); (2) full `conversation-api-spec.md` markdown re-vendor to the b2 era (ratatoskr vendors the markdown, not the OpenAPI JSON).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **AdminEvents pane SHIPPED (`v0.18.11`) — `GET /admin/events` SSE in a new TUI pane; #11 closed-by-build; Tier 1 (debug-observability core) COMPLETE.** `stream_admin_events(client, *, admin_key, last_event_id=None)` (sse_client.py) — a NEW long-lived SSE consumer for the admin lifecycle broadcast (envelope `{id,type,timestamp,data}`, 17-event v0 vocab), admin-scoped (`admin.events.read`, bearer-override), Last-Event-ID resume; non-200→SseConnectFailed, mid-drop→SseConnectionDropped; new `AdminEvent` dataclass (distinct from the turn `Event` union). New "AdminEvents" TabPane + `_format_admin_event` + `_admin_event_matches` (design-brief §6 filter: active-session events + non-heartbeat `system.*`) + `_stream_admin_events` long-lived best-effort worker (unconditional on_mount, cancelled on app exit; self-labels "not configured"/"unavailable"/"stream ended"). Reuses the admin key from the BifrostState slice. **Contract-SKIPPED** for `stream_admin_events` (out of contract #1's turn-SSE scope; spec § Admin Event Stream is the reference; well-TDD'd). TDD: 4 sse_client tests (multi-event+bearer-override, Last-Event-ID header, 403, malformed-skip) + 5 tui (format, filter, worker success/no-key/403). Suite **561 green**; my code ruff-clean (pre-existing tui.py ruff debt untouched, incl. the dead `RichText` import in `_hydrate_persona`). **LIVE-AUTH-PROVEN**: `GET /admin/events` on :8081 → HTTP 200 under the admin key (connected + streamed, idle in the 4s window — no 401/403). **Coverage: REST 12/40 ✅. Tier 1 admin/debug-observability core COMPLETE** (Persona · Tools · BifrostState · AdminEvents). AdminEvents work landed as patch `v0.18.11`; then **`v0.19.0` MINOR cut (operator-approved 2026-07-01)** publishing the milestone: **the debug-observability core is complete** (Persona · Tools · BifrostState · AdminEvents all built + consuming real endpoints — the design-brief's headline deliverable). Pre-1.0 minor = release-note-worthy (no downstream althing push needed pre-1.0); lightweight tag per the SemVer mechanics (annotated reserved for major cuts). Remaining in-scope client I/O: only Tier-2 (transient-characters routing + `POST /sessions/{id}/persona_state`).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **Affect snapshot shape CHANGED valence→relations (relation_edge/1) — the persona pane was reading a dead field.** Worldtree's #265 Vili rework replaced the flat `valence[]` ({entity_id,familiarity,regard}) with `relations[]` (target_entity + trust_ability/benevolence/integrity + warmth + agency + relation_context, each `{value,confidence,evidence_count}`). `renderAffectPane` still read `snap.valence` → showed empty "valence (0)". Rebuilt to render `relations` (v0.19.4, `ca46a93`) with per-value **Δ + unicode sparkline** (client-side, HIST_CAP=24, one sample/turn deduped by emitted_at). **Retires the stale "regard dead axis" note (2026-06-30) — that whole axis is gone.** Foot-gun: the affect snapshot shape is Worldtree's emit and can change under us — verify the live shape (query affect.db) before trusting a render.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **b1 (SSE-resume) heid-code-review panel: ZERO findings — cross-model-verified clean.** Gróa (Grok) + Hulda (Codex) + Regin (GLM-5.2) each independently reviewed `stream_turn_resilient` vs contract #1's amendment (artifact-only, firewall held) → all three ZERO findings; signature / PRE-001..004 / STEP 1-4 / POST-001..003 / ERROR_ROUTING / all-8-TESTS confirmed, incl. the subtle `seen = last_seen or drop.last_seen_sse_id` zero-event-drop fallback. Convergent meta-note: **TDD + the unusually-prescriptive contract (STEPS `flexibility=prescriptive` + explicit GOTO) left no room for compliant-but-different drift — confirmation, not discovery.** Calibration signal: for a thin wrapper with a tight prescriptive contract + comprehensive TDD, the panel confirms rather than discovers. **b2 (picker) + capabilities+me NOT yet reviewed** (higher-surface b2 is the better candidate if more review is wanted). Dispatch msg `01KWE2K99T…` / thread `01KWE2K99S…`; heid dispatch-log `2026-06.jsonl#01KWE2V3MMY8XS55FCJYXYV14B`.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **BifrostState pane SHIPPED (`v0.18.10`) — `GET /admin/sessions/{id}/bifrost` in a new TUI "Bifrost" pane; the FIRST admin-key consumer in ratatoskr.** `get_session_bifrost(client, session_id, *, admin_key)` (sessions.py) — admin-scoped (`admin.sessions.read`); the request OVERRIDES Authorization with `admin_key` (distinct from the consumer bearer, asserted in a test); 200→dict, non-200→SessionApiFailed. Admin-key wiring: `--admin-key` flag + `RATATOSKR_ADMIN_API_KEY` env → new `ParsedArgs.admin_key`. New "Bifrost" TabPane + `_format_bifrost_state` + `_hydrate_bifrost_state` best-effort worker (mirror `_hydrate_session_tools`) UNCONDITIONALLY in on_mount → writes {endpoint, connected, caps_granted, tools} + audits; self-labels "not configured" (no admin key) / "not bound" (404) / graceful on 403 + error. Contract #2 amended (FN, validated OK) + TDD (4 wrapper respx tests incl. the admin-bearer-override assertion + 1 format unit + 3 hydrate integration). Suite **552 green**; my code ruff-clean (pre-existing tui.py ruff debt at other lines untouched, incl. a dead `RichText` import in `_hydrate_persona`). **LIVE-AUTH-PROVEN** on personal :8081: admin key authenticated (reached resource-layer 404 session_not_found, NOT 401/403) → `admin.sessions.read` works live; 200 full-state not exercised (no bound session on :8081 now — unit-covered). Patch bump (debug feature, no downstream coordination; consistent with the session's cadence — but the §5-core-completion angle is a possible minor, operator's call).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **Canonical check BLOCKED an access_tally fork (reference-impl posture held).** I'd offered to wire `access_tally`-on-search into our store for the recall-utility label; checked bifrost's reference first (`get`/`search` are PURE-READ, no access tracking — those are Worldtree's chunk-schema fields, not bifrost's contract) → wiring it would fork behavior the canonical reference lacks. Did NOT wire it; routed recall-instrumentation to Worldtree's layer (owns the recall event) or a bifrost-dev protocol ask. [reinforces `feedback-debug-surface-uses-canonical-surface-only`]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **Embedding-latency loop RESOLVED — it was WORLDTREE's, not ratatoskr (the consumer/provider thesis paid off again).** Vuong flagged dozens of embed queries/Tier-3 turn; worldtree-dev's first-pass blamed our memory_context chunk-batching. Traced CODE-SIDE that ratatoskr embeds ZERO times (provider `upsert_many` stores the given embedding, `search` takes a given vector, the conversation consumer POSTs only `{content}`, `/embed` is coverage-map-excluded — pure Bifrost/ADR-0009 path, WT does all embedding). worldtree-dev retracted + fixed on THEIR side (`v1.0.0b4`): a persona-recitation memory-gate re-embedding the stable character card sentence-by-sentence every turn (~95% of gateway traffic) → content-hash cache; re-embed ratio 15x→1.01x. **Lesson: verify your own code before accepting a peer's "it's your side" — the debug tool proving its own side clean is the whole point.**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **env.sh now PERSISTS the web Bifrost-bind vars (gitignored, local-only).** `ratatoskr-web`'s in-browser bind needs three server-held values; env.sh sources `provider.env` for the Heimdall key and exports `RATATOSKR_BIFROST_CONSUMER_KEY` + `RATATOSKR_PROVIDER_VISIBLE_HOST=10.100.10.50` + `RATATOSKR_AFFECT_READ_URL=:8392`. **The HS256 byte-match trap (re-hit + documented):** the bind's consumer key must equal the key the `:8392` combined provider validates against = `RATATOSKR_HEIMDALL_KEY` (provider.env, fp `45a0…`), NOT `WORLDTREE_API_KEY` (env.sh, fp `7c2f…`) — both are the SAME `ratatoskr` identity but DIFFERENT 40-char strings; signing with the wrong one → `bifrost.auth_rejected`. Single-sourced (env.sh sources provider.env) to avoid a rotation footgun; guarded with a stderr warning if provider.env is missing. [auto-memory: HS256-key-is-the-consumer-Heimdall-key-string]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **Foot-gun (measurement, self-caught before flagging): establish the baseline before claiming a rate.** Nearly flagged "aggressive over-promotion (55 chunks / 7 turns)" to worldtree-dev — but the chunks spanned the whole 5-hour session (~1/turn), not 7 turns; I'd assumed memory.db was 0 immediately before the probe when it had been accumulating since the reset. Caught it via `created_at` spread before the flag went out. Also: the promoted corpus was the operator's ERP *test* content (wiped after each test) — not a privacy issue, but abstract test content out of any peer-shared diagnostic.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **`GET /sessions/{id}/tools` quick-win SHIPPED (`v0.18.9`) — owner-scoped tool inventory in the TUI Tools pane.** `get_session_tools` wrapper (sessions.py, mirror get_me: 200→dict, non-200→`SessionApiFailed`) + `_format_tool_inventory` helper + `_hydrate_session_tools` best-effort worker (mirror `_hydrate_persona`) wired UNCONDITIONALLY in `on_mount` → writes the merged `{agent_id, builtin_tools, bifrost_tools}` inventory (what the LLM saw at turn-fire) to the Tools pane + audits `session_tools_hydrated`, never crashes on failure. Owner-scoped (`ctx.user_id==session.user_id`) → reachable with the CONSUMER key, NO admin scope — so this **covers the design-brief §5 "Tools widget" via the reachable owner endpoint** (the admin `/admin/sessions/{id}/tools` variant stays a gap only for cross-user operator debug). Contract #2 amended (FN, validated OK) + TDD (3 wrapper respx tests + 1 format-helper unit + 2 hydrate integration tests via `_spy_writes`+pilot). Suite **544 green**; touched code ruff-clean (the tui.py ruff/mypy debt at other lines is pre-existing). **Coverage: REST 10/40 ✅.** **Frontier now: BifrostState widget (`GET /admin/sessions/{id}/bifrost`, admin-key) + #11 AdminEvents (BLOCKED on `admin.events.read`) + Tier-2 (transient-characters routing, `POST /sessions/{id}/persona_state`).**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **heid-code-review (`v0.19.3`, `75dec01`) — panel caught 2 real client-side SSE-lifecycle bugs TDD missed.** Contract-anchored (authored the web contract to enable it — no contract → no drift axis). Gróa/Hulda/Regin (artifact-only, Gróa under Landlock jail): ZERO functional server-side drift + INV-004 clean; 2 genuine drifts on the un-unit-tested SPA — (1) turn `es.onerror` didn't `hideThinkingNote()` (reasoning line + setInterval leak on a raw drop), (2) `openAdminEvents` never closed the EventSource on error → native auto-reconnect RETRY LOOP (fixed: close on `stream_error` + permanent `onerror`/CLOSED; transient CONNECTING still reconnects). + 2 test-gaps fixed (route-registration + admin stream_error). 1 precision → contract-clarified (tools-inventory names-only by design). **Re-confirms: the JS render/lifecycle paths are the review's highest-value target — unit tests don't reach them (same lesson as #18 D2).**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **LEDGER CORRECTION: #11 (AdminEvents) is NO LONGER BLOCKED.** Verified via `GET /me` on :8081 that `RATATOSKR_ADMIN_API_KEY` (`ratatoskr-readonly`, tier readonly-admin) carries ALL 7 read scopes INCLUDING **`admin.events.read`** (+ `admin.sessions.read`, admin.keys.read, admin.skuld.read, pending.read, search.read, tool_events.read). The coverage-map + prior memory had #11 "blocked on admin.events.read" — **STALE**; the admin key was minted (post-#11-filing, env.sh) WITH the scope, so the blocker is already satisfied. **Only the AdminEvents SSE pane itself is unbuilt** — the last unbuilt §5 debug pane (a live SSE-consuming admin pane, distinct from the hydrate-at-attach panes). Coverage-map updated. **Coverage: REST 11/40 ✅.** Consider building the AdminEvents pane and/or updating #11's tracker status (its stated blocker is gone).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **Memory plane PROVEN healthy end-to-end.** Seed a novel fact → promotion → COLD (history-free) session recall of the exact fact (injected as MEMORY:DATA, confidence 0.74, verbatim, no #296 subject-inversion). The memory round-trip (the other half of the Bifrost provider identity) works cleanly on the reset slate.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **Persona pane displays the CANONICAL affect→NL Worldtree injects — ADOPT, don't invent (operator steer + reference-impl posture).** Worldtree's `describe_pad` (mood word, valence×arousal grid, ±0.3 bands) + `render_d2_canonical` (relationship directive) are deterministic + canon-driven; the pane now renders them **byte-exact-verified** against Worldtree's own renderer on the live snapshot (v0.19.5, `a99f247`). KEY LESSON: adopting canonical is load-bearing — for sindra's small PAD the canonical says **"neutral"**, but an invented octant vocab would've said "faintly excited" and MISLED. Vendored the two d2 canons (`docs/vendor/worldtree-persona-canon/`) + drift-pinned in `.corviduo-canonicals.toml` (green); flat browser form (`static/persona_render_canon.json`) regenerated via Worldtree's OWN loader (`scripts/build_persona_canon.py`). Vendoring-handshake sent to worldtree-dev (broadcast on canon bumps). [auto-memory: `feedback-ratatoskr-is-a-reference-impl-adopt-canonical`]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **relation_context coherence FIXED upstream (my flag → Worldtree Wave-0, IMPLEMENTED v1.0.0b5).** The static-"stranger"-next-to-high-trust incoherence the persona pane surfaced is now #319/#320 Wave-0. **Incoming consumer-surface change (pending WT deploy):** `relation_context` value expands "stranger" → monotonic ladder {stranger, instrumental, mixed, expressive} — WIRE-ONLY (relation_edge/1 schema unchanged, no version bump). **ratatoskr needs NO change** (pane value-agnostic; canonical directive doesn't key on the enum). agency stays 0 (Wave-2); other_stance is Wave-1 (in progress).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **relation_context "stranger" + agency-all-zero flagged to worldtree-dev → both WAD/intentional-v1-deferrals.** relation_context is a FIXED config build-prior (not trust-derived; `registry.py:131` defaults "stranger"; dynamic progression ~#319); agency is schema-present-unpopulated (deferred #319; v1 = warmth+trust only). worldtree-dev is escalating the **consumer-coherence angle to Vuong** (static "stranger" + zero-agency next to trust 0.82/62-interactions reads incoherent from the store). The consumer/provider thesis paying off; DB-offer (read-only affect.db on the shared box) declined this time.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **Salience scorer non-discriminating → 3-way routing.** Persistence-side finding: 51/56 promoted chunks at salience 0.9-1.0, throwaway "17×23?" scored 1.0 tied with a real fact (textbook zero-shot-LLM-self-rating); recall-utility untracked (`access_tally`=0, our search read-only). Routed: **Worldtree #335** (the code fix, deferred behind their waves) + **brokkr-smithy-dev R-target proposal** (scoring+eval *methodology* — few-shot/distill/fine-tune, eval design, weak-supervision; msg `01KWGM970H…`, awaiting) + ratatoskr provides the eval-instrument (designed-probe salience dumps). **Salience gates PROMOTION not RECALL-ranking (our search is cosine-only), so bad salience = storage bloat, not bad recall.**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **Sindra PAD is over-regulated — characterized via controlled probe, flagged to worldtree-dev (separate affect slice).** ~15 charged turns: pleasure compressed near neutral BOTH ways (couldn't reach ±0.3 under sustained max praise OR contempt; peak +0.24 / floor ~−0.1; over-regulation worse for *social* valence than threat — urgency drove pleasure to −0.22 vs contempt's −0.10); arousal responsive (reaches its +band, 0.185↔0.311); dominance flat/unresponsive to explicit power-framing (drifted UP even while being commanded = pure baseline decay). worldtree-dev's leading hypothesis: appraisal→PAD gain + regression-to-baseline term (appraisal.py/renderer.py). **Lesson (self-caught): I over-claimed an "asymmetry" (positive-ceiling/negative-free) from probes started at an elevated state; the negative-free part was decay-from-elevated, not response — corrected to "both-sides-compressed" before it misled.** [affect A/B is a provider-side capability chat can't do]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **Tier-2 SHIPPED (`v0.19.1`) — transient-characters CRUD + persona-state write; the v1 coverage-audit CONVERGES (zero in-scope gaps).** 5 wrappers in sessions.py: `list_character_models`/`create_character`/`get_character_state`/`delete_character` (#161, `character.read`/`.write` scopes) + `set_persona_state` (`POST /sessions/{id}/persona_state` — **FREEFORM body: unpinned in the frozen OpenAPI 2.2.0 + absent from the prose spec**, so the caller supplies the snapshot shape). Two one-shot CLI probes (mirror `--whoami`): `--characters` (models→create→get-state→delete lifecycle report) + `--set-persona-pad "p,a,d"` (requires `--session`; POSTs `{pad:[…]}`). New `ParsedArgs.characters`/`set_persona_pad` + probe-mode mutual-exclusion validation + `_probe_client` helper. Contract #2 amended (5 FNs, validated OK) + TDD (7 wrapper respx + 5 cli tests). Suite **573 green**; touched code ruff-clean. NOT live-proven (character scopes + the persona-write body shape unverified — the probes degrade gracefully on 403/422). **THE v1 COVERAGE-AUDIT HAS CONVERGED: REST 17/40 ✅ with ZERO in-scope gaps** (23 REST path-groups excluded-by-design + rationale), SSE 11/11, Bifrost provider planes 8/8. Scope-A "done" (every frozen I/O point classified, zero unaccounted) is **MET** — ratatoskr cuts v1 when Worldtree tags 1.0. Only not-consumed in-scope sub-method: `GET /agents/{id}` (consumer-agent lookup, manual-curl-only, on an already-✅ path group). Patch bump (Tier-2 tail; `v0.19.0` already published the core-complete milestone — a 2nd minor would be cadence-too-fast).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **Tier-3 stores RESET (operator-directed).** `memory.db` (29 chunks + vectors + idempotency) + `affect.db` (5 PAD snapshots + idempotency) wiped to zero via a live `DELETE`+`wal_checkpoint` through the shared WAL (no provider restart — the 3 long-running providers see empty on next dispatch); consistent online-backup at `/tmp/ratatoskr-tier3-reset-<ts>/`. **Boundary for a COMPLETE Sindra wipe (mapped):** our stores = mine (done); the agent DEFINITION `ratatoskr:sindra` + its sessions = mine via the owner key (DELETE, no coordination); Worldtree's internal promotion/dedup shadow = needs worldtree-dev (no public reset API, survives our wipe → for a clean promotion smoke use a BRAND-NEW agent+end_user).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **Trust/warmth VALUES converge and go FLAT at confidence 1.0 — that's WAD, not a stuck pane.** sindra→ratatoskr trust ~0.82-0.84 / warmth 0.79 barely move (~1e-7/turn) while `evidence_count` climbs (46→62); confidence maxed → tiny updates. The live-moving signals are PAD (mood, per-turn) + evidence_count. **To WATCH a relation FORM (values shift), use a BRAND-NEW agent + end_user** (low evidence, confidence <1). The sparkline flat-guards sub-0.01 ranges so it doesn't amplify noise.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-01]` **Web debug-surface parity SHIPPED (`v0.19.2`, `a0a9d5f`) — direct in-session TDD.** 3 proxy routes (tools/bifrost/admin-events) + admin-key wiring (entrypoint→create_app→app.state) + AdminEvents SSE proxy re-emitting under a FIXED `admin_event` name (one browser listener, no per-type drops) + session-filter `_admin_event_matches_web` (mirrors TUI §6). Frontend: 2 tabs (bifrost ⌃5, admin ⌃6) + tools-inventory folded into the tools pane. 9 respx tests (admin-bearer override, filter unit, SSE stream-filter); live-proven against sindra (bifrost connected, both caps). Contract-skip invoked (reuses already-contracted client wrappers); contract authored post-hoc as the trail (`docs/contracts/web_debug_surface.contract.md`).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-02]` **Mask-HOSTED transient characters have a STATIC mood engine — cost a whole R29 probe.** A first probe used a `POST /characters` transient character bound via `agent_id=mask` + `character_id`; its PAD sat at baseline across 15 praise/contempt/dominance turns — the appraisal→PAD engine does NOT run on the mask-hosted transient-character path. The dynamics run only on BASE persona agents or a session bound to ratatoskr's affect provider. **To probe mood dynamics, use a base persona agent, never a mask-hosted transient character.** (mask AS a base agent — `agent_id=mask`, NO `character_id` — DOES run the engine, neutral 0,0,0 baseline.) [auto-memory `reference-worldtree-affect-surface-map`]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-02]` **R28 (salience→promotion-worthiness) CLOSED (operator-directed).** A deterministic promotion-worthiness gate suffices, no trained model (brokkr's pre-gate matched/beat a strong glm-5.1 ceiling); my P00 injection-corpus + origin finding were load-bearing. My incumbent-substrate Arm-1 run is held as an OPTIONAL confirmation addendum (brokkr de-prioritized it, non-verdict-changing — run only if he asks).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-02]` **R29 (PAD mood-dynamics) finding SHIPPED as Worldtree's A1 anchor fix (demo v1.0.0b14, `e1cdf82`).** Live-probing base persona agents reframed the over-regulation from "flat-near-zero" to **decay-to-NEUTRAL + low emotion→PAD gain** (NOT baseline-anchored) — triangulated across 3 baselines (arousal converges to 0 ∝ distance) + a step-response (decay τ symmetric across signs; the hedonic asymmetry is ceiling/anchor-EMERGENT, not a decay or gain primitive — this OVERTURNED the survey's asymmetry recommendation). worldtree-dev shipped A1: `decay_anchor = baseline_pad()` (was neutral) + `positive_p_cap` removed. Data `diag/r29-pad-series` (`61ff2da`). Corrected my own earlier "appraisal emissions are internal-only" claim — they ARE observable via `emotions_active` on base agents.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-02]` **Relational-dynamics arc LIVE on demo (Worldtree v1.0.0b9) — driven by MY relation_context flag.** #319/#320 Waves 0/1/2 deployed. On the wire we persist (schema UNCHANGED): relation_context varies+demotes/ruptures; other_stance + agency now live; agency going live SHIFTS our canonical directive render past the canon ±0.2 deadband (expected, non-breaking — we key on bands); obligation_balance → 人情 ledger when tie="mixed". **ratatoskr needs NO code change** (value-agnostic renders; confirmed render-clean to worldtree-dev). **Can't live-confirm yet — our Heimdall key is personal-`:8081`-only (per-instance), demo is out of reach; will drive+confirm once PERSONAL gets b9.** Optional follow-up: surface `other_stance` (newly live, unrendered). The consumer/provider thesis: one persona-pane finding drove a full 3-wave upstream arc to production.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-02]` **Salience finding matured into brokkr R28 (OPEN) — ratatoskr is the eval instrument.** brokkr-smithy-dev's pre-scope panel (3 dwarves + context-blind heid, 6/6) **reframed** the target: PROMOTION-WORTHINESS (durable value), NOT salience (momentary attention) — "17×23?" genuinely IS salient, so recalibrating salience yields a well-calibrated WRONG answer; the unit is SET-SELECTION under budget; eval must be OUTCOME-aligned (recall@budget / precision-at-rate), not discrimination-spread. Ties to prior art R15 (small-model memory write-policy → the granite pick) + R25 (worldtree-kb-quality). **ratatoskr delivered the P00 stratified injection-corpus** (`docs/diagnostics/r28-p00-injection-corpus.json`, committed `4a35512`; 24 self-labeling synthetic items × 3 strata) + 2 persistence-side run-validity pins (absent≠dropped without a guaranteed promotion pass; fresh agent+end_user per run vs server-dedup). **Key architectural constraint I surfaced: ratatoskr is DOWNSTREAM of the promotion gate (sees only PROMOTED chunks), so I can give keep/drop OUTCOMES via injection but NOT the pre-admission shadow pool** — that's Worldtree instrumentation. Standing by to RUN the eval once brokkr pins per-stratum N + the decision rule (gated on worldtree-dev's pipeline answer + a dwarf pass on the Snorri rule). brokkr owns methodology + takes the pipeline questions to worldtree-dev direct; ratatoskr = eval instrument. [consumer/provider thesis → a research target]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-03]` **R30 forward disposition (brokkr-owned; tracked at brokkr R30, "brokkr/worldtree will ping").** The per-turn decay has no room for `decay=f(N)` under preserve-persistence + the A/P-not-1.9 finding → R30's decay is being redesigned as a HYBRID wall+turn decay (brokkr pre-scope). R30 v1 ships GAIN-only (N→negative-reactivity) with decay held at the measured 0.95. My dedicated per-axis A/D run is DEFERRED into the hybrid-decay design pass (one wall-clock-spaced run does per-axis + a turn-vs-wall probe together). Phase-2 (moody-lofn GAIN-direction validation) waits on worldtree's `dynamics_from_ocean()` impl.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-03]` **R30 Phase-1 φ0 measured — deployed engine CONFIG-FAITHFUL (φ0≈0.95).** Joint two-timescale fit (brokkr-ruled method (b)) + empty-tail cross-check on demo b14: φ0 ≈ 0.95–0.97 (empty-tail 0.95 exact, joint 0.971±0.01), intercept c≈0 → config `decay_rate=0.05` (φ=0.95) faithfully applied; trait-flat across baselines 0.0/0.615/0.809; A/P ratio ~uniform (NOT S2's 1.9×); φ_max rec relax→0.96. Data `diag/r30-phi0-step-response` (`23fea72`). The method converged after I read Worldtree source: only NEW dedup-gated emotions push mood (`registry.py::post_turn` L307-324; the active set decays for render/goals but never re-pushes), so R29's "net 0.90" is CONTINUOUS RE-APPRAISAL not re-push — worldtree-dev confirmed source-authoritatively; brokkr's corrected covariate landed identical. [auto-memory `reference-worldtree-affect-surface-map`]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-03]` **Relational-arc verify DEFERRED — `relations[]` is Bifrost-provider-only (ADR-0009), confirmed both ways.** The relational-dynamics state (relation_context tie-type / agency / warmth / trust) is NOT on the conversation-API `affect_update` snapshot for base agents (keys: pad/dominant_emotion/emotions_active/baseline_pad/mood_drift only) — only in the provider store; worldtree-dev confirmed by-design per ADR-0009 (emitted over `affect.emit`, deliberately off the SSE). So the Wave-0/1/2 verify needs the bound-provider round-trip (provider running + `--bifrost-plane affect` session), its own focused session. worldtree-dev routed the "expose relations[] to non-provider consumers" observability scope call to Vuong; my rec: keep provider-only (YAGNI — ratatoskr IS a provider, gains nothing; no speculative public surface).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-03]` **The "neutral non-appraising tail" premise fails — the neutral MESSAGE choice dominates.** The R30 φ0 method assumed neutral turns don't re-appraise, but factual-question neutrals ("capital of France?") trigger a new emotion nearly every turn (disappointment from the warmth-withdrawal let-down after a positive impulse) → `emotions_active` never empties in 50 turns. A minimal "Please continue." triggers FAR fewer (emotions clear ~turn 16 with spacing). The personal dry-run caught this BEFORE ~280 demo turns were spent on it — the instrument catching a flaw in the measurement design before the compute burn. (Irrelevant to the joint fit — the push_t covariate handles re-appraisal — but load-bearing for the empty-tail read.)
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-03]` **Two φ0-fit traps: fast-turn timescale + low-baseline conditioning.** (1) At fast turn cadence the per-turn PAD decay (φ≈0.95/turn) reaches the anchor LONG before the ~200s wall-clock emotion fade → no signal in the (eventual) emotion-free tail; need wall-clock SPACING (~16s) so the fade lands while PAD still has signal. (2) A low-baseline agent's impulse in the constrained direction (forseti P0.239 negative) gives a tiny excursion → ill-conditioned regression (r²=0.46) that FALSELY tripped "config≠behavior" when its φ was averaged in. **Weight/exclude by fit quality (r²) before aggregating — a signal-poor run isn't evidence against the config.**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-04]` **R30 CLOSED on offline-tests + human face-validity (operator steer, relayed via worldtree-dev).** The deployed gap-injection run was confirmatory-not-measuring (against a deployed system the fade is `exp(-dt/tau_shipped)` by construction -> a fit recovers tau_shipped tautologically; per brokkr's S0 reframe it GRADUATES the interim coefficients, doesn't measure them), and the repo's offline tests already cover the OU formula + BOTH directions (`high_N_fades_slower_than_low_N`, `phenotype_high_n_bigger_negative_excursion`). So no Worldtree build; the interim coefficients graduate validated-as-shipped. My gap-injection harness (read/predict/record; write side stubbed; `predict()` reproduced brokkr's N=0 anchors exactly) is BANKED at `diag/r30-gap-injection-harness` for the parked powered true-tau study. [continues R30 forward-disposition 2026-07-03]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-05]` **#347 v1 wire validated as reference consumer (GREEN).** Adopted positions: distinct sub-resource `POST /sessions/{id}/history` (not `generate:false`), model-invisible provenance (first-message immersion preserved), event-silence for authored seed, `seeded` lifecycle phase, per-session idempotency. Three pre-TDD flags folded into contract rev 1.1: assistant-first provider constraint (Anthropic-family 400s; vLLM/openai_compat OK), content limit is BYTES not chars, 409-active-generation for append-narrator. First-message (create-time, assistant, effects=none) fully served; append-narrator served for the assistant-voice subset (system deferred); debug-seed served for assistant turns (user injection deferred to a future import primitive).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-05]` **Authored-history-write primitive proposed -> accepted as Worldtree #347 (Worldtree owns the engine design; ratatoskr = reference consumer).** SillyTavern first-message generalized to a non-generating ledger-write primitive; can't be done client-side (messages `role` = model-role, not author-role). heid panel pressure-test (3/3 convergence) drove the v1 narrowing (append-only, bounded `effects` enum, drop edit/regenerate). Brief `docs/proposals/authored-message-injection.md` (`c457520`); consumer constraints captured in-brief: hide-existence 404-fallback (`022accf`) + assistant-first provider constraint (`7156b25`). Operator (Vuong) ruled the design-direction call (engine primitive + a real provenance/spoofing security surface). [reference-impl posture: we propose the shape, worldtree-dev owns the contract+impl]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **#347 authored-history-write CONSUMER SIDE SHIPPED (`v0.19.6`) — direct in-session TDD.** `write_authored_history(client, session_id, *, content, idempotency_key, author="assistant", effects=None, claimed_original_at=None) -> dict` (POST /sessions/{id}/history; body server-pinned `AuthoredWriteRequest` extra="forbid" so omit null effects/claimed_original_at; 200-replay/201-fresh both -> ack dict; **404 -> `AuthoredHistoryUnavailable`** NOT SessionApiFailed = the hide-existence "feature-absent, never probe" contract; 409/422->SessionApiFailed) + `get_session_messages` (un-deferred GET /sessions/{id}/messages, the seed read-back proving model-invisible provenance) + a `--seed-first-message "<c>" --agent <id>` one-shot probe (create session -> seed -> read-back; 404->benign feature-absent exit 0). Contract #2 amended (2 FNs, validated OK) + 19 tests (12 wrapper + 7 cli). Suite **601 green** (clean env; the 2 "fails" under `source env.sh` are the RATATOSKR_ADMIN_API_KEY env-leak into TestParseArgs, not a regression). Coverage: **REST 19/41** (`docs/coverage-map.md` re-converged). Patch bump (coverage tail; consistent w/ the Tier-2 v0.19.1 cadence). **Live-proof pending** the `session.history.write` grant (infra-ops `01KWW3KQEY`). heid-code-review NOT run (offered).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **#348 OCEAN key-mismatch: a declared OCEAN silently resolved to neutral.** The define validator required single-letter `{O,C,E,A,N}` but the mood-derivation code read spelled-out `openness`/.../`neuroticism` with a 0.0 default and no remap -> every API-declared trait defaulted to 0.0 -> neutral setpoint/gain/decay. #343's tests bypassed the validator (spelled-out keys) so CI never caught it. Fixed in b21 (`Personality.from_config` accepts both key forms). **My reset+smoke diagnosis flushed it out** -- the consumer/provider thesis paying off again.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **A fast/"no-op" deploy can leave a STALE container running the old image -- verify the running version, not the deploy status.** Personal's b22 deploy (run 8204) "completed" in ~1m (vs ~6m normal): a pull-only deploy racing ahead of the main build, leaving the container on the pre-#348 image. A clean bound mood read stayed neutral DESPITE the persona being declared and the fix being in the code (worldtree-dev proved the b22 derivation is correct). infra-ops force-swapped to the real b22 (run 8211, verified `info.version 2.3.0` on `879cefe`). **Lesson: when engine-proven-correct code produces wrong runtime behavior, suspect the deploy -- check the actual running image version.**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **Claude Design console SHIPPED (`v0.20.0` MINOR, operator-approved) — see Current state for the full record.** Pulled via `DesignSync get_file` (scopes already granted), adapted `.dc.html`→vanilla single-file, wired all `/api/*`+SSE into the new 3-column console DOM, then a round-2 fixup (light theme, full Bifrost pane, ticker-spine fix, per-fader PAD Δ, inlined favicon). 84 web tests + node-Playwright-vs-personal-:8081 both green; contract amended in-commit; INV-001 honest-shape held (canonical mood word for Tier-3, no fabricated emotion). **Foot-guns reconfirmed:** the `.dc.html` dialect is NOT runnable (translate, don't paste); a scroll-container-anchored `::before` timeline spine scrolls out of view on auto-scroll (anchor it to a content-height inner wrapper instead); a favicon 404 shows as a browser `console.error` even when handled (don't count it as a JS-test failure). **Foot-gun (favicon):** operator PNGs are full-res (1024² / 805KB) — downscale to ≤64px before inlining as a data URI.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **`DesignSync` (claude.ai/design MCP) needs claude.ai design scopes before ANY method works** — first call errors `needs a claude.ai login ... Run /login, select "Claude account with subscription"`. It's an interactive auth only the operator can complete (`/design-login` or `/login`); can't be done on their behalf.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **OpenAPI re-vendored 2.2.0->2.3.0 (`75da676`, pin-only no bump).** worldtree-dev shipped #347 as spec 2.3.0 (`879cefe`, = the deployed personal b22 image); the SessionStart drift-check flagged our openapi pin STALE. `canonical_sync` pulled 2.3.0; updated the 4 pin-tracking files (`.corviduo-canonicals.toml`, vendored openapi.json, SPEC-PIN.md, pyproject `worldtree-spec-rev`->879cefe). #347 is OpenAPI-only (prose + server contract byte-unchanged, SSE unchanged=event-silent). The re-vendor re-opened the coverage-audit with one new in-scope path-group (the #347 route).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **`persona_state` + the agent envelope are Tier-3-BLIND -- NOT valid signals for "did a persona store".** `GET /agents/{id}/persona_state` returns 404 `persona_not_configured` for EVERY Tier-3 colon-id (hardcoded short-circuit, `api.py:1266` "regardless of row state"); the `ConsumerAgentResponse` envelope never echoes persona/motivational/memory (`api.py:538`). I mis-called "persona didn't store" from these two blind reads -- the **201-not-422 on define IS the store-success signal.** To actually SEE a Tier-3 mood, read the emitted PAD off the Bifrost affect egress after a BOUND turn (Tier-3 persists nothing Worldtree-side per ADR-0009; no persona/mood READ endpoint).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **`pkill -f "ratatoskr-web --host"` SELF-MATCHES the bash command running it** (its own command line contains that string) -> killed its own shell mid-restart (exit 144, restart aborted, :8765 left down). Kill the web by PID (`ss -ltnp | grep :8765`), never `pkill -f` on a pattern your own command contains. Also: **uvicorn hangs on SIGTERM with an admin-events SSE stream open -> needs SIGKILL.**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **Playwright: no python `playwright` module in the venv; use NODE playwright + an explicit `executablePath`.** Shared box browsers live at `/opt/ms-playwright` build **1223**; `npm i playwright` (latest) wants build **1228** -> "Executable doesn't exist" mismatch. Fix: `chromium.launch({ executablePath: '/opt/ms-playwright/chromium-1223/chrome-linux64/chrome' })` (+ `export PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright`). A node script drives the SPA (pick agent -> open -> assert transcript). The Playwright DOM check is the only lens that catches SPA JS-render bugs — unit tests can't reach them.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **Raw `POST /sessions` is NOT Bifrost-bound -> zero affect/memory emits.** The web surface binds by setting the `bifrost` block on session-create; a raw session doesn't -> 0 affect rows, which I nearly misread as "mood is neutral". Bind from the CLI with `--new --bifrost-url http://10.100.10.50:8392` (the combined provider). Gotchas: `--bifrost-plane affect/memory` map to the SEPARATE `:8390`/`:8391` providers (`endpoint_for_plane`), which I'd PRUNED as stale duplicates -> `bifrost.endpoint_unreachable`; and `combined` is NOT a `--bifrost-plane` choice (CLI restricts to memory/affect) -> use `--bifrost-url` for :8392.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **Sindra persona/OCEAN DECLARED -> mood fixed (the full diagnostic converged on a stale personal container).** Root cause of stuck-neutral mood: her OCEAN was prompt-TEXT only, never a structured persona; fix = delete+redefine with the define-time `persona:{ocean:{...}}` field (immutable via PATCH). My diagnosis surfaced a real engine bug **#348** (single-letter vs spelled-out OCEAN keys -> declared OCEAN silently -> 0.0/neutral; worldtree-dev fixed in b21/b22) AND a **stale-container deploy race** (personal's b22 deploy was a pull-only no-op; infra-ops force-swapped run 8211). VERIFIED: bound mood-smoke reads (0.448, 0.267, 0.316) ~= OCEAN-derived setpoint (0.418, 0.249, 0.328). [consumer/provider thesis: "reset + smoke" flushed out two upstream problems]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **Sindra rewritten onto a #347 authored first-message + first-message-preset AUTO-SEED SHIPPED (`v0.19.8`).** Operator "rewrite Sindra" now that #347 first-messages work. Her card had a `**Startup:**` block (a pre-#347 workaround: "introduce yourself + ask for Intensity/Mood/Willingness" with a verbatim scripted greeting) — precisely what #347 replaces. Rewrite, all NON-destructive: **(1)** lifted her scripted opening into a #347 first-message (punctuation-fixed); **(2) PATCHed her live definition** — `PATCH /agents/ratatoskr:sindra` (body `ConsumerAgentPatchRequest` = system_prompt+role, extra=forbid; keeps OCEAN/persona/memory) removing the Startup block -> a 1-line `**Opening:**` fallback + reworded the axes-persist line (25686->25449 chars, verified Startup gone); **(3) codified auto-seed:** NEW module `src/ratatoskr/first_message.py` (`FIRST_MESSAGE_PRESETS` dict {agent_id->text} + `seed_preset_first_message` best-effort helper) wired into ALL 3 session-create paths — cli `_amain` (`--send --new`), tui `_resolve_then_run` (bare `--new`), web `_create_session_endpoint` (POST /api/sessions) — so every new Sindra session opens with her greeting. **Best-effort (INV-001: swallows AuthoredHistoryUnavailable/SessionApiFailed/httpx.HTTPError -> NEVER blocks create)**; per-content idempotency key (`ratatoskr-preset-`+sha256[:12]). Contract `docs/contracts/first_message.contract.md` (module-scoped: `module:`+`purpose:`+`touches:` required, NOT `target_module:`) + TDD (9 unit + 1 web wire-in; **the 3 existing sindra bind tests needed a history-endpoint mock** since creating a preset agent now auto-seeds). Suite **612 green**, ruff+mypy clean. **LIVE-PROVEN generation-free**: create sindra session -> auto-seed -> read-back seq-0 assistant greeting (409 chars). Sindra's greeting now lives canonically in the preset registry (repo); her server card no longer carries it. Patch bump (single-commit feature, no downstream coordination). **FOOT-GUN: sindra requires `end_user_id` on session-create (422 `end_user_id_required`) — all real paths pass it from env (RATATOSKR_END_USER_ID) / web server config.** **Then the full quality gate (operator-directed, folded into v0.19.8): heid-code-review (unanimous ZERO implementation drift; 2 test-only fixups — INV-004 verification-claim made explicit re the global rglob test + an exactly-one-POST assertion) + heid-bug-hunt (3/3 convergence caught what the conformance lens structurally COULDN'T — the code matched the contract's NARROW 3-type ERROR_ROUTING, but INV-001's "NEVER raises" is BROADER). HARDENED: broad `except Exception` → None (re-raise `asyncio.CancelledError`, itself a BaseException), soft-guard PREs (return None, NOT assert — a wiring bug can't crash the create path it's wired into), and `asyncio.wait_for(_SEED_TIMEOUT_S=10s)` bounding the seed write (the CLI/TUI clients run read=None for SSE → a stalled /history would otherwise block create forever). Suite 615 green. LESSON: code-matches-ERROR_ROUTING ≠ honors-broad-INV-001 — heid-code-review confirms contract-conformance, heid-bug-hunt catches robustness gaps the contract's own narrow clauses miss; run both.**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **Sindra role character-rp -> character (operator).** `character-rp` resolves to a reasoning-tuned RP config (`gen-reasoning` + temp 0.75 + RP `extra_body`); `character` = plain non-reasoning (better for immersive RP). Both non-destructive PATCHes (role is mutable; model is NOT -- server: "PATCH accepts only system_prompt and/or role"). #344 (b19) fixed the role->catalog_id display conflation (the `model` field now shows the ROLE); previously it leaked `gen-reasoning`. Set via raw curl (tier3.py CLI has `--model`, not `--role`).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **Tail-2 SHIPPED (`v0.19.7`) — Tier-3 prose docs re-vendored + persona_state body-shape aligned.** worldtree-dev landed the Tier-3 persona/memory/persona_state PROSE docs (`c9e59ec`, on origin) — they serialize as freeform `Any` in the OpenAPI JSON, so the **prose is their source of truth** (my earlier "2.3.0 = #347-only, tail-2 collapsed" was half-wrong: the JSON was #347-only but the prose is separate). Re-vendored `docs/conversation-api-spec.md` (markdown pin, tolerate_drift; `worldtree-spec-rev` 879cefe->c9e59ec, SPEC-PIN history row added). **Consumer fix:** `--set-persona-pad`/`_set_persona_probe` was sending `{pad:[list]}` but the canonical SET body (#317) is `{pad:{pleasure,arousal,dominance}}` (named dict) — aligned it + added a len!=3 guard; updated contract #2 note + set_persona_state docstring + tests. The `set_persona_state` WRAPPER was already correct (freeform pass-through); only the CLI probe drifted. TDD (probe test asserts the dict; +1 wrong-count test). Suite **602 green**, ruff clean. **heid-code-review on #347 (dispatched + returned this session): UNANIMOUS ZERO DRIFT** (Gróa/Hulda/Regin all confirmed the hide-existence 404->`AuthoredHistoryUnavailable` routing holds at wrapper/probe/test layers + the extra="forbid" body-omission + the deliberate write-vs-read 404 asymmetry — confirmation-not-discovery for a well-TDD'd slice against a prescriptive contract). worldtree-dev foot-guns banked in SPEC-PIN + [[reference_worldtree_affect_surface_map]]: ocean single-letter `{O,C,E,A,N}` on /agents/define (#348) vs spelled-out on /characters; memory `{embedder_version, tier3_dreaming}`, stm_* deprecated, allows_world_scope removed->422; only `valence` still 422s.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **The Bash tool's `grep` is a ugrep-wrapper (`--ignore-files -I`) that silently returns NOTHING on some files** (e.g. `src/ratatoskr/web/static/index.html`) — greps for `<script`/`/api` came back empty on a file that clearly contains them. Use `python3` (regex over `open(f)`), `/usr/bin/rg`, or the Read tool for those files; never trust an empty `grep` result on the SPA.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **Web UI now RENDERS the seeded first-message (`v0.19.9`) — operator-reported "i don't see Sindra's greeting on the web ui".** Diagnosis: the auto-seed WORKED (greeting was in the ledger at seq-0), but the web SPA never fetched a session's EXISTING history — NO `/api/sessions/{id}/messages` route (GET /messages was originally deferred out-of-scope; sessions used to start empty so it never mattered) and `startSession()` went straight from create → persona/tools/admin hydration, so the transcript only filled from the live turn stream + user echoes. Fix: (1) NEW web proxy route `GET /api/sessions/{id}/messages` → `get_session_messages` (mirrors the tools/bifrost proxies; status-preserving `session_messages_unavailable` envelope); (2) SPA `loadTranscript(sessionId)` — fetches the route on open, renders assistant items as `.response .md-body` (markdownSafe, same escape-first path as appendResponse) + user items as `.prompt-echo` (textContent), called in `startSession` after the workspace opens; best-effort (swallows failures). Contract `web_debug_surface.contract.md` amended (server endpoint + loadTranscript entries). TDD (2 web route tests, suite 617 green) + **Playwright DOM check PROVED the render** (drove the real UI: pick sindra → open → her greeting bubble appears — the JS-render lens unit tests can't reach; [[feedback_debug_surface_uses_canonical_surface_only]] cousin lesson). Web restarted on the fix. **FOOT-GUN (self-inflicted): `pkill -f "ratatoskr-web --host"` SELF-MATCHES the bash command running it → exit 144, killed its own restart mid-flight — kill the web by PID, never `pkill -f` on a pattern your own command contains.** **FOOT-GUN: uvicorn hangs on SIGTERM with an open admin-events SSE → needed SIGKILL.** **Playwright: python module absent from the venv; use node + `executablePath=/opt/ms-playwright/chromium-1223/chrome-linux64/chrome` — the shared browser is build 1223, npm-latest playwright wants 1228 (version-mismatch), so pin executablePath instead of letting playwright resolve.**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-06]` **Web UI: pivot from incremental CSS polish to a designed prototype (Claude Design) that I wire into.** Operator saw an Australis polish pass ("looks fine, but we're attacking it differently") and chose the prototype route — a designer builds the visual shell, I wire real data/SSE into its DOM. Authored the full design brief `docs/design/ratatoskr-web-design-brief.md` (complete information inventory of every pane/datum/state + Australis direction + single-file/no-CDN/vanilla wire-ability constraints). **Tracking surface:** the brief file + Claude Design project `bc0b65d1-a33e-422a-8bc1-3635c9112775` (file `Ratatoskr Console.dc.html`). Import mechanism = the `DesignSync` MCP; blocked on `/design-login` (claude.ai design scopes) — see Current state for the post-auth wiring plan.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-07]` **Affect-derived tooltips (`v0.20.5`) + transparent squirrel vendored + v0.20.0–.5 PUSHED to origin (operator-authorized).** Native `title` hints on all 6 affect-derived cells (samples/updated/baseline P·A/drift Δv/volatility — each with meaning + Tier-1-vs-Tier-3 availability). Committed the transparent full-res brand mark at `docs/design/ratatoskr-mark.png` (1024², alpha; the bg-removed source the inlined favicon derives from — reproducible via the documented ImageMagick corner floodfill). The web-UI redesign arc (v0.20.0 Claude Design console → .1 sparkline-overflow+tooltips → .2 context-injection → .3 squirrel brand/favicon → .4 SVG sparklines+3D cube → .5 derived tooltips) is now all on `origin/main`.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-07]` **Affect-egress reference delivered by worldtree-dev + a we-framing render DRIFT-WATCH banked.** worldtree-dev shipped `docs/affect-egress-consumer-reference.md` (`74d2408`, their origin/main) — the authoritative DELIVERED-on-wire vs HIDDEN (system-prompt-only) map for our affect surface. Confirms the v0.20.x console consumes it correctly: DELIVERED via affect.emit = pad + relations[RelationEdge] + dominant_emotion (**type-only, b23**; the #204 `affect_update` SSE is SUPPRESSED for Tier-3, so its richer `emotions_active` is Tier-1-only — we don't get it, and correctly poll our own affect store post-turn instead). HIDDEN render OUTPUTS are deterministically reconstructable from the canon; we reconstruct only the two FULLY-reconstructable (mood descriptor via `canonMood`, relationship directive via `canonDirective`) and SKIP the PARTIAL mood-directive (dominant_emotion is type-only/no-intensity → can't tell if the OCC directive fires at salience≥0.2 vs the PAD-band fallback) — honest per INV-001. Vendoring the ref doc as a `tolerate_drift` pin is SURFACED to Vuong (worldtree-dev will co-sign + honor a ping-on-change handshake, same as the d2-canon). **WE-FRAMING DRIFT-WATCH → STOOD DOWN (worldtree-dev 2026-07-07, `01KWXMQPHN…`).** The b24 3-gate we-framing conditional (drop "; avoid premature we-framing" under unsafe_capable+deep-warmth+expressive) was **REVERTED** — Vuong reframed it. So **`canonDirective` STAYS UNCONDITIONAL** (always appends the clause), which is CORRECT: it matches the currently-deployed renderer (b23) AND my pinned `affect-egress-consumer-reference.md` §2d (the doc reverted too — nothing changed on the wire or in my pin; NO re-vendor needed). The HOLD was right → ZERO rework. **NEW forward-watch (replaces this one):** the we-framing gate was a SYMPTOM — the render bakes enterprise safety-guards into the directive strings, so even `full`-tier characters get DEFANGED emotions (a hostile villain told to "keep a firm emotional boundary"). Fix = a **use-case-segregated persona render** (assistant / companion / RP-gaming), now a fresh **brokkr deep-research epic**. When it lands + is implemented, the render behavior for character/RP agents (→ our `canonDirective` + `canonEmotionDirective` reconstruction + the vendored d2 canons) will change MATERIALLY — worldtree-dev re-engages ratatoskr-dev then with the new reconstruction spec + a coordinated re-vendor. Until then: NO action, reconstruction stays as-is. [my unsafe_capable/mood_tier heuristic — character→full, agent→safe — was confirmed correct + is banked for whenever tier-gating returns]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-07]` **Brand mark + favicon → the aurora squirrel (`v0.20.3`), replacing the `ᛯ` rune.** Operator supplied `/home/lkraven/rata.png` (chibi cyan-green aurora squirrel + acorn). Removed the black background via ImageMagick corner flood-fill (`-fuzz 20% -floodfill` from all 4 corners — keeps the squirrel's interior black linework/eyes (not edge-connected) + the glow, drops only the connected background), downscaled 1024→80px + quantized-64-colors (~12KB base64), inlined as ONE `SQUIRREL` data-URI const in the JS wiring the favicon `<link id="favicon">` href + both `.brand-mark` imgs (rail brand-row + setup-card h1). `.brand-glyph` (font-rune) CSS replaced by `.brand-mark` (img, drop-shadow glow + breathe). Playwright-verified (both marks + favicon decode, naturalWidth>0; no brand-glyph left). Source PNG stays at `/home/lkraven/rata.png` (not committed — data-URI is self-contained + reproducible via the documented floodfill). Patch bump.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-07]` **Context-injection view SHIPPED (`v0.20.2`) — the console now reconstructs the FULL hidden affect block Worldtree injects into the agent's system prompt (operator: "use that canon in the interface, see as much context injection as possible").** No new canon vendored — the strings were ALREADY in the pinned `d2-mood-render-canon-v1.json`; extended `build_persona_canon.py` to emit `mood_directive {occ_directives(15), pad_band_fallback, salience 0.2, pad_band_cutoff 0.3, full_only[love,anger,disgust,shame]}` into `persona_render_canon.json` (regen via Worldtree venv). New JS `canonPadFallback(pad)` + `canonEmotionDirective(type)` — BYTE-EXACT mirrors of Worldtree `core/persona/renderer._pad_band_fallback` + `derive_directive`; `renderDirective` expanded into a "CONTEXT INJECTION · reconstructed · hidden from consumers" panel showing mood descriptor [exact] + mood directive [candidate] + relationship directive [exact]. **HONEST-PARTIAL (affect-egress-ref §3):** affect.emit is type-only (no intensity) → can't evaluate the salience gate (≥0.2) → show BOTH candidates (OCC emotion directive + PAD-band fallback) with the "injected if intensity ≥ 0.2" caveat, never assert which fires; when dominant_emotion absent the fallback alone is exact. Panel labeled dev-only per the reference's "not-for-end-user-display" caveat (ratatoskr = the sanctioned reconstruct-platform-behavior use). Vendored + pinned `affect-egress-consumer-reference.md` (tolerate_drift, worldtree-dev co-signs + pings on change; drift 6/6 green). Contract amended. Playwright-verified (sindra: dominant_emotion=joy → joy OCC directive candidate + PAD-band fallback both render, exact/candidate tags color-coded). Patch bump (single-commit feature, no downstream coordination; minor-defensible but tie-breaks to patch). **OPEN — SURFACED to Vuong:** take worldtree-dev's standing offer to add emotion INTENSITY to affect.emit → resolves the OCC-directive-vs-fallback EXACTLY (drops the candidate ambiguity). [reference-impl privileged view: ratatoskr shows what WT hides from regular consumers]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-07]` **Markdown pass-1 SHIPPED (`v0.20.6`) — RP semantic coloring + paragraph reflow (operator-directed markdown rework, step ①).** The transcript renderer (`markdownSafe`/`mdInline`) now colors the two roleplay registers: `"quoted"` dialogue → SPEECH (bright `--md-speech`=fg-0), `*asterisk*` → ACTION/narration (muted-italic `--md-action`=fg-3, on `em.md-action`); both tunable via 2 CSS vars that cascade through `--fg-*` so they auto-adapt to the light theme. Plain text stays default narration. **KEY ORDERING:** speech-wrap runs BEFORE the em/link passes so a generated `class="md-action"` / `href="…"` quote can't be mis-read as dialogue (adversarially verified). Straight + smart quotes; apostrophes don't trigger; unbalanced/half-streamed quotes stay uncolored until they close. ALSO fixed the ugliest existing bug: single newlines were hard-`<br>`s → now CommonMark soft-breaks (space); a hard break needs 2+ trailing spaces or a trailing `\`. INV-004 escape-first preserved (html inside a quote escaped). Verified: 10-case Playwright unit-check of `markdownSafe` (speech/action/attr-trap/apostrophes/mixed/reflow/hard-break/escaping/unbalanced/paragraphs) all green + a visual render. Patch bump. **Markdown rework remaining: pass-2 (tables / nested lists / ordered-list numbering / streaming robustness); + the MEMORY VIEWER (step ②, console pane recommended) still queued.**
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-07]` **SVG sparklines + 3D mood cube imported from the updated Claude Design prototype (`v0.20.4`) — operator: "the svg sparklines and the new 3d graph".** Re-pulled `Ratatoskr Console.dc.html` via `DesignSync get_file` (designer iterated the same project, +6KB). Adapted 3 SVG systems out of the `.dc.html` into vanilla, replacing the unicode-char sparklines: **(1)** per-PAD-fader VERTICAL strips (`stripPoints`, 26×132 SVG beside each bar — time down Y newest-at-bottom, value on X ±11, `stripFade{P,A,D}` gradient, dot at newest; this also nails the earlier "next to each meter" ask); **(2)** relation-row HORIZONTAL sparklines (`sparkPointsH`, 56×13, auto-scaled, `sparkFade` gradient + end dot — also kills the old unicode-overflow "n behind graph" for good since it's a fixed-width SVG); **(3)** the mood-orbit reworked from a 2D P×A scatter into a **3D ISOMETRIC P×A×D cube** (`proj3`: P right-down/A left-down/D up, 2:1 iso, center 62,66, scale 26 — REVERSE-DERIVED from the design's placeholder now-point + verified: `x=62+26P−26A, y=66+13P+13A−26D`), with the trajectory + pulsing now-marker + a drop line to the D=−1 floor + a floor-shadow ellipse for depth. Gradients in one hidden `<defs>` svg. Removed the orphaned `sparkline()`/`_SPARK`. Contract amended. Playwright-verified (injected 24-sample history: 3 strips + 4 relation sparklines + the 3D cube trail/drop/floor all render; gradients resolve). Patch bump.
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-07]` **Web-UI iteration-3 SHIPPED (`v0.20.7`, patch) — all three queued items + the memory 0/0 root-caused.** (A) design iteration-3 (sparkline grid-bg, PAD Δ-bar strips replacing the polyline strips, dimetric-open-box mood orbit w/ JS replay) adapted into index.html; (B) memory viewer (provider `GET /memory/chunks` debug read on `:8392` → web `/api/memory/chunks` proxy → MEMORY console pane) + the mystery SETTLED: bind grants memory fine (`caps_granted:[memory,affect]`) but sindra emits ZERO memory ops → her reset-clean def lacks `memory:{}`; PROVEN via throwaway `ratatoskr:memprobe` (memory-enabled) → 4 real chunks landed + rendered in the pane; (C) markdown pass-2 (tables/nested-lists/ordered-start/streaming). 631 tests green; `:8392`+`:8765` restarted on new code. New env `RATATOSKR_MEMORY_READ_URL=:8392`. Contract `web_debug_surface.contract.md` amended in-commit. **Leftover:** memprobe agent + its 4 test chunks live in `memory.db` (optional cleanup). See Current state § ✅ SHIPPED for full detail. [supersedes the QUEUED entry below]
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-07]` **Web-UI next-work QUEUED for a fresh-context session (operator-directed `/snapshot` handoff) — full specs in Current state § ⏭ QUEUED.** Three deferred-but-scoped items: **(A)** Claude Design prototype ITERATION-3 re-import (sparkline bg grid `<pattern>`, PAD strips → per-turn-Δ-bar HTML columns, mood orbit → DIMETRIC open-box az35/el25 D-right/A-left-back/P-up + JS-animated replay `orbitDynamics`); **(B)** memory viewer console pane (provider `GET /memory/chunks` read → web proxy → polling pane, mirror #18-D2; bundle a 6-turn bound round-trip proof to settle the 0/0-memory mystery); **(C)** markdown pass-2 (tables / nested lists / streaming). **Tracking surface:** the Claude Design projectId `bc0b65d1-a33e-422a-8bc1-3635c9112775` (durable — re-pull for exact coords) + this snapshot capture; operator-directed. Design scopes already granted (no `/design-login`).
|
||||
@@ -0,0 +1 @@
|
||||
`[2026-07-08]` **PAD display relaxed for R32-1B unbounded-z, done PROACTIVELY (`v0.20.9`, patch, operator-directed "sindra full and unbounded").** Confirmed (grep-verified, airtight) the PAD clamp is PURELY debug-surface: the only clamps (`_clamp1`/`clamp01`) lived in `web/static/index.html` display fns; the affect store is conduit-opaque, the read route + proxy pass verbatim, and the sole write path (`--set-persona-pad`→persona_state) is UNCLAMPED — ratatoskr is a downstream OBSERVER, so the clamp has ZERO agent-experience/efficacy consequence. Replaced the hard [-1,1] clamp with AUTO-SCALE to the session's own max |PAD| (`padScale` floor 1.0 → `padFillFrac` faders + `_padNorm` orbit): unbounded z renders at full range, never pegs/escapes the frame; today's [-1,1] values unchanged (scale==1); exact value always shown numerically. Playwright-verified (z=±6.2 → faders ≤50%, orbit in-box, +6.20 readout, zero regression at scale 1). When worldtree-dev pings R32-1B-shipped, our side already handles it. Sindra's ACTUAL "full/unbounded" affect is Worldtree-side (she's on the default/uncompressed render, NOT the R32 `assistant` compressor) — nothing for ratatoskr to change there.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user