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.
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +220 -0
- package/bin/cli.js +34 -0
- package/install.sh +161 -0
- package/package.json +50 -0
- package/providers/_shared/wake-core.mjs +358 -0
- package/providers/claude/CLAUDE.md.snippet +31 -0
- package/providers/claude/install.sh +186 -0
- package/providers/claude/scripts/arm-session.sh +66 -0
- package/providers/claude/scripts/claude-armed.sh +101 -0
- package/providers/claude/scripts/heartbeat-watchdog.sh +209 -0
- package/providers/claude/scripts/inbox-selftest.sh +277 -0
- package/providers/claude/scripts/kijito-persona-lib.sh +94 -0
- package/providers/claude/scripts/kijito-qa-pass.sh +12 -0
- package/providers/claude/scripts/lifecycle-lib.sh +167 -0
- package/providers/claude/scripts/myctx.sh +46 -0
- package/providers/claude/scripts/self-clear.sh +134 -0
- package/providers/claude/scripts/session-autosend.sh +85 -0
- package/providers/claude/scripts/session-catchup-hint.sh +330 -0
- package/providers/claude/scripts/statusline-context.sh +67 -0
- package/providers/claude/skills/kijito-qa-memory/SKILL.md +110 -0
- package/providers/claude/skills/kijito-recall/SKILL.md +53 -0
- package/providers/claude/skills/kijito-start/SKILL.md +122 -0
- package/providers/claude/wiring/README.md +93 -0
- package/providers/claude/wiring/kijito-heartbeat@.service +21 -0
- package/providers/codex/README.md +75 -0
- package/providers/codex/codex-kijito-parity-plan.md +265 -0
- package/providers/codex/docs-codex-setup.md +64 -0
- package/providers/codex/install.mjs +184 -0
- package/providers/codex/n0-capability-probe-protocol.md +472 -0
- package/providers/codex/n0-harness/README.md +57 -0
- package/providers/codex/n0-harness/cli.mjs +58 -0
- package/providers/codex/n0-harness/evidence-manifest.mjs +77 -0
- package/providers/codex/n0-harness/fixture.mjs +279 -0
- package/providers/codex/n0-harness/lib.mjs +141 -0
- package/providers/codex/n0-harness/manifest.mjs +36 -0
- package/providers/codex/n0-harness/oracle.mjs +464 -0
- package/providers/codex/n0-harness/parser.mjs +200 -0
- package/providers/codex/n0-harness/prompt.mjs +43 -0
- package/providers/codex/n0-harness/snapshot.mjs +120 -0
- package/providers/codex/n0-harness/specimen.mjs +37 -0
- package/providers/codex/notify/kijito-notify-count.mjs +117 -0
- package/providers/codex/notify/kijito-notify-count.test.mjs +76 -0
- package/providers/codex/plans/gate4-battery-log.md +184 -0
- package/providers/codex/plans/gate4-battery-protocol.md +43 -0
- package/providers/codex/plans/hive-user-first-plan-QA.md +34 -0
- package/providers/codex/plans/hive-user-first-plan.md +247 -0
- package/providers/codex/release-manifest.json +53 -0
- package/providers/codex/same-chat-continuation-plan-gate.md +235 -0
- package/providers/codex/same-chat-continuation-plan.md +458 -0
- package/providers/codex/skills/kijito-qa-memory/SKILL.md +193 -0
- package/providers/codex/skills/kijito-qa-memory/agents/openai.yaml +4 -0
- package/providers/codex/skills/kijito-recall/SKILL.md +53 -0
- package/providers/codex/skills/kijito-start/SKILL.md +173 -0
- package/providers/codex/skills/kijito-start/agents/openai.yaml +4 -0
- package/providers/codex/test/n0-cli-refusal.test.mjs +31 -0
- package/providers/codex/test/n0-counterexample-matrix.test.mjs +118 -0
- package/providers/codex/test/n0-guard-census-core.mjs +1372 -0
- package/providers/codex/test/n0-guard-census.json +35479 -0
- package/providers/codex/test/n0-guard-census.mjs +31 -0
- package/providers/codex/test/n0-guard-census.test.mjs +374 -0
- package/providers/codex/test/n0-guard-counterexamples.mjs +1068 -0
- package/providers/codex/test/n0-guard-independent-projection.mjs +70 -0
- package/providers/codex/test/n0-guard-independent-projection.test.mjs +53 -0
- package/providers/codex/test/n0-guard-manifest-author.mjs +424 -0
- package/providers/codex/test/n0-guard-mutation-operators.test.mjs +244 -0
- package/providers/codex/test/n0-guard-mutation-runner.mjs +599 -0
- package/providers/codex/test/n0-guard-remainder-classes.test.mjs +314 -0
- package/providers/codex/test/n0-guard-sanitizer-pairs.test.mjs +68 -0
- package/providers/codex/test/n0-guard-shared-context-predicates.test.mjs +84 -0
- package/providers/codex/test/n0-harness.test.mjs +553 -0
- package/providers/codex/test/n0-notable-regressions.test.mjs +70 -0
- package/providers/codex/test/n0-subject-boundary.mjs +97 -0
- package/providers/codex/test/n0-subject-boundary.test.mjs +72 -0
- package/providers/codex/test/same-chat-plan-preflight.sh +167 -0
- package/providers/codex/tools/refresh-manifest.mjs +81 -0
- package/providers/codex/wake-helper/TRANSPORT-NOTES.md +25 -0
- package/providers/codex/wake-helper/integration.test.mjs +285 -0
- package/providers/codex/wake-helper/kijito-wake-helper.mjs +488 -0
- package/providers/codex/wake-helper/kijito-wake-helper.test.mjs +128 -0
- package/providers/codex/wake-helper/mock-daemon.mjs +64 -0
- package/providers/codex/wake-helper/status-probe.mjs +45 -0
- package/providers/codex/wake-helper/ws-uds.mjs +153 -0
- package/providers/monitor/.github/workflows/publish-npm.yml +38 -0
- package/providers/monitor/.github/workflows/publish-pypi.yml +30 -0
- package/providers/monitor/CHANGELOG.md +465 -0
- package/providers/monitor/IMPORT-PROVENANCE.md +68 -0
- package/providers/monitor/LICENSE +202 -0
- package/providers/monitor/NOTICE +7 -0
- package/providers/monitor/OPAQUE-OUTPUT-ENFORCEMENT.md +45 -0
- package/providers/monitor/README.md +571 -0
- package/providers/monitor/RELEASING.md +125 -0
- package/providers/monitor/arm-hive-monitor.sh +13 -0
- package/providers/monitor/bin/cli.js +43 -0
- package/providers/monitor/com.kijito.inbox-monitor.plist.template +59 -0
- package/providers/monitor/docs/DESIGN.md +984 -0
- package/providers/monitor/kijito-inbox-monitor@.service.template +95 -0
- package/providers/monitor/kijito_inbox_monitor.py +3666 -0
- package/providers/monitor/package.json +35 -0
- package/providers/monitor/pyproject.toml +42 -0
- package/providers/monitor/scripts/mutation-check.py +399 -0
- package/providers/monitor/scripts/prepublish-gate.sh +284 -0
- 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.
|