repo-mailbox/docs/2026-09-03-coordination-debt-measurement.md
Kjell Tore Guttormsen 2f8ceb3f97 feat(sweep): schedule the FYI sweep - invocation was the gap, not the mechanism
coord-sweep.sh shipped in 0.10.0 and had never run once against the real
mailbox. Measured 2026-09-03 with a denominator (docs/2026-09-03-coordination-
debt-measurement.md): 55 mailbox directories, 52 with an inbox/, 27 pending
directed messages - 23 of them pure notices, re-injected at every session start
in repos nobody had opened. The script was correct and unreachable.

WP5 (order 20260902T113745Z-1254925290) asked for a mechanism and named two
candidates. The measurement chose neither, and the first session returned the
order saying so: bulk-ack for pure notices was already built - it is this
script - so the second candidate would have been two copies of one policy, and
the broadcast class converges on its own (reading sets seen), with 34 of 263
unread pairs belonging to two mailboxes no session can hold, so a TTL would
have closed those rather than reduced them. The operator then chose the window
and authorized the schedule.

launchd/com.ktg.repo-mailbox-sweep.plist runs --write --days 14 daily at 05:30.
That is the entire behavioural change. The window is written out in the plist
rather than inherited from the script's default: it is a policy constant chosen
on a measured distribution (30d -> 0 messages, 14d -> 7, 7d -> 13), so a later
change to DAYS=14 must not silently change what an unattended job closes across
51 other repos. It runs BEFORE the 06:00 briefing agent, which scans the same
mailbox this mutates, so the morning briefing reports the debt that remains
rather than counting notices being closed underneath it.

coord-selftest.sh section 38 pins the launchd templates (242 -> 257 checks). A
wrong program path is the one defect here that nothing catches at runtime: the
agent loads cleanly and then silently never runs, with no output to be wrong
and no exit status to read. launchctl list proves an agent is LOADED, never
that it is RIGHT. The section covers every plist in launchd/, not only the new
one - the plist grammar gets one reader rather than one per agent - while
board-selftest.sh section 9 keeps owning brief-nightly.sh's behaviour. Each
plist must name a script that exists here, carry a Label matching its filename,
keep its __CHECKOUT__/__HOME__ placeholders (public mirror), and never point
into the version-pinned plugin cache.

The cache assertion runs on the extracted path, never the whole file - caught
by the check itself on its first run: the brief plist's header explains in
prose why it does not point at the cache, and a file-wide grep read that
explanation as the defect it warns about, the same shape as prose saying
status=done triggering the board's done-guard. Four controls present; mutation-
verified against the real file, where a one-letter typo (coord-sweeep.sh) turns
exactly that check red. XML well-formedness is deliberately not checked:
plutil is not coreutils, and malformed XML already fails loudly at launchctl
load - the opposite of the silent failure this section exists for.

Also fixes the README selftest-checks badge, stale at 529 since 0.25.0; the
real total is 868 (257 + 368 + 73 + 116 + 54).

Suites: coord 257, board 368, route 73, orders 116, guard 54. npm test 11/11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 08:20:33 +02:00

7.3 KiB

Coordination debt: what does not converge, and why building a second

mechanism would have been wrong

Measured 2026-09-03 against the live mailbox, for order 20260902T113745Z-1254925290-from-.claude (WP5). Every number below was produced by a command, and every negative result carries the control that proves the query could have found something.

The order offered two candidate mechanisms and said to choose by measurement, not taste: a broadcast TTL, or a bulk-ack for pure notices. The measurement chose neither. One of them is already built and has never been run; the other addresses the class that converges on its own.

Denominators

population count
mailbox directories under the coord root 55
of those, holding an inbox/ 52
pending directed messages across all inboxes 27
broadcasts in _broadcast/inbox/ 17
(mailbox x broadcast) delivery pairs 884

The three directories with no inbox/ are named rather than silently dropped: jobbsok, mediemon, medieovervaaking. 55 - 3 = 52 is the reconciliation, stated because an unreconciled pair of denominators in one report is the same positive-looking null this engine refuses everywhere else.

The two classes behave in opposite directions

Directed messages do not converge. They are re-injected at every session start until a session closes them by hand. Of the 27 pending, 4 owe a reply and 23 are pure notices (reply-expected: no). By age:

class <7d 7-13d 14-29d >=30d
owes a reply 0 3 1 0
pure notice 10 6 7 0

Broadcasts converge on their own. Reading one records it as seen, so a mailbox clears its whole backlog on its next session. 263 of the 884 pairs are unread (29.8%), and the distribution shows the self-clearing: 10 mailboxes are fully current, 26 sit at exactly 6 unread (the newest announcements), and the tail is short.

The floor under the broadcast number, which strengthens the case

Two mailboxes hold all 17 broadcasts unread, and neither can ever read them:

  • ktg-plugin-marketplace is a retired --to address. It is a polyrepo directory, not a git repo, so basename(git toplevel) can never resolve to it and no session can hold that identity. This is already documented as engine behaviour; the 17 unread are its permanent consequence.
  • llm-ingestion-guard has no checkout anywhere under /Users/ktg (find -maxdepth 4, with llm-ingestion-okf as the known-positive control proving the query finds a real one) and no .origin. It also holds the single oldest pending notice, 24 days.

So 34 of 263 unread pairs (12.9%) are a permanent floor no TTL would reduce to zero - it would close them, but it would be closing announcements for mailboxes that were never going to read anything. .origin absence alone is NOT a proxy for unholdable: repos also lacks one, yet sits at 2 unread of 17, which is only possible if something reads it.

Why no second mechanism was built

coord-sweep.sh already is the bulk-ack for pure notices: machine-wide, one mechanically decidable class (reply-expected: no, older than a grace window), dry-run by default, closing through coord-done.sh, logging sender and subject for every closure. Building the order's second candidate would have been a second copy of a shipped policy - the defect class this repo names repeatedly.

Its own dry-run reports what it would close today, and the figures agree exactly with the independent classification above:

--days 30  ->  0 messages
--days 14  ->  7 messages
--days  7  -> 13 messages

The gap is invocation, not mechanism. No _sweep.log exists at the default path, and no sweep log exists anywhere under ~/.claude (the one *sweep* hit is an unrelated plugin file) - stated that way because --log can override the default, so an absent default-path log alone would not prove it never ran.

What remains, and why it is not this repo's call

Making the notice class converge without opening each repo means running coord-sweep.sh --write unattended. That closes mail in 51 other repos' inboxes, unread, and the script's own design says so in as many words: a notice to a repo left unopened for the whole window is closed unread, and the log is the only thing standing between that and a silent disappearance.

Deciding that on another repo's behalf is the one anti-pattern with no exception clause, and the grace window is a policy constant of the same class as the STATE.md line limit, which was an operator decision both times it moved. Dry-run is the default precisely because this is the script that destroys pending state; flipping that to a schedule is the operator's act, not a plugin's.

The messages that owe a reply are untouched by any of this, at any age, with any flag. That is not a gap to close later - it is the rule that keeps a procedural duty from becoming a substantive one.


Appendix: the before-state, captured 2026-09-04 08:0x UTC

The operator authorized the sweep on 2026-09-03 (14-day window, scheduled via launchd). This is coord-count.sh immediately before the authorized --write --days 14 run, recorded here because a before-state stops existing the moment the write happens, and the order asks for debt before/after with a denominator.

mailbox                        pending  owed  origin-age
app-creator                          1     1  -
claude-playlist-corpus               1     0  -
graceful-handoff                     3     0  -
human-friendly-style                 1     0  -
ki-produktivitetsmodell              1     0  -
llm-ingestion-guard                  1     0  25
llm-security                         2     0  -
mcp-servere                          2     0  -
okr                                  1     0  -
org-ops                              5     2  -
portfolio-optimiser-commons          3     0  -
repo-standard                        1     1  -
wiki-advise                          2     0  -
.claude                              1     0  -
.profile                             1     0  -
------------------------------------------------------
15 mailboxes with pending mail      26     4

$CLAUDE_COORD_DIR/_sweep.log did not exist: the sweep had still never run.

The control this file exists to make runnable: after the write, the owed column must be BYTE-IDENTICAL (total 4), because the sweep spares every message that owes a reply at any age. A changed owed figure means the sweep closed something it must never touch, and is a defect, not a result.

The dry-run said 11, not the 7 this document measured a day earlier, and the gap is entirely the moving cutoff. Verified two ways rather than assumed. Yesterday's 14-day cutoff was ~20260820T184902; today's is 20260821T055442. Four notices timestamped 2026-08-20 between those two instants (human-friendly-style T210113Z, ki-produktivitetsmodell T205611Z, org-ops T210955Z and T211358Z) crossed the boundary in one calendar day: 7 + 4 = 11. Independently, --days 15 today yields 6, and those 6 plus mcp-servere/20260820T104644Z - which sits between the 15-day cutoff and yesterday's 14-day one - reconstruct yesterday's 7 exactly. The engine is consistent; the 7 was a measurement of a moment, never a constant, and reading it as one would have been face 3 of the verification law pointed at our own report.