Status:
accepted · ADR-29 · Filed 2026-04-29Decision
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 += 1reset()→0
- Unread counter — “how many signals do I still owe attention to?” — should drain on read.
- Recent activity memory — “what came in over the last hour?” — should NOT drain on read.
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.
