Milestone verification caught the exact defect class it exists for: the
client's GM Surprised toggle dials /api/v1/engine/combat/surprise but
the gateway never registered the proxy — the button 404'd in production
while the vitest mock (which stubs fetch) passed green. Adds
EngineSurpriseAdjudicationRequest + engine_combat_surprise forwarding
{entity_id, surprised} + the caller's real identity; the engine's own
RBAC still enforces GM-seat and first-round window. Regression guards:
combat-surprise in anonymous-401 cases + TestCombatProxy (missing-token
401, path+coerced-UUID payload+real-actor forwarding, extra-field 422,
unreachable-engine 502).
New engine-live module test_live_surprise_delay_timeline.py drives the
real gateway against the real engine (skips when engine down, matching
repo convention): surprise grant through the proxy, hidden-NPC surprise
not leaked to a player feed, ENTITY_SURPRISED delay refusal, surprise
release after first turns, delayed-slot skip, spotlight report +
SPOTLIGHT_SPOOFED_SEAT denial, timeline surfacing SURPRISE_GRANTED/
RELEASED + DELAY_TAKEN with no hidden name in the player feed.
Benchmark run: all gates PASS — playtest MCR 100%, HCI 1.0, AFPR 0.0%,
auditor recall 14/14; cargo 518; pytest 1350/34 standalone (+3 guards),
1366/18 engine-up; client 768/58 + relay 66.
Co-Authored-By: Claude <[email protected]>
POST /sessions/{id}/spotlight/report accepts participant self-reports of
local speaking (0..600000ms, member seat only, per-IP tight spotlight
bucket); GET /sessions/{id}/spotlight returns per-roster-seat rolling
scores decayed at half-life 3min with is_quiet = score < mean - sigma
and an observed_at staleness stamp; DELETE clears scores (GM/admin).
Honesty contract: survey confirmed the CRDT speech ledger is real and
remote-aware at the client layer, but python has no read-path into the
Y.Doc (Node relay), so the self-report channel is the honest seam this
iteration can implement; every route states that nonzero values
originate from spoofable client self-reports (insight tooling for the
DM, not an authentication boundary). DirectorAgent remains unwired by
design — aggregation is built so a future iteration can feed it. The
legacy VoiceSpotlightTracker hardcoded demo roster is left untouched.
Gates: pytest 1347 passed / 33 skipped (baseline 1302, +45).
Co-Authored-By: Claude <[email protected]>
F1 (HIGH): per-session chat message cap (CHAT_MESSAGE_CAP = 10_000) auto-
trims the head on overflow in both MemoryStore and PostgresStore; next
id computed before trim so trimmed ids never reappear. New DELETE
/api/v1/sessions/{id}/chat/{message_id} reuses the timeline gate
(404 SESSION_NOT_FOUND / 403 outsider) plus author-or-staff check
(403 CHAT_DELETE_FORBIDDEN; 404 CHAT_MESSAGE_NOT_FOUND).
F2 (MED): timeline projection now also collapses actor_id to None and
filters referenced_entity_ids to drop hidden ids when the hidden-entity
collapse branch fires — visible ids still survive. Regression test
asserts no hidden UUID appears anywhere in a non-GM projected entry.
F5 (LOW): system-channel write gate tightened to {gm, admin} only,
mirroring the gm-channel posture; runtime gate only, schema stays
forward-compatible.
Gates: pytest 1302 passed / 33 skipped (baseline 1288, +14).
Co-Authored-By: Claude <[email protected]>
GET /api/v1/sessions/{id}/timeline returns the chronological merge of
narrative chat and engine ledger events (engine sequence_ids namespaced
e_<n>, narrative n_<n>), sorted by (created_at_ms, kind, sequence_id),
paginated by an opaque cursor with default 100 / cap 500. Hidden-entity
projection lifted from the existing _entity_is_visible convention;
private channels (gm/whisper) collapse to "Something happens" for
players and drop entirely for spectators. ALSO had to add the missing
narrative chat log + POST /chat route — survey found NarrativeChat
consumed chat from React local state only with no server anchor.
Summary numbers (ATTACK_RESOLVED damage, HP, etc.) are stripped from
spectator view to avoid leaking tactical information.
Gates: pytest 1288 passed / 33 skipped (baseline 1248, +40).
Co-Authored-By: Claude <[email protected]>
GET /api/v1/sessions/{id}/safety/boundaries/summary returns aggregate
counts only ({you.{lines,veils}, others.{lines,veils}, redacted.lines})
so client UIs can render declaration-count badges without pulling the
full topic list. Reuses iter20's _boundary_gate for auth/HMAC/404/403.
The response body carries no topic strings, entry ids, actor ids, or
timestamps — enforced by a canary-string sweep test that fetches the
full listing first to harvest strings, then asserts the summary body
contains none of them. Empty registry returns all zeros (no 404).
Gates: pytest 1248 passed / 33 skipped (baseline 1232, +16).
Co-Authored-By: Claude <[email protected]>
F1: /api/v1/engine/delay and /api/v1/engine/delay/resume proxies added
mirroring the dodge/dash/disengage template (EngineEntityActionRequest +
_maneuver_proxy) — the client's existing Delay/Resume UI now actually
reaches the engine; engine verdicts surface verbatim.
F7: encounter-balance roster capped at min=1/max=64; new
_require_staff_role sub-dependency hoists role verification ahead of
body parsing so a non-staff caller with an oversized/malformed body
gets 403 without the gateway allocating/validating the array; route
moved into the tight media bucket (10/min) with rationale documented.
F8: ambience coalescing rewritten with asyncio.shield + an _adopt done-
callback — leader cancellation propagates only to the leader; the
upstream generation continues to populate the cache for later callers,
and late joiners keep coalescing onto the adopted task (exactly 1
upstream call). Normal failure paths still propagate via the shared
future so coalesced-failure semantics are preserved; cap violations
(413) also wake waiters.
Gates: pytest 1232 passed / 33 skipped (baseline 1217, +15).
Co-Authored-By: Claude <[email protected]>
GOALS.md pillar 11 safety toolkit beyond the X-Card: per-session boundary
registry under /api/v1/sessions/{id}/safety/{boundaries,lines,veils}.
Any participant may declare under their own actor id; GM/admin manage
all; deletes are author-or-staff. Load-bearing redaction rule: veils are
shared openly, but a non-staff viewer sees OTHER participants' line
topics as [redacted] (metadata intact) while their own stay verbatim.
Topics trimmed/capped at 120 chars with machine-readable 422 codes;
in-process storage matching the established session-scoped-state
convention — documented trade-off: restart drops declarations rather
than persisting the most privacy-sensitive strings.
Gates: pytest 1217 passed / 33 skipped (+29 vs same-environment control).
Co-Authored-By: Claude <[email protected]>
Six D&D soundscape presets (tavern murmur, dungeon drips, forest night,
battle clash, thunderstorm, campfire) generated on demand via the SFX
capability. GET /media/ambience lists presets to any authenticated seat;
POST /media/ambience/{slug} is GM/admin-only like /media/sfx. In-process
LRU (12 entries, keyed by slug+model) absorbs repeat spends; concurrent
duplicates coalesce onto one upstream call; failures and over-cap
payloads are never cached and wake all waiters.
Gates: pytest 1190 passed / 31 skipped (baseline 1151, +39).
Co-Authored-By: Claude <[email protected]>
POST /engine/encounter/balance: full level 1-20 XP threshold table +
official multipliers extracted from starter_adventures into a shared
model (one source of truth, pinned by agreement test); returns raw/
adjusted XP and difficulty tier; gm/admin only. 31 tests red-first;
pytest 1151 passed / 31 skipped.
Co-Authored-By: Claude <[email protected]>
All media responses now Cache-Control: no-store; image (10MB) and SFX
(20MB) relay caps with honest 413s; trailing-slash normalization closes
the bucket-alias path that let diffusion ride the loose default bucket.
Upload-storage/prompt-injection/cross-session-leak angles verified
non-issues and documented. 10 tests red-first; pytest 1120 passed.
Co-Authored-By: Claude <[email protected]>
POST /media/narrate (20/min bucket, MEDIA_TTS_VOICE env default, session
attribution gated by lobby-standing) synthesizes kokoro speech for any
authenticated seat's own text; successful narrations logged to
narrative_state.narrations with GET /media/narrations listing (standing-
gated). 29 tests red-first; pytest 1110 passed / 31 skipped.
Co-Authored-By: Claude <[email protected]>
OpenAI-compatible media client with canonical MEDIA_GATEWAY_URL/_API_KEY
env vars and per-capability model config (MEDIA_IMAGE/TTS/STT/SFX_MODEL);
LEMONADE_* kept as deprecated fallbacks with one honest INFO line.
Capability discovery via GET /v1/models label mapping, advertised at
startup. Any OpenAI-compatible media server works — Lemonade, OpenAI,
llama.cpp, or an Omni-route gateway once it wires media providers.
14 tests; pytest 1082 passed / 30 skipped.
Co-Authored-By: Claude <[email protected]>
New OpenAICompatibleEmbeddingBackend reaches self-hosted embedding
servers directly (REMOTE_EMBEDDINGS_BASE_URL/_API_KEY/_MODEL), taking
precedence over fastembed when set — no model download, no ONNX.
Dimension probed at init and adopted from the server. Verified live
against Lemonade serving harrier-oss-v1-0.6b: 1024-dim, semantic
separation 0.75 vs 0.70 on a spell-retrieval probe. Honest provenance
label remote-embeddings:<model>; unreachable server degrades to the
hash-fallback with its usual warning. Full suite green.
Co-Authored-By: Claude <[email protected]>
Opt-in 2x2 seeded sim run against the real endpoint through ASGI:
combat+social decisions, NPC replies, disposition interactions, director
tension — llm provenance asserted, honest fallbacks tolerated with
reasons, fabrication rejected, skip-degrade per suite contract. Live
run: 4 model combat turns + 4 model social replies in ~20s. Offline
suite 1006 passed / 30 skipped.
Co-Authored-By: Claude <[email protected]>
Closes A5 F1: policy PUT now derives standing from lobby membership
(fail-closed, no admin bypass on unbound sessions); the periodic loop
re-verifies fresh each cycle and auto-disables the policy + skips the
fetch when standing is lost — no more fabricated GM authority pulling
full state past revocation. 7 tests red-first; pytest 996.
Co-Authored-By: Claude <[email protected]>
rule_version/starting_level/party_size on lobby create (validated 422,
idempotent ALTER migration, exposed on all roster reads); launch passes
rule_version to engine session creation only when the lobby recorded
one — legacy payloads byte-identical. 11 tests red-first; pytest 940.
Co-Authored-By: Claude <[email protected]>
EntityState.position is world feet ('world units == feet', vtt-core
distance_to_feet); _bundle_token_to_entity was passing raw grid cells,
landing every bundle token at cell/5 — inside walls on complex maps.
The deepened Sunken Crypt exposed it: all 14 spawns rejected
INGRESS_TARGET_BLOCKED and silently swallowed to tokens_spawned=0.
Convert cell centers via (cell + 0.5) * 5.0 ft.
Co-Authored-By: Claude <[email protected]>
Improvised player lore no longer evaporates at classification: triples
derived via the auditor's windowed predicate heuristic stage through
the same submit_assertion path, always SUBJECTIVE_RUMOR regardless of
role; paradoxes rejected; verdict rides an X-Rumor-Capture header so
the response contract stays byte-identical. Survey honestly declined
speculative prefetch (no streaming-STT surface exists) and confirmed
the retry loop already wired. 7 tests red-first; pytest 929.
Co-Authored-By: Claude <[email protected]>
spend_inspiration forwarded on attack/check/save; new authenticated
escape-grapple proxy via _maneuver_proxy (seed non-forwardable per
policy, engine re-checks force privilege); GM-gated inspiration
grant/revoke proxies with zero engine dials for players. 23 tests
red-first; pytest 922 passed / 28 skipped.
Co-Authored-By: Claude <[email protected]>
Closes A4 F1: aggregator read identity from event payloads, which
DEATH_SAVE_RESOLVED never carries — every save was skipped and episodes
stuck in_progress forever. Fixtures rewritten to the verbatim engine
shape with a module contract comment: fixtures MUST mirror emission.
Red proof showed real-shape ledgers yielding all-in_progress before
the fix. pytest 899 passed / 28 skipped.
Co-Authored-By: Claude <[email protected]>
GET /sessions/{id}/replay/export?format=json|markdown: GM/admin get
verbatim events; players get summary-only projection (no raw payloads or
state hashes ride along); spectators fail closed 403. Markdown renders
round-by-round with X-card rewind markers and honest truncation at 5000
events. 25 tests red-first; pytest 885 passed / 28 skipped.
Co-Authored-By: Claude <[email protected]>
Benchmark boot with a LIVE engine exposed EngineRejectedError (404 unknown
session) escaping the x-card handler as a hard failure — the orchestrator-
side intervention must still record. Engine rewind is best-effort: rejection
now surfaces as engine_rewind.status=ENGINE_REJECTED with the real code.
Proven against a booted engine; full suite green.
Co-Authored-By: Claude <[email protected]>
Autosave snapshot carries quest-graph and faction-state slots on the
existing campaign_saves JSONB; explicit POST /campaign/restore (GM-only,
ownership-scoped) reapplies them; readonly quest reads self-heal from the
newest owned save instead of rolling new canon. Corrupt slots → honest
422 *_CORRUPT; legacy saves restore cleanly. Inventory of memory-only
singletons audited and documented durable-vs-local. 9 tests red-first;
pytest 839 passed / 27 skipped.
Co-Authored-By: Claude <[email protected]>
Closes A3 #5/#6: anonymous/garbage-token callers resolve the default
corpus with zero engine round trips (junk-uuid amplification closed);
deterministic resolutions cached 30s/256 entries. 404 vs unreachable vs
missing-version now produce distinct provenance statuses with the real
HTTP status surfaced. 16 tests red-first; pytest 830 passed/27 skipped.
Co-Authored-By: Claude <[email protected]>
Closes A3 #1/#2: one shared extraction budget draws down across all
entries (mid-copy cutoff), declared-size sum refused pre-extraction,
chunked streaming upload caps memory on lying Content-Length; manifest
pack paths contained via commonpath with skip-with-warning. Lying
comments corrected. 8 tests red-first incl. a lying-reader seam for
the zipfile declared-size cap; pytest 812 passed / 29 skipped.
Co-Authored-By: Claude <[email protected]>
Both SRD editions load at startup when fixtures exist; compendium and
stream-grounding routes resolve the session's rule_version from the
engine snapshot and serve the matching corpus with additive provenance
(rule_version/source/reason). Version-mismatched RAG queries honestly
degrade to versioned substring scan instead of mislabeled semantic hits.
14 tests red-first; pytest 796 passed / 27 skipped.
Co-Authored-By: Claude <[email protected]>
Sorted-set sliding windows keyed per bucket+identity when REDIS_URL is
set and reachable; rejected attempts still cost nothing and Retry-After
matches the memory backend value-for-value. Availability-first: any Redis
failure flips one permanent degraded flag with a single honest warning,
then delegates to the shared memory backend. Two-replica budget-sharing
test proves the quota-multiplication defect gone. 12 tests red-first;
pytest 776 passed / 26 skipped.
Co-Authored-By: Claude <[email protected]>
SyntheticPlaytestRunner now takes a seed (default 42) driving utterance
mix + 10% recall-probe injection via random.Random(seed) — benchmark
deltas are reproducible across runs instead of depending on global
RNG state.
Co-Authored-By: Claude <[email protected]>
Live checkpoint vs llm.heretek.one found the gateway deterministically
degrades when a raw single quote reaches it — the router prompt's
'player's' flipped 'I attack the goblin' from MECHANICAL_INVOCATION
(0.95) to IN_CHARACTER_DIALOGUE (0.72). Prompt reworded, live suite
6/6 green. Root cause is upstream template/escaping; documented at
the call site.
Co-Authored-By: Claude <[email protected]>
Replaces hash pseudo-embeddings with BAAI/bge-small-en-v1.5 dense +
Qdrant/bm25 sparse legs fused by reciprocal-rank fusion over Qdrant
named vectors. Opt-in via QDRANT_EMBEDDINGS=1; offline fallback keeps
hash embedder relabeled qdrant-hash-fallback with an honest warning.
Live-verified semantically on a compendium subset. 27 new offline-safe
tests; pytest 713 passed / 25 skipped.
Co-Authored-By: Claude <[email protected]>
Closes audit F10 at the gateway: ac 0..30, hp 0..1000, finite speed,
typed spell-slot dict; damage expressions validated against a strict
dice grammar (sides/dice/constant caps) since the engine rolls spawned
expressions unclamped. ingress is now a strict EngineIngressEvent model
matching the engine's SCREAMING_SNAKE_CASE wire format. 18 tests
red-first; pytest 690 passed / 21 skipped. All SRD corpus callers pass.
Co-Authored-By: Claude <[email protected]>
Signup accepts only player/spectator — staff roles come from the
VTT_ADMIN_EMAILS allowlist (rotatable without restart). Gateway aborts
startup without AUTH_SECRET/VTT_ENGINE_SECRET, mirroring engine semantics.
GET /lobbies/{id} member-or-staff only with no existence oracle; invite
codes stay visible to members. 20 tests red-first; pytest 672 passed.
Co-Authored-By: Claude <[email protected]>
All narrative-facing /engine/* proxies resolve callers via _require_auth and
forward verified identity (_caller_actor); optional-token service-principal
fallback removed. 28-route anonymous-401 contract test matrix. Client now
attaches ?token=/Bearer on session/attack/check/map/metrics/presence with an
honest NOT_AUTHENTICATED rejection when signed out. Full benchmark run green:
MCR 100%, HCI 1.0, AFPR 0.0%, recall 100%, trust probes 10/10.
Co-Authored-By: Claude <[email protected]>