instar 1.3.1339 → 1.3.1341

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/dist/commands/init.d.ts.map +1 -1
  2. package/dist/commands/init.js +8 -0
  3. package/dist/commands/init.js.map +1 -1
  4. package/dist/commands/server.d.ts.map +1 -1
  5. package/dist/commands/server.js +323 -15
  6. package/dist/commands/server.js.map +1 -1
  7. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  8. package/dist/config/ConfigDefaults.js +15 -0
  9. package/dist/config/ConfigDefaults.js.map +1 -1
  10. package/dist/core/BackupManager.d.ts.map +1 -1
  11. package/dist/core/BackupManager.js +8 -0
  12. package/dist/core/BackupManager.js.map +1 -1
  13. package/dist/core/FileClassifier.d.ts.map +1 -1
  14. package/dist/core/FileClassifier.js +6 -0
  15. package/dist/core/FileClassifier.js.map +1 -1
  16. package/dist/core/MachineIdentity.d.ts.map +1 -1
  17. package/dist/core/MachineIdentity.js +3 -0
  18. package/dist/core/MachineIdentity.js.map +1 -1
  19. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  20. package/dist/core/PostUpdateMigrator.js +58 -2
  21. package/dist/core/PostUpdateMigrator.js.map +1 -1
  22. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  23. package/dist/core/devGatedFeatures.js +6 -0
  24. package/dist/core/devGatedFeatures.js.map +1 -1
  25. package/dist/core/keyMaterialPaths.d.ts +73 -0
  26. package/dist/core/keyMaterialPaths.d.ts.map +1 -0
  27. package/dist/core/keyMaterialPaths.js +139 -0
  28. package/dist/core/keyMaterialPaths.js.map +1 -0
  29. package/dist/core/types.d.ts +20 -0
  30. package/dist/core/types.d.ts.map +1 -1
  31. package/dist/core/types.js.map +1 -1
  32. package/dist/data/standards-guard-index.json +1 -1
  33. package/dist/data/standards-guard-index.meta.json +2 -2
  34. package/dist/data/standards-registry.meta.json +1 -1
  35. package/dist/messaging/TelegramAdapter.d.ts +19 -0
  36. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  37. package/dist/messaging/TelegramAdapter.js +47 -9
  38. package/dist/messaging/TelegramAdapter.js.map +1 -1
  39. package/dist/messaging/telegram-origin/OriginAwareness.d.ts +2 -0
  40. package/dist/messaging/telegram-origin/OriginAwareness.d.ts.map +1 -1
  41. package/dist/messaging/telegram-origin/OriginAwareness.js +4 -0
  42. package/dist/messaging/telegram-origin/OriginAwareness.js.map +1 -1
  43. package/dist/messaging/telegram-origin/OriginConfig.d.ts +4 -0
  44. package/dist/messaging/telegram-origin/OriginConfig.d.ts.map +1 -1
  45. package/dist/messaging/telegram-origin/OriginConfig.js +26 -0
  46. package/dist/messaging/telegram-origin/OriginConfig.js.map +1 -1
  47. package/dist/messaging/telegram-origin/OriginForwardToHolder.d.ts +236 -0
  48. package/dist/messaging/telegram-origin/OriginForwardToHolder.d.ts.map +1 -0
  49. package/dist/messaging/telegram-origin/OriginForwardToHolder.js +337 -0
  50. package/dist/messaging/telegram-origin/OriginForwardToHolder.js.map +1 -0
  51. package/dist/messaging/telegram-origin/OriginMesh.d.ts.map +1 -1
  52. package/dist/messaging/telegram-origin/OriginMesh.js +17 -2
  53. package/dist/messaging/telegram-origin/OriginMesh.js.map +1 -1
  54. package/dist/messaging/telegram-origin/OriginMeshRelay.d.ts +7 -0
  55. package/dist/messaging/telegram-origin/OriginMeshRelay.d.ts.map +1 -1
  56. package/dist/messaging/telegram-origin/OriginMeshRelay.js +15 -15
  57. package/dist/messaging/telegram-origin/OriginMeshRelay.js.map +1 -1
  58. package/dist/messaging/telegram-origin/OriginStore.d.ts +38 -1
  59. package/dist/messaging/telegram-origin/OriginStore.d.ts.map +1 -1
  60. package/dist/messaging/telegram-origin/OriginStore.js +8 -1
  61. package/dist/messaging/telegram-origin/OriginStore.js.map +1 -1
  62. package/dist/messaging/telegram-origin/OriginStore.worker.js +1 -1
  63. package/dist/messaging/telegram-origin/OriginStore.worker.js.map +1 -1
  64. package/dist/messaging/telegram-origin/OriginStoreBackend.d.ts +58 -1
  65. package/dist/messaging/telegram-origin/OriginStoreBackend.d.ts.map +1 -1
  66. package/dist/messaging/telegram-origin/OriginStoreBackend.js +209 -23
  67. package/dist/messaging/telegram-origin/OriginStoreBackend.js.map +1 -1
  68. package/dist/messaging/telegram-origin/StoreTypes.d.ts +24 -0
  69. package/dist/messaging/telegram-origin/StoreTypes.d.ts.map +1 -1
  70. package/dist/messaging/telegram-origin/TelegramOriginBoot.d.ts.map +1 -1
  71. package/dist/messaging/telegram-origin/TelegramOriginBoot.js +7 -0
  72. package/dist/messaging/telegram-origin/TelegramOriginBoot.js.map +1 -1
  73. package/dist/messaging/telegram-origin/TelegramOriginRuntime.d.ts +49 -7
  74. package/dist/messaging/telegram-origin/TelegramOriginRuntime.d.ts.map +1 -1
  75. package/dist/messaging/telegram-origin/TelegramOriginRuntime.js +178 -2
  76. package/dist/messaging/telegram-origin/TelegramOriginRuntime.js.map +1 -1
  77. package/dist/messaging/telegram-origin/TelegramOriginService.d.ts +8 -0
  78. package/dist/messaging/telegram-origin/TelegramOriginService.d.ts.map +1 -1
  79. package/dist/messaging/telegram-origin/TelegramOriginService.js +19 -3
  80. package/dist/messaging/telegram-origin/TelegramOriginService.js.map +1 -1
  81. package/dist/messaging/telegram-origin/types.d.ts +12 -0
  82. package/dist/messaging/telegram-origin/types.d.ts.map +1 -1
  83. package/dist/messaging/telegram-origin/types.js +6 -0
  84. package/dist/messaging/telegram-origin/types.js.map +1 -1
  85. package/dist/monitoring/A2ARedeliverySentinel.d.ts +145 -6
  86. package/dist/monitoring/A2ARedeliverySentinel.d.ts.map +1 -1
  87. package/dist/monitoring/A2ARedeliverySentinel.js +451 -11
  88. package/dist/monitoring/A2ARedeliverySentinel.js.map +1 -1
  89. package/dist/scaffold/templates.d.ts.map +1 -1
  90. package/dist/scaffold/templates.js +7 -1
  91. package/dist/scaffold/templates.js.map +1 -1
  92. package/dist/server/fileRoutes.d.ts +26 -0
  93. package/dist/server/fileRoutes.d.ts.map +1 -1
  94. package/dist/server/fileRoutes.js +204 -56
  95. package/dist/server/fileRoutes.js.map +1 -1
  96. package/dist/server/routes.d.ts.map +1 -1
  97. package/dist/server/routes.js +103 -9
  98. package/dist/server/routes.js.map +1 -1
  99. package/dist/testing/selfActionRegistry.d.ts.map +1 -1
  100. package/dist/testing/selfActionRegistry.js +128 -0
  101. package/dist/testing/selfActionRegistry.js.map +1 -1
  102. package/dist/threadline/A2ADeliveryTracker.d.ts +55 -1
  103. package/dist/threadline/A2ADeliveryTracker.d.ts.map +1 -1
  104. package/dist/threadline/A2ADeliveryTracker.js +97 -5
  105. package/dist/threadline/A2ADeliveryTracker.js.map +1 -1
  106. package/dist/threadline/ThreadlineMCPServer.d.ts +15 -0
  107. package/dist/threadline/ThreadlineMCPServer.d.ts.map +1 -1
  108. package/dist/threadline/ThreadlineMCPServer.js +3 -0
  109. package/dist/threadline/ThreadlineMCPServer.js.map +1 -1
  110. package/dist/threadline/client/ThreadlineClient.d.ts +47 -0
  111. package/dist/threadline/client/ThreadlineClient.d.ts.map +1 -1
  112. package/dist/threadline/client/ThreadlineClient.js +93 -0
  113. package/dist/threadline/client/ThreadlineClient.js.map +1 -1
  114. package/dist/threadline/mcp-http-client.d.ts.map +1 -1
  115. package/dist/threadline/mcp-http-client.js +2 -0
  116. package/dist/threadline/mcp-http-client.js.map +1 -1
  117. package/dist/threadline/peerDark.d.ts +165 -0
  118. package/dist/threadline/peerDark.d.ts.map +1 -0
  119. package/dist/threadline/peerDark.js +204 -0
  120. package/dist/threadline/peerDark.js.map +1 -0
  121. package/package.json +1 -1
  122. package/src/data/builtin-manifest.json +64 -64
  123. package/src/data/standards-guard-index.json +1 -1
  124. package/src/data/standards-guard-index.meta.json +2 -2
  125. package/src/data/standards-registry.meta.json +1 -1
  126. package/src/scaffold/templates.ts +7 -1
  127. package/upgrades/1.3.1340.md +32 -0
  128. package/upgrades/1.3.1341.md +94 -0
  129. package/upgrades/side-effects/a2a-peer-dark-reporting.md +149 -0
  130. package/upgrades/side-effects/files-never-serve-key-material.md +399 -0
  131. package/upgrades/side-effects/telegram-origin-forward-to-holder.md +151 -0
@@ -0,0 +1,32 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ Part §3 of `docs/specs/a2a-single-agent-identity.md` (CMT-706, approved 2026-10-09): when messages to another agent sit queued at the relay for hours with nothing back, the sender now knows — on the send itself, on the health read, and (once the notice is switched on) as one operator item per peer.
9
+
10
+ - **`dark` on the delivery ledger.** `GET /threadline/peers/health` and `/threadline/peers/:fp/health` (and `?scope=pool`) carry `dark`, `darkSince`, `queuedCount`, `queuedExpiresAt`, `lastDeliveredAt`, `connectedNow`, `connectedNowReason`, `connectedAsOf`; the list carries `darkCount`. A peer is dark when its oldest message that is queued, unconfirmed or expired — sent after the last ack or inbound from it — is older than `threadline.peerDarkNotice.queuedDarkAfterMs` (2 h), or than that peer's own entry in `threadline.peerDarkNotice.perPeer` (by fingerprint; e.g. 30 min for a pair that answers in minutes). Relay expiry never clears it. A relay `delivered` verdict alone never clears it either — it shows only that something holding the peer's address took the message. An acknowledgement or an inbound message does. Messages the relay queued and later handed to a connection stay counted until then (`handedUnackedCount`), and the wording names them rather than calling them queued.
11
+ - **`peerDark` on the send.** `POST /threadline/relay-send` and the `threadline_send` tool answer a send to a dark peer with `peerDark: { since, queuedCount, expiresAt, connectedNow, connectedNowReason, connectedAsOf }`. `connectedNow` is the relay presence map's answer (`presence_change` frames now update it; `null` when unknown — never a fresh discover on the send path). When it is `null`, `connectedNowReason` says why (`no-row`, `relay-down` = my own side, `stale`); `connectedAsOf` is when the record was written, and the sentence states that age ("as of 3 min ago"). With `dryRun: false` the `deliveryOutcome` becomes "no acknowledgement from <peer> for N h; this and K other messages are still queued (oldest expires T); <peer> is not connected to the relay right now — it may be offline, or listening under a different address" (or the connected / unknown variant) — worded to the evidence, never "nothing will arrive".
12
+ - **One item per peer through the reworked `A2ARedeliverySentinel`.** It is now constructed under EITHER `monitoring.a2aRedelivery.enabled` OR `threadline.peerDarkNotice` (escalate-only under the latter). Trigger: dark, with queued rows, after the awake machine healed its own side (reconnect a dropped relay, one presence refresh, the identity self-check; two passes 40 s apart). Deterministic id `a2a-peer-dark:<agent>:<peerFp>`, raise and resolve both read pool-scope evidence (a peer that answered on another of my machines is not dark), the resolve line names how many messages expired unacknowledged, 12 h cooldown per peer. My own relay down → one aggregated item instead. A standby heals nothing and raises nothing.
13
+ - **Bounded ledger.** The silence sweep deletes rows older than 30 days in every state; `findOverdue` reads the oldest 500.
14
+ - **Dev-gated, dry-run first.** `threadline.peerDarkNotice: { dryRun: true, queuedDarkAfterMs: 7200000, cooldownMs: 43200000 }` (plus an optional `perPeer` map) with `enabled` omitted (live on a development agent, dark on the fleet; registered as `a2aPeerDarkNotice`). Dry-run writes would-raise / would-sentence rows to `logs/a2a-peer-dark.jsonl`; the health fields are live everywhere. CLAUDE.md section "A2A dark peers (did my message arrive?)" for new and existing agents.
15
+
16
+ ## What to Tell Your User
17
+
18
+ When I message another agent and the relay has to hold the message because that agent is not connected, I used to say "queued" and nothing more — and if the message quietly expired a day later, nobody heard. Now I keep count: if messages to an agent have been waiting for hours with nothing back, I say so on the spot, with how many are waiting, when the oldest expires, and whether the relay saw that agent connected, with how many minutes old that reading is. If I cannot tell, I say whether that is because my own connection is down or because I have no fresh reading for them. A message the relay merely handed to that agent's address does not count as an answer; only a reply or an acknowledgement does. For an agent that normally answers within minutes, the wait before I speak up can be set shorter than the two-hour default. I cannot always tell "offline" from "listening under a different address", so I say which it might be rather than guessing. Once the notice is switched on, you also get a single heads-up per agent — after I have first checked and repaired my own connection — and a note when that agent is back saying how many messages expired unanswered so you can resend what still matters.
19
+
20
+ ## Summary of New Capabilities
21
+
22
+ - `peerHealth()` / `GET /threadline/peers/health`: `dark`, `darkSince`, `queuedCount`, `queuedExpiresAt`, `lastDeliveredAt`, `connectedNow`, `connectedNowReason`, `connectedAsOf`, `handedUnackedCount`, `darkCount`.
23
+ - `POST /threadline/relay-send` + `threadline_send`: `peerDark` object; evidence-worded `deliveryOutcome` when live.
24
+ - `ThreadlineClient.peerConnectedNow()` / `peerPresence()` / `refreshPresence()`; `presence_change` frames feed the presence map.
25
+ - `A2ARedeliverySentinel`: per-peer dark path (deterministic item id, pool-scope raise/resolve, cooldown, local-relay aggregate, awake-only self-heal), constructed under either gate.
26
+ - `threadline.peerDarkNotice` config block (dev-gated, dry-run) with per-peer thresholds (`perPeer`), `logs/a2a-peer-dark.jsonl`.
27
+
28
+ ## Evidence
29
+
30
+ - `tests/unit/a2a-peer-dark.test.ts` (51): the classification (threshold, per-peer threshold, every clearing event, a `delivered` verdict alone NOT clearing, expiry never clears, `escalated` never suppresses, counts, expiry, retention in every state, `findOverdue` bound, bounded `allPeerHealth`); the presence map (`presence_change`, stale frame → null, rejected refresh → false/null, relay down → null, and the reason + age for each unknown); the sentinel (gate-alone construction, one item after the heal, dry-run rows, pool-cleared raise, pool-life resolve with the expired count, cooldown + reopen, restart rebuild, local-relay aggregate, standby, split supersede, cleared-during-heal, legacy loop coexistence, failing pool read); helpers (resolver + floors, every sentence variant, HTML-escaped label, audit writer); migration parity (template = migrator wording, deep-merged defaults with `enabled` omitted, dev-gate entry).
31
+ - `tests/integration/threadline/a2a-peer-dark-route.test.ts` (8): a real `ThreadlineClient` against the in-repo `RelayServer` — `peerDark` + would-sentence in dry-run, the worded sentence when live, the MCP client passthrough, the live-read threshold, a per-peer threshold, `relay-down` vs `no-row`, dark NOT clearing on a reconnect's `delivered` verdict and clearing on the first inbound, the health fields.
32
+ - `tests/e2e/a2a-peer-dark-alive.test.ts` (5): the real `AgentServer` on the production init path — the routes answer 200 with the §3 fields, a row in the real on-disk ledger reads dark, the construction rule under the gate alone, migration E2E (defaults twice + CLAUDE.md once).
@@ -0,0 +1,94 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ The dashboard file routes (`/api/files/read`, `/download`, `/list`, `/link`) now refuse every
9
+ key-bearing file under `.instar/` — the agent identity and its superseded/invalid/temp
10
+ siblings, the threadline HMAC/invitation/token files, the dedicated SSH keys, the headless key
11
+ vault, origin-session credentials, the inbound-delivery and conversation-bind secrets, the
12
+ manifest signing key, the machine key pair, the remediation key vault and the telemetry secret — by a code-owned list the config cannot loosen.
13
+ The check runs on the requested path, the resolved path AND the opened descriptor (device +
14
+ inode), so a symlink swapped between the check and the read, a symlink with an innocent name,
15
+ or a hard link outside `.instar/` (static key names, the generation-named SSH keys and the origin-session files alike) no longer reaches the bytes; `list` omits such entries. The
16
+ same list now feeds the backup exclusions, the gitignore entries written at init and by the
17
+ post-update migration (both repos), and the git-sync secret classifier, so the four surfaces
18
+ cannot drift apart. The standalone init path also now writes its multi-machine gitignore
19
+ entries into the project `.gitignore` (they were landing in `.instar/.gitignore`, where they
20
+ matched nothing).
21
+
22
+ When the agent runs on more than one machine, a reply authored on a machine
23
+ that holds the bot token but not the serving lease used to be refused by the
24
+ local destination check and parked in memory: no send, no notice, lost on
25
+ restart. That machine now forwards the sealed reply to the lease holder over
26
+ the existing signed origin mesh, and the holder runs the same lease check the
27
+ direct path runs (it used to execute forwarded operations unchecked). The
28
+ reply request re-reads the lease for up to 15 seconds, then makes one forward
29
+ attempt; any failure becomes a durable hold with the new reason
30
+ `lease-not-held` (the existing `destination-not-authorized` still means a
31
+ foreign chat and is never forwarded). The fixed notice "I have your message;
32
+ my reply is delayed while it is routed through <holder>" goes out through the
33
+ holder, and one attention item is raised (three held topics on one machine
34
+ within an hour collapse to one). Recovery re-forwards the same operation at
35
+ +10 s and +20 s from the origin tick, then on the existing 15-minute schedule;
36
+ an attempt the holder never confirmed is resolved at that holder's receipt,
37
+ never re-sent blind; after a lease move the old record is superseded and a new
38
+ one bound to the new holder lands once. A hold that reaches its 6-hour
39
+ deadline is reported once with honest wording. `/health` carries
40
+ `telegramOrigin.heldForward`, and the origin status lists held rows with their
41
+ reason. The forward is switchable per agent
42
+ (`messaging[].config.messageOrigin.forwardToHolder.enabled`, default on, read
43
+ live); the durable held state, its reason and the notice are not. Single-machine
44
+ agents are unaffected.
45
+
46
+ A latent recovery bug is fixed on the way: the store rebuilt a replayed
47
+ operation with an empty `allowedDerivations` array the sealed original omits,
48
+ so every store-replayed forward failed the holder's seal check.
49
+
50
+ ## What to Tell Your User
51
+
52
+ The Files tab in the dashboard no longer shows, reads, downloads or links the files that hold
53
+ my private keys and secrets, and they are never included in a backup or synced to another
54
+ machine. If you see a 403 on one of those files, that is the floor working, not a bug: move
55
+ the document you wanted somewhere else; I never serve the key.
56
+
57
+ If you run me on more than one machine, a reply I write on a machine that is
58
+ not currently in charge of Telegram now reaches you through the machine that
59
+ is, instead of vanishing. If it cannot be handed over, I tell you it is delayed
60
+ and keep retrying; you see the held reply listed on the Message origins page
61
+ and in your attention hub. Nothing changes on a single machine.
62
+
63
+ ## Summary of New Capabilities
64
+
65
+ - Key material is refused by path, by resolved path and by the opened file's identity in all
66
+ four file routes; a listing hides it instead of naming it.
67
+ - One shared list drives the file routes, backups, gitignore and the sync classifier.
68
+ - A behavioural test boots a real agent home, pairs it, and walks `.instar/` for anything that
69
+ looks like a key; a key file the list misses fails the build.
70
+
71
+ - Non-lease machines forward Telegram replies to the lease holder.
72
+ - Failed forwards are held durably with a named reason, reported once, and retried.
73
+ - A lease move supersedes the old record and delivers once via the new holder.
74
+
75
+ ## Evidence
76
+
77
+ Unit (`tests/unit/file-routes-never-served-key-material.test.ts`), integration walk
78
+ (`tests/integration/file-routes-never-served-walk.test.ts`, real init + real pairing +
79
+ every in-tree key producer, 7 tests) and E2E production-boot + migration idempotency
80
+ (`tests/e2e/files-never-serve-key-material-alive.test.ts`) are green; the existing
81
+ file-route, backup, classifier and gitignore-migration suites re-run green (271 tests).
82
+
83
+ Unit tests cover the settling states, the one-attempt request, the ladder
84
+ state, same-operation re-forward, receipt-first on an unconfirmed attempt,
85
+ supersede on a holder change, item collapse, expiry wording, the config
86
+ switch and migration, the durable store rows, and the adapter send decision.
87
+ Integration tests drive a standby and a holder over the real signed mesh HTTP
88
+ pipeline with real stores and routes: the reply lands via the holder, a
89
+ `not-lease-holder` refusal is retried once by the ladder and lands once, a
90
+ lease move supersedes and lands once, a dark holder yields the 409 and the
91
+ status/health listing and is delivered once when it returns, and the notice
92
+ goes through the holder. The E2E test boots the production origin path and
93
+ shows the feature alive, the hold durable across a restart, and the migration
94
+ landing once. The self-action ratchet proves the retry ladder settles.
@@ -0,0 +1,149 @@
1
+ # Side-Effects Review — A2A dark peers: honest sender-side reporting of a send that stays queued
2
+
3
+ **Version / slug:** `a2a-peer-dark-reporting`
4
+ **Date:** `2026-10-09`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `independent reviewer subagent (see below)`
7
+
8
+ Spec: docs/specs/a2a-single-agent-identity.md §3 (converged 2026-10-09, approved by Justin — Telegram topic 9210; CMT-706, autonomous run run-mv181ge1-8af47048). This artifact covers §3 ONLY; §1, §2, §4 and §5 are built by sibling worktrees.
9
+
10
+ ## Summary of the change
11
+
12
+ Before: a message to a peer that was offline — or listening under a different address — came back `relayStatus: queued`, the relay silently expired it a day later, and nothing on the sender's side ever said "nothing has come back from this peer for hours". The per-message `A2ARedeliverySentinel` (ships off) escalated with a fresh item id per sweep, had no resolve path and no cooldown.
13
+
14
+ Now (files: `src/threadline/peerDark.ts` NEW, `src/threadline/A2ADeliveryTracker.ts`, `src/threadline/client/ThreadlineClient.ts`, `src/monitoring/A2ARedeliverySentinel.ts`, `src/server/routes.ts`, `src/commands/server.ts`, `src/threadline/ThreadlineMCPServer.ts`, `src/threadline/mcp-http-client.ts`, `src/core/types.ts`, `src/config/ConfigDefaults.ts`, `src/core/devGatedFeatures.ts`, `src/scaffold/templates.ts`, `src/core/PostUpdateMigrator.ts`):
15
+
16
+ - **Classification (§3.1).** `peerHealth()` gains `dark`, `darkSince`, `queuedCount`, `queuedExpiresAt`, `lastDeliveredAt`. The unanswered set is rows queued at the relay (`awaiting-ack` + relay `queued`), `unconfirmed`, `escalated` (not delivered), or `failed`+`expired`, sent AFTER the last ack / inbound from that peer; the peer is `dark` when the oldest such row is older than `queuedDarkAfterMs` (2 h by default; `perPeer` sets it for one peer by fingerprint, same 5-min floor). Relay expiry never clears it (an expired row stays in the set), and a relay `delivered` verdict ALONE never clears it either: it shows only that a connection under the peer's fingerprint took the message, which a split or a non-serving holder also produces. `lastDeliveredAt` stays on the read as information. `allPeerHealth` is bounded to peers active in the last 30 days. The silence sweep gains one retention statement (rows with `sent_at` older than 30 days in every state) and `findOverdue` is bounded to the oldest 500.
17
+ - **Presence (§3.2).** `presence-change` frames now feed the same `knownAgents` map `discover-result` fills (merge, never strip keys); `peerConnectedNow(fp)` answers `true`/`false` from a fresh row and `null` when there is no row, my relay is not connected, or no frame arrived in the last 15 min. `peerPresence(fp)` is the full read behind it: `connectedNowReason` (`no-row` / `relay-down` / `stale`) says why a `null` is null, and `connectedAsOf` is when that peer's own row was written. `refreshPresence()` is one discover; a rejected call answers `false` and leaves the map untouched.
18
+ - **Send response (§3.2).** `POST /threadline/relay-send` (and `threadline_send`, additively) carries `peerDark: {since, queuedCount, expiresAt, connectedNow, connectedNowReason, connectedAsOf}` on a send to a dark peer, computed from THIS machine's ledger + presence map (never a peer fan-out, never an inline discover). `queuedCount` counts this send only when its own verdict was `queued`. With the notice on and `dryRun: false` the `deliveryOutcome` is the sentence worded to the evidence; in dry-run the legacy sentence stands and a `would-sentence` row is recorded. A standby's send is forwarded, so the relay holder computes the field and the forward answer transcribes it.
19
+ - **Health reads (§3.2).** `GET /threadline/peers/health` (+ per-peer, + pool) carry `dark`, `darkSince`, `queuedCount`, `connectedNow`, `connectedNowReason`, `connectedAsOf`, plus `darkCount` on the list. With no relay client (a standby, relay off) the reason is `relay-down`: my side, not the peer's.
20
+ - **Sentinel rework (§3.2 iii–iv, §3.3).** `A2ARedeliverySentinel` is constructed when EITHER `monitoring.a2aRedelivery.enabled` OR the resolved `threadline.peerDarkNotice` gate is on (`redeliver` only under the former). Under the new gate the operator notice is per peer: trigger `dark && queuedCount > 0 && selfHealExhausted`, `dark` for the raise computed over pool-scope acks/inbounds (a `delivered` verdict alone neither clears the raise nor resolves the item) and the peer's own threshold; deterministic id `a2a-peer-dark:<agent>:<peerFp>` (reopened via `upsertAttentionItem`, never a stamped id); resolve on every machine over its own item store from pool-scope life newer than the raise, with the expired-unacknowledged count in the line; 12 h per-peer cooldown (in-process + the durable item's last update, restart-safe). Self-heal BEFORE notify, awake machine only: reconnect a dropped relay, one discover, the §2 self-check (`identitySelfCheck` dep, `unknown` until §2 wires it), two passes 40 s apart. My relay not connected → ONE aggregated item (`a2a-relay-unreachable:<agent>`), per-peer suppressed. The legacy per-message escalation item is raised ONLY when the new gate is off (today's behaviour byte-identical when dark).
21
+ - **Rollout (§3.4).** `threadline.peerDarkNotice: {dryRun: true, queuedDarkAfterMs: 7200000, cooldownMs: 43200000}` (plus an optional `perPeer` map, absent by default), `enabled` omitted (dev gate; `DEV_GATED_FEATURES` entry `a2aPeerDarkNotice`), delivered by the `ConfigDefaults` deep-merge. Dry-run rows → `logs/a2a-peer-dark.jsonl`. CLAUDE.md section "A2A dark peers (did my message arrive?)" in the template + `migrateClaudeMd` + the framework shadow list.
22
+
23
+ ## Decision-point inventory
24
+
25
+ - Per-peer `dark` classification (`A2ADeliveryTracker.peerHealth`) — **add** — invariant: a time threshold over ledger rows; signal-only.
26
+ - `peerDark` + the worded `deliveryOutcome` on a send (`routes.ts` relay-send) — **add** — a read on the response; the send itself is already submitted; the sentence rides the dry-run flag.
27
+ - `connectedNow` (`ThreadlineClient.peerConnectedNow`) — **add** — invariant: a fresh presence row or `null`; never an inline discover.
28
+ - The per-peer raise / resolve / cooldown / aggregate (`A2ARedeliverySentinel`) — **modify** — raise iff `dark && queuedCount>0 && selfHealExhausted` over pool-scope evidence; signal into the Attention queue.
29
+ - The self-heal (`reconnectRelay` / `refreshPresence` / self-check) — **add** — awake-only; the reconnect is the existing idempotent re-arm.
30
+ - Sentinel construction rule (`server.ts`) — **modify** — either gate constructs; `redeliver` only under the legacy gate.
31
+ - Legacy per-message escalation item — **modify** — raised only when the peer-dark gate is off.
32
+ - Retention delete + `findOverdue` bound (`sweepSilence`, `findOverdue`) — **add** — invariant: `sent_at` older than 30 days; oldest 500.
33
+
34
+ ---
35
+
36
+ ## 1. Over-block
37
+
38
+ No send is ever blocked. The only "rejections" are what the notice declines to SAY:
39
+
40
+ - A peer whose only unanswered rows are `delivered`-but-unacked (the peer's relay connection took them; silence, not darkness) never reads `dark`. A peer that reads everything and never replies therefore stays `stale` (6 h) but not `dark` — by design: `dark` is the offline / wrong-address proxy.
41
+ - A peer that is genuinely offline for less than 2 h is not reported. The threshold is config (floor 5 min), and can be set lower for one peer (`perPeer`) when a pair normally answers in minutes.
42
+ - A peer that comes back and takes its queued messages or a new one (`delivered`) but has not yet acknowledged or replied still reads `dark` until it does. That is deliberate: `delivered` cannot tell the real peer from another holder of its address. Cost: a returning peer that never acks stays dark until its first inbound; an ack-capable peer clears within seconds.
43
+ - A peer that goes dark again INSIDE the 12 h cooldown is reported only when the cooldown lapses (stated accepted cost); the health fields show it immediately.
44
+ - A standby raises nothing even for a peer it alone has rows for; the awake machine's own rows or a later send from the awake machine carry the episode. A conversation whose only queued rows live on a standby is reported only once the awake machine also has queued rows to it (bounded by the spec's awake-only rule).
45
+ - `connectedNow` answers `null` (not a stale boolean) when the last presence frame is older than 15 min; the sentence then says "whether <peer> is connected right now is unknown" rather than guessing.
46
+
47
+ ## 2. Under-block
48
+
49
+ - `dark` cannot tell offline from wrong-address; the data model carries no cause. The wording says so on every surface. The relay-side `recipientLastSeenAt` that could tell them apart is §Out of scope (CMT-026).
50
+ - A dark peer whose rows are ALL older than 30 days leaves `allPeerHealth` and its open item is resolved only if life arrives; an item for a peer that never comes back stays open until the operator closes it (the cooldown and the 30-day prune bound the surface, not the item).
51
+ - The sentinel raises on the awake machine only; if the lease moves while an item is open on the old machine, that machine resolves it when it next ticks on pool evidence (the deterministic id makes a later re-raise on the new machine the same item name, not a duplicate flood).
52
+ - The pool read for raise/resolve is best-effort: a peer machine that is down answers nothing, so its acks are invisible for that tick — the local verdict stands (audited `pool-read-failed`). A false raise on a peer that answered ONLY on a dark machine is possible for the length of that outage; the resolve catches it on the next tick the machine is back.
53
+ - `identitySelfCheck` is `unknown` until §2 lands its wiring; the "if I am the split one, that item supersedes this" branch is inert until then (both items can coexist for a split in the meantime).
54
+ - The route's `peerDark` is THIS machine's ledger only (per spec): a standby's own queued rows are never consulted on a send, because a standby never computes the field (its send is forwarded).
55
+
56
+ ## 3. Level-of-abstraction fit
57
+
58
+ Right layers. The classification lives where the rows live (the tracker), the presence read lives on the client that owns the map, the send-time sentence lives in the one route every A2A send funnels through (the MCP tool and the forward path both transcribe it), and the operator notice reuses the sentinel that already owned "a peer went dark" instead of a parallel watcher. The Attention queue (upsert / status) is the existing notice surface; no new topic, no new channel. Nothing here duplicates the relay's own expiry logic — it reads the verdicts the relay already sends.
59
+
60
+ ## 4. Signal vs authority compliance
61
+
62
+ **Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
63
+
64
+ - [x] No — this change produces a signal consumed by an existing smart gate.
65
+ - [x] No — this change has no block/allow surface.
66
+ - [ ] Yes — but the logic is a smart gate with full conversational context.
67
+ - [ ] ⚠️ Yes, with brittle logic — STOP.
68
+
69
+ Every piece is a read or a notice: `dark` is a time threshold over durable rows, `connectedNow` is a presence-map lookup, `peerDark` is a response field, the sentinel raises/resolves Attention items. No send, receive, or session is gated; a failing ledger read answers `dark: unknown` (no item, one audit row) and a failing pool read degrades to the local verdict. The only "authority" is the operator's dry-run flip.
70
+
71
+ ---
72
+
73
+ ## 4b. Judgment-point check (Judgment Within Floors standard)
74
+
75
+ No new static heuristic at a competing-signals decision point. The dark threshold, the cooldown and the 30-day retention are invariants over time (enumerable: a duration), declared in the spec's "Decision points touched" table as tier-0 invariants; the raise additionally requires the heal to have run (a floor, not a judgment). Where signals genuinely compete — offline vs wrong-address — the change does NOT decide: it reports both possibilities in the wording and leaves the cause to the relay-side corroboration tracked in §Out of scope.
76
+
77
+ ---
78
+
79
+ ## 5. Interactions
80
+
81
+ - **Legacy redelivery loop:** unchanged under its own gate. With the peer-dark gate ALSO on, its `markEscalated` state change still happens but the stamped `a2a-redelivery-<ts>` item is NOT raised beside the dark item (one voice per peer). An `escalated` row stays in the unanswered set so it cannot suppress an episode (spec §3.2).
82
+ - **Silence sweep (`honestDeliveryWiring`):** the retention delete rides the same `sweepSilence` call; its audit row shape is unchanged (deleted rows are counted on `lastPruned` / `prunedTotal`, not listed).
83
+ - **`stale` (6 h):** untouched. `dark` (2 h) sits below it so the sender hears first; a `delivered`-unacked peer can be stale without being dark.
84
+ - **Relay forward (standby):** the holder computes `peerDark`; `buildForwardAnswer` spreads the holder body so the standby's caller sees it. No change to the forward module.
85
+ - **Hub-routed Attention:** `upsertAttentionItem` reopens the same id on a later episode and re-posts when the body changes (the resolve line lands through it, then the item is set `DONE` silently). `createAttentionItem`'s dedupe-on-id would have swallowed a reopen — that is why the §3 items use upsert.
86
+ - **`/threadline/peers/health` pool scope:** rows merge as today; the new fields ride along (an older peer answers without them — `darkCount` counts only `dark === true`).
87
+ - **Double-fire:** the sentinel tick is single-flight (`ticking` guard); the two heal passes are inside one tick. A restart rebuilds an open per-peer episode from the durable item (no second raise), and the aggregate item's durable OPEN state is consulted too, so a relay that comes back after a restart still resolves it.
88
+ - **Legacy item while the new path is dry-run:** the legacy per-message stamped item is suppressed only when the §3 path is LIVE (`enabled && !dryRun`). With legacy redelivery on and the new path in dry-run, the legacy item still goes out — there is no silent window; a would-raise row is written beside it.
89
+
90
+ ## 6. External surfaces
91
+
92
+ - Other agents: nothing new leaves the machine. The one discover in the heal is a call the client already makes at connect; the reconnect is the existing re-arm. No new relay frame types.
93
+ - Operator: ONE new per-peer Attention item kind + ONE aggregate kind, both dark (dry-run) on every agent until a deliberate flip; the send response and health reads gain fields (additive JSON).
94
+ - Timing dependence: `connectedNow` depends on frame freshness (15 min) — honest `null` otherwise; the dark threshold depends on wall-clock vs ISO `sent_at` (the tracker's existing invariant).
95
+ - Peer-supplied text: display names are clamped + HTML-escaped in every sentence and item body (`peerLabel`); audit rows carry fingerprints, counts and timings only — never bodies or thread ids.
96
+
97
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
98
+
99
+ **Proxied-on-read** for the ledger + `dark` (rows live on the machine that carried the send; `GET /threadline/peers/health?scope=pool` merges them — existing), with the item **raised by the awake machine only** (the only one whose heal may run) and **resolved on every machine over its own item store** from the pool-scope read, under one deterministic id so a lease move cannot strand it. `connectedNow` is **machine-local by design** (the presence map belongs to the one relay connection; a standby has none and answers `null`; its sends are forwarded so the holder computes the field). The dry-run audit log is machine-local (one file per machine, like the sibling A2A logs). One-voice: a single item id per peer; the aggregate suppresses per-peer items while my relay is down. No durable state is created that would strand on topic transfer (items are per-agent, not per-topic). Single-machine agents: the pool read answers `[]` and every verdict is local.
100
+
101
+ ## 8. Rollback cost
102
+
103
+ - Flag off (`threadline.peerDarkNotice.enabled: false` — the sentinel's construction reads it at boot; the send-route sentence and the health reads read it live): the raw health fields stay populated (they are reads), no sentence, no item; the sentinel is then constructed only under `monitoring.a2aRedelivery.enabled` — today's behaviour. Dry-run back on: `dryRun: true`.
104
+ - Code revert: additive JSON fields disappear; the retention delete and the `findOverdue` bound revert (rows already deleted are gone — they were 30+ days old in terminal/unanswered states and the spec accepts that); `PeerHealth` loses the fields (older peers already tolerate their absence).
105
+ - No data migration, no hook, no skill. The CLAUDE.md section is content-sniffed; a revert leaves a harmless paragraph on migrated agents.
106
+
107
+ ## Class-Closure Declaration (display mirror — the counted host is the decision-audit entry)
108
+
109
+ - **Defect class:** `unbounded-self-action` — the reworked `A2ARedeliverySentinel` is a self-triggered controller (a sweep tick that reconnects a relay, runs a discover, raises and resolves Attention items).
110
+ - **Closure:** `guard` — ratchet `tests/unit/self-action-convergence.test.ts` over the registry model `a2a-peer-dark-raise` (`src/testing/selfActionRegistry.ts`; source marker `@self-action-controller: a2a-peer-dark-raise` in `src/monitoring/A2ARedeliverySentinel.ts`), which proves one emit per (peer, episode) under 24 h of sustained silence, horizon-independent and restart-stable. The brakes it models, enforced inside the controller: per-peer 12 h cooldown (in-process AND the durable item's last update, restart-safe); one deterministic item id per peer (reopen, never a stamped flood); ONE aggregate item while my relay is down with per-peer items suppressed; single-flight tick (`ticking`); heal capped at two passes 40 s apart inside the 120 s ceiling; `findOverdue` bounded to 500 rows and the ledger pruned at 30 days; dry-run default (`threadline.peerDarkNotice.dryRun: true`) so nothing is raised on the fleet until a deliberate flip. Every would-raise / raise / resolve / skip lands in `logs/a2a-peer-dark.jsonl`.
111
+ - **How caught:** spec §3 named the brakes up front (Watchers table row "Dark peer with queued sends"); `tests/unit/a2a-peer-dark.test.ts` covers cooldown, reopen-after-cooldown, restart rebuild (no duplicate raise), aggregate-replaces-per-peer, standby-no-heal.
112
+
113
+ ---
114
+
115
+ ## Second-pass review
116
+
117
+ **Required** — the change touches a sentinel (name + behaviour) and the Attention surface. Reviewer: an independent subagent reading this artifact and the diff, without the author's conclusions.
118
+
119
+ **Reviewer response (verbatim, independent subagent, 2026-10-09 11:37 PDT):**
120
+
121
+ > Checked the artifact against the live diff, the four new files, spec §3 / Watchers row / AC5, and `docs/signal-vs-authority.md`. Unit file runs green (41/41).
122
+ >
123
+ > 1. **Signal vs authority** — holds. The route computes `peerDark` after the send is already submitted; the sentinel only reads the ledger, raises/resolves Attention items, and re-arms an existing relay client. No 4xx/hold path anywhere.
124
+ > 2. **Dark pass vs spec** — matches: trigger `dark && queuedCount>0` → pool-scope clear → awake-only heal (two passes, 40 s) → second-pass re-read → cooldown → raise with `a2a-peer-dark:<agent>:<peerFp>`; resolve runs before the awake check on every machine; aggregate on relay down; dry-run writes to `<stateDir>/logs/a2a-peer-dark.jsonl`.
125
+ > 3. **Send path** — local `peerHealth` + `peerConnectedNow` only; no discover, no fan-out. ✓
126
+ > 4. **`delivered` as life** — `lastDelivered` is folded into `lifeBound`; the route skips the read entirely on a `delivered` verdict. ✓
127
+ >
128
+ > **Discrepancies (artifact vs code):**
129
+ > - The legacy stamped item was suppressed on `!peerDark.enabled`, not on live — with legacy ON and the new gate in dry-run no item would be raised at all.
130
+ > - `aggregateOpen` was in-process only; a durable OPEN `a2a-relay-unreachable` item would not resolve after a restart until the relay dropped again.
131
+ > - §8 said the flag is "read at boot"; the route reads it live for the sentence, only the sentinel's construction is boot-read.
132
+ > - `peerConnectedNow` used a per-row stamp where the spec says "last presence frame older than 15 min".
133
+ > - The route added `+1` to `queuedCount` whenever this row was not yet `queued`, including on a `rejected` verdict.
134
+ > - Spec §3.1 enumerates three states; code adds `escalated`-not-delivered. Disclosed; note only.
135
+ >
136
+ > None of these touches blocking authority or the §3 guarantees; they are alignment-pass fixes.
137
+ >
138
+ > Concur with the review
139
+
140
+ **Author follow-up (same session):** all five discrepancies fixed in code before commit — legacy item suppressed only when `peerDarkLive`; the aggregate resolve consults the durable item state; `peerConnectedNow` keys freshness on the last presence frame; `+1` only on a `queued` verdict; §8 wording corrected above.
141
+
142
+ **Follow-up commit (same session):** full-suite alignment — the dev-gate hand map shifted +15 for the new config block, the audited pool-read fallback carries its marker, and the peer-health route fixture sits inside the 30-day window.
143
+
144
+ **Review folds (peer review of §3, built 2026-10-10):** three changes asked for by the receiving agent's review and accepted before the build closed. (1) `perPeer` thresholds: a config map by peer fingerprint, resolved with the same floor, capped at 200 entries, applied on the health reads, the send read and the sentinel; a signal-only tuning knob with no new authority. (2) `connectedNowReason` + `connectedAsOf`: additive read fields; the sentence now states the record's age ("as of N min ago") and, when unknown, which side the unknown is on. (3) `delivered` no longer counts as a sign of life: `lifeBound` in the ledger and the sentinel's pool-scope life both read ack + inbound only. This supersedes item 4 of the second-pass review above ("`delivered` as life"). Under-block effect: none added. Over-block effect: none (no send is gated); the notice can stay up slightly longer for a peer that returns without acking, stated in §1. Rollback is unchanged (`threadline.peerDarkNotice.enabled: false`; the fields are reads). Spec §3.1/§3.2/§3.4 text was brought in line in the same commit.
145
+
146
+ **Second-pass review of the folds (independent reviewer, 2026-10-10): concern raised, then fixed.** The reviewer confirmed folds (1) and (2), that no send is gated, that every threshold consumer reads the per-peer value (including the post-heal re-check), and the presence edge cases. It raised two real defects in fold (3): a relay queue FLUSH rewrites queued rows to `delivered`, which dropped them out of the unanswered set and cleared `dark` with nothing from the peer (the exact split case the fold exists for); and after such a flush the open item could never resolve. Fix: a row the relay queued and later handed over (`delivered` with `relay_expires_at` still set, a column only a queued verdict writes) stays in the unanswered set until an ack or an inbound; `handedUnackedCount` counts those rows apart and the sentence and item body name them, so nothing handed over is called "queued". The peer therefore stays a candidate and the item resolves on the first ack or inbound. Two minors also fixed: the per-peer map is null-prototype with an own-key lookup (a fingerprint such as `constructor` reads the default), and the boolean-only presence reader no longer has a reason guessed for it. Known remaining edge, accepted: an `unconfirmed` row that receives a late `delivered` verdict has no queued marker and leaves the set; that needs a 24 h-late relay verdict and is bounded by the `stale` window. Tests for each fix are in the unit file.
147
+
148
+ **Reviewer re-check of the fix (same reviewer, 2026-10-10): Concur with the review.** Verified in code: `relay_expires_at` has a single writer, set only on a `queued` verdict and kept on a later `delivered`, so it is a reliable "queued, then handed over" marker; no path clears `dark` without an ack or an inbound; acked flushed rows leave the set and an inbound reply excludes them, so nothing reads falsely dark; the open item resolves on the first ack or inbound. One residual it named, which this change did not introduce: a peer that never returns loses its rows at the 30-day retention, and an item still open for it then has no automatic resolve path (the operator closes it by hand).
149
+