Story-1217 · pramaan-docs ADR-110 T5 · foundation increment

Atlas emits agent_event_stream.v1 — walkthrough

Branch feature/1217-atlas-agent-event-stream-v1 · Story: pramaan-io/pramaan-spec#1217 · Decision: pramaan-docs ADR-110 (accepted 2026-06-12)

1 · Why this exists

pramaan-docs ADR-110 fixes PRAMAAN's agent substrate: one WebSocket delivery rail (postToConnection, never SSE), one event language (agent_event_stream.v1), the broker owning all safety machinery exactly once, and brains as stateless config+brain units. Atlas is the template for agents #2–#25. This increment lays the brain-side foundation of that contract.

2 · What this PR delivers

ChangeWhereWhat it is
Event buildersagents/atlas/events.pyPure, stateless builders for all six ADR-110 §3.2 event types — run.started, turn.delta, model.usage, turn.completed, run.completed, run.failed — each wrapped in the frozen four-key envelope {schema, type, run_id, payload}.
Contract testsagents/atlas/tests/test_atlas_events.py14 tests written TDD (red first), pinning the wire shape per event type, optional-field handling for run.failed, and the envelope-keys invariant for every builder.
DoctrineAGENTS.md, agents/atlas/AGENTS.md, src/pramaan_agents/AGENTS.mdRoot: new "Agent substrate — THE canonical shape" section + legacy-paths retirement table. Atlas: the brain event contract (replaces the reversed "convergence" plan). Rails: LEGACY banner — retiring at ADR-110 T13, do not extend, do not delete early.
Reference docsdocs/design/atlas-invocation-path.md, docs/tutorials/apigw-websocket-tutorial.htmlEvidence-backed map of the as-built invocation path (file:line cited) and a teaching doc on the API Gateway WebSocket pattern with the two-pipeline trap explained.

3 · The contract, in one block

{"schema": "agent_event_stream.v1", "type": "run.started",   "run_id": "…", "payload": {"model_id", "prompt_version", "schema_version"}}
{"schema": "agent_event_stream.v1", "type": "turn.delta",     "run_id": "…", "payload": {"partial": {…partial AtlasTurn…}}}
{"schema": "agent_event_stream.v1", "type": "model.usage",    "run_id": "…", "payload": {"input_tokens", "output_tokens", "model_id"}}
{"schema": "agent_event_stream.v1", "type": "turn.completed", "run_id": "…", "payload": {"turn": {…full AtlasTurn…}, "usage": {…}}}
{"schema": "agent_event_stream.v1", "type": "run.completed",  "run_id": "…", "payload": {"stop_reason", "usage": {…}}}
{"schema": "agent_event_stream.v1", "type": "run.failed",     "run_id": "…", "payload": {"error_class", "error_message"[, "failure_stage", "failure_kind"]}}

The brain echoes run_id for sanity-checking only; the broker assigns persisted seq and stamps run truth (ADR-110 §3.2).

4 · What this PR deliberately does NOT do

Sequencing, not omission
runtime.py still emits the current AtlasTurn wire dicts. Flipping the live output to v1 events would break the staging SSE consumer before its replacement exists. The flip lands when the shared contracts package (ADR-110 T4, pramaan-functions) and the clerk worker (T6) are in place — per the critical path T1→T2→T3→T4→(T5‖T6‖T8)→T7→T9→T10. Story #1217 therefore stays open; this PR uses Refs, not Closes.

5 · Verification

$ .venv/bin/python -m pytest agents/atlas/tests -q
29 passed                       # 14 new event-contract tests + 15 existing, zero regressions
$ .venv/bin/python -m ruff check agents/atlas/events.py agents/atlas/tests/test_atlas_events.py
All checks passed!
TDD receipt
Tests were written first and watched fail (ModuleNotFoundError: No module named 'events') before events.py existed; then minimal implementation to green; then lint autofix re-verified green.

6 · Renumber note (ADR-109 → ADR-110)

The broker-substrate decision was drafted as ADR-109 but that number was taken by ADR-109-terraform-owns-agent-infrastructure (pramaan-docs #153) while the draft sat uncommitted. It is filed as ADR-110 (pramaan-docs #154); every reference in this branch uses ADR-110.

7 · Increment 2 — the flip (closes the Story)

runtime.handle_request now emits agent_event_stream.v1 envelopes for every mode: run.started → (chat: turn.delta per partial → model.usage → turn.completed) → run.completed, with mid-run failures becoming a terminal run.failed and the fail-closed missing-input invariant still raising before any stream. The agent-typed turn (proposal + source_snapshot + provenance stamps) travels inside the event payloads. 9 new stream tests + 4 legacy tests updated to the envelope shape (38 pass) and a golden JSONL fixture committed byte-identical in both repos — the functions side parses it with the canonical parse_event_line (ADR-110 §12 amendment 1). Safe to flip now because the SSE consumer was verified physically dead (no VPC route) and Step-5 UI binding does not exist yet.