# ADR-0009 — Proposal lifecycle multiplexes onto existing `/api/events/stream` SSE

**Status:** Accepted
**Date:** 2026-05-05
**Deciders:** JT, Claude (in dialogue)
**Supersedes:** none
**Superseded by:** none

> *This ADR was drafted during the embedded-claude-terminal Phase 11 cleanup commit. It locks in the SSE-routing decision made in BUILD_SPEC §4 (item ADR-0006 in spec text; renumbered to 0009 because the repo already has ADRs 0001–0005). The decision was reinforced by Gemini round-1 review of the consultation.*

## Context

The Phase 8 proposals lifecycle (create / list / approve / reject) needs to push real-time updates to every connected console client — proposal appeared, proposal approved, proposal expired. Two routing options:

1. **Dedicated stream.** A new `/api/proposals/stream` SSE endpoint, separate `BUS`-equivalent, separate subscriber set.
2. **Multiplex onto existing `/api/events/stream`.** Reuse the existing single-connection-per-client SSE surface; tag proposal events with `scope="chat/proposals"` so clients filter on the wire.

## Decision

Proposal lifecycle events multiplex onto the existing `/api/events/stream` SSE surface using `scope="chat/proposals"`. No new long-lived connection.

The HTTP route paths for the proposals CRUD surface live under `/api/chat/proposals/...` (e.g. `POST /api/chat/proposals`, `GET /api/chat/proposals`, `POST /api/chat/proposals/<id>/approve`) to avoid colliding with `mutation_routes`' pre-existing legacy `/api/proposals/<id>/...` endpoints. The CRUD routes and the SSE scope use different namespaces — the former is HTTP path namespacing, the latter is BUS event namespacing.

## Consequences

**One long-lived connection per client.** The console already holds open `/api/events/stream` for engine events, slash dispatches, workspace mutations, and SSE heartbeats. Adding proposals to that stream avoids doubling the per-client connection count.

**Client-side filtering on `scope`.** ProposalTray subscribes to events where `scope === "chat/proposals"`; other surfaces ignore them. The wire format is unchanged — same `BUS` shape, same SSE framing.

**Single BUS surface.** All event emission goes through one `BUS` instance. Forking the BUS surface (one for engine events, one for proposals) would require two emit paths and two subscriber sets in every BUS-consumer module — a refactor pressure that would compound as more event types arrive.

**Path collision avoided.** Using `/api/chat/proposals/...` for CRUD prevents the route from masking or being masked by `mutation_routes.py`'s legacy `/api/proposals/<id>/...` endpoints, which serve a different (workspace-mutation) feature.

## Alternatives considered

- **Dedicated `/api/proposals/stream`.** Rejected per Gemini round-1 review — doubles long-lived connections per client (browsers cap parallel HTTP/1.1 connections per origin at ~6; Console v2 is already at 2–3) and forks the BUS surface, creating two emit paths to maintain.
- **WebSocket instead of SSE.** Rejected — SSE is already the wire format for every other engine event surface; switching just for proposals introduces a second protocol with no payoff.

## Implementation

- `recoil/api/proposals_routes.py` — CRUD on `/api/chat/proposals/...`; emits `BUS.emit(scope="chat/proposals", ...)` on every state transition.
- `recoil/api/sse_routes.py` — unchanged; the existing `/api/events/stream` carries proposal events alongside everything else.
- `recoil/console-v2/.../ProposalTray.tsx` — subscribes to the existing event stream, filters on `scope`.

## Verification

```
$ curl -N localhost:8431/api/events/stream &
$ curl -X POST localhost:8431/api/chat/proposals -d '{...}'
# stream emits: data: {"scope":"chat/proposals","severity":"info", ...}
```
