Skip to main content
Status: draft · Version 0.1 · Filed 2026-04-29

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:
ConcernHow addressed
Bell never sustainsDrain marks entries read but keeps the ring (§6, ADR-029)
No memory of recent activityPersistent JSONL ring, last 50 entries (§5)
All signals look alike4-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?
CatMeaningOperator response
INFOFYI, no action required.Glance, dismiss.
TASKWork handoff; receiver schedules and executes when free.Acknowledge receipt, work the queue, report when done. No go-ahead round-trip required by default.
ASKSender 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.
BLOCKERSender 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_typeDefault category
TaskAssignedTASK
ReviewRequestedASK
ReviewCompletedINFO
AcknowledgmentINFO
StatusUpdateINFO
(system) PeerJoined, PeerLeft, MasterPreemptedNULL (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:
{
  "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:
{"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):
{
  "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.
actionReturns
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}}.
bothboth 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)

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
Last modified on May 3, 2026