Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca6af6bdaa | ||
|
|
a0c6c73ab9 |
@@ -10,7 +10,7 @@ complexity: "medium"
|
|||||||
estimated_loc: 180
|
estimated_loc: 180
|
||||||
confidence: 0.85
|
confidence: 0.85
|
||||||
assumptions:
|
assumptions:
|
||||||
- "bifrost>=0.6.1 is installed and exposes build_affect_app, dispatch_affect_call, JwtVerifier, ConsumerRegistration, AffectInvalidArguments, AffectIdempotencyConflict per bifrost/docs/implementing-a-consumer.md @ 8df54ed and bifrost/reference_server/affect.py."
|
- "bifrost>=0.10.0 is installed and exposes build_affect_app, build_combined_app, dispatch_affect_call, JwtVerifier, ConsumerRegistration, AffectInvalidArguments, AffectIdempotencyConflict, and REQUIRES a callable affect-store fetch for the affect capability (_supports_affect_plane, bifrost/affect.py:75-80, strong-or-absent) per bifrost/reference_server/affect.py."
|
||||||
- "The affect snapshot dict always carries string addressing keys 'agent_id' and 'end_user_id'; the bifrost wire validates the envelope before the store is called."
|
- "The affect snapshot dict always carries string addressing keys 'agent_id' and 'end_user_id'; the bifrost wire validates the envelope before the store is called."
|
||||||
- "A Heimdall HS256 key for consumer_id='ratatoskr' is provisioned (deploy-time, brokered via infra-ops); the store itself never sees raw auth — the library verifies per-dispatch JWTs and hands a DispatchContext (ctx)."
|
- "A Heimdall HS256 key for consumer_id='ratatoskr' is provisioned (deploy-time, brokered via infra-ops); the store itself never sees raw auth — the library verifies per-dispatch JWTs and hands a DispatchContext (ctx)."
|
||||||
- "The idempotency actor is derivable from ctx (mirrors bifrost's reference `_ctx_actor(ctx)` — the dispatch subject/actor identity)."
|
- "The idempotency actor is derivable from ctx (mirrors bifrost's reference `_ctx_actor(ctx)` — the dispatch subject/actor identity)."
|
||||||
@@ -21,7 +21,9 @@ external_invariants:
|
|||||||
- source: ~/development/bifrost/docs/contracts/affect.contract.md
|
- source: ~/development/bifrost/docs/contracts/affect.contract.md
|
||||||
invariant_id: "INV-001" # conduit opacity — the governing rule of the affect plane
|
invariant_id: "INV-001" # conduit opacity — the governing rule of the affect plane
|
||||||
- source: ~/development/bifrost/bifrost/reference_server/affect.py
|
- source: ~/development/bifrost/bifrost/reference_server/affect.py
|
||||||
invariant_id: "InMemoryAffectStore.emit" # the executable reference for the wire semantics we parity-prove against
|
invariant_id: "InMemoryAffectStore.emit" # the executable reference for the emit wire semantics we parity-prove against
|
||||||
|
- source: ~/development/bifrost/bifrost/reference_server/affect.py
|
||||||
|
invariant_id: "InMemoryAffectStore.fetch" # the executable reference for the affect.fetch read shape ({found, snapshot})
|
||||||
revisions:
|
revisions:
|
||||||
- version: "1.1"
|
- version: "1.1"
|
||||||
at: 2026-06-14
|
at: 2026-06-14
|
||||||
@@ -39,6 +41,22 @@ revisions:
|
|||||||
- "basic_emit wording — semantic round-trip (was: byte-identical)"
|
- "basic_emit wording — semantic round-trip (was: byte-identical)"
|
||||||
REMOVED:
|
REMOVED:
|
||||||
- "the 'same idempotency_key + different content hash -> LWW overwrite' clause (it was backwards: bifrost treats that as a conflict)"
|
- "the 'same idempotency_key + different content hash -> LWW overwrite' clause (it was backwards: bifrost treats that as a conflict)"
|
||||||
|
- version: "1.2"
|
||||||
|
at: 2026-06-19
|
||||||
|
summary: "Adopt bifrost 0.10.0's mandatory affect.fetch (strong-or-absent, INV-012): _supports_affect_plane now requires a callable fetch for the affect cap to advertise/dispatch at all, so an emit-only store 400s on EVERY affect op. Promote the sync get() read seam to an async wire fetch() returning bifrost's {found, snapshot} shape; conform to the reference InMemoryAffectStore.fetch. affect.fetch leaves 'reserved'. Forced prerequisite of the #18 D1 composite (build_combined_app)."
|
||||||
|
delta:
|
||||||
|
ADDED:
|
||||||
|
- "fetch() function block (async wire verb; mirrors reference InMemoryAffectStore.fetch)"
|
||||||
|
- "INV-010 (affect cap = affect_supported + emit + fetch, strong-or-absent)"
|
||||||
|
- "parity_vs_reference_fetch test"
|
||||||
|
- "InMemoryAffectStore.fetch external invariant"
|
||||||
|
MODIFIED:
|
||||||
|
- "INV-005 — cross-refs INV-010 (the affect cap now requires fetch present too)"
|
||||||
|
- "assumptions — bifrost pin >=0.10.0 (build_combined_app + mandatory affect.fetch)"
|
||||||
|
- "get() BRIEF — the sync read seam fetch() wraps (no longer 'affect.fetch RESERVED')"
|
||||||
|
- "Data flow — add the fetch read-back path"
|
||||||
|
REMOVED:
|
||||||
|
- "the 'affect.fetch / affect:read RESERVED in v1' out-of-scope line"
|
||||||
---
|
---
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
@@ -75,13 +93,19 @@ affect; we only persist and round-trip it.** We run no affect logic.
|
|||||||
conflict cache: `digest` is a content fingerprint of the snapshot;
|
conflict cache: `digest` is a content fingerprint of the snapshot;
|
||||||
`expires_at` records the short-retry deadline for a future pruning pass
|
`expires_at` records the short-retry deadline for a future pruning pass
|
||||||
(TTL eviction deferred — see INV-009).
|
(TTL eviction deferred — see INV-009).
|
||||||
- **Out:** `{"stored": True}` ack (the library wraps it with the transport
|
- **Out (emit):** `{"stored": True}` ack (the library wraps it with the transport
|
||||||
`{"success": True}` envelope).
|
`{"success": True}` envelope).
|
||||||
|
- **Fetch (read-back):** Worldtree's `affect.fetch` → `POST /bifrost/affect-call`
|
||||||
|
→ `store.fetch(agent_id=..., end_user_id=...)` → `{"found": False}` or
|
||||||
|
`{"found": True, "snapshot": <verbatim snapshot>}` (the library wraps it via
|
||||||
|
`affect_result(**fetched)`). The snapshot is returned opaque/verbatim — `fetch`
|
||||||
|
never reads `pad` / `valence` / `persona_baselines` / `emitted_at` (INV-001).
|
||||||
|
|
||||||
**Async surface:** `emit` is `async def` (the bifrost consumer Protocol awaits
|
**Async surface:** `emit` and `fetch` are `async def` (the bifrost consumer
|
||||||
it); `open_affect_store` and `get` are sync (no I/O await — `get` is a read-back
|
Protocol awaits them); `open_affect_store` and `get` are sync (no I/O await —
|
||||||
seam). The `FN` lines below omit the `async` keyword only because the contract
|
`get` is the read-back seam `fetch` wraps). The `FN` lines below omit the
|
||||||
grammar's `FN <name>` form has no async marker.
|
`async` keyword only because the contract grammar's `FN <name>` form has no
|
||||||
|
async marker.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
@@ -113,7 +137,7 @@ grammar's `FN <name>` form has no async marker.
|
|||||||
`stored` specifically).
|
`stored` specifically).
|
||||||
- **INV-005** [hard]: The store advertises `affect_supported = True`; it is the
|
- **INV-005** [hard]: The store advertises `affect_supported = True`; it is the
|
||||||
REQUIRED store — `build_affect_app(store=None, ...)` raises (no silent
|
REQUIRED store — `build_affect_app(store=None, ...)` raises (no silent
|
||||||
in-memory default).
|
in-memory default). See INV-010 for the full affect-capability surface.
|
||||||
- **INV-006** [hard]: Authorization identity/scope — and the **idempotency
|
- **INV-006** [hard]: Authorization identity/scope — and the **idempotency
|
||||||
actor** — are taken from `ctx` (DispatchContext), never from the snapshot or
|
actor** — are taken from `ctx` (DispatchContext), never from the snapshot or
|
||||||
other call arguments. The snapshot addressing keys are used ONLY as the
|
other call arguments. The snapshot addressing keys are used ONLY as the
|
||||||
@@ -137,6 +161,13 @@ grammar's `FN <name>` form has no async marker.
|
|||||||
grows unbounded until a follow-up pruning patch. Wire-observable behavior is
|
grows unbounded until a follow-up pruning patch. Wire-observable behavior is
|
||||||
unaffected (replay/conflict still resolve correctly); only cache size is.
|
unaffected (replay/conflict still resolve correctly); only cache size is.
|
||||||
`affect_snapshots` is already bounded to one row per `(agent_id, end_user_id)`.
|
`affect_snapshots` is already bounded to one row per `(agent_id, end_user_id)`.
|
||||||
|
- **INV-010** [hard]: **The affect capability is `affect_supported` + `emit` +
|
||||||
|
`fetch`, strong-or-absent** (bifrost ≥0.10.0 `_supports_affect_plane`,
|
||||||
|
`bifrost/affect.py:75-80`; the INV-012 no-degraded-path rule). bifrost gates
|
||||||
|
EVERY affect op (emit included) on all three being present, so a store missing
|
||||||
|
a callable `fetch` is rejected with `affect.unsupported_capability` and the
|
||||||
|
handshake never advertises `affect`. We therefore implement `fetch` fully (not
|
||||||
|
a stub) — the canonical surface admits no emit-only affect store.
|
||||||
|
|
||||||
## Concurrency
|
## Concurrency
|
||||||
|
|
||||||
@@ -186,8 +217,10 @@ already have rejected a malformed envelope.
|
|||||||
Protocol are a later contract.
|
Protocol are a later contract.
|
||||||
- **The combined two-plane server** (guide §7): one handshake negotiating both
|
- **The combined two-plane server** (guide §7): one handshake negotiating both
|
||||||
memory + affect is deferred; `build_affect_provider_app` mounts affect alone.
|
memory + affect is deferred; `build_affect_provider_app` mounts affect alone.
|
||||||
- **`affect.fetch` / `affect:read` / persona-baseline rehydrate**: RESERVED in
|
- **`affect:read` scope enforcement / persona-baseline rehydrate shaping**: the
|
||||||
v1; only `emit` + the test-only `get()` exist.
|
library owns scope auth (`affect:read` for fetch); `fetch` returns the stored
|
||||||
|
blob verbatim — any richer rehydrate shaping beyond a snapshot round-trip is
|
||||||
|
Worldtree's concern, not the store's.
|
||||||
- **`idempotency_class`**: accepted and ignored (affect.* uses a single
|
- **`idempotency_class`**: accepted and ignored (affect.* uses a single
|
||||||
short-retry class).
|
short-retry class).
|
||||||
- **WAL/concurrency hardening, deployment DB path, auth-key provisioning**:
|
- **WAL/concurrency hardening, deployment DB path, auth-key provisioning**:
|
||||||
@@ -254,7 +287,7 @@ TESTS:
|
|||||||
|
|
||||||
```contract
|
```contract
|
||||||
FN get(self, agent_id: str, end_user_id: str) -> dict | None
|
FN get(self, agent_id: str, end_user_id: str) -> dict | None
|
||||||
BRIEF: Read-back of the stored snapshot (tests / future rehydrate-seed). NOT a wire verb — affect.fetch is RESERVED in v1.
|
BRIEF: Sync read-back seam returning the verbatim stored snapshot (or None). The async wire verb fetch() wraps this; tests / the D2 read route / rehydrate-seed also use it directly.
|
||||||
POST: [POST-001 return_value] returns the verbatim snapshot for the key, or None if absent -- (INV-003)
|
POST: [POST-001 return_value] returns the verbatim snapshot for the key, or None if absent -- (INV-003)
|
||||||
STEPS:
|
STEPS:
|
||||||
1. [sequential] SELECT snapshot_json FROM affect_snapshots WHERE agent_id = ? AND end_user_id = ?
|
1. [sequential] SELECT snapshot_json FROM affect_snapshots WHERE agent_id = ? AND end_user_id = ?
|
||||||
@@ -264,6 +297,30 @@ TESTS:
|
|||||||
get_after_emit [happy]: returns the emitted snapshot, deserialized equal
|
get_after_emit [happy]: returns the emitted snapshot, deserialized equal
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```contract
|
||||||
|
FN fetch(self, agent_id: str, end_user_id: str) -> dict
|
||||||
|
BRIEF: Wire affect.fetch read handler — return the stored snapshot in bifrost's {found, snapshot} shape, conduit-opaque. Mirrors the reference InMemoryAffectStore.fetch verbatim (INV-010 strong-or-absent: this method MUST exist for the affect cap to advertise/dispatch).
|
||||||
|
PRE: [PRE-001 hard] agent_id and end_user_id are non-empty strings -- else raise AffectInvalidArguments (mirrors reference; the wire validates the envelope first, this is belt-and-suspenders)
|
||||||
|
POST: [POST-001 return_value] returns {"found": False} when no snapshot for the key -- (the library wraps via affect_result(**fetched))
|
||||||
|
POST: [POST-002 return_value] returns {"found": True, "snapshot": <verbatim snapshot>} when present; snapshot deserializes equal to the emitted snapshot -- (INV-003)
|
||||||
|
POST: [POST-003 return_value] never reads pad/valence/persona_baselines/emitted_at — returns the whole blob opaque -- (INV-001)
|
||||||
|
ERROR_ROUTING:
|
||||||
|
AffectInvalidArguments:
|
||||||
|
local_handling: raise on missing/empty agent_id or end_user_id
|
||||||
|
flow_control: abort
|
||||||
|
state_recovery: none (read-only; no state touched)
|
||||||
|
STEPS:
|
||||||
|
1. [setup, flexibility=prescriptive] IF agent_id/end_user_id missing or not non-empty str: RAISE AffectInvalidArguments
|
||||||
|
2. [sequential] SET snap = self.get(agent_id, end_user_id) -- the existing sync read seam; whole-blob json.loads, no field reads (INV-001)
|
||||||
|
3. [branch] IF snap is None: RETURN {"found": False}
|
||||||
|
4. [cleanup] RETURN {"found": True, "snapshot": snap}
|
||||||
|
TESTS:
|
||||||
|
fetch_absent [boundary]: no row for key → {"found": False}
|
||||||
|
fetch_after_emit [happy,tracer]: emit then fetch → {"found": True, "snapshot": equals the emitted snapshot}
|
||||||
|
fetch_missing_key [adversarial]: empty/missing agent_id or end_user_id → raises AffectInvalidArguments
|
||||||
|
parity_vs_reference_fetch [scenario]: drive identical affect.fetch envelopes (found + not-found) through dispatch_affect_call against InMemoryAffectStore and RatatoskrAffectStore → (status, body) tuples agree (#195)
|
||||||
|
```
|
||||||
|
|
||||||
```contract
|
```contract
|
||||||
FN build_affect_provider_app(store: RatatoskrAffectStore, heimdall_key: bytes, consumer_id: str = "ratatoskr") -> Starlette
|
FN build_affect_provider_app(store: RatatoskrAffectStore, heimdall_key: bytes, consumer_id: str = "ratatoskr") -> Starlette
|
||||||
BRIEF: Wire the JWT verifier + registration and hand the store to bifrost's build_affect_app.
|
BRIEF: Wire the JWT verifier + registration and hand the store to bifrost's build_affect_app.
|
||||||
|
|||||||
+39
-30
@@ -39,37 +39,39 @@ upstream API key stays server-side (INV-003).
|
|||||||
|
|
||||||
## Current state / in-flight
|
## Current state / in-flight
|
||||||
|
|
||||||
_As of 2026-06-18 (PM):_
|
_As of 2026-06-19:_
|
||||||
|
|
||||||
**#17 SHIPPED END-TO-END + LIVE-SMOKE PROVEN.** Bifrost-binding the chat client
|
**#18 DELIVERABLE 2 SHIPPED + PUSHED — the persona-telemetry gap is CLOSED.** The web
|
||||||
(self-drive + observe) is DONE across CLI/TUI/web — 6 commits `v0.17.8`→`v0.17.13`,
|
pane now renders live PAD/valence for Tier-3 agents from OUR `:8390` affect store
|
||||||
full suite **470 green**, **NOT pushed** (operator's call). Slices: (1) `create_session`
|
(`v0.17.14`, `39eebd1`, suite **482 green**, **pushed to origin**). Three pieces:
|
||||||
bind primitive (`7be162e`); (2) dispatch-layer op-feed `ratatoskr.provider.opfeed`
|
provider read route `GET /affect/state/{agent_id}` (non-bifrost, added to the affect app
|
||||||
(`8ebe227`); (3a) CLI `--bifrost-plane`/`--bifrost-url` (`0bebad7`); (3b) TUI
|
via `app.add_route` — keeps `/bifrost/*` top-level + op-feed-skipped); web proxy
|
||||||
pre-alt-screen (`016defc`); (3c) web server-side bind + UI plane selector
|
`GET /api/affect/{agent_id}` (server-supplied `end_user_id`, colon-id `quote()`'d,
|
||||||
(`2806aba`+`179a8df`). **Live smoke:** a bound CLI→sindra session vs personal `:8081`
|
`RATATOSKR_AFFECT_READ_URL` config, default `127.0.0.1:8390`); pane affect-render
|
||||||
→ handshake 200 → op-feed captured 2 recall searches with the EXACT bound session_id
|
(`renderAffectPane`/`loadAffect`, honest pad+valence+emitted_at, labelled "affect", NO
|
||||||
(`2c0c7482`) carrying `scope_any=[{end_user},{agent_self:ratatoskr:sindra}]` @ top_k=128
|
fabricated Tier-1 fields, explicit empty-state, 2s post-turn poll). Live-smoke + a
|
||||||
(the #297/#298 union recall, observed provider-side). #17's whole thesis validated:
|
Playwright DOM check PROVEN against real sindra/vuong PAD. The push also published the
|
||||||
ratatoskr owns both ends → sees the round-trip.
|
previously-held **#17** arc (`v0.17.8`→`v0.17.13`) — origin/main is now fully caught up.
|
||||||
|
|
||||||
**OPERATOR SESSION STATE — background shells UP:** web `:8765` bind-configured
|
**#18 DELIVERABLE 1 (composite `:8392` endpoint) — PARKED on bifrost** (tracked Gitea #18).
|
||||||
(consumer key + `RATATOSKR_PROVIDER_VISIBLE_HOST=10.100.10.50`, sindra + plane
|
Routed to bifrost-dev for a public `build_combined_app` rather than hand-rolled from
|
||||||
selector live); memory provider `:8391` + affect provider `:8390` running WITH the
|
bifrost privates (debug-surface-uses-canonical principle). bifrost-dev confirmed it: clean
|
||||||
op-feed (`/tmp/opfeed-{memory,affect}.jsonl`); althing light-monitor armed (not signed
|
additive minor (~`v0.9.0`), design locked (advertise-by-presence handshake, per-route
|
||||||
off). Consumer/owner key = `wt_live_d81b…`. Providers: SQLite + sqlite-vec, separate
|
call-time isolation), slotted AFTER WT #289. FR-1 RESOLVED — composite is bifrost-only,
|
||||||
DB per plane (`memory.db` / `affect.db` at repo root).
|
ZERO Worldtree change (single-endpoint caps-routed, worldtree-dev code-verified). NEXT:
|
||||||
|
when bifrost ships `build_combined_app`, **repin + reimplement D1 against it** (per-plane
|
||||||
|
failure status + op-feed plane-per-request derivation already specced in the issue).
|
||||||
|
Nothing blocks on our side.
|
||||||
|
|
||||||
**PERSONA TELEMETRY GAP → #18 (the live ask).** The affect bind WORKS — PAD persists
|
**OPERATOR SESSION STATE — running shells are PRE-#18 code (foot-gun).** web `:8765` +
|
||||||
to our `:8390` store (vuong session: pleasure +0.146, familiarity climbing 0.18→0.59
|
affect `:8390` + memory `:8391` are the prior session's background shells running OLD code
|
||||||
over 8 turns). But the web persona pane shows "telemetry isn't exposed" because it
|
(no read route; web has no `RATATOSKR_AFFECT_READ_URL`). To see D2 live in the operator's
|
||||||
reads Worldtree `persona_state` (`loadPersona` index.html:707), which 404s for Tier-3
|
own session, RESTART `:8390` (affect provider, new code → gains the read route) + `:8765`
|
||||||
(ADR-0009 Tier-1-only), AND a Tier-3 turn emits **zero `affect_update` SSE** (wire-
|
(web, new code + `RATATOSKR_AFFECT_READ_URL=http://127.0.0.1:8390` + `RATATOSKR_END_USER_ID`).
|
||||||
verified). Both Worldtree-side sources are dead for consumer agents; the pane was never
|
This session's live-smoke used THROWAWAY `:8393`/`:8766` instances vs the same `affect.db` to
|
||||||
wired to render PAD from OUR store. **#18 filed** (composite endpoint + PAD read-endpoint).
|
avoid disrupting them. Consumer/owner key = `wt_live_d81b…`; providers SQLite + sqlite-vec,
|
||||||
NEXT proposed: fast-track #18's small PAD-display half (provider read-endpoint → pane
|
`memory.db`/`affect.db` at repo root (affect.db has live sindra PAD: vuong pleasure 0.146,
|
||||||
renders our store) so telemetry shows now — **awaiting operator go**; composite-endpoint
|
familiarity 0.589, interaction_count 8).
|
||||||
half stays contract-first.
|
|
||||||
|
|
||||||
**Tier-3 memory PROVEN end-to-end** (earlier this session): `ratatoskr:terse-probe`
|
**Tier-3 memory PROVEN end-to-end** (earlier this session): `ratatoskr:terse-probe`
|
||||||
cold-recalled a seeded user fact (scope_any → 1 hit @ cosine 0.6994), and the verbose
|
cold-recalled a seeded user fact (scope_any → 1 hit @ cosine 0.6994), and the verbose
|
||||||
@@ -87,9 +89,10 @@ linguistic layer → Worldtree #305). `:8081` runs v0.36.0.
|
|||||||
honesty-fix FYI `858ba58` — we don't pin/assert it, no-op our side). Heimdall key env-only
|
honesty-fix FYI `858ba58` — we don't pin/assert it, no-op our side). Heimdall key env-only
|
||||||
at `~/.config/ratatoskr/provider.env` (mode 600); rotate via infra-ops. `graphify-out/`
|
at `~/.config/ratatoskr/provider.env` (mode 600); rotate via infra-ops. `graphify-out/`
|
||||||
runs dirty (auto-regen, not chased). Open issues: #10 (subject migration), #11 (AdminEvents
|
runs dirty (auto-regen, not chased). Open issues: #10 (subject migration), #11 (AdminEvents
|
||||||
pane), **#18** (composite + PAD-read) — all deferred. Codex-first pilot dormant.
|
pane) — deferred; **#18** (D2 PAD-read SHIPPED `v0.17.14`; D1 composite PARKED on bifrost
|
||||||
|
`build_combined_app`). Codex-first pilot dormant.
|
||||||
|
|
||||||
Branch: `main`. Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
|
Branch: `main` (== `origin/main` @ `39eebd1`). Remote: `origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
|
||||||
|
|
||||||
## Recent decisions
|
## Recent decisions
|
||||||
|
|
||||||
@@ -119,6 +122,10 @@ decision. Captures rationale that won't be obvious from code alone.
|
|||||||
- `[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.
|
- `[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.
|
||||||
- `[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.
|
- `[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.
|
||||||
- `[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.
|
- `[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.
|
||||||
|
- `[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`]
|
||||||
|
- `[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.
|
||||||
|
- `[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).
|
||||||
|
- `[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`.
|
||||||
|
|
||||||
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
||||||
|
|
||||||
@@ -146,5 +153,7 @@ defense against re-attempting the same cul-de-sac.
|
|||||||
- `[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.
|
- `[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.
|
||||||
- `[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.)
|
- `[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.)
|
||||||
- `[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.
|
- `[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.
|
||||||
|
- `[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.**
|
||||||
|
- `[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.**
|
||||||
|
|
||||||
_18 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
_18 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.17.14"
|
version = "0.17.15"
|
||||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
@@ -30,7 +30,7 @@ web = [
|
|||||||
# from the debug TUI. Recipe: bifrost/docs/implementing-a-consumer.md.
|
# from the debug TUI. Recipe: bifrost/docs/implementing-a-consumer.md.
|
||||||
provider = [
|
provider = [
|
||||||
"ratatoskr[web]", # reuse the starlette + uvicorn ASGI stack
|
"ratatoskr[web]", # reuse the starlette + uvicorn ASGI stack
|
||||||
"bifrost>=0.8.0", # consumer engines + library (0.8.0/wire-v0.6: scope_filter split into scope_all (AND) + scope_any (OR/union, #11); 0.7.0/v0.5 added agent_self)
|
"bifrost>=0.10.0", # consumer engines + library (0.10.0: build_combined_app (#18) + mandatory affect.fetch, strong-or-absent; 0.8.0/wire-v0.6: scope_all/scope_any split (#11); 0.7.0/v0.5: agent_self)
|
||||||
"jsonschema>=4", # bifrost runtime dep — envelope validation
|
"jsonschema>=4", # bifrost runtime dep — envelope validation
|
||||||
"sqlite-vec>=0.1.6", # vector index for the memory plane (vec0 virtual table)
|
"sqlite-vec>=0.1.6", # vector index for the memory plane (vec0 virtual table)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -102,13 +102,39 @@ class RatatoskrAffectStore:
|
|||||||
return {"stored": True}
|
return {"stored": True}
|
||||||
|
|
||||||
def get(self, agent_id: str, end_user_id: str) -> dict | None:
|
def get(self, agent_id: str, end_user_id: str) -> dict | None:
|
||||||
"""Read-back of the stored snapshot (tests / future rehydrate-seed)."""
|
"""Sync read-back seam returning the verbatim stored snapshot (or None).
|
||||||
|
|
||||||
|
The async wire verb `fetch` wraps this; tests, the D2 read route, and
|
||||||
|
rehydrate-seed also call it directly.
|
||||||
|
"""
|
||||||
row = self._conn.execute(
|
row = self._conn.execute(
|
||||||
"SELECT snapshot_json FROM affect_snapshots WHERE agent_id = ? AND end_user_id = ?",
|
"SELECT snapshot_json FROM affect_snapshots WHERE agent_id = ? AND end_user_id = ?",
|
||||||
(agent_id, end_user_id),
|
(agent_id, end_user_id),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return json.loads(row[0]) if row is not None else None
|
return json.loads(row[0]) if row is not None else None
|
||||||
|
|
||||||
|
async def fetch(self, agent_id: str, end_user_id: str) -> dict:
|
||||||
|
"""Async affect.fetch handler — return the stored snapshot in bifrost's
|
||||||
|
{found, snapshot} shape, conduit-opaque.
|
||||||
|
|
||||||
|
INV-010 (strong-or-absent): bifrost >=0.10.0 gates EVERY affect op on the
|
||||||
|
store advertising affect_supported + emit + fetch (`_supports_affect_plane`),
|
||||||
|
so this method MUST exist for the affect capability to dispatch at all —
|
||||||
|
an emit-only store 400s. Mirrors the reference InMemoryAffectStore.fetch;
|
||||||
|
returns the whole blob opaque (INV-001 — never reads pad/valence).
|
||||||
|
"""
|
||||||
|
if not (
|
||||||
|
isinstance(agent_id, str)
|
||||||
|
and agent_id
|
||||||
|
and isinstance(end_user_id, str)
|
||||||
|
and end_user_id
|
||||||
|
):
|
||||||
|
raise AffectInvalidArguments("fetch missing agent_id / end_user_id")
|
||||||
|
snap = self.get(agent_id, end_user_id)
|
||||||
|
if snap is None:
|
||||||
|
return {"found": False}
|
||||||
|
return {"found": True, "snapshot": snap}
|
||||||
|
|
||||||
|
|
||||||
def open_affect_store(db_path: str) -> RatatoskrAffectStore:
|
def open_affect_store(db_path: str) -> RatatoskrAffectStore:
|
||||||
"""Open the SQLite-backed affect store, creating the schema on first use."""
|
"""Open the SQLite-backed affect store, creating the schema on first use."""
|
||||||
|
|||||||
@@ -162,6 +162,32 @@ async def test_get_after_emit_returns_equal():
|
|||||||
assert store.get("a1", "u1") == snap
|
assert store.get("a1", "u1") == snap
|
||||||
|
|
||||||
|
|
||||||
|
# --- fetch (affect.fetch wire verb — bifrost >=0.10.0, INV-010 strong-or-absent) ---
|
||||||
|
|
||||||
|
async def test_fetch_absent_returns_found_false():
|
||||||
|
"""fetch_absent: no row for the key → {"found": False} (mirrors reference)."""
|
||||||
|
store = open_affect_store(":memory:")
|
||||||
|
assert await store.fetch("nope", "nope") == {"found": False}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_after_emit_returns_snapshot():
|
||||||
|
"""fetch_after_emit [tracer]: emit then fetch → {"found": True, "snapshot": <verbatim>}."""
|
||||||
|
store = open_affect_store(":memory:")
|
||||||
|
snap = _snapshot()
|
||||||
|
await store.emit(snap, idempotency_key="k1", ctx=_ctx())
|
||||||
|
assert await store.fetch("a1", "u1") == {"found": True, "snapshot": snap}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_missing_key_raises():
|
||||||
|
"""fetch_missing_key: empty/missing addressing key → AffectInvalidArguments
|
||||||
|
(PRE-001; symmetric across both keys, belt-and-suspenders behind the wire)."""
|
||||||
|
store = open_affect_store(":memory:")
|
||||||
|
with pytest.raises(AffectInvalidArguments):
|
||||||
|
await store.fetch("", "u1")
|
||||||
|
with pytest.raises(AffectInvalidArguments):
|
||||||
|
await store.fetch("a1", "")
|
||||||
|
|
||||||
|
|
||||||
# --- build_affect_provider_app ---
|
# --- build_affect_provider_app ---
|
||||||
|
|
||||||
def test_build_app_exposes_handshake_and_affect_routes():
|
def test_build_app_exposes_handshake_and_affect_routes():
|
||||||
@@ -243,6 +269,36 @@ async def test_parity_vs_reference_store_through_dispatch():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_env(agent_id: str = "agent-1", end_user_id: str = "user-1") -> dict:
|
||||||
|
return {"operation": "affect.fetch", "args": {"agent_id": agent_id, "end_user_id": end_user_id}}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_parity_vs_reference_fetch_through_dispatch():
|
||||||
|
"""#195 parity for affect.fetch: cold (not-found) + warm (found) read envelopes
|
||||||
|
yield identical (status, body) through the real engine against the reference store
|
||||||
|
and ours. Conforms to bifrost's InMemoryAffectStore.fetch ({found, snapshot})."""
|
||||||
|
from bifrost.affect import dispatch_affect_call
|
||||||
|
from bifrost.consumer.testing import InMemoryAffectStore
|
||||||
|
|
||||||
|
ref = InMemoryAffectStore()
|
||||||
|
mine = open_affect_store(":memory:")
|
||||||
|
write_ctx = _dispatch_ctx("affect:write")
|
||||||
|
read_ctx = _dispatch_ctx("affect:read")
|
||||||
|
|
||||||
|
# cold fetch (nothing persisted): both -> {found: false}
|
||||||
|
assert await dispatch_affect_call(_fetch_env(), read_ctx, ref) == await dispatch_affect_call(
|
||||||
|
_fetch_env(), read_ctx, mine
|
||||||
|
)
|
||||||
|
|
||||||
|
# seed both via emit, then fetch -> both {found: true, snapshot: <verbatim>}
|
||||||
|
snap = _ref_shaped_snapshot()
|
||||||
|
await dispatch_affect_call(_env(snap), write_ctx, ref)
|
||||||
|
await dispatch_affect_call(_env(snap), write_ctx, mine)
|
||||||
|
assert await dispatch_affect_call(_fetch_env(), read_ctx, ref) == await dispatch_affect_call(
|
||||||
|
_fetch_env(), read_ctx, mine
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- PAD read route (issue #18 Deliverable 2) ---
|
# --- PAD read route (issue #18 Deliverable 2) ---
|
||||||
# Non-bifrost GET /affect/state/{agent_id}?end_user_id=… → store.get snapshot.
|
# Non-bifrost GET /affect/state/{agent_id}?end_user_id=… → store.get snapshot.
|
||||||
|
|
||||||
|
|||||||
@@ -190,14 +190,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bifrost"
|
name = "bifrost"
|
||||||
version = "0.8.0"
|
version = "0.10.0"
|
||||||
source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }
|
source = { registry = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "jsonschema" },
|
{ name = "jsonschema" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.8.0/bifrost-0.8.0.tar.gz", hash = "sha256:28194877c81a056a0803b052e86902c092e965d4ce63a5623d7a31240cedb645" }
|
sdist = { url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.10.0/bifrost-0.10.0.tar.gz", hash = "sha256:aba1869dba68d921f2e0be8fb560277073da09ec2ad5f410e226cacd5e84fe1a" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.8.0/bifrost-0.8.0-py3-none-any.whl", hash = "sha256:2aac5e4a7828d718389748a78dae6baeb5e9ee4a801a10c427c06c5cc7ed6597" },
|
{ url = "https://gitea.phasefinal.com/api/packages/vh/pypi/files/bifrost/0.10.0/bifrost-0.10.0-py3-none-any.whl", hash = "sha256:88adbce23fa8840a14f9493e4f0cf6f9320f4950845f7a6080387defe574a5cc" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1052,7 +1052,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.17.14"
|
version = "0.17.15"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
@@ -1086,7 +1086,7 @@ web = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "bifrost", marker = "extra == 'provider'", specifier = ">=0.8.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
|
{ name = "bifrost", marker = "extra == 'provider'", specifier = ">=0.10.0", index = "https://gitea.phasefinal.com/api/packages/vh/pypi/simple/" },
|
||||||
{ name = "httpx", specifier = ">=0.27" },
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "httpx-sse", specifier = ">=0.4" },
|
{ name = "httpx-sse", specifier = ">=0.4" },
|
||||||
{ name = "jsonschema", marker = "extra == 'provider'", specifier = ">=4" },
|
{ name = "jsonschema", marker = "extra == 'provider'", specifier = ">=4" },
|
||||||
|
|||||||
Reference in New Issue
Block a user