> ## Documentation Index
> Fetch the complete documentation index at: https://prism.ntecdev.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# SPEC-052 v0.1: Signal Taxonomy and Per-Identity Signal Cache — Categorized Inbox + Statusline Contract

> Signal Taxonomy and Per-Identity Signal Cache — Categorized Inbox + Statusline Contract

<Info>**Status:** `draft` · **Version** `0.1` · Filed 2026-04-29</Info>

# SPEC-052: Signal Taxonomy and Per-Identity Signal Cache — Categorized Inbox + Statusline Contract

## Version

0.1

## Status

draft

## Origin

The SPEC-034 wire delivers signals to other agents. The SPEC-037 piggyback drain and SPEC-044 channel push deliver them in real time. What was missing was an operator-facing **inbox shape**: a structured, persistent view of what came in, what category of attention it needs, and what's still unread.

The previous local approximation — `mcp/sigcount.py`, a single-integer counter per identity — clobbered itself on every drain. The eye couldn't catch the sub-second window between increment and reset, so the statusline bell never sustained visibly. Frank's directive after diagnosing this: preserve count, keep a tail of last 5 signals, target per-identity (no broadcast noise like `PeerJoined`), and categorize signals so the operator can triage by intent.

This spec collapses three concerns into one architectural change:

| Concern                              | How addressed                                             |
| ------------------------------------ | --------------------------------------------------------- |
| Bell never sustains                  | Drain marks entries read but keeps the ring (§6, ADR-029) |
| No memory of recent activity         | Persistent JSONL ring, last 50 entries (§5)               |
| All signals look alike               | 4-category taxonomy: INFO / TASK / ASK / BLOCKER (§2–3)   |
| System-event noise (PeerJoined/Left) | Filtered at the cache door (§9)                           |

## §1 — Scope

In scope:

* A 4-category operator-facing taxonomy threaded end-to-end (sender → wire → backend column → MCP cache → statusline).
* A per-identity local signal cache: append-only JSONL ring + derived count summary.
* A `prism_signals` MCP verb for tail/count read access.
* A statusline rendering contract that consumes the count summary.
* The drain-keeps-ring semantic (the non-obvious decision, captured separately in ADR-029).

Out of scope:

* Cross-machine cache sync. The cache is per-host because the statusline reads it locally; signals are mirrored to every host where the identity has an active session via the existing SPEC-034/044/045 fan-out.
* Reordering or aggregating signals across categories. The ring is strict time-order.
* Persistence in the backend beyond the existing `signal_queue` table — the new `category` column is the only schema change.

## §2 — The 4-Category Taxonomy

Categories bucket agent-to-agent signals by **operator-meaningful intent** — what should the operator do when they see this on their statusline?

| Cat         | Meaning                                                                                                                                         | Operator response                                                                                  |
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| **INFO**    | FYI, no action required.                                                                                                                        | Glance, dismiss.                                                                                   |
| **TASK**    | Work handoff; receiver schedules and executes when free.                                                                                        | Acknowledge receipt, work the queue, report when done. No go-ahead round-trip required by default. |
| **ASK**     | Sender is blocked on a response from this receiver. `payload.shape='review'` (multi-turn until consensus) or `'question'` (single-turn answer). | Respond promptly — sender is waiting.                                                              |
| **BLOCKER** | Sender is stuck and needs unblock or decision.                                                                                                  | Highest urgency; the work being done by the sender cannot proceed until this clears.               |

The four cover the conversational space without overlapping. ASK and BLOCKER both block the sender, but ASK is a normal back-and-forth (a question to be answered) where BLOCKER is an exceptional condition (something is preventing forward progress). The distinction matters at render time — BLOCKER gets the loudest visual treatment.

System-originated envelope events (`PeerJoined`, `PeerLeft`, `MasterPreempted`) are NOT categorized. They are envelope-class events about the session-mesh state, not categorized agent-to-agent intent. The cache filters them out before display (§9).

## §3 — Default Mapping (Legacy `signal_type` → Category)

Senders SHOULD set `category` explicitly when sending a signal. For backward compatibility and for senders who don't set it, the backend applies this default mapping at write time:

| `signal_type`                                        | Default `category`              |
| ---------------------------------------------------- | ------------------------------- |
| `TaskAssigned`                                       | `TASK`                          |
| `ReviewRequested`                                    | `ASK`                           |
| `ReviewCompleted`                                    | `INFO`                          |
| `Acknowledgment`                                     | `INFO`                          |
| `StatusUpdate`                                       | `INFO`                          |
| (system) `PeerJoined`, `PeerLeft`, `MasterPreempted` | `NULL` (no category — filtered) |

Authoritative sources:

* Backend: `backend.app.services.signal_service._DEFAULT_CATEGORY_BY_TYPE`
* MCP cache (mirror, used for legacy envelopes that pre-date the column): `mcp.signal_cache._DEFAULT_CATEGORY_BY_TYPE`

When the two diverge, the backend wins — it's the writer. The mirror exists because the MCP subscriber sees envelopes whose `category` may be missing if they were written before alembic 024 ran.

Senders escalating to `BLOCKER` MUST set the category explicitly — there is no `signal_type` that defaults to `BLOCKER`, since it's the operator-meaningful escalation channel rather than a wire-shape distinction.

## §4 — Wire Envelope (amends SPEC-034 §5.2 / §5.3)

The signal envelope published to Redis (consumed by the SPEC-045 unified WebSocket plane and the SPEC-037 piggyback) gains an optional `category` field:

```json theme={null}
{
  "signal_id": "uuid",
  "signal_type": "TaskAssigned",
  "category": "TASK",
  "from_identity": "Donna",
  "from_session": "uuid",
  "to_identity": "Desiree",
  "payload": { "summary": "...", "title": "...", ... },
  "in_reply_to": "uuid|null",
  "created_at": "iso8601"
}
```

`category` is omitted when null (system events). Receivers MUST tolerate its absence — legacy senders, system events, and pre-024 backfilled rows can all surface with no `category` on the envelope. The MCP cache resolves the final category via the §3 default mapping when the field is absent.

The HTTP API for sending signals (`mcp.prism_signal`) accepts an optional `category` argument; the backend validates against `KNOWN_CATEGORIES = {INFO, TASK, ASK, BLOCKER}` and rejects unknown values with a 400. The signal type still drives delivery shape (target resolution, supersede-chain rules); category is purely the operator-facing label.

## §5 — Per-Identity Signal Cache

Two files per identity in `~/.prism/`:

### 5.1 — Ring file: `signals-<Identity>.jsonl`

Append-only JSONL ring buffer, last `RING_MAX = 50` entries, oldest trimmed. Each line is a single JSON object:

```json theme={null}
{"ts":"iso8601","cat":"TASK","sig_type":"TaskAssigned","from":"Donna","summary":"...","sid":"uuid","read":false}
```

Field shapes:

* `ts`: ISO-8601 UTC timestamp from the envelope's `created_at` (falls back to wall clock at record time).
* `cat`: resolved category — `INFO`, `TASK`, `ASK`, or `BLOCKER`.
* `sig_type`: original `signal_type` from the envelope, retained for debugging and future filtering.
* `from`: `from_identity` from the envelope.
* `summary`: truncated payload summary (§10), max 120 chars.
* `sid`: original `signal_id`, used for idempotent record + targeted mark-read.
* `read`: drain state (§6).

### 5.2 — Count file: `sigcount-<Identity>.json`

Derived view of the ring, written atomically every time the ring changes. Read on every statusline tick (1s refresh):

```json theme={null}
{
  "unread": 3,
  "by_cat": {"INFO": 0, "TASK": 1, "ASK": 2, "BLOCKER": 0},
  "last_sid": "uuid",
  "last_ts": "iso8601",
  "latest_actionable": {
    "cat": "ASK", "from": "Lafonda",
    "summary": "...", "ts": "iso8601", "sid": "uuid"
  }
}
```

`latest_actionable` is the most recent unread entry whose category is `ASK` or `BLOCKER`. The statusline uses it to render the inline preview (§7) without parsing the ring on every tick — a single small JSON read per refresh.

### 5.3 — Filter at the door

`signal_cache.record(envelope, self_identity)` applies these filters before adding an entry:

1. `signal_type` must be present and NOT in `{PeerJoined, PeerLeft, MasterPreempted}`. System events do not appear in the operator inbox.
2. `to_identity == self_identity`. Broadcasts (`to=*`) and foreign-targeted signals are dropped. The operator only sees what was meant for them.
3. `signal_id` must not already be in the ring. Idempotent on `sid` so the SPEC-045 WebSocket subscriber and the SPEC-037 piggyback drain can both call `record()` for the same envelope without producing duplicate entries.

## §6 — Drain Semantics (see ADR-029)

The non-obvious decision: **`mark_read(sids)` flips the `read` flag on entries but KEEPS them in the ring.** The unread counter goes to zero; the tail view (`prism_signals(action='tail')`) keeps showing recent history.

Why this matters: the prior design clobbered the only state on drain — the count returned to 0 and there was no record of what came in. The operator could miss the bell entirely. The new design persists the ring independently of the unread counter, so the statusline can be quiet (count=0) but the operator can still see "what arrived in the last hour" via the tail.

Two drain entry points both call `mark_read`:

* **Piggyback drain** (SPEC-037 §2): on every verb response, the backend includes whatever's pending for the caller. The MCP client receives them, calls `signal_cache.record()` on each (idempotent if already seen via WS), then `signal_cache.mark_read([sids])` to flip the flags.
* **Explicit poll** (`prism_signals_pending`): same shape — drain returns rows; client records + marks read.

When `mark_read` is called with no signal\_ids (or empty list), it marks every unread entry read. That's used by the piggyback drain in cases where the response shape doesn't surface the sids individually.

Trade-off explicitly accepted: the ring grows to 50 entries per identity (\~10–20KB). Trimming on overflow is the only retention policy — old entries are not promoted to long-term storage. The backend `signal_queue` remains the durability source-of-truth (SPEC-034 §6).

## §7 — Statusline Rendering Contract

`bin/statusline-claude-code.sh` (new in this spec) is the reference renderer. Composition:

```
[Persona] ~/path                                        # 0 unread
[Persona] ~/path · 🔔 3 ASK:1 TASK:2                    # by-category breakdown
[Persona] ~/path · 🔔 1 ASK · Lafonda: PR #6 ready      # +preview when fresh ASK/BLOCKER
```

Rules:

* **Identity** comes from `$PRISM_AGENT_IDENTITY` (set by the launcher). When unset the line falls back to `<cwd>` with no signal info.
* **Bell** appears only when `unread > 0`.
* **Per-category breakdown** shows non-zero buckets only, in the order ASK → BLOCKER → TASK → INFO. Colors: ASK red, BLOCKER magenta, TASK cyan, INFO dim.
* **Preview** appears only when `latest_actionable` is set, its `cat` is `ASK` or `BLOCKER`, AND its age is `< 30s`. Truncated to 60 chars. The intent is to surface fresh blocking arrivals inline so the operator sees who's waiting on them; older arrivals show only as a count bump.

The launcher title watchers (`bin/coder.sh`, `bin/coder.ps1`) read the same count file and update the terminal title in their idle loop.

## §8 — New Verb: `prism_signals`

```
prism_signals(action='tail'|'count'|'both', n=5)
```

Pure local read of the JSONL ring + count file. Sub-millisecond; no backend call; works in offline mode. Returns the SAME data the launcher and statusline see, so the operator can query "what's the statusline showing right now" without a separate code path.

| `action` | Returns                                                                                                            |
| -------- | ------------------------------------------------------------------------------------------------------------------ |
| `tail`   | `{tail: [{ts, cat, sig_type, from, summary, sid, read}, ...]}` — last `n` entries, oldest-first within the window. |
| `count`  | `{count: {unread, by_cat, last_sid, last_ts, latest_actionable}}`.                                                 |
| `both`   | both fields above.                                                                                                 |

Identity is resolved from `PRISM_AGENT_IDENTITY`; when unset, every action returns empty/zero shapes (no error).

## §9 — System Events Are Filtered

`PeerJoined`, `PeerLeft`, and `MasterPreempted` are emitted by the controller (SPEC-030 / SPEC-049) as awareness signals, not as messages from a peer agent. Frank's directive: *"I don't want to see when everyone comes online. That is just noise."*

Cache behavior: `record()` returns early before writing the ring entry when `signal_type` is in `_SYSTEM_TYPES`. The unread counter never increments; the bell never rings; the tail never shows them.

`MasterPreempted` deserves a separate top-line indicator (it's a session-state event the operator should see), but that surface is out of scope for this spec — it lives in a separate visual channel rather than the per-message bell.

## §10 — Sender Contract: `payload.summary`

Senders SHOULD include `payload.summary` as a one-line human-readable description of the signal, ≤120 chars. The cache reads `payload.get('summary')` first; if absent, it synthesizes a fallback from the first present field of `{title, message, body, ack, subject}`, truncated to 120 chars with an ellipsis.

The synthesis fallback is graceful-degrade for legacy or sloppy senders — it is NOT a license to skip the field. New code SHOULD set `summary` explicitly because:

* The display will be more useful (a hand-curated one-liner beats a truncated payload field).
* Tooling stays consistent across signal types.
* The wire field is named — every downstream consumer can rely on it.

`summary` itself is a hint, not a contract for routing or correctness. Routing decisions are made entirely from `signal_type`, `category`, and `to_identity`. The `summary` exists purely for human-readable display.

## §11 — Migration

### 11.1 — Schema (alembic 024)

```sql theme={null}
ALTER TABLE signal_queue ADD COLUMN category VARCHAR(16) NULL;
ALTER TABLE signal_queue ADD CONSTRAINT ck_signal_category
  CHECK (category IS NULL OR category IN ('INFO','TASK','ASK','BLOCKER'));
UPDATE signal_queue SET category = CASE signal_type
  WHEN 'TaskAssigned'    THEN 'TASK'
  WHEN 'ReviewRequested' THEN 'ASK'
  WHEN 'ReviewCompleted' THEN 'INFO'
  WHEN 'Acknowledgment'  THEN 'INFO'
  WHEN 'StatusUpdate'    THEN 'INFO'
END WHERE category IS NULL AND signal_type IN
  ('TaskAssigned','ReviewRequested','ReviewCompleted','Acknowledgment','StatusUpdate');
```

System-type rows stay `NULL`. The migration is non-destructive.

### 11.2 — File-format migration

`~/.prism/sigcount-<Identity>.txt` (the old single-integer file from SPEC-034) is removed by the new MCP code. The new files are independent — no parsing of the old format is attempted. On first restart after this spec lands, the old `.txt` is left on disk (harmless; `mcp/sigcount.py` is deleted) and the new `.jsonl` + `.json` files start fresh.

### 11.3 — Coordination across hosts

Because the cache is per-host, an operator running multiple Prism hosts will see independent caches on each. That's intentional — the statusline is a local UX surface, and the WebSocket fan-out (SPEC-045) ensures every host's MCP receives every signal addressed to its identity, so each host's cache is consistent with what was delivered there.

## §12 — Relationship to Other Specs

* **Amends SPEC-034 §5.2** — signal types now carry an optional `category` column on the queue table.
* **Amends SPEC-034 §5.3** — wire envelope adds optional `category` field.
* **Amends SPEC-037 §2** — piggyback drain now also calls `signal_cache.record()` before `mark_read()` so LAN clients populate their ring even when the WS plane lost or skipped the envelope.
* **Supersedes** the implicit `mcp/sigcount.py` contract from SPEC-034 (file removed).
* **Coordinates with SPEC-044** — channel push is one of two paths (alongside SPEC-037 piggyback) that calls `record()`.
* **Coordinates with SPEC-049** — the Identity & Session Manager is the source of `to_identity` resolution; the cache trusts that resolution.

## §13 — Test Strategy

Implementation is verified by two test surfaces:

* **`mcp/signal_cache.py` Python contract** — unit-style smokes for `record` (idempotent, filter-system, filter-foreign), `mark_read` (selective + all), `read_counts` + `read_tail` (correct derivation), and the `_summarize_payload` priority order including the synthesis fallback.
* **`bin/statusline-claude-code.sh` rendering** — visual smokes across the state matrix: 0 unread, by-cat breakdown alone, fresh ASK/BLOCKER triggering preview, stale ASK suppressing preview, BLOCKER-only.

End-to-end (real signal → record → render) requires an MCP restart to load the new module; that path is exercised after deploy.

## §14 — Future Work (Out of Scope for v0.1)

* Promote `BLOCKER` to a top-line operator indicator (separate from the per-message bell).
* Cross-host cache aggregation for fleet operators.
* Optional retention policy beyond the 50-entry trim (e.g., keep last 24h + last 50).
* Sender-defined custom shapes inside ASK (not just `review` / `question`).

Status: draft
