kijito-tools 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +6 -0
  3. package/README.md +220 -0
  4. package/bin/cli.js +34 -0
  5. package/install.sh +161 -0
  6. package/package.json +50 -0
  7. package/providers/_shared/wake-core.mjs +358 -0
  8. package/providers/claude/CLAUDE.md.snippet +31 -0
  9. package/providers/claude/install.sh +186 -0
  10. package/providers/claude/scripts/arm-session.sh +66 -0
  11. package/providers/claude/scripts/claude-armed.sh +101 -0
  12. package/providers/claude/scripts/heartbeat-watchdog.sh +209 -0
  13. package/providers/claude/scripts/inbox-selftest.sh +277 -0
  14. package/providers/claude/scripts/kijito-persona-lib.sh +94 -0
  15. package/providers/claude/scripts/kijito-qa-pass.sh +12 -0
  16. package/providers/claude/scripts/lifecycle-lib.sh +167 -0
  17. package/providers/claude/scripts/myctx.sh +46 -0
  18. package/providers/claude/scripts/self-clear.sh +134 -0
  19. package/providers/claude/scripts/session-autosend.sh +85 -0
  20. package/providers/claude/scripts/session-catchup-hint.sh +330 -0
  21. package/providers/claude/scripts/statusline-context.sh +67 -0
  22. package/providers/claude/skills/kijito-qa-memory/SKILL.md +110 -0
  23. package/providers/claude/skills/kijito-recall/SKILL.md +53 -0
  24. package/providers/claude/skills/kijito-start/SKILL.md +122 -0
  25. package/providers/claude/wiring/README.md +93 -0
  26. package/providers/claude/wiring/kijito-heartbeat@.service +21 -0
  27. package/providers/codex/README.md +75 -0
  28. package/providers/codex/codex-kijito-parity-plan.md +265 -0
  29. package/providers/codex/docs-codex-setup.md +64 -0
  30. package/providers/codex/install.mjs +184 -0
  31. package/providers/codex/n0-capability-probe-protocol.md +472 -0
  32. package/providers/codex/n0-harness/README.md +57 -0
  33. package/providers/codex/n0-harness/cli.mjs +58 -0
  34. package/providers/codex/n0-harness/evidence-manifest.mjs +77 -0
  35. package/providers/codex/n0-harness/fixture.mjs +279 -0
  36. package/providers/codex/n0-harness/lib.mjs +141 -0
  37. package/providers/codex/n0-harness/manifest.mjs +36 -0
  38. package/providers/codex/n0-harness/oracle.mjs +464 -0
  39. package/providers/codex/n0-harness/parser.mjs +200 -0
  40. package/providers/codex/n0-harness/prompt.mjs +43 -0
  41. package/providers/codex/n0-harness/snapshot.mjs +120 -0
  42. package/providers/codex/n0-harness/specimen.mjs +37 -0
  43. package/providers/codex/notify/kijito-notify-count.mjs +117 -0
  44. package/providers/codex/notify/kijito-notify-count.test.mjs +76 -0
  45. package/providers/codex/plans/gate4-battery-log.md +184 -0
  46. package/providers/codex/plans/gate4-battery-protocol.md +43 -0
  47. package/providers/codex/plans/hive-user-first-plan-QA.md +34 -0
  48. package/providers/codex/plans/hive-user-first-plan.md +247 -0
  49. package/providers/codex/release-manifest.json +53 -0
  50. package/providers/codex/same-chat-continuation-plan-gate.md +235 -0
  51. package/providers/codex/same-chat-continuation-plan.md +458 -0
  52. package/providers/codex/skills/kijito-qa-memory/SKILL.md +193 -0
  53. package/providers/codex/skills/kijito-qa-memory/agents/openai.yaml +4 -0
  54. package/providers/codex/skills/kijito-recall/SKILL.md +53 -0
  55. package/providers/codex/skills/kijito-start/SKILL.md +173 -0
  56. package/providers/codex/skills/kijito-start/agents/openai.yaml +4 -0
  57. package/providers/codex/test/n0-cli-refusal.test.mjs +31 -0
  58. package/providers/codex/test/n0-counterexample-matrix.test.mjs +118 -0
  59. package/providers/codex/test/n0-guard-census-core.mjs +1372 -0
  60. package/providers/codex/test/n0-guard-census.json +35479 -0
  61. package/providers/codex/test/n0-guard-census.mjs +31 -0
  62. package/providers/codex/test/n0-guard-census.test.mjs +374 -0
  63. package/providers/codex/test/n0-guard-counterexamples.mjs +1068 -0
  64. package/providers/codex/test/n0-guard-independent-projection.mjs +70 -0
  65. package/providers/codex/test/n0-guard-independent-projection.test.mjs +53 -0
  66. package/providers/codex/test/n0-guard-manifest-author.mjs +424 -0
  67. package/providers/codex/test/n0-guard-mutation-operators.test.mjs +244 -0
  68. package/providers/codex/test/n0-guard-mutation-runner.mjs +599 -0
  69. package/providers/codex/test/n0-guard-remainder-classes.test.mjs +314 -0
  70. package/providers/codex/test/n0-guard-sanitizer-pairs.test.mjs +68 -0
  71. package/providers/codex/test/n0-guard-shared-context-predicates.test.mjs +84 -0
  72. package/providers/codex/test/n0-harness.test.mjs +553 -0
  73. package/providers/codex/test/n0-notable-regressions.test.mjs +70 -0
  74. package/providers/codex/test/n0-subject-boundary.mjs +97 -0
  75. package/providers/codex/test/n0-subject-boundary.test.mjs +72 -0
  76. package/providers/codex/test/same-chat-plan-preflight.sh +167 -0
  77. package/providers/codex/tools/refresh-manifest.mjs +81 -0
  78. package/providers/codex/wake-helper/TRANSPORT-NOTES.md +25 -0
  79. package/providers/codex/wake-helper/integration.test.mjs +285 -0
  80. package/providers/codex/wake-helper/kijito-wake-helper.mjs +488 -0
  81. package/providers/codex/wake-helper/kijito-wake-helper.test.mjs +128 -0
  82. package/providers/codex/wake-helper/mock-daemon.mjs +64 -0
  83. package/providers/codex/wake-helper/status-probe.mjs +45 -0
  84. package/providers/codex/wake-helper/ws-uds.mjs +153 -0
  85. package/providers/monitor/.github/workflows/publish-npm.yml +38 -0
  86. package/providers/monitor/.github/workflows/publish-pypi.yml +30 -0
  87. package/providers/monitor/CHANGELOG.md +465 -0
  88. package/providers/monitor/IMPORT-PROVENANCE.md +68 -0
  89. package/providers/monitor/LICENSE +202 -0
  90. package/providers/monitor/NOTICE +7 -0
  91. package/providers/monitor/OPAQUE-OUTPUT-ENFORCEMENT.md +45 -0
  92. package/providers/monitor/README.md +571 -0
  93. package/providers/monitor/RELEASING.md +125 -0
  94. package/providers/monitor/arm-hive-monitor.sh +13 -0
  95. package/providers/monitor/bin/cli.js +43 -0
  96. package/providers/monitor/com.kijito.inbox-monitor.plist.template +59 -0
  97. package/providers/monitor/docs/DESIGN.md +984 -0
  98. package/providers/monitor/kijito-inbox-monitor@.service.template +95 -0
  99. package/providers/monitor/kijito_inbox_monitor.py +3666 -0
  100. package/providers/monitor/package.json +35 -0
  101. package/providers/monitor/pyproject.toml +42 -0
  102. package/providers/monitor/scripts/mutation-check.py +399 -0
  103. package/providers/monitor/scripts/prepublish-gate.sh +284 -0
  104. package/providers/monitor/test_kijito_monitor.py +5609 -0
@@ -0,0 +1,984 @@
1
+ # Kijito Inbox Monitor: Design & Implementation Spec
2
+
3
+ **Updated:** 2026-08-15 (rev 9: §6.5 Darwin `boottime` re-sourced from `CLOCK_MONOTONIC_RAW` - Darwin's
4
+ `CLOCK_MONOTONIC` is calendar-derived and read below `CLOCK_UPTIME_RAW` at fresh uptime - plus the
5
+ inverted-pair quarantine at the emit chokepoint).
6
+ Rev 8 (2026-07-25): the bounded-window / delivery-acknowledgement contracts, from seven rounds of
7
+ adversarial audit - §5 pagination consistency, §7.0 acknowledged delivery + durability ordering, §7.3 strict
8
+ persisted schema + corrupt-state recovery + case-only identity migration, §14.7 the three case layers.
9
+ Rev 7 was remote-only: watches your Kijito inbox at `api.kijito.ai`, token required; the `--url`/SSRF-by-class
10
+ machinery is gone - see §5, §8, §11. Builds on rev 6 (v2 multi-persona + supervised producer).
11
+ **Status:** shipped and live (v2 under launchd).
12
+
13
+ **Goal:** give Kijito a solid, usable client-side liveness watcher for its built-in inbox. The concrete
14
+ Kijito-inbox monitor is the win. Agnosticism is a means (generalize only where it makes the tool more
15
+ useful), not the end. Learn from prior art, and don't gold-plate.
16
+
17
+ §1 through §13 are the v1 single-persona core, still accurate and load-bearing (one process watches one
18
+ inbox; the cursor/dedup/FSM/self-test/state/seam contracts apply per-persona unchanged). §14 records
19
+ the v2 deltas: the deployed build watches your whole Kijito account from one supervised process and writes
20
+ one owned, self-rotating event log per persona. Read §14 alongside §1, §11, and §12 for current reality.
21
+
22
+ ---
23
+
24
+ ## 1. What it is
25
+
26
+ A standalone, single zero-dependency Python-stdlib script (urllib, json, signal, select, fcntl, subprocess;
27
+ no pip installs) that polls the Kijito inbox and emits one event per new message into whatever harness is
28
+ running, as NDJSON on stdout and/or exec-a-command-per-event. It is the client-side liveness watcher: it
29
+ keeps a running agent's inbox live by waking it between tool calls. It is not a server, and not a
30
+ notification service. POSIX target (Linux/macOS); Windows runs interval-only (no SIGUSR1 seam, no flock,
31
+ per §10/§7.3).
32
+
33
+ ## 2. The problem
34
+
35
+ The "inbox-liveness" LLM-UX bug: agents predictably fail to keep an independent inbox check alive. They tie
36
+ it to a work loop that ends, or never set one up. The fix is to move the burden off agent-discipline and
37
+ onto a running guarantee: an independent process that watches and emits, decoupled from any work loop.
38
+ (Dogfooded; adversarial review surfaced real bugs in its own early versions, which are folded into this rev.)
39
+
40
+ ## 3. The composition contract (locked with the server side)
41
+
42
+ This is the client half of Kijito's server-side inbox-liveness system. Two complementary layers:
43
+
44
+ | Layer | What | Where | Guarantee |
45
+ |-------|------|-------|-----------|
46
+ | **Banner** (server) | unread banner in every Kijito tool response | server-side, every client | zero-setup floor; delivery-on-next-call |
47
+ | **Watcher** (this) | independent process polls and emits per-new-item | client-side, harnesses that run a process | proactive; wake-without-a-call |
48
+
49
+ - One shared signal source (the `control_plane` urgent counter) so liveness never diverges; the watcher
50
+ consumes a server count over HTTP (§9) and never reimplements liveness.
51
+ - v1 is a pure poller of the Kijito API plus the opaque-wake seam (§10), so a hosted bridge can later push wake-then-pull.
52
+ - Marketplace: the goal is to surface it as "the local liveness watcher for your Kijito inbox."
53
+
54
+ ## 4. Architecture
55
+
56
+ `SOURCE adapter (http-poll) → GENERIC CORE (cursor/dedup/alert-FSM/self-test/state/wake-seam) → EMIT (stdout-jsonl | exec-per-event)`.
57
+ v1 ships one adapter (`http-poll`, the Kijito reference). Future adapters are explicitly deferred.
58
+
59
+ ## 5. The `http-poll` adapter: Kijito inbox contract (code-verified, audited 2026-06-17)
60
+
61
+ - **Endpoint:** `GET /api/inbox?persona=<P>&mark_read=false`
62
+ - **Response:** `{"result": [ {"id":<int>,"from":"<persona>","content":"<plaintext>","created":"<iso-str>","read":<bool>}, ... ]}`
63
+ (keys verbatim, in that order, per messaging.py:85-89). v1 hard-bakes this Kijito response shape (there is no
64
+ generic parse config; that's deferred, see §scope). The destination is the fixed Kijito API; only the persona varies.
65
+ - **`mark_read` defaults to `true`** (web_api.py:504; SET m.read=true at messaging.py:90-96). The URL must carry
66
+ `&mark_read=false`. A watcher must peek, never consume: every fetch site (the poll loop and `--self-test`) uses
67
+ the `mark_read=false` URL. (Triple-confirmed; the original seed was fixed for this.)
68
+ - **`id` is a SERIAL PK, so it is strictly monotonic** (schema.py:168), with gaps allowed. The cursor keys on
69
+ max-id, never on read/unread state.
70
+ - ⚠️ **The window is BOUNDED and PAGINATED** (this supersedes the original "no pagination: the response is a full
71
+ list" note, which was true when it was audited and is not now - and assuming it still held is how a bounded window
72
+ turns into permanent mail loss). The endpoint returns the **newest** rows that fit a count limit AND an aggregate content budget, and
73
+ declares what it left out via `truncated` (rows withheld, quantity NOT stated), `size_dropped` (exactly N rows
74
+ withheld) and `size_truncated` (a lone oversized message had its BODY clipped - no row withheld, so it is NOT an
75
+ omission). Older rows are reached by passing the OLDEST id you were returned back as `before_id`.
76
+ - **The omission declaration and the continuation are two halves of ONE statement.** The server sets
77
+ `next_before_id = <oldest row returned> if (truncated or size_dropped) else null` - "present exactly when mail was
78
+ withheld". So a window withheld rows **if and only if** it hands back a continuation, and either half contradicting
79
+ the other is a contract violation that PINS rather than something to interpret:
80
+ - withheld rows + `next_before_id: null` → "I hid rows" and "there is nothing older" (Loom re-audit 6, HIGH 3);
81
+ - withheld nothing + a non-null `next_before_id` → "I hid nothing" and "there is more" (Loom re-audit 7, HIGH 4).
82
+ The second also follows from how the window is BUILT: a page returns every older row that fit, so if it withheld
83
+ nothing there is nothing older left to point at. Believing either half steps over what the other one asserts.
84
+ Verified live across 14 pages including the exactly-at-limit edge (a page returning exactly `limit` rows with more
85
+ behind it declares `truncated: true`; one that exactly exhausts the mailbox declares nothing and terminates), so
86
+ the check cannot fire on healthy traffic. Emit only the diff (id > cursor); never dump the body.
87
+ - **The window ALSO declares `unread_not_shown`** - how much unread mail the inbox holds that this response did
88
+ not hand over. It is a separate axis from the omission/continuation pair above: those describe THIS WINDOW's
89
+ completeness, while `unread_not_shown` counts unread mail anywhere in the inbox, including messages already
90
+ emitted to the stream that the agent simply has not read. So it is an OBSERVATION, never a diagnosis of missed
91
+ mail, and coverage of an un-emitted span is proven by the backward walk, never by this count (§5.2).
92
+ - **Auth:** a Kijito API token is **required** (the API is authenticated). Supply it via `$KIJITOMON_TOKEN` or
93
+ `--token-file` (file wins over env); it is injected as `Authorization: Bearer <token>`, or with `--auth-header NAME`
94
+ as `NAME: <token>` verbatim. The header name (`--auth-header`) and the token-value source are independent axes. A
95
+ missing token is a fatal config error, and an unreadable `--token-file` is a fatal config error. Every request also
96
+ carries a named `User-Agent` (the API WAF rejects the default Python-urllib UA with a 403).
97
+ - **A poll is healthy iff** HTTP 2xx, and the body parses, and the envelope is shape-valid (`result` is a list; every
98
+ row is an object with an integer `id`). Anything else (non-2xx, connection-refused, DNS failure, connection-reset,
99
+ timeout, parse-fail, shape-violation) is a liveness failure (UNKNOWN), never "no mail." (A 200 with a
100
+ truncated-but-parseable body that fails the shape check is a failure.)
101
+ - **Empty `{"result":[]}`** is healthy, with no new items.
102
+ - **Hive-off / 404 timing matters:** detected at startup or `--self-test`, it is a fatal config error (exit
103
+ non-zero). Appearing mid-run, it is a per-poll liveness failure (the daemon may have restarted or the hive toggled
104
+ transiently); it feeds the §7.1 FSM and does not kill the process (a transient server blip must not destroy the
105
+ dead-man's-switch).
106
+ - **Config:** `poll_seconds` (default 60). The destination is the hard-baked Kijito API inbox URL including
107
+ `mark_read=false`; only the persona varies.
108
+
109
+ ### 5.1 A bounded window must not silently swallow mail (fail closed)
110
+
111
+ The cursor is a **confirmed-contiguous watermark**: everything at or below it is known delivered. It may only
112
+ advance over a span the watcher has actually seen.
113
+
114
+ - **The discriminator.** If the returned window reaches back *past* the cursor, every omitted message is older
115
+ than the watermark and was already delivered - the ordinary case, since long-polling keeps the backlog to a
116
+ message or two. If the window starts *above* the cursor while the server admits it dropped rows, the span
117
+ between them may hold mail never emitted.
118
+ - **Coverage comes from EXHAUSTION, not arithmetic.** `truncated` says rows were withheld without saying how
119
+ many, so no count can prove a span empty - a single recovered message would "close" an unbounded hole. The
120
+ watcher instead pages BACKWARD with `before_id` until it reaches the watermark or the chain ends. Walking
121
+ terminates; counting cannot. This is also what makes an *inexact* omission closable at all, and it reaches
122
+ mail someone has already read, which an unread-only reconcile structurally cannot see.
123
+ - **A walk that does not complete is not coverage.** Transient failure, a non-advancing cursor, or the
124
+ `WALK_BACK_MAX_PAGES` budget leaves the watermark **PINNED** and raises an `alert`. Visible mail keeps
125
+ flowing while pinned; ids emitted above the pin are remembered (and persisted) so nothing is re-delivered.
126
+ - **Pagination contract:** pass the OLDEST id you were returned as `before_id`; repeat until the page is empty
127
+ or `next_before_id` is null; OMIT the parameter for the newest page, because `0` is a real cursor rather
128
+ than "no cursor". A malformed cursor is a hard 400, never a silent fallback to the newest page - that
129
+ loudness is what makes a completed walk usable as evidence. Order by **`id`**, never `created`: timestamps
130
+ are stamped pre-lock while ids are assigned under it, so concurrent senders invert.
131
+
132
+ ### 5.2 `unread_not_shown`: a cheap alarm, never a coverage mechanism
133
+
134
+ `unread_not_shown` reports how many unread messages the server holds that this response did not hand back
135
+ (`max(0, unread_count - rows_returned_still_unread)`, evaluated after this fetch's `mark_read`). Above zero,
136
+ the watcher raises an `alert`; it is a superset of "withheld by the budget", which is the right answer for an
137
+ alarm because you want to know regardless of *why* mail is absent.
138
+
139
+ Three properties keep it honest:
140
+
141
+ - **It is an observation, not a diagnosis.** The count covers unread mail anywhere in the inbox, including
142
+ messages this watcher already delivered that the agent never read, so it is not by itself evidence of missed
143
+ mail. The event carries `above_watermark` - whether the window floor sits above the cursor - as the
144
+ discriminating fact, and leaves the interpretation to the reader. Coverage stays with §5.1's walk: this is a
145
+ COUNT with no cursor of its own, so it can say THAT something is out of view but never WHICH rows.
146
+ - **A zero is not self-justifying.** The server computes the field ONLY when it withheld something;
147
+ otherwise it is `0` **by construction**. So the negative answer requires positive evidence - either the zero
148
+ was genuinely computed (`next_before_id` is not null), or the window is structurally complete (nothing older
149
+ and nothing withheld). A count the server never stated at all is a THIRD state, and asserts nothing in
150
+ either direction; coercing that silence to `0` would manufacture an all-clear.
151
+ - **Evaluate it on the NEWEST-PAGE poll only.** On a backward-walk page, `next_before_id is null` means
152
+ merely "nothing older than this page". Measured live against an inbox holding four unread: the newest page
153
+ reported `0` (correct - all four were in it), a mid-walk page reported `4` (the whole inbox's unread, not
154
+ that window's), and the terminal page reported `0` with all four sitting above it. Feeding walk pages to the
155
+ check would invent alarms and clear real ones.
156
+
157
+ It is evaluated on full inbox polls only, so the §9 fast path (which skips the inbox fetch while the unread
158
+ count is not rising) can delay it by at most `--resync-every` skips. That is acceptable for an alarm whose
159
+ whole point is cheapness: the condition it reports is not one anybody can act on faster for hearing sooner.
160
+
161
+ Routing follows the stranded-mail alarm - an `alert` rather than a new event name, no ack, self-clearing when
162
+ the condition goes away - but fails the OPPOSITE way on an unknown directory. The stranded alarm withholds,
163
+ because alarming with no directory would flag every persona; this one concerns the target's own inbox, where
164
+ the worst case of firing is a line in a stream nobody reads and the worst case of withholding is the silent
165
+ wake gap the tool exists to prevent.
166
+
167
+ ### 5.4 Authorship: an attributable liveness signal, collected for free
168
+
169
+ `--activity-file PATH` publishes, refreshed each tick, the newest message id each persona has been observed
170
+ to have AUTHORED. It exists so a harness can answer "has X been active since my message?" without inventing
171
+ its own scan.
172
+
173
+ **Why authorship and not the obvious signals.** Two seemingly better sources are both forgeable by accident:
174
+
175
+ - **inbox read-state** - any agent calling the inbox with the default `mark_read=true` produces X's read
176
+ bit, so "X read their mail" only means "somebody read X's mail". It also fails the other way: a member
177
+ consuming its `events.<persona>.ndjson` stream reads its mail without ever touching read-state.
178
+ - **`/api/presence`** - a GET carrying `?persona=X` BEATS X into the active roster, so any observer probing
179
+ X makes X look alive. A diagnostic read that writes the state being diagnosed.
180
+
181
+ Only B produces B's outbound, and no third party can manufacture or erase it. That is the whole selection
182
+ criterion: a liveness check built on a bit any observer can flip is not a check.
183
+
184
+ **It costs nothing.** All-personas mode already fetches every inbox every tick, the URL already hardcodes
185
+ `mark_read=false`, and every row already carries `from`. The alternative - a client polling every inbox on a
186
+ timer to reconstruct this - is not merely wasteful but dangerous: one missing `mark_read=false` in that loop
187
+ destroys read-state fleet-wide, on a schedule.
188
+
189
+ **Two coverage limits, both published, because a claim of silence is only as good as the watching.**
190
+
191
+ - `observed_since` - this process saw nothing before it started.
192
+ - `observation_floor_id` - the **MAXIMUM** of the per-inbox window floors, deliberately not the minimum. A
193
+ persona's outbound lands in whichever inbox they wrote to, so "they authored nothing" is only as strong as
194
+ the WORST-covered inbox; between the lowest and highest floor there are inboxes we have not seen into.
195
+ Measured live: the watcher had seen ids down to 1160 in one inbox while another reached only 1179, which
196
+ made a question about id 1165 look answerable when it was not.
197
+
198
+ So `activity_since()` is a TRI-STATE - active / no-activity-in-a-span-we-covered / **NOT OBSERVABLE** - the
199
+ same discipline as §5.2. Absence of evidence is evidence of absence only if you were actually watching.
200
+
201
+ **Evaluating it: `--check-activity PERSONA --since-id N [--waits K]`.** A one-shot read of a published
202
+ report, with no token, no network and no watch loop, so a shell heartbeat can call it. The exit codes are the
203
+ contract, and 1 and 2 are distinct on purpose:
204
+
205
+ | exit | meaning |
206
+ |------|---------|
207
+ | 0 | evidence of activity - nothing to report |
208
+ | 1 | no activity in a span this report actually covered; the observation is printed |
209
+ | 2 | NOT OBSERVABLE, or the report is missing/corrupt - no claim in either direction |
210
+
211
+ Collapsing 2 into 1 would turn "I was not watching" into "they were silent", which is the false assertion the
212
+ whole signal exists to refuse. Both the running watcher and the one-shot go through the same
213
+ `evaluate_activity()`, because a second implementation of a tri-state this subtle is a second chance to get
214
+ it wrong.
215
+
216
+ **The observation states what was seen, never why.** `activity_observation()` renders the finding with the
217
+ wait count and the last-evidence stamp alongside it, and a test asserts the text contains none of
218
+ `FORBIDDEN_DIAGNOSES` - deadlocked, unreachable and still-working are indistinguishable from this data and
219
+ need opposite responses (one wants a ping, one wants a human to restart a bridge). That rule lives in the
220
+ code, not only here, because a rule that lives only in prose does not run.
221
+
222
+ ### 5.5 Urgent-unanswered: escalated mail nobody is answering
223
+
224
+ ALARM IF unread_urgent > 0 AND activity_since(persona) is False
225
+
226
+ **Why this alarm can exist at all.** "Is this member stuck?" normally cannot be answered from outside,
227
+ because a member idle BY DESIGN and one that is wedged look identical - so the alarm fires on every dormant
228
+ persona and rots into noise, which is worse than not having it. What breaks the tie is a declared
229
+ EXPECTATION. `unread_urgent` is one: not the recipient declaring liveness, but a **sender** declaring that
230
+ this needs attention now. Silence only means something once something was expected, and this is the only
231
+ place the hive records an expectation.
232
+
233
+ The consequence is that a quiet persona with no urgent mail NEVER trips it. The alarm fires exactly where
234
+ somebody escalated and nothing happened, which is the population worth waking a human for.
235
+
236
+ **Both halves must be positive.** `activity_since` is a tri-state (§5.4) and only an explicit `False`
237
+ qualifies - a NOT-OBSERVABLE answer means the watcher was not running for the span in question, and
238
+ reporting that as silence would be the fabrication the tri-state exists to refuse.
239
+
240
+ **It costs nothing.** `unread_urgent` arrives on the same `/api/notify/pending` row as the unread count the
241
+ fast path already fetches every tick; the field was previously parsed and discarded.
242
+
243
+ **Kept disjoint from stranded-mail (§ above) on purpose:** that alarm is for inboxes nobody OWNS, this one
244
+ for real directory members who are not responding. Two alarms covering one inbox drift apart and then
245
+ disagree about it, so this one skips any persona the directory does not know and lets the stranded check own
246
+ that case.
247
+
248
+ Routing and honesty follow the same rules as every other alarm here: an `alert` rather than a new event
249
+ name, one summarising event per watcher so discovering several at once cannot become a wake storm, the
250
+ OBSERVATION and never the diagnosis, and self-clearing when **either** half of the predicate clears - with
251
+ no ack, since an ack would let someone silence "nobody is answering escalated mail" while it stayed true.
252
+
253
+ > **Known uncovered property.** The alarm is evaluated AFTER the per-target polls, so this tick's authorship
254
+ > is already recorded when it judges. That ordering is asserted by a comment and by review, not by a test -
255
+ > it is a property of the run loop's composition that the unit suite does not reach. Its failure mode is
256
+ > benign and self-correcting: a member who authored mail during the same tick could be reported quiet once,
257
+ > and the next tick clears it.
258
+
259
+ ### 5.6 Alarm routing: evidence of a consumer, not just a name
260
+
261
+ Account-level alarms (stranded-mail, urgent-unanswered) go to watchers, and "every directory persona" is the
262
+ wrong list: a directory accumulates names, and long-dead test personas keep receiving alerts into streams
263
+ nobody reads. That is the same defect as a broadcast amplifying phantoms, so it is fixed ONCE here rather
264
+ than separately in each alarm - two predicates for one question drift apart and then disagree.
265
+
266
+ `has_consumer_evidence(persona)` is POSITIVE and mirrors the stranded-mail ownership test deliberately:
267
+ observed authorship, or memories the directory says they own. Authorship alone suffices, because a brand-new
268
+ persona that has written mail but owns no memories yet is real - excluding it would break first contact. An
269
+ unreported memory count leaves a persona eligible: no data is not evidence of absence.
270
+
271
+ **It fails open, and that matters more than the filtering.** If the predicate would leave NOBODY, every
272
+ directory watcher is used instead. An alarm delivered to a stream nobody reads costs one line; an alarm
273
+ delivered to nobody is the silent failure this tool exists to prevent, and a filter that can silence every
274
+ recipient at once is a worse bug than the noise it removes.
275
+
276
+ Measured on a live account: recipients fell from 25 to 18. The seven dropped own zero memories and were
277
+ never observed authoring; two remaining test personas own two memories each, so they carry positive evidence
278
+ someone worked under those names and the same predicate keeps them - consistent with the ownership rule that
279
+ decides whether an inbox is stranded.
280
+
281
+ ## 6. Emit modes (portability)
282
+
283
+ "NDJSON-on-stdout is universal" is false on ingestion: Claude Code ingests per-event (hooks: JSON-on-stdin,
284
+ exit-code, `additionalContext`; plus FileChanged); LangGraph/OpenAI-Agents/Cursor are in-process (no stdin/stdout
285
+ event ingestion). So `exec-per-event` is the more portable primitive; `stdout-jsonl` is the ergonomic default.
286
+
287
+ ### 6.1 Event schema (stdout-jsonl)
288
+
289
+ One object per line; every event carries `event`, `source`, `ts` (emit-time UTC ISO), `event_id` (§6.3),
290
+ `nonce` (§6.4) and `emitted` (§6.5).
291
+ ```
292
+ {"event":"new", "source":"kijito-inbox","ts":"<iso>","id":246,"from":"river","content":"<≤N or omitted>","created":"<iso>"}
293
+ {"event":"armed", "source":"kijito-inbox","ts":"<iso>","cursor":250}
294
+ {"event":"alert", "source":"kijito-inbox","ts":"<iso>","reason":"unreachable","consecutive_failures":3,"seconds":180}
295
+ {"event":"recovered", "source":"kijito-inbox","ts":"<iso>","cursor":250}
296
+ {"event":"heartbeat", "source":"kijito-inbox","ts":"<iso>","cursor":250} # only if --heartbeat; cursor may be null
297
+ {"event":"seed_ahead", "source":"kijito-inbox","ts":"<iso>","seeded":600,"current_max":539} # seed > reality (§7.0)
298
+ {"event":"replay_capped","source":"kijito-inbox","ts":"<iso>","capped_to":539,"dropped":389} # backlog > --max-replay (§7.0)
299
+ ```
300
+ - `new` carries `id`, `from`, `content`, `created`. `content` is a silent hard cut to `--content-chars` (default 220),
301
+ with no marker; or it is omitted with `--no-content`. `seconds` in `alert` is **config-derived, not a measurement**:
302
+ it is exactly `consecutive_failures * poll_seconds` (a function of two flags), while the failure path backs off
303
+ exponentially from 1 s and detection can lag inside a `--wait` long-poll, so it does not equal the outage duration
304
+ and is routinely off by more than an order of magnitude (e.g. `seconds:90` observed against a measured ~48 s outage).
305
+ Do NOT back-date onset as `ts - seconds`. A measured-monotonic replacement (stamp the first failure, subtract) is
306
+ queued; the row already carries `emitted.monotonic` for it.
307
+ - **Within-poll emit order (deterministic, total):** `alert`/`recovered` (FSM edge), then `replay_capped`/`seed_ahead`,
308
+ then `armed`, then `new` (ascending id), then `heartbeat`. So `armed`/`recovered` set `cursor` before any `new`/`heartbeat`
309
+ in the same cycle, which means `recovered.cursor` is non-null whenever a baseline has occurred (a `recovered` on a poll
310
+ that also baselines carries the just-set cursor).
311
+
312
+ ### 6.2 `exec-per-event` (`--emit exec-per-event --exec 'CMD'`, `--exec` required iff this mode)
313
+
314
+ Every event invokes `CMD`; inapplicable env vars are unset:
315
+
316
+ | env var | new | armed | alert | recovered | heartbeat | seed_ahead | replay_capped |
317
+ |---|---|---|---|---|---|---|---|
318
+ | `KIJITOMON_EVENT`,`_SOURCE`,`_TS`,`_EVENT_ID`,`_NONCE` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
319
+ | `KIJITOMON_ID`,`_FROM`,`_CONTENT`,`_CREATED` | ✓ | - | - | - | - | - | - |
320
+ | `KIJITOMON_CURSOR` | - | ✓ | - | ✓ | ✓ | - | - |
321
+ | `KIJITOMON_REASON`,`_FAILURES` | - | - | ✓ | - | - | - | - |
322
+ | `KIJITOMON_SEEDED`,`_CURRENT_MAX` | - | - | - | - | - | ✓ | - |
323
+ | `KIJITOMON_CAPPED_TO`,`_DROPPED` | - | - | - | - | - | - | ✓ |
324
+
325
+ The spawned command has a 10s timeout; a non-zero exit or timeout is logged to stderr and is non-fatal.
326
+
327
+ 🛑 **CORRECTED 2026-08-05 - THIS PARAGRAPH USED TO END "(and never holds the cursor back, per §7.0)", WHICH
328
+ WAS THE EXACT OPPOSITE OF WHAT THE CODE DOES, AND HAD BEEN FOR AS LONG AS THE ACKNOWLEDGEMENT CONTRACT HAS
329
+ EXISTED.** In `exec-per-event` mode **your command's exit status IS the acknowledgement**: exit 0 and the
330
+ cursor advances; **exit non-zero or time out and the cursor is HELD below that message and it is re-delivered
331
+ on the next poll** (`emit()` returns False - the Loom re-audit 7 HIGH 1 fix). Delivery also stops at the first
332
+ failure, so a consumer never sees message N+1 before a retried N.
333
+ **Measured, not reasoned:** the unacknowledged-delivery drill (2026-08-05, `evidence/unack-delivery-drill-20260805/`)
334
+ ran an `--exec` that exits 7 and observed one message delivered **six times** with the persisted cursor held at
335
+ the id below it throughout. The README described this correctly the whole time; only this section was wrong.
336
+ ★ **The lesson worth keeping: two documents disagreed about a safety-critical contract and nothing detected it,
337
+ because each was internally consistent. A drill that exercises the behaviour is what adjudicated them - a doc
338
+ review comparing prose to prose could not have.**
339
+
340
+ **`KIJITOMON_NONCE` is AUTHORITATIVE and must NOT be re-derived by consumers** (river's ruling, 2026-08-05,
341
+ after a drill measured that the nonce reached the ndjson wire but never the exec env - so the one channel the
342
+ docs recommend first could not see it). It is derivable from `KIJITOMON_EVENT_ID`, and that is precisely the
343
+ hazard: re-derivation is a second implementation of sha256 + base62 + a pinned alphabet + an 11-char
344
+ truncation, and two implementations diverge - the unpinned alphabet has already produced one false integrity
345
+ alarm against correct data. The divergence surfaces in the *receiving* system as a delivery fault that never
346
+ occurred, not in the consumer that caused it. ⇒ **Duplicate instruments, transmit data:** for a measurement,
347
+ two independent implementations are a safety property; for a shared identifier, divergence *is* the defect.
348
+
349
+ ### 6.3 `event_id`: the producer owns event identity
350
+
351
+ Every emitted event carries an `event_id`, stamped at the single `Emitter.emit()` chokepoint so a future event
352
+ kind cannot forget one. It exists because leaving identity absent does not remove the need for it - it
353
+ relocates the problem into N consumers, each of whom invents a key and some of whom get it wrong. The observed
354
+ case: a consumer deduping ID-less events by `event+ts`, which is unique only while two events never land inside
355
+ one clock tick, and our `ts` is stamped at emit time.
356
+
357
+ Two identities, because messages and signals need opposite guarantees:
358
+
359
+ | kind | id | guarantee |
360
+ |------|----|-----------|
361
+ | `new` | `<persona>:new:<message id>` | the SAME message always yields the SAME id - across a restart, a re-delivery after state loss, and two watchers of one inbox |
362
+ | everything else | `<persona>:<event>:<run>-<n>` | unique to that emission; a recurrence is a different event and does not collapse into its earlier self |
363
+
364
+ The asymmetry follows from the cost of being wrong in each direction: a duplicated message is duplicated WORK,
365
+ while a duplicated signal is only noise - and conversely, collapsing two outages into one hides the second.
366
+ Repeated announcements of an *unchanged* condition are suppressed at the source (the alarms are edge-triggered
367
+ and self-clearing, §5.2), which is where suppression belongs.
368
+
369
+ `<run>` is 8 random bytes per process. A bare in-process counter is specifically ruled out: it restarts at 1 and
370
+ issues ids a consumer has already seen to brand-new events, so a correct consumer DROPS live mail - a worse
371
+ failure than the duplicate the id was introduced to prevent.
372
+
373
+ Deliberately NOT a hash of the emitted line. Byte-hashing couples the consumer to our serialisation, so a change
374
+ to key order, spacing, or `--content-chars` silently changes the dedupe key and re-delivers old events. Verified
375
+ by emitting the same mail from two processes with different `--content-chars`: the `new` ids are identical.
376
+
377
+ ### 6.4 `nonce`: a wake label DERIVED from the event_id, never minted beside it
378
+
379
+ `nonce = base62(sha256(event_id))[:11]` - 11 base62 characters, top-level, on every event.
380
+
381
+ ⚠️ **THE ALPHABET IS PART OF THE DERIVATION, AND "base62" DOES NOT PIN IT.** The alphabet is
382
+ **lowercase-first**: `abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789`, consumed
383
+ **least-significant-digit first** (`s += A[v % 62]; v //= 62`, eleven times, over the big-endian integer of
384
+ the SHA-256 digest). An auditor who assumes the conventional **digit-first** ordering recomputes a different
385
+ string and concludes the nonce does not verify - **a false integrity alarm against correct data**, which is
386
+ worse than no check at all. Found by a reviewer reproducing the recompute independently; recorded here so the
387
+ next one does not have to.
388
+
389
+ ```python
390
+ A = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
391
+ v = int.from_bytes(hashlib.sha256(event_id.encode("utf-8")).digest(), "big")
392
+ nonce = "".join(A[(v // 62**i) % 62] for i in range(11))
393
+ ```
394
+
395
+ It exists so a consumer-side wake ledger can join a delivered wake to the queue entry that carried it. The
396
+ obvious implementation - a fresh random value per emission - is **wrong here, and wrong in a way that pages.**
397
+ This producer already has an identity with deliberate semantics (§6.3): a `new` event keeps the SAME id across a
398
+ restart, a re-delivery after state loss, and two watchers of one inbox. A per-emission random nonce would call
399
+ one re-delivered message **two different wakes**; the consumer would find no queue entry containing the second,
400
+ score it LOST, and alarm - on precisely the recovery path this producer exists to survive.
401
+
402
+ Deriving from the `event_id` makes the nonce stable exactly where that id is stable and distinct exactly where it
403
+ is distinct, so **one rule serves both identity families and neither family's meaning changes.** It also makes
404
+ "recompute-asserted uniqueness" literally true: an auditor recomputes the nonce from the `event_id` in the same
405
+ row, needing nothing else.
406
+
407
+ The deeper reason, and the one that survives any change in failure rates: **a random nonce DESTROYS information at
408
+ the producer** - "this is the same work re-delivered" becomes unrecoverable downstream, because the identity that
409
+ would have said so was never minted. A derived nonce merely **defers a decision to the consumer**, where a
410
+ `LATE-AFTER-DELIVERED` outcome can absorb it. Between two schemes that each have a false-alarm mode, prefer the
411
+ one whose defect is repairable.
412
+
413
+ **11 is forced, not chosen.** The consumer contract wants ≥64 bits in ≤11 base62 characters: 10 chars is 59.54
414
+ bits and fails the floor; 12 breaks the ceiling. There is no slack in either direction.
415
+
416
+ Two constraints on consumers:
417
+
418
+ - ⛔ **It is an attribution label, not a capability.** It is deterministic and therefore guessable by anyone who
419
+ knows the `event_id`. Nothing may treat nonce-presence as evidence of authenticity - a forger able to write
420
+ transcript rows already has what it needs and gains nothing from this value. A consumer requiring an
421
+ *unguessable* nonce needs a different mechanism, not this one.
422
+ - ⚠️ **It identifies a WAKE, not a DELIVERY.** Two different panes delivered the same message carry the SAME
423
+ nonce - correctly, it is the same work. Ledgers must key rows on `(nonce, session_id)`, never the nonce alone,
424
+ or two panes' deliveries collide into one row and per-nonce outcomes silently overwrite each other.
425
+
426
+ ⚠️ **ERRATUM (2026-08-05) - "a re-delivery after state loss" conflates two components.** That phrase, used
427
+ above and in §6.3, names an **emitter capability** and a **watcher trigger** as though they were one thing.
428
+ The emitter does handle a re-delivery correctly when one occurs. **The watcher does not produce one by losing
429
+ its state file:** an absent state file baselines to the newest visible id (§7.0), so the backlog is skipped
430
+ rather than re-emitted - measured, not inferred. The path that *does* reach re-delivery is an **unacknowledged
431
+ delivery** (a refused sink, a non-zero `--exec`), where the cursor is held below the message and the next poll
432
+ re-delivers it. **Do not cite the state-loss case as evidence that re-delivery works: it is the one case that
433
+ cannot reach it.** Written down because the original sentence misled a reviewer into designing a drill around
434
+ the one trigger that cannot fire.
435
+
436
+ ⚠️ **AND THE BEHAVIOUR THAT ERRATUM EXPOSED IS NOW ANNOUNCED.** An absent state file means two things that
437
+ demand opposite responses - a **first launch** (baseline; never flood a new agent with inbox history) and a
438
+ **lost state file** (everything since the vanished cursor is owed to someone). The producer cannot tell them
439
+ apart, because **absence leaves no evidence** - which is why the neighbouring *exists-but-corrupt* case can
440
+ fail closed and this one cannot. The baseline therefore stands, but it now emits a **`baseline_skipped`**
441
+ lifecycle event naming the skipped count, the id range, and the persona's unread count. Nothing is re-emitted;
442
+ the anti-flood behaviour is unchanged. A known-zero unread count stays silent; an **unknown** one announces,
443
+ because reading "I could not determine the count" as "there is none" is the same defect one level up.
444
+ ⚠️ Scope of the loss, stated at its true size: the fetch is non-consuming (`mark_read=false`), so **the mail
445
+ survives and stays readable in the inbox. What goes dark is the WAKE** - nothing will announce it.
446
+
447
+ ### 6.5 `emitted`: three clocks read together, so dwell is measurable rather than assumed
448
+
449
+ `emitted` carries `wall`, `monotonic`, `boottime` and `src`, all read at the same instant at the `emit()`
450
+ chokepoint. It is stamp 1 of a three-stamp wake ledger; the consumer supplies the other two.
451
+
452
+ ⛔ **THE KEYS NAME SEMANTICS, NOT OS CONSTANTS - AND ON DARWIN THE TWO ARE INVERTED.**
453
+
454
+ | key | semantic |
455
+ |---|---|
456
+ | `monotonic` | does **not** advance while the machine is not executing |
457
+ | `boottime` | **does** advance while the machine is not executing |
458
+
459
+ | platform | `monotonic` ← | `boottime` ← |
460
+ |---|---|---|
461
+ | Linux | `CLOCK_MONOTONIC` | `CLOCK_BOOTTIME` |
462
+ | Darwin | `CLOCK_UPTIME_RAW` | `CLOCK_MONOTONIC_RAW` |
463
+
464
+ On Linux the names coincide with the meanings. **On macOS they do not, and there are two separate traps.**
465
+ First, `CLOCK_UPTIME_RAW` is the sleep-excluding clock, `CLOCK_MONOTONIC_RAW` includes sleep (it carries Linux
466
+ `CLOCK_BOOTTIME`'s semantic), and `CLOCK_BOOTTIME` does not exist. Measured on a real Mac: the sleep-including
467
+ clocks ran **18.20 h** ahead of `CLOCK_UPTIME_RAW` - a difference that *is* the accumulated sleep, matching an
468
+ independent `kern.boottime` derivation to two decimals. Second, **Darwin's `CLOCK_MONOTONIC` is
469
+ calendar-derived**: measured 2026-08-15 it read *exactly* `wall − kern.boottime` (201341.498, to three
470
+ decimals), so it absorbs NTP adjustments to the wall clock. (On Linux, `CLOCK_MONOTONIC_RAW` EXCLUDES suspend -
471
+ the same constant name carries a different semantic per platform, which is why the dispatch is on semantics.)
472
+
473
+ Two prior revisions each fell into one trap. The first read `CLOCK_MONOTONIC` on every platform: on a Mac that
474
+ publishes the *sleep-including* clock under the key `monotonic` and drops the sleep-excluding quantity
475
+ altogether, a consumer differencing wall against `monotonic` measures **~0 freeze forever, on every Mac row,
476
+ with nothing raising** - and a Linux-only test suite cannot see it, because there the names are honest. The
477
+ second sourced Darwin's `boottime` from `CLOCK_MONOTONIC` - right direction, wrong clock: on a fresh-uptime Mac
478
+ whose wall clock NTP-stepped back ~8.3 s after boot, it emitted `boottime 4957.865 < monotonic 4966.194`
479
+ (measured 2026-08-14), violating the definitional invariant `boottime ≥ monotonic`. Both semantics must come
480
+ from **raw** clocks; `CLOCK_MONOTONIC_RAW` and `CLOCK_UPTIME_RAW` share one tick source, so the invariant holds
481
+ by construction.
482
+
483
+ **The invariant is also enforced at the chokepoint**: if a platform ever hands the producer an inverted pair,
484
+ the `boottime` reading is quarantined - removed from the stamp set (omitted, never faked) and preserved under
485
+ `emitted.clock_defect` (`kind: "boottime_below_monotonic"`, with the rejected value and its source constant) -
486
+ so the row reports the broken mapping loudly instead of feeding it to consumers as data. A positive-control
487
+ test proves the quarantine fires on a deliberately inverted pair.
488
+
489
+ `src` records which constant supplied each semantic (`{"monotonic":"CLOCK_UPTIME_RAW", ...}`), so the mapping is
490
+ **auditable from the row** rather than resting on the reader's assumptions about the platform.
491
+
492
+ Three clocks because none answers alone: **wall** is comparable across hosts and to every other timestamp in the
493
+ system, but it *steps* (NTP, hypervisor time sync), so a wall delta is not an elapsed time; **monotonic** never
494
+ steps or goes backwards, but *stops while the machine is not executing*; **boottime** keeps counting through a
495
+ suspend.
496
+
497
+ Differencing them across two events is what separates two states a single clock conflates: `wall delta −
498
+ monotonic delta` over an interval is time the machine **did not execute**, which is the difference between "this
499
+ wake sat in a queue for three hours" and "the host was frozen". That is not hypothetical - measured on a
500
+ Parallels guest, **72.79 h of hypervisor freeze presented as ordinary elapsed wall time**, while
501
+ `BOOTTIME − MONOTONIC` read exactly `0.00 s` throughout, because a hypervisor pause stops the guest's clocks
502
+ *together* and the guest is not executing to notice.
503
+
504
+ A key is **omitted, never faked**, where its *semantic* is genuinely unavailable on the platform. A fabricated
505
+ value would be indistinguishable from a genuine zero-freeze reading, which is the failure this field exists to
506
+ prevent. Note the omission rule applies to the semantic, not the constant: Darwin lacks `CLOCK_BOOTTIME` but
507
+ still supplies the sleep-including semantic via `CLOCK_MONOTONIC_RAW`, so `boottime` is present there.
508
+
509
+ `ts` is deliberately left alone: it is stamped microseconds earlier in the convenience constructors and existing
510
+ consumers depend on it. Use `emitted.wall` when you need the wall reading coherent with the other two clocks.
511
+
512
+ ## 7. Robustness contract
513
+
514
+ ### 7.0 Cursor / dedup algorithm
515
+ - A "re-arm" is a cursor-initialization at startup (resume / seed / baseline). It is not a §7.1 FSM `recovered`
516
+ (recovery resumes normal diffing with the cursor already tracking; the replay cap below never re-applies on
517
+ recovery).
518
+ - **Cursor and FSM resolve independently at startup** (they are two separate resolutions, not one ladder):
519
+ - **Cursor:** (1) explicit `--seed-at <id>` sets `cursor = id` (operator intent wins, overriding any state-file
520
+ cursor); else (2) a valid identity-matching `--state-file` (§7.3) with an integer cursor sets `cursor =
521
+ resumed value`; else (3) `cursor = UNSET`.
522
+ - **FSM** (`state`, `consecutive_failures`): a valid identity-matching `--state-file` always supplies it,
523
+ independent of `--seed-at` (so `--seed-at` plus a DOWN state-file resumes DOWN, preserving dead-man's-switch
524
+ continuity); an absent/mismatched/invalid state-file means the FSM starts `UP`/`0`.
525
+ - **`armed` fires on the first healthy poll** (never before a fetch). There is exactly one `armed` per (re)arm,
526
+ carrying the post-decision `cursor`. A failed first poll does not baseline (it's a §7.1 failure; `armed` waits).
527
+ - **Each healthy poll:** select items with `id > cursor`; sort ascending; emit one `new` per item; then advance
528
+ `cursor` **only over what was ACKNOWLEDGED**. `cursor` is monotonic.
529
+ - **The cursor IS the acknowledgement** (Loom re-audit 7, HIGH 1). Advancing past an id means that message is
530
+ never fetched again, so it may only advance over a message the emitter reports as delivered: `exec` exit 0,
531
+ or a successful write to the events file/stdout. A non-zero exit, an exec timeout, a failed spawn or a failed
532
+ write is **not** a delivery: the cursor holds below that id and the message is re-delivered next poll. This
533
+ replaces the previous "best-effort/at-most-once, an exec failure neither holds back nor re-emits" contract,
534
+ which silently dropped mail on the one path (`--exec`) whose entire purpose is waking an agent - while the
535
+ README promised exactly-once. Two documents cannot state opposite guarantees; this one is the guarantee.
536
+ - **Delivery stops at the FIRST failure in a batch**, so a consumer never sees message N+1 ahead of a retried N.
537
+ The guarantee is **at-least-once, IN ORDER**; consumers must be idempotent on `id`.
538
+ - A `--suppress-author` drop is a deliberate POLICY drop and counts as acknowledged (otherwise suppressing an
539
+ author would pin the watermark on that author's next message forever).
540
+ - **LIFECYCLE events (`armed`, `alert`, `recovered`, `heartbeat`, diagnostics) are deliberately NOT
541
+ acknowledged and NOT gated.** The guarantee is about MESSAGES, which the cursor is a record of; a lifecycle
542
+ event carries no cursor obligation, and holding the watermark because a heartbeat failed to deliver would
543
+ freeze mail for an unrelated reason. They are best-effort, and an `--exec` consumer will see them re-run
544
+ only when the message beside them is re-delivered. Note this means a broken `--exec` produces a failed
545
+ `armed`/`heartbeat` too - that is expected, not a second defect.
546
+ - **A delivery failure is reported on stderr, never as an `alert` event.** The event channel is the thing that
547
+ just failed, so an alarm about it would be routed down the broken pipe (exec mode re-runs the same failing
548
+ command; sink mode writes to the file that just refused a write). Reporting a fault through the faulty
549
+ channel is how the fault stays invisible. It is keyed on the condition and self-clears, like every other
550
+ alarm here (§ALARM HONESTY).
551
+ - **Durability ordering** (Loom re-audit 7, MEDIUM): the event is `fsync`ed BEFORE the cursor that acknowledges
552
+ it is persisted, and the state-file's directory is `fsync`ed after `os.replace` so the rename itself is
553
+ durable. Otherwise a power loss can leave a cursor that has forgotten an event no consumer ever received.
554
+ If the sink cannot be synced, NOTHING emitted in that poll counts as delivered - the acknowledgement is
555
+ withdrawn wholesale rather than left half-true.
556
+ - **First-healthy-poll branches** (mutually exclusive; each emits exactly one `armed`):
557
+ - **UNSET baseline** (cursor was UNSET, including a state-file resume whose cursor was `null`): `cursor = max(id)`
558
+ (or 0 if empty); emit `armed`; no `new`, no cap (nothing to replay). This branch is exempt from the cap.
559
+ - **Non-null re-arm** (cursor came from `--seed-at` or a non-null state-file resume): let `n = count(id > cursor)`,
560
+ `current_max = max(ids) if result else 0`.
561
+ - if `cursor > current_max` (seed/resume ahead of reality): emit `armed{cursor}` plus `seed_ahead{seeded=cursor,
562
+ current_max}` (real ids ≤ cursor are intentionally skipped); no `new`.
563
+ - elif `n > --max-replay`: fast-forward `cursor = current_max`; emit `armed{cursor}` plus `replay_capped{capped_to=
564
+ current_max, dropped=n}`; no `new`.
565
+ - else (`n ≤ --max-replay`): emit `armed{cursor}`, then replay all `n` as `new` (so exactly `--max-replay`
566
+ replay at the boundary; `>` is the cut).
567
+
568
+ Seven lessons (mandatory): use a standalone file, not inline-shell; use a single long-lived process with per-line
569
+ flush; dedup/cursor by max-id plus peek; emit the diff, not the body; a parse/shape/HTTP failure is UNKNOWN, not
570
+ "no mail" (§5); re-arm via `--state-file` or `--seed-at-last-handled`; avoid auto-stop-on-volume (handled by dedup,
571
+ peek, edge-alerts, and the replay cap).
572
+
573
+ ### 7.1 Liveness alert FSM (dead-man's-switch)
574
+ States are **UP** (default) and **DOWN**; `consecutive_failures` counts from 0. A "failure" is any non-healthy poll (§5).
575
+ - **Healthy poll:** set `consecutive_failures = 0`; if DOWN, go UP and emit one `recovered`.
576
+ - **Failure:** `consecutive_failures += 1`; the UP-to-DOWN edge is crossed when `consecutive_failures` first
577
+ reaches `--alert-after` while state is UP. Then set DOWN and emit one `alert`. (The `state==UP` guard is what makes
578
+ it edge-once; a resumed `state==DOWN` never re-crosses the edge, so there is no duplicate `alert`.)
579
+ - `alert`/`recovered` are per-edge: a run may alert, recover, then alert again. A sub-threshold blip emits neither.
580
+ - `--alert-after` has a minimum of 1 (0 is rejected) and a default of 3 (a single transient failure is normal,
581
+ bouncing in ~1-2s). SIGUSR1-triggered polls participate identically.
582
+
583
+ ### 7.2 `--self-test`: runs once and exits (no poll loop)
584
+ Runs for the selected persona(s) (default: every persona in the account). (a) One real peek-mode (`mark_read=false`)
585
+ fetch, checked healthy per §5 (so hive-off/404/unreachable correctly fails self-test); (b) a synthetic `new` through the real emit path (stdout: line
586
+ written and flushed is ok; exec: spawn `--exec` with a fake `new`, child exit 0 within timeout is ok). Exit 0 iff
587
+ both healthy and emit-ok; else non-zero. The fetch result is printed regardless. The probe is peek-mode, so it is
588
+ read-state-neutral (DONE-WHEN #5 holds after self-test).
589
+
590
+ ### 7.3 State persistence (`--state-file PATH`, optional, recommended under a supervisor)
591
+ - **Content (JSON):** `{"identity":<canonical-id>, "cursor":<int|null>, "state":"UP|DOWN",
592
+ "consecutive_failures":<int>}`, plus the optional pin fields written only when they are in force:
593
+ `emitted_above` (list of int), `gap_alerted` (int), `pin_forced` (true), `pin_evidence_intact` (false),
594
+ `state_corrupt` (true), `pin_release_at` (int).
595
+ - **Every persisted field is read STRICTLY, and anything unrecognised fails CLOSED** (Loom re-audit 7, HIGH 2).
596
+ Booleans must be JSON booleans and integers must be real integers - a JSON `1` for `pin_forced` used to
597
+ normalise to `false` and silently UNPIN the watermark, letting the replay cap cross the very span the pin was
598
+ protecting; `pin_evidence_intact: 0` had the mirror bug. A malformed field is evidence the file cannot be
599
+ trusted, so it is treated as CORRUPT (below), never as a permissive default. `true` is not a message id.
600
+ - **Canonical identity (`<canonical-id>`)** is computed before DNS resolution so trivial URL variations don't flip
601
+ it. From the effective inbox URL, it is the tuple `(scheme.lower(), host.lower(), effective_port, path,
602
+ sorted(query_params except the constant mark_read))`. Normalize by stripping a trailing `/` on path, filling the
603
+ scheme's default port, sorting query params, and lowercasing host. (So `?persona=river&mark_read=false` ≡
604
+ `?mark_read=false&persona=river`.) `persona` is encoded via the `persona=` query param already in the URL.
605
+ - **Single-writer lock:** on startup, acquire an exclusive `fcntl.flock` on the state-file (LOCK_EX|LOCK_NB); if it's
606
+ held, exit non-zero ("state-file in use"), which prevents two watchers tearing the cursor backwards. Hold the lock fd
607
+ open for the whole process lifetime. flock is advisory and auto-released by the OS on process exit
608
+ (normal/SIGTERM/SIGKILL/crash), so there is no stale lockfile to clean (unlike a pidfile).
609
+ - **Write:** after each poll, write atomically: `mkstemp` in the same dir, then write, `fsync`, `os.replace`;
610
+ best-effort remove of stale temps.
611
+ - **Resume validity:** valid iff it parses as the schema (integer-or-null `cursor`, `state ∈ {UP,DOWN}`, integer
612
+ `consecutive_failures`) and `identity` matches the current canonical-id. On a valid match, resume per §7.0 (cursor
613
+ unless `--seed-at` overrides; FSM always). A present-but-unreadable path is a fatal config error.
614
+ - **ABSENT and CORRUPT are different answers, and the difference is the whole point** (Loom re-audit 5 HIGH 2,
615
+ re-audit 6 HIGH 4). ⚠️ This supersedes the earlier "a parse/schema-invalid or empty file is treated as absent
616
+ (fall through)" contract, which was the exact fail-open it describes:
617
+ - **Absent** (no file) → first launch → baseline the cursor to the newest visible id.
618
+ - **Present but unusable** - unparseable, zero-byte/whitespace, valid-envelope-with-invalid-fields, or a
619
+ malformed pin field → **CORRUPT**. A file that EXISTS is evidence a cursor existed here, so baselining would
620
+ silently skip everything between that lost cursor and now. Instead the watcher arms **below** the visible
621
+ window (`cursor = min(visible) - 1`), re-emits that window, forces the pin, marks its evidence unusable, and
622
+ emits a `state_corrupt` diagnostic. Duplicates are recoverable; skips are not.
623
+ - **The corruption pin must be DISCHARGEABLE** (Loom re-audit 7, HIGH 5). Because it parks the watermark one
624
+ below the window it re-emits, the ordinary release test ("a complete window reaches back to at-or-below the
625
+ watermark") can never be met by that window - its floor is always `cursor+1`. So the pin records
626
+ `pin_release_at = min(visible)`, persisted with it, and releases when a complete window reaches that floor.
627
+ A fail-open repaired into a permanent fail-closed is not a repair: it re-emitted the same window on every
628
+ poll, forever, across restarts.
629
+ - **Identity mismatch → re-baseline, EXCEPT a case-only difference, which MIGRATES** (Loom re-audit 7, HIGH 3).
630
+ A mismatch normally means a different source, so the cursor is not resumed (that would yield a silently-blind
631
+ watcher): log a loud warning, re-baseline the cursor as UNSET, start the FSM fresh (UP/0). But the state PATH
632
+ casefolds the persona while the identity keeps the directory's spelling, so one file written as `persona=Loom`
633
+ is reloaded by a run that discovered `loom` - and "mismatch" there destroys a live cursor and skips everything
634
+ since. When the stored identity differs ONLY by the case of a query VALUE (scheme, host, port, path and every
635
+ query KEY must match exactly), it is the same watched source spelled differently: resume it and rewrite the
636
+ identity on the next save. See §14.7 - this is a THIRD case layer, not a harmonisation of the other two.
637
+ - **Without `--state-file`:** no lock and no persistence, so the FSM is per-process (a restart resets it), and two
638
+ no-state watchers for the same inbox would both emit `new` (duplicate delivery; they don't corrupt read-state since
639
+ both peek, but the harness is woken twice). Run a single instance, or use `--state-file` under a supervisor (which
640
+ both locks and persists). The external dead-man's-switch (`--heartbeat` to healthchecks.io / Dead Man's Snitch) is
641
+ then the cross-restart liveness guarantee, and DONE-WHEN #3's "no re-emit while down" scopes to a single process.
642
+
643
+ ## 8. Security (connection hardening + creds)
644
+
645
+ - **No user-supplied URL to guard.** The destination is the fixed Kijito API host, so there is no SSRF surface from
646
+ config and no destination-class allow/deny machinery. Two hardenings remain as defense-in-depth: **(IP-pin)**
647
+ resolve the host once and pin the connection to that IP - no re-resolve at connect time, so no TOCTOU
648
+ (`_PinnedHTTPSConnection` connects to the pinned IP while verifying the cert against the real hostname via SNI);
649
+ **(no redirects)** redirects are never followed - a redirect is treated as an unhealthy poll, never chased.
650
+ Per-request timeout default is 5s. Stdlib: no-redirect via `HTTPRedirectHandler.redirect_request → None`; IP-pin via
651
+ a custom `HTTPConnection` through `do_open`; `urlopen(timeout=)`.
652
+ - **Creds via env/file, never argv** (`$KIJITOMON_TOKEN` / `--token-file`; §5 for header and precedence).
653
+ - ⚠️ **ON-DISK CONFIDENTIALITY: THE EVENT STREAM IS AS SENSITIVE AS THE TOKEN** (Loom re-audit 8 HIGH 1,
654
+ re-audit 9 HIGH 1/2). It contains message bodies unless `--no-content`. Attention naturally follows the
655
+ word "secret", so the token was 0600 from the start while the file full of plaintext was created with a
656
+ plain `open()` and inherited the umask - 0644 on any normal machine, verified live across 53 files.
657
+ The contract now:
658
+ - Event streams, their rotated archives, the state file and its lock sidecar are **exactly 0600**; every
659
+ directory this tool creates is 0700, at **every level** (`os.makedirs(mode=)` applies the mode to the
660
+ leaf only, so a nested path silently left its parents 0755).
661
+ - **Existing** artifacts are repaired on startup, not just newly created ones - the creation mode does
662
+ nothing for a file that already leaked, and those files are never recreated.
663
+ - ★ **The repair FAILS CLOSED, and this is the part that was wrong first.** The initial fix followed
664
+ symlinks, validated neither owner nor file type, and wrote the mail anyway when the chmod failed - so
665
+ it chmod'ed and appended to a link's *target*, and a *dangling* link caused it to create that target
666
+ elsewhere. **A passive disclosure had been turned into an active write primitive.** Opens now use
667
+ `O_NOFOLLOW` (the final component must not be a symlink) and `O_NONBLOCK` (a FIFO planted at the path
668
+ would otherwise block the writer forever - a hang, which is worse than a crash because nothing
669
+ reports it), then validate **on the fd we already hold** that it is a regular file owned by us at
670
+ 0600. Anything else raises and the caller turns it into a **failed delivery**: the cursor holds, the
671
+ mail is retried, and nothing is written or diverted. "Best-effort so we do not crash" is the wrong
672
+ instinct for a file we are about to append private mail to.
673
+ - A directory anyone else can write is reported (sticky directories like `/tmp` excluded, since the
674
+ sticky bit is exactly what makes a shared writable directory safe).
675
+ - **Opaque mode:** content is fetched over the authenticated channel; `--no-content` omits message bodies entirely,
676
+ and any future hosted bridge carries an opaque wake only.
677
+
678
+ ## 9. Signal strategy: the all-unread fast-path (implemented, server PR#66)
679
+
680
+ - **Baseline:** the inbox-list poll (§5) is always the floor and the source of truth. The max-id cursor decides
681
+ what to emit, so the fast-path can never cause a missed or duplicate emit.
682
+ - **Fast-path (cheap O(1) pre-check):** `GET /api/notify/pending` (SLASH path; the hyphen `/api/notify-pending`
683
+ 404s), read-only, never marks read. Response `{"result":[{"persona","unread","unread_urgent"},...]}`; `unread` is
684
+ all read=false for that persona (a persona with 0 unread is absent, treat as 0). The watcher probes it once on
685
+ arm; if available it consumes `unread` for its persona and does the full inbox-list fetch only when `unread`
686
+ increases, saving the full-list diff on quiet polls. It auto-falls-back to baseline if the endpoint is absent or
687
+ non-2xx (a server without the field simply runs baseline).
688
+ - **Safety floor (`--resync-every`, default 10):** the watcher never skips more than N consecutive polls; it
689
+ forces a full inbox poll regardless. So a stale / wrong / unsupported count can at worst add latency, never blind
690
+ the watcher. `unread` is only the wake trigger.
691
+ - `--no-fast-path` forces baseline (always full-poll). Note: a self-sent message does not bump your own `unread`
692
+ (the server doesn't treat your own outgoing mail as unread-for-you), so the fast-path wakes you on incoming mail,
693
+ which is the intended liveness behaviour.
694
+
695
+ ## 10. Opaque-wake seam (build the hook, not the bridge)
696
+
697
+ An internal "poll now" trigger besides the interval, wired to SIGUSR1 (POSIX only). Mandatory race-free mechanics:
698
+ - **Install a no-op Python handler** `signal.signal(SIGUSR1, lambda *_: None)`. This is required, or the default
699
+ disposition terminates the process and `set_wakeup_fd` writes nothing.
700
+ - **Self-pipe via a non-blocking `socketpair`** (more portable than `os.pipe` for `set_wakeup_fd`): set both ends
701
+ non-blocking, `signal.set_wakeup_fd(w)`; the main loop blocks in `select.select([r],[],[],timeout)`. A signal at
702
+ any instant either interrupts the in-progress `select` or leaves a byte that makes the next `select` return
703
+ immediately, so no wakeup is lost.
704
+ - **Read-and-clear by draining the pipe** (`os.read(r, 4096)`) at the start of each poll (before fetch). Any
705
+ SIGUSR1 after that drain, even during the same poll's fetch/emit, leaves a byte guaranteeing a subsequent poll.
706
+ This gives "at most one extra poll per quiescent signal" with no signal lost once a poll has begun.
707
+ - **One polling site on the main loop; the handler does no work**, so re-entrancy is structurally impossible.
708
+ - v1 opens no remote listener. A later hosted bridge turns an opaque wake into a SIGUSR1/FIFO poke, then pull over
709
+ the authenticated channel (the client-side consumer in Kijito's notify-then-pull matrix). Windows: interval-only.
710
+
711
+ ## 11. CLI / config surface (v1)
712
+
713
+ ```
714
+ kijito-inbox-monitor \
715
+ [--persona P]... [--personas A,B] [--all-personas] \ # default: every persona in the account
716
+ [--rediscover-every 600] \ # all-persona mode: pick up new personas
717
+ [--poll-seconds 60] [--alert-after 3] \ # --alert-after min 1
718
+ [--emit stdout-jsonl|exec-per-event] [--exec 'CMD'] \ # --exec required iff emit=exec-per-event
719
+ [--content-chars 220 | --no-content] \
720
+ [--seed-at LAST_HANDLED_ID] [--max-replay 50] \
721
+ [--state-file PATH] [--heartbeat SECONDS] \
722
+ [--auth-header NAME] [--token-file PATH] \ # also $KIJITOMON_TOKEN (a token is required)
723
+ [--self-test]
724
+ ```
725
+ **Arg matrix:** with no persona flag, every persona in the account is watched (`--all-personas` is the explicit
726
+ spelling); `--persona`/`--personas` select an explicit subset. An explicit `--seed-at` overrides a state-file cursor
727
+ (single-persona target only).
728
+ **`--heartbeat SECONDS`:** emitted on the poll cycle (healthy or failed; it proves the watcher is alive) once at
729
+ least SECONDS have elapsed since process start / last heartbeat; carries `cursor` (null before baseline); resolution
730
+ is `--poll-seconds`.
731
+
732
+ ## 12. v1 scope & DONE-WHEN (binary)
733
+
734
+ **In v1:** the generic core plus `http-poll` (Kijito reference, hard-baked shape) plus `stdout-jsonl` and
735
+ `exec-per-event` plus the full §7 contract (cursor/FSM/self-test/state-file) plus §8 connection-hardening/creds plus
736
+ the §10 SIGUSR1 self-pipe seam plus the §9 baseline poll and the all-unread fast-path (`/api/notify/pending`) with the
737
+ `--resync-every` no-blindness safety floor.
738
+ **Deferred (explicit, not dropped):** the generic parse-config (`list_path`/`id_field`/arbitrary `fields`) for
739
+ non-Kijito REST shapes, the adapter zoo (file/IMAP/Slack/GitHub), native A2A/MCP, pip packaging, notification fan-out,
740
+ the hosted wake bridge, and the final published name (§13). (The server all-unread count and its consumption are now
741
+ done, see §9.)
742
+
743
+ **DONE-WHEN (each independently verifiable):**
744
+ 1. `--self-test` exits 0 (one real peek-mode shape-valid fetch healthy and synthetic emit ok); exits non-zero
745
+ against an unreachable or hive-off source. Reachability is printed.
746
+ 2. (stdout-jsonl mode) Armed against the live inbox; after observing the `armed` event (cursor=C), send a test hive
747
+ message M, and the watcher emits exactly one `new` with `id=M.id` (M.id > C); no `new` is emitted for any message
748
+ with `id ≤ C`. (Framed as the cursor boundary, not wall-clock "pre-existing", so it's deterministic against a live
749
+ multi-writer inbox.)
750
+ 3. `--alert-after 3` with `--state-file`: force a source-down condition (e.g. a bad token / non-2xx response, or a
751
+ network-unreachable interval) for ≥3 consecutive polls, giving one `alert`; restore, giving one `recovered`; no
752
+ re-emit while down.
753
+ 4. **Restart-safe (cursor + dedup):** Stop the watcher at cursor=C (state-file written). Send message M (id>C) while
754
+ stopped. Relaunch with the same `--state-file` (or `--seed-at C`). Pass means M emitted exactly once and no message
755
+ ≤C re-emitted.
756
+ 5. **Peek-stable:** after a poll and after `--self-test`, an unread message's `read` field is unchanged. Verify by a
757
+ direct `GET /api/inbox?persona=P&mark_read=false` before and after (the target row's `read` stays the same).
758
+ 6. **Connection hardening:** a redirect response is refused (treated as an unhealthy poll, never chased); the
759
+ connection is pinned to the resolved IP with no re-resolve at connect time (no TOCTOU); the per-request timeout is
760
+ enforced.
761
+ 7. **Replay cap:** with `cursor` set below a backlog of more than `--max-replay` items, the first poll emits
762
+ `replay_capped` plus `armed` and zero `new`; with a backlog ≤ `--max-replay`, all replay as `new`.
763
+ 8. **Shape/empty:** empty `{"result":[]}` is healthy no-new; a non-2xx / non-JSON / shape-invalid body is a liveness
764
+ failure (counts toward alert), never a false "no mail."
765
+ 9. **State-file safety:** a state-file whose `identity` mismatches the current persona target does not resume its
766
+ cursor (it re-baselines with a warning); a second watcher on the same state-file exits non-zero (flock).
767
+ 10. Lives in `monitor/` as a single zero-dep stdlib file, committed and pushed (private GitHub
768
+ `KijitoAI/kijito-inbox-monitor`, 2026-06-20; stays private until the public-flip gate), with a README
769
+ documenting the supervision requirement plus `--state-file` (§7.3) and the CLI (§11). (v2: still one file; see §14
770
+ for the multi-persona DONE-WHEN that supersede the single-persona framing of #2/#4 above. They hold per-persona.)
771
+
772
+ ## 13. Naming: decided (2026-06-20; renamed 2026-06-24)
773
+
774
+ **Name: Kijito Inbox Monitor** (package `kijito-inbox-monitor`; GitHub `KijitoAI/kijito-inbox-monitor`,
775
+ matching the `Kijito`/`KijitoWeb` siblings). **Argus** is retained as the builder persona and internal codename, not
776
+ the product name. The name describes the product (marketplace tagline: "the local liveness watcher for your Kijito
777
+ inbox"), and it is collision-safe against the crowded "Argus" monitoring/observability namespace.
778
+
779
+ > **Rename note (2026-06-24):** the original 2026-06-20 call was `Kijito Monitor` / `kijito-monitor`, justified
780
+ > partly by "zero churn" since the deployed surface already encoded it. Before any external user existed, the choice
781
+ > was made to do it right and rename to the more descriptive **Kijito Inbox Monitor**, accepting the one-time internal
782
+ > churn (launchd label `com.kijito.inbox-monitor`, cache dir `~/.cache/kijito-inbox-monitor`, script
783
+ > `kijito_inbox_monitor.py`, repo) as a coordinated migration rather than ship an under-described public name.
784
+ > `KIJITOMON_*` env vars are unchanged.
785
+
786
+ For the record: the names `mailwatch`/`mail-watcher`/`agent-watch`/`nudge` were taken or avoided; the Kijito-ward
787
+ shortlist was `kijito-watch`/`kijito-inbox-watch`. Confirm `kijito-inbox-monitor` on PyPI/npm before any public
788
+ package publish (verified free 2026-06-24).
789
+
790
+ ---
791
+
792
+ ## 14. v2: multi-persona hive watch + supervised producer (shipped + deployed, 2026-06-19/20)
793
+
794
+ The deployed build watches your whole Kijito account from one process and is supervised by launchd. The §1 through §13
795
+ single-persona contracts are unchanged and apply per persona; this section records what was added on top. (Origin:
796
+ the multi-persona fold-in, folded into the canonical `monitor/` tree; per-persona event streams; the current arming
797
+ recipe.)
798
+
799
+ ### 14.1 Multi-persona watch (one process, N inboxes)
800
+ - **Default (no `--persona`/`--personas`):** watch every persona returned by `GET /api/personas`. A new
801
+ persona comes online with no new process or flag. `--all-personas` is the explicit spelling.
802
+ - **Explicit subsets:** `--persona P` (repeatable) / `--personas A,B`.
803
+ - **Per-persona isolation:** each watched persona has its own cursor, alert FSM, state-file, and flock, derived from
804
+ the `--state-file` base path as `hive.<persona>.json` (so `--state-file ~/.cache/kijito-inbox-monitor/hive.json`
805
+ yields `hive.argus.json`, `hive.river.json`, and so on). All §7.0/§7.1/§7.3 semantics hold independently per persona.
806
+ - **Periodic rediscovery (`--rediscover-every`, default 600s):** in all-persona mode, re-scan `/api/personas` and add
807
+ newly-created personas without a restart. It is add-only; it never drops a persona mid-run. Explicit
808
+ `--persona`/`--personas` subsets stay fixed (no rediscovery).
809
+
810
+ ### 14.2 One signal fetch per tick, fanned out in-process
811
+ The §9 fast-path generalizes cleanly to the whole account: one `GET /api/notify/pending` per tick returns the per-persona
812
+ `{persona, unread, unread_urgent}` map; the watcher fans it out in-process to each persona's wake decision, and does not
813
+ issue one request per watched persona. A persona's full inbox-list poll (§5) still fires only on arm, on its `unread`
814
+ increase, on its `--resync-every` floor, or on fast-path fallback. The `--resync-every` no-blindness floor (§9)
815
+ applies per persona.
816
+
817
+ ### 14.3 Owned, self-rotating EVENT sinks (the consume-your-own fix)
818
+ Two emit-to-file modes for supervised runs (both write NDJSON the watcher owns and size-rotates in-process, with no
819
+ `newsyslog`/`logrotate`/`sudo`, so there is no orphaned-fd silent-blinding; consumers `tail -F`):
820
+ - **`--events-file PATH`**: one shared log. Correct for a single-target supervised watch.
821
+ - **`--events-file-template PATH`**: one log per persona, e.g. `events.{persona}.ndjson` (one `RotatingFileSink` per
822
+ persona, created lazily, all closed on shutdown). The `{persona}` placeholder is required, and it is mutually
823
+ exclusive with `--events-file`. This is what the deployed hive producer runs.
824
+ - **Rotation:** `--max-bytes` (default 5_000_000; `<=0` disables) keeping `--keep-logs` archives (default 5, min 1).
825
+ - **`--suppress-author P`** (repeatable): drop `new` events authored by P, which kills the self-echo an all-persona
826
+ watcher gets for mail it sent (a dogfood finding). Liveness events (`alert`/`recovered`/`heartbeat`) are unaffected;
827
+ the cursor still advances (no re-emit).
828
+
829
+ **Why per-persona event files (LLM-UX):** off a single shared log, a session can only get its own mail by inventing
830
+ an undocumented consumer-side `grep "persona": "X"` filter, which is not discoverable and which each agent improvises
831
+ differently. One file per persona makes "subscribe to only my own mail" a self-evident `tail -F
832
+ events.<persona>.ndjson`: zero filtering, discoverable by filename.
833
+
834
+ **Disambiguation (load-bearing):** `hive.<persona>.json` is internal state (cursor/FSM bookkeeping; do not tail);
835
+ `events.<persona>.ndjson` is the event stream a session tails to consume its mail.
836
+
837
+ **Migration trap:** the older single shared `events.ndjson` is retired. A consumer still tailing it goes silently
838
+ blind (no writer appends). Repoint to `events.<persona>.ndjson`. (This was hit live during cutover; silence is not
839
+ success.)
840
+
841
+ ### 14.4 Deployment: single supervised producer, many tailing consumers
842
+ - **Producer:** one launchd user LaunchAgent `com.kijito.inbox-monitor` (`~/Library/LaunchAgents/`, RunAtLoad +
843
+ KeepAlive) runs the all-persona producer with `--events-file-template`. KeepAlive covers the `kill -9` /
844
+ process-death gap a bare file-tail can't see (kill-9-proven). stderr goes to `~/.cache/kijito-inbox-monitor/monitor.err`.
845
+ - **Consumers:** each agent session is a consumer that tails only its own `events.<persona>.ndjson` into its harness's
846
+ wake mechanism. A session does not start its own watcher; a second producer would collide on the per-persona
847
+ state-file flock.
848
+ - **Cutover discipline:** retire any existing detached producer first (the per-persona flocks permit one writer), then
849
+ `launchctl bootstrap` and `kickstart` the agent. Self-rotating event files mean consumers reattach across rotations
850
+ via `tail -F` (follow-by-name) with no gap.
851
+
852
+ ### 14.5 v2 DONE-WHEN (supersede the single-persona framing of §12 #2/#4; they hold per-persona)
853
+ - **m1.** Bare arm (no flags) watches every `/api/personas` persona from one process; each gets its own
854
+ `hive.<persona>.json` (separate cursor/FSM/lock), with no shared `hive.json` and no replay flood on restart.
855
+ - **m2.** Exactly one `/api/notify/pending` request per tick regardless of persona count (fanned out in-process).
856
+ - **m3.** `--events-file-template` writes one `events.<persona>.ndjson` per persona; a session tailing its own file
857
+ receives only its own `new` events; rotation reopens in-process (the consumer reattaches via `tail -F`).
858
+ - **m4.** `--all-personas` plus `--suppress-author P` drops `new` events authored by P; liveness events still flow.
859
+ - **m5.** Supervised under `com.kijito.inbox-monitor` (RunAtLoad + KeepAlive): a `kill -9` of the producer is recovered
860
+ automatically; exactly one producer runs; per-persona cursors resume (no replay flood).
861
+
862
+ ### 14.6 Still open (not blocking; tracked elsewhere)
863
+ - **Name decided** (Kijito Inbox Monitor, §13) and pushed private (`KijitoAI/kijito-inbox-monitor`, 2026-06-20).
864
+ ✔ **DONE, all three parts, verified 2026-07-29** - this item read as open long after it was finished, which is
865
+ its own lesson: a "still open" list is a claim like any other and nothing re-checks it. The repository is PUBLIC;
866
+ this spec is VENDORED into the repo and tracked at `docs/DESIGN.md`; and the README link is the in-repo relative
867
+ `docs/DESIGN.md`, which resolves on GitHub. An older copy of this spec also survives in the private workspace that
868
+ hosts this repo; it is a STALE rev, and the in-repo file is the only spec. Read the rev from a file's own header
869
+ rather than from any prose that claims one.
870
+ - **Marketplace** surfacing, at launch-time.
871
+ - **Codex-side consumer bridge:** Codex sessions aren't yet woken by their event file; the Claude harness Monitor
872
+ tool is the native consumer (done).
873
+
874
+
875
+ ### 14.7 The three case layers (they point different ways ON PURPOSE - do not "harmonise" them)
876
+
877
+ Reading the source, the case rules look inconsistent and invite a tidy-up. They are not: they answer different
878
+ questions about different systems. `CaseAsymmetryInvariantTest` is the defence, and it fails BOTH harmonisations.
879
+
880
+ 1. **PATH (`_state_safe_persona`) CASEFOLDS.** The local filesystem is case-INSENSITIVE (APFS, NTFS), so
881
+ `Claude-chat` and `claude-chat` name the SAME file. Not casefolding made the producer block on a flock it
882
+ already held, leaving that persona with no event stream at all - a silent wake gap.
883
+ 2. **SERVER NAMESPACE (`stranded_inboxes`) DOES NOT CASEFOLD.** The server's inbox namespace is case-SENSITIVE:
884
+ the `Claude-chat` inbox held a genuinely different message set from `claude-chat`. Casefolding here merges two
885
+ real inboxes and hides stranded mail - shipped that way for an hour, and it stopped detecting the very incident
886
+ it exists for.
887
+ 3. **STATE-FILE IDENTITY (`identity_migratable`) MIGRATES A CASE-ONLY DIFFERENCE** (Loom re-audit 7, HIGH 3). This
888
+ follows FROM layer 1 rather than contradicting layer 2: because the path already collapses the variants, ONE
889
+ state file can only ever describe ONE of them, so a casefold-equal identity in that file is the same watched
890
+ source spelled differently - a migration to accept and rewrite, not a different source to baseline over.
891
+ Deliberately narrow: only the query VALUE is compared case-insensitively; scheme, host, port, path and every
892
+ query KEY must match exactly, so nothing here invents case-insensitivity for a URL path.
893
+
894
+ The variant inbox remains unwatchable locally (layer 1) AND unwatched remotely (layer 2), which is precisely why
895
+ it is ALARMED on rather than adopted - while layer 3 keeps a live cursor from being destroyed by a spelling change.
896
+
897
+ ### 14.8 The safety-state register: what SETS it, what CLEARS it (Loom re-audit 10, the class sweep)
898
+
899
+ Ten consecutive RED audit rounds shared one generator, named by loom after round 10:
900
+
901
+ > **Safety repair checks are locally correct but their RESULT/LIFECYCLE is not propagated end-to-end; test
902
+ > or recovery surfaces then preserve the old unsafe state or create permanent liveness loss.**
903
+
904
+ It has exactly two halves, and they are the same bug facing opposite ways - one loses the ANSWER, the other
905
+ loses the EXIT:
906
+
907
+ * **WHO CONSUMES THIS?** A check that computes a correct verdict which nobody reads.
908
+ * **WHAT CLEARS THIS?** A safety STATE that is set and never released.
909
+
910
+ Rather than patch instances (which produced the next round's findings three times running), the whole file
911
+ was swept. **Two rules now bind, and both are mechanically checkable:**
912
+
913
+ 1. **A call to a bool-returning safety helper may not appear as a bare statement** unless the comment at
914
+ that site says the verdict is deliberately ignored AND why. There is exactly ONE such site today:
915
+ `_repair_mode(archive)` in `RotatingFileSink._open`, because refusing to open the live events file
916
+ because a months-old ARCHIVE is unreadable would convert a stale-permission leak into a delivery outage.
917
+ 2. **Every safety flag has a release condition, written here.** "Nothing releases it" is an acceptable
918
+ answer only when it is the ANSWER (a property re-evaluated from scratch at process start), never when
919
+ it is an oversight.
920
+
921
+ | state | set when | **cleared when** |
922
+ |---|---|---|
923
+ | `RotatingFileSink._pending` | bytes written, not yet fsynced | a successful `sync()` |
924
+ | `RotatingFileSink._dir_pending` | a directory ENTRY was created/rotated | the directory fsync succeeds |
925
+ | `RotatingFileSink._sync_failed` | an fsync we can never retry failed (fd rotated away) | reopen - a new fd makes it retryable |
926
+ | `RotatingFileSink._broken` | reopen after rotation failed | the next `write()` reopens successfully |
927
+ | `Emitter._broken_sinks[key]` | a persona sink could not be opened safely | `BROKEN_SINK_RETRY_S` elapses **and** the reopen succeeds |
928
+ | `_WARNED_PERSONAS` entry | a per-persona warning was emitted once | `_clear_persona_warning()` on that persona's recovery |
929
+ | `_REPORTED_STRANDED` | a stranded inbox was alarmed on | `intersection_update` drops it when it is no longer stranded |
930
+ | `StateFile.unsafe` | `_repair_mode` could not prove the state file private | *nothing in-process, deliberately* - it is a property of the path on disk, re-derived at next start |
931
+ | `WatchTarget.delivery_blocked` | an emit failed; the cursor is held | the next successful delivery |
932
+ | `WatchTarget.state_not_durable` | a cursor write could not be proven durable | the next durable cursor write |
933
+ | `WatchTarget.pin_forced` / `state_corrupt` | a pin was forced / state was corrupt on load | the pin discharges against `_pin_release_floor()` |
934
+ | `WatchTarget.pin_evidence_intact` | *(false)* pin tracking overflowed or was corrupt | an authoritative read only - never by counting (invariant 3) |
935
+ | `WatchTarget.emitted_above` | ids delivered above a pinned watermark | reassigned empty when the pin releases |
936
+
937
+ **Invariant 2 restated, because it is what half B protects:** every pin must be DISCHARGEABLE. A permanent
938
+ fail-closed is the same defect as a fail-open - it just fails in the direction that looks responsible.
939
+
940
+ **⚠️ The class does NOT cover everything audit 10 found, and pretending otherwise is how the next round
941
+ gets missed.** H1, H2 and M3 are instances of the two halves above. M4 (a repair loop whose RANGE came from
942
+ CURRENT config, so a shrunk `keep` stranded `.7` at 0644 forever), M5 (a gate whose fall-through arm was the
943
+ optimistic one, so a mutant that killed the interpreter scored as CAUGHT) and L6 (a harness leaking the temp
944
+ trees and descriptors it opened) are three DIFFERENT shapes. Sweeping for loom's two halves alone would have
945
+ left all three in place. The mechanical detectors that do reach them: a loop bound derived from live config
946
+ rather than from the directory; an if/elif chain whose terminal `else` is the success arm; an `open()`/
947
+ `mkdtemp()` whose handle or tree is never released.
948
+
949
+ ### 14.9 Which lifecycle events are GUARANTEED, and which are deliberately not (re-audit 11, F1)
950
+
951
+ §14.8 required every safety VERDICT to have a consumer. Re-audit 11 found a third half of the class that
952
+ neither of those questions reaches:
953
+
954
+ > **(C) What did we WRITE DOWN as if the action had succeeded?** - a state committed as if an operation
955
+ > succeeded, ordered before and independently of whether it did.
956
+
957
+ Every alarm committed its "already alarmed" state BEFORE emitting and discarded the emit's answer, and
958
+ three of the four had no second channel. So an alarm that was never delivered was never re-raised - not
959
+ when the channel recovered, and not after a restart, because `gap_alerted` is persisted. **Mail was never
960
+ at risk** (the cursor holds correctly throughout); the ALARMS vanished. That is worse than it sounds,
961
+ because the headline promise is that a walk which cannot complete pins **loudly** rather than in silence.
962
+
963
+ ★ **The class as previously stated did not merely miss this - it CLEARED it.** Asked "who consumes
964
+ `lifecycle()`'s answer?", the correct answer is "nobody, deliberately" (§14.7 / §170 below). A satisfying
965
+ answer to the class's question sat directly on top of the defect.
966
+
967
+ **THE RULE NOW, and it is a two-tier one:**
968
+
969
+ | tier | events | contract |
970
+ |---|---|---|
971
+ | **GUARANTEED** | `alert`, `recovered` | emitted via `WatchTarget._alarm`, which RETURNS delivery. An undelivered one is written to **stderr** - never retried down the event channel, which is the thing that just failed. A **pure announcement latch** (`gap_alerted`) commits ONLY on delivery; a **behavioural** state (`fsm_state`, `pin_evidence_intact`) commits regardless, because refusing to record evidence loss would trade a lost alarm for a lost invariant. |
972
+ | **INFORMATIONAL** | `armed`, `heartbeat`, `persona_added`, `seed_ahead`, `replay_capped`, `state_corrupt` | deliberately NOT acknowledged and NOT gated (§170 stands). They record something that already happened; nothing latches "we announced it", so a lost one costs a notification, not a fact. |
973
+
974
+ `stranded-mail` is a third case: its unconditional `stderr` write happens BEFORE the event, so an
975
+ undelivered alert is already on the record.
976
+
977
+ ⚠️ **"Do not gate the cursor on a lifecycle event" and "do not record that you alarmed when you did not"
978
+ are DIFFERENT propositions, and only the first was ever documented.** §170 is unchanged and correct; it
979
+ was never a licence for the second.
980
+
981
+ ★ AND THE IRONY THAT MAKES THIS WORTH REMEMBERING: this codebase gets acknowledge-before-deliver **exactly
982
+ right for MAIL** - the cursor IS the acknowledgement, delivery stops at the first failure, the durability
983
+ barrier retracts wholesale - and got it **exactly backwards for its own ALARMS**. The architecture knew the
984
+ principle by name and did not apply it to itself.