Skip to main content
Status: accepted · ADR-29 · Filed 2026-04-29

Decision

When the SPEC-052 per-identity signal cache drains (signal_cache.mark_read(...)), it flips the read flag on each affected entry to true but keeps the entry in the JSONL ring. The unread counter (sigcount-<Identity>.json) goes to zero; the ring (signals-<Identity>.jsonl) retains the last 50 entries regardless of read state. The ring is bounded only by RING_MAX = 50. Trim happens on overflow at write time — drain never removes entries.

Rationale

The previous design (mcp/sigcount.py, single-integer file from SPEC-034) clobbered its only state on every drain. Both the increment and the reset wrote the same file:
  • increment()n += 1
  • reset()0
Because the piggyback drain (SPEC-037) fires on every verb response and clears the counter, the bell window between increment and reset was sub-second under typical agent-session load. The eye couldn’t catch it. Frank repeatedly observed signal arrivals through transcript noise rather than the statusline indicator the design intended. Two distinct UX needs existed without distinct state:
  1. Unread counter — “how many signals do I still owe attention to?” — should drain on read.
  2. Recent activity memory — “what came in over the last hour?” — should NOT drain on read.
The single integer collapsed both into one number and served neither well. Splitting them — counter derived from read=false entries, ring persisted independently — gives each a dedicated representation. The persistence is what makes the operator inbox actually usable. After the bell rings (count=N), the operator drains by acting on the verbs that surface the signals; the count goes to zero; but prism_signals(action='tail') keeps showing what arrived, so the operator can return after a context switch and reconstruct what happened. Without the persistent ring, the bell is a tree falling in a forest with no one around.

Alternatives Considered

Alt-1: Drain removes entries. Symmetric with the old design — mark_read becomes “delete from ring.” Simpler model. Rejected because it perpetuates the original problem: the operator loses the memory of recent activity the moment they drain. The whole point of this rework is that the bell needs to persist visibly; a self-erasing ring fails that goal. Alt-2: Two separate stores — one for unread, one for archive. A live unread queue (FIFO, drained on read) and a separate archive log (append-only, never drained). Cleaner conceptually. Rejected because it doubles the I/O and complicates idempotency. The read=true flag on a single ring captures the same distinction with one file write per drain operation. Alt-3: Time-based retention (e.g., keep 24h regardless of read state). Drain still removes from a working set, but a separate “recently-seen” log keeps a time window. Rejected as over-engineering for v0.1. The 50-entry trim is approximately equivalent in practice for normal Prism signal volume (sparse, single-operator fleets), and a fixed-count ring is simpler to reason about than a time-windowed structure. Alt-4: Drain marks read but trims read entries on the next overflow. Compromise — read=true entries are first to evict when ring exceeds 50. Rejected because it introduces inconsistent retention based on operator behavior; an operator who drains aggressively would see their tail collapse to nothing, while an operator who never drains would see it stay full. Strict time-order trim is more predictable.
Last modified on May 3, 2026