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,399 @@
1
+ # Side-Effects Review — Key material is never served, backed up or listed (a2a-single-agent-identity §5)
2
+
3
+ **Version / slug:** `files-never-serve-key-material`
4
+ **Date:** `2026-10-09`
5
+ **Author:** Echo (instar-dev agent)
6
+ **Second-pass reviewer:** reviewer subagent (see "Second-pass review" below)
7
+
8
+ ## Summary of the change
9
+
10
+ Section 5 of `docs/specs/a2a-single-agent-identity.md` (converged, approved). One new
11
+ module, `src/core/keyMaterialPaths.ts`, holds the list of key-bearing files under
12
+ `.instar/` — the agent identity (`identity.json` and its `.superseded-*` / `.invalid-*` /
13
+ temp siblings), the legacy threadline mirror, the threadline HMAC / invitation / token files,
14
+ the dedicated SSH keys, the headless key vault (`local-state/`), origin-session credentials,
15
+ the inbound-delivery and conversation-bind secrets, the manifest signing key, the machine
16
+ key pair, the remediation key vault's flatfile and the telemetry HMAC secret. Four consumers derive their own spelling from that ONE list: the dashboard file
17
+ routes' code-owned `NEVER_SERVED_PREFIXES` (`src/server/fileRoutes.ts`), `BackupManager`'s
18
+ never-backup prefix set, `GITIGNORE_ENTRIES` at init plus `migrateGitignore` for existing
19
+ agents (`src/core/MachineIdentity.ts`, `src/core/PostUpdateMigrator.ts`), and the git-sync
20
+ `FileClassifier`'s secret patterns.
21
+
22
+ The file routes also close the §5.2 gaps: `blockedFilenames` and the never-served check run
23
+ on the requested AND the resolved path in read/download/list/link; `list` resolves each entry
24
+ and OMITS one whose realpath fails or is denied; `link` resolves before minting; a `realpath`
25
+ failure refuses (403) instead of a 500; and read/download open the file FIRST, `fstat` the
26
+ descriptor, refuse unless its device+inode equals the stat taken at validation, refuse a
27
+ multi-link inode that equals any listed key file, and serve FROM THAT DESCRIPTOR
28
+ (`handle.readFile()` / `handle.createReadStream()`), never a by-path re-open.
29
+
30
+ `src/commands/init.ts` gets a one-line fix the §5.4 walk found: the standalone init path
31
+ called `ensureGitignore(stateDir)`, which wrote the project-relative entries into
32
+ `.instar/.gitignore` where they match nothing; it now also calls `ensureGitignore(projectDir)`
33
+ like the other two init paths. The CLAUDE.md template and migrator carry a "Never served" row.
34
+
35
+ Tests: `tests/unit/file-routes-never-served-key-material.test.ts` (static list parity on all
36
+ four surfaces + the route behaviour by direct path, symlink, hard link, swapped descriptor,
37
+ dangling symlink, config narrowing), `tests/integration/file-routes-never-served-walk.test.ts`
38
+ (the §5.4 walk: real init + real sealer/installer pairing + every in-tree key producer, then
39
+ every 0600 / private-field / key-named file asserted refused, excluded, ignored, classified),
40
+ `tests/e2e/files-never-serve-key-material-alive.test.ts` (production `AgentServer` boot: 200 on
41
+ an allowed file, 403 on the identity, migration idempotency).
42
+
43
+ ## Decision-point inventory
44
+
45
+ - `isNeverServed` / `NEVER_SERVED_PREFIXES` (`src/server/fileRoutes.ts`) — modify — the
46
+ enumerated deny gains the key-material prefixes.
47
+ - `validatePath` (`src/server/fileRoutes.ts`) — modify — carries the resolved `stat`; a
48
+ non-ENOENT realpath failure now refuses (403) instead of 500.
49
+ - `openCheckedDescriptor` (`src/server/fileRoutes.ts`) — add — descriptor identity proof
50
+ (device+inode) and the multi-link key-inode refusal; read/download consume it.
51
+ - `resolveListEntry` (`src/server/fileRoutes.ts`) — add — per-entry admission for `list`
52
+ (omit on denied/unresolvable/blocked requested-or-resolved path).
53
+ - `GET /api/files/link` — modify — full `validatePath` resolution before minting.
54
+ - `BLOCKED_PATH_PREFIXES` (`src/core/BackupManager.ts`) — modify — key prefixes in both spellings.
55
+ - `DEFAULT_SECRET_PATTERNS` (`src/core/FileClassifier.ts`) — modify — key prefixes as globs.
56
+ - `GITIGNORE_ENTRIES` / `ensureGitignore` (`src/core/MachineIdentity.ts`) — modify — key entries.
57
+ - `PostUpdateMigrator.migrateGitignore` / `migrateClaudeMd` — modify — key entries both repos; the "Never served" row.
58
+ - standalone `initProject` gitignore step (`src/commands/init.ts`) — modify — also writes the project `.gitignore`.
59
+
60
+ ---
61
+
62
+ ## 1. Over-block
63
+
64
+ **What legitimate inputs does this change reject that it shouldn't?**
65
+
66
+ - A document the operator placed under one of the listed PREFIXES — e.g. a note saved as
67
+ `.instar/identity.json.notes.md` or anything inside `.instar/local-state/` — is refused by the
68
+ Files tab. Spec §Rollback states this cost explicitly: a false positive is fixed by moving
69
+ the document, never by serving keys. The prefixes are narrow (`identity.json`,
70
+ `origin-sessions-`, `machine-ssh/`, `local-state/`, …) and every listed directory is one
71
+ Instar itself owns for key storage.
72
+ - `realpath` failure now 403s instead of 500. A transient EACCES on an allowed file reads as
73
+ "could not be resolved" rather than an internal error; the file is not served either way,
74
+ so nothing that used to work stops working — only the status code changes (500 → 403).
75
+ - A multi-link file whose inode equals a listed key file is refused even when reached through
76
+ an innocent name outside `.instar/`. That is the intended refusal; an unrelated multi-link
77
+ file (asserted in the unit test) is still served.
78
+ - `list` omits a dangling symlink and a symlink into denied material instead of listing the
79
+ name. An operator who expected to SEE the dangling name will not; nothing was readable
80
+ through it before either.
81
+ - `conversations.json`, `trust-profiles.json`, thread history, `AGENT.md`, `config`-adjacent
82
+ docs: asserted servable in all three tiers (no over-block on the audit surfaces).
83
+
84
+ ---
85
+
86
+ ## 2. Under-block
87
+
88
+ **What failure modes does this still miss?**
89
+
90
+ - A key file produced by a path the §5.4 walk does not drive. The walk drives the real init
91
+ (machine key pair, manifest key, pairing session), the real pairing sealer/installer (the
92
+ agent identity), and these runtime producers by name: `InvitationManager`,
93
+ `SecureInvitationManager`, `loadOrCreateDeliveryHmacKey`, `ensureBindTokenSecret`,
94
+ `ManifestIntegrity`, `MachineSshIdentity`, `WorktreeKeyVault` (flatfile),
95
+ `RemediationKeyVault` (env-passphrase flatfile) and `TelemetryAuth.provision` — the last
96
+ two were ADDED after the second-pass review found `remediation-keys.age` and
97
+ `telemetry/local-secret` neither driven nor listed — plus the relay-token writer and the
98
+ legacy threadline mirror/inbox key written through the same owner-only writer. This is the
99
+ producer set a grep for `mode: 0o600` / `writeFileAtomicOwnerOnly` across `src/` yields
100
+ today; it is a list maintained by audit, not a proof of completeness. A generator added
101
+ later that writes somewhere new fails the walk only if it is also called from the walk:
102
+ one wired into init is caught automatically (the walk boots the real init); one reached
103
+ only at runtime must be added to the walk's step 3 (the test header says so).
104
+ - `telemetry/install-id` (a bare UUID, 0600) and `state/identity-epochs.json` (public
105
+ verification material, 0600) are excused from the walk by a per-file proof predicate
106
+ re-run on the live bytes, and stay servable on purpose — the list is kept to key material.
107
+ - A key file written OUTSIDE `.instar/` (e.g. `~/.ssh`) is out of scope by design — the Files
108
+ tab is jailed to the project directory and the list is `.instar/`-relative.
109
+ - The hard-link check compares the opened inode against the static `KEY_MATERIAL_FILES`
110
+ (now including `machine/secrets-master.key` and `secrets/passkeys/store.enc`, added on the
111
+ reviewer's finding) PLUS, on the same rare `nlink > 1` path, a `readdir` of `machine-ssh/`
112
+ (generation-named `<kind>-ed25519-g<N>` keys) and of the stateDir root for
113
+ `origin-sessions-<digest>` files — the two name shapes a static list cannot enumerate. What
114
+ remains outside the inode check: a hard link to a `.superseded-*` / `.invalid-*` / temp
115
+ sibling of `identity.json` (not enumerated per request). Every such sibling is still refused
116
+ by every PATH check; only a hard link to it under an innocent name OUTSIDE `.instar/`
117
+ escapes. Hard links require write access on the same filesystem as the agent home, i.e. the
118
+ operator's own account — the cost is stated, not hidden.
119
+ - The descriptor proof defeats a symlink swap between check and open; it does not defend
120
+ against a file being REPLACED IN PLACE (same inode, rewritten bytes) — that is not a
121
+ confused-deputy problem, it is the operator editing their own file.
122
+
123
+ ---
124
+
125
+ ## 3. Level-of-abstraction fit
126
+
127
+ This is a low-level, enumerated, deterministic deny at the serving chokepoint, which is the
128
+ right layer: the question "is this byte sequence a private key?" has an exact answer here
129
+ (the file IS the key file by path or inode), so no higher-level reasoning gate should own it.
130
+ The existing primitives are reused rather than re-implemented — `isNeverServed` for the deny,
131
+ `validatePath` for traversal/realpath, `BackupManager`'s prefix set, `FileClassifier`'s
132
+ patterns, `addGitignoreEntry` for migration. The one new abstraction (`keyMaterialPaths.ts`)
133
+ exists so four surfaces read ONE list and cannot drift; the walk test is the instrument that
134
+ can disagree with that list.
135
+
136
+ ---
137
+
138
+ ## 4. Signal vs authority compliance
139
+
140
+ **Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
141
+
142
+ **Does this change hold blocking authority with brittle logic?**
143
+
144
+ - [ ] No — this change produces a signal consumed by an existing smart gate.
145
+ - [ ] No — this change has no block/allow surface.
146
+ - [x] Yes — but the logic is an enumerated floor, not a heuristic: exact path prefixes and
147
+ inode equality, over files Instar itself writes. (Spec §5.1: "Exact-path access control,
148
+ not a meaning filter — Signal vs. Authority does not apply to an enumerated floor.")
149
+ - [ ] ⚠️ Yes, with brittle logic — STOP.
150
+
151
+ The deny decides on identity (path prefix, device+inode), never on content interpretation.
152
+ There is nothing to misjudge about a listed path; the only failure class is an INCOMPLETE
153
+ list, and that is covered by the walk test rather than by giving the gate judgment. No config
154
+ key can loosen it (`PATCH /api/files/config` narrows `allowedPaths`; the deny is consulted
155
+ independently) — by design, since a loosening lever would be the first thing a compromised
156
+ operator session reaches for.
157
+
158
+ ---
159
+
160
+ ## 4b. Judgment-point check (Judgment Within Floors standard)
161
+
162
+ No new static heuristic at a competing-signals decision point. Every decision here is
163
+ enumerable (a listed path, an equal inode) — an invariant, not a judgment.
164
+
165
+ ---
166
+
167
+ ## 5. Interactions
168
+
169
+ - **Shadowing:** `isNeverServed` already ran before `allowedPaths`; the new prefixes join the
170
+ same list, so ordering is unchanged. In `list`, the per-entry resolution runs where the
171
+ `blockedFilenames` + stat-skip used to run; the output shape is identical for every entry
172
+ that was listed before and is still admissible. In `link`, the full `validatePath` replaces
173
+ the lighter `checkRelativePathAllowed` pre-check; every refusal the pre-check produced is
174
+ still produced (the pre-check is the first layer inside `validatePath`), plus the resolved-
175
+ path refusals. `isNeverEditable` already included `isNeverServed`, so the save route refuses
176
+ the new prefixes with no change.
177
+ - **Double-fire:** the backup exclusion and the classifier exclusion both apply to the same
178
+ files; both are "skip", so a double-skip is harmless. The migrator's gitignore step and
179
+ `ensureGitignore` at init may both write the same entries; both are idempotent (existence
180
+ check), asserted by the E2E double-run.
181
+ - **Races:** the check-then-serve race is the one this change CLOSES (descriptor proof). The
182
+ `list` route performs realpath per entry (≤500) — more syscalls than before, bounded by the
183
+ existing 500-entry cap.
184
+ - **Feedback loops:** none — nothing here writes state that feeds a later decision.
185
+ - **`IDENTITY_AUTO_ACCEPT_PROTECTED_PATHS`** already denied `.instar/machine/` and
186
+ `.instar/identity.json` on the routes; the new list overlaps it for those two and extends to
187
+ the rest. Overlap is additive, never contradictory.
188
+ - **Classifier label for `origin-sessions-*`:** these files were classified `exclude`
189
+ (generated, via `ORIGIN_LOCAL_PROJECT_GLOBS`); the secret check runs first, so they are now
190
+ `never-sync`. `GitSync` treats the two strategies identically at both callsites (staging skip
191
+ and conflict resolution), so behaviour is unchanged; only the label and the degradation
192
+ reason text differ. `tests/unit/telegram-origin/local-custody.test.ts` pinned the old label
193
+ and is updated to pin the new one for those two files (found by CI shard 4/4).
194
+
195
+ ---
196
+
197
+ ## 6. External surfaces
198
+
199
+ - Other agents on the same machine: none — the file routes serve this agent's project dir only.
200
+ - Users of the install base: the dashboard Files tab now returns 403 on key files that were
201
+ previously readable through a symlink or a crafted listing; direct paths were already
202
+ refused for `identity.json`/`machine/`. A 500 on an unresolvable path becomes a 403. The
203
+ "Never served" CLAUDE.md row (template + migration) tells the agent to explain a 403 there
204
+ as correct.
205
+ - External systems: none.
206
+ - Persistent state: `.gitignore` (project) and `.instar/.gitignore` gain entries on update —
207
+ additive lines, idempotent. No other durable state.
208
+ - Timing/runtime: the descriptor proof relies on `fstat` device+inode — stable on every
209
+ filesystem Instar runs on (APFS, ext4); on a filesystem that reports `nlink` unreliably the
210
+ worst case is an extra dozen `stat`s per request, never a false admit.
211
+ - **Operator surface:** no new operator-facing action — the change only refuses. "No
212
+ operator-facing actions" applies.
213
+
214
+ ---
215
+
216
+ ## 6b. Operator-surface quality (Operator-Surface Quality standard)
217
+
218
+ No operator surface — not applicable. No `dashboard/*` file is touched; the dashboard Files
219
+ tab receives the same 403 JSON shape it already renders for never-served paths.
220
+
221
+ ---
222
+
223
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
224
+
225
+ **machine-local BY DESIGN**, for a security-boundary reason: key material lives on the disk
226
+ of the machine that holds it, and the deny guards THAT machine's serving, backup, gitignore
227
+ and sync surfaces. The list itself is code (identical on every machine at the same version);
228
+ no state replicates because there is no state. Multi-machine consequence worth stating: the
229
+ gitignore + classifier arms are exactly what keep a key from riding git-sync to a sibling, so
230
+ this change REDUCES cross-machine leakage rather than needing a replication path. No
231
+ user-facing notices (nothing to one-voice-gate), no durable state to strand on transfer, no
232
+ generated URLs (the `link` route mints the same relative dashboard URL it did before and
233
+ refuses for denied paths).
234
+
235
+ ---
236
+
237
+ ## 8. Rollback cost
238
+
239
+ - **Hot-fix release:** revert the code; ship as the next patch. Spec §Rollback: "no lever by
240
+ design; a false positive is fixed by moving the document, not by serving keys."
241
+ - **Data migration:** none. The added `.gitignore` lines are inert if the code is reverted and
242
+ harmless to leave.
243
+ - **Agent state repair:** none. The CLAUDE.md row describes a 403 that, after a revert, would
244
+ simply stop occurring.
245
+ - **User visibility:** a reverted install would again serve key material through the symlink/
246
+ listing gaps — that is the regression the floor exists to prevent, so the back-out is a
247
+ deliberate operator decision, never automatic.
248
+
249
+ ---
250
+
251
+ ## Conclusion
252
+
253
+ The review produced two rounds of design change. First, the §5.4 walk disagreed with the
254
+ list twice and both disagreements were real — the manifest signing key (`state/.manifest-key`)
255
+ and the machine key pair (`machine/`) were not in the spec's §5.1 enumeration and are now on
256
+ the list, and the standalone init path was writing the project-relative gitignore entries into
257
+ the wrong file (fixed in `init.ts`). Second, the independent second-pass review found two
258
+ producers the walk never drove (`remediation-keys.age`, `telemetry/local-secret`): both are
259
+ now listed on all four surfaces and driven in the walk, two more static names joined the
260
+ inode set, and the hard-link check now enumerates the generation-named SSH keys and the
261
+ origin-session files at request time. The remaining findings are the stated costs (an operator
262
+ document under a listed prefix must be moved; a hard link to an `identity.json` sibling under
263
+ an innocent name outside `.instar/` is not inode-checked) and are recorded in §2. Clear to
264
+ ship once the second-pass reviewer confirms the resolutions.
265
+
266
+ ---
267
+
268
+ ## Second-pass review (if required)
269
+
270
+ **Reviewer:** reviewer subagent (independent read)
271
+ **Independent read of the artifact: concern**
272
+
273
+ Independent audit against the diff, `src/core/keyMaterialPaths.ts`, `src/server/fileRoutes.ts`
274
+ (validatePath, openCheckedDescriptor, resolveListEntry, the four handlers) and the three test
275
+ tiers (`tests/unit/file-routes-never-served-key-material.test.ts` re-run: 19/19 green). The
276
+ over-block, signal-vs-authority, §5 Interactions and §7 multi-machine conclusions hold. The
277
+ concern is the §2 under-block inventory and the §5.4 walk's producer coverage: two in-tree
278
+ key producers under `.instar/` are neither driven by the walk nor covered by the list, and the
279
+ artifact's claim that the walk drives "every in-tree generator found by audit" is therefore
280
+ not true as written.
281
+
282
+ - **Concern 1 — `remediation-keys.age` is served, synced and backed up.**
283
+ `src/remediation/RemediationKeyVault.ts:392` writes the env-passphrase flat-file key vault
284
+ to `path.join(stateDir, 'remediation-keys.age')` with mode 0600 (`:280`, `SECURE_FILE_MODE`
285
+ at `:51`). Probed against the built predicates: `isNeverServed('.instar/remediation-keys.age')`
286
+ → `false`; `FileClassifier.classify(...)` → `llm` (git-sync WOULD replicate it); it is in no
287
+ gitignore entry and no backup prefix. It is the same class as `local-state/keys.enc`, which
288
+ the list does include (`keyMaterialPaths.ts:48,84`). The walk never constructs the vault on
289
+ that backend, so the "a key file the list does not cover fails the build" guarantee is blind
290
+ to it. Resolution: add `remediation-keys.age` to `KEY_MATERIAL_PATHS` and `KEY_MATERIAL_FILES`,
291
+ and drive `RemediationKeyVault` (env-passphrase backend) in the walk's step 3.
292
+ - **Concern 2 — `telemetry/local-secret` is served.** `src/monitoring/TelemetryAuth.ts:28,63`
293
+ writes a 32-byte HMAC secret to `.instar/telemetry/local-secret` (0600). Probed:
294
+ `isNeverServed('.instar/telemetry/local-secret')` → `false` (read/download/link/list reach
295
+ it); the classifier already says `never-sync`, so only the route floor and backup/gitignore
296
+ are open. The walk does not drive `TelemetryAuth`, and its name predicate (`\.secret$`) would
297
+ not match `local-secret` even if it did (the 0600 arm would). Resolution: add
298
+ `telemetry/local-secret` to the list (and `KEY_MATERIAL_FILES`), drive `TelemetryAuth.ensure`
299
+ in the walk.
300
+ - **Concern 3 — §2 understates the hard-link inode gap.** §2 names only a `.superseded-*`
301
+ sibling as outside `KEY_MATERIAL_FILES`. Also outside it: `machine/secrets-master.key`
302
+ (`src/core/SecretStore.ts:120` — the master key for the encrypted secret store), every
303
+ `machine-ssh/<kind>-ed25519-g<N>` private key (`src/core/MachineSshIdentity.ts:84`, generation-
304
+ named so not statically enumerable), `secrets/passkeys/store.enc`
305
+ (`src/core/PasskeyCredentialStore.ts:45`) and the `origin-sessions-<digest>.json` file
306
+ (`OriginSessionRegistry.ts:57`). All are refused by every PATH check; only a hard link under an
307
+ innocent name outside `.instar/` escapes (`fileRoutes.ts:337-350` compares the opened inode
308
+ against `KEY_MATERIAL_FILES` only). Resolution: add `machine/secrets-master.key` and
309
+ `secrets/passkeys/store.enc` to `KEY_MATERIAL_FILES` (static names), and either readdir
310
+ `machine-ssh/` on the rare `nlink > 1` path or state the SSH-key and origin-session cases
311
+ explicitly in §2 as the same operator-write-access cost.
312
+ - **Concern 4 — the "every in-tree generator found by audit" sentence (§2, first bullet).**
313
+ Rewrite to name the producers actually driven (the walk's step 3 list) and the two above as
314
+ added, so the artifact does not certify a completeness the grep (`mode: 0o600` /
315
+ `writeFileAtomicOwnerOnly` across `src/`) does not support.
316
+
317
+ Non-blocking notes (verified, no change required):
318
+ - Over-block: none found. `conversations.json`, `trust-profiles.json`, thread history,
319
+ `invitations.json`, `AGENT.md` and ordinary docs stay servable (unit `:92-100`, walk `:273-278`,
320
+ e2e `:97-103`). The `realpath` non-ENOENT → 403 change (`fileRoutes.ts:292-298`) only
321
+ re-codes what was a 500 (ELOOP/EACCES); a dangling symlink still answers 404 (lstat succeeds,
322
+ realpath ENOENT), so no previously-served case stops serving.
323
+ - Under-block through the four routes by PATH: none found. Every handler goes through
324
+ `validatePath` (requested-path deny at `:220`, resolved-path deny at `:283`, resolved
325
+ `blockedFilenames` at `:649`/`:992`/`:1060`), `list` admits per entry via `resolveListEntry`
326
+ (`:368-388`) in both the root and the sub-directory branch, `read`/`download` serve from the
327
+ proven descriptor (`:678-690`, `:1014-1029`), `link` resolves before minting and its
328
+ `editable` flag consults the resolved path (`:1074-1077`). `isNeverServed` case-folds
329
+ (`:151-158`), so the macOS case gap is closed.
330
+ - Signal-vs-authority: an enumerated floor (prefix list + device/inode equality); no heuristic.
331
+ - §5 Interactions and §7 posture match the code: `isNeverEditable` delegates to `isNeverServed`
332
+ (`:458`), the list is code with no replicated state, the gitignore/classifier arms are what
333
+ keep the key off a sibling.
334
+ - (f) Deferral scan: neither the artifact nor `upgrades/next/files-never-serve-key-material.md`
335
+ contains a pattern the pre-commit check matches (`scripts/instar-dev-precommit.js:901-914`:
336
+ "deferred", "out of scope today/for now", "not in this PR", "follow-up"); the artifact's
337
+ "out of scope by design" (§2) is not a match, and the check reads the SPEC (`:645,666`), not
338
+ the artifact.
339
+
340
+ **Author's resolution of the concerns (same change, before commit):**
341
+
342
+ - Concern 1 — `remediation-keys.age` added to `KEY_MATERIAL_PATHS` and `KEY_MATERIAL_FILES`
343
+ (`src/core/keyMaterialPaths.ts`); the walk now drives
344
+ `RemediationKeyVault.forStateDir(stateDir, { forceBackend: 'env-passphrase', … })` and
345
+ asserts the file is found.
346
+ - Concern 2 — `telemetry/local-secret` added to both lists; the walk drives
347
+ `new TelemetryAuth(stateDir).provision()`; `telemetry/install-id` (a bare UUID) is excused by
348
+ a UUID-shape predicate, re-run on the live bytes, so it stays servable without hiding a key.
349
+ - Concern 3 — `machine/secrets-master.key` and `secrets/passkeys/store.enc` added to
350
+ `KEY_MATERIAL_FILES`; `openCheckedDescriptor` now builds its inode candidates from the static
351
+ list PLUS a `readdir` of `machine-ssh/` and of the stateDir root for `origin-sessions-*`
352
+ (`keyInodeCandidates` in `src/server/fileRoutes.ts`), on the `nlink > 1` path only. §2
353
+ states the remaining `identity.json`-sibling case as a cost.
354
+ - Concern 4 — the §2 sentence now names the producers the walk drives and says the list is
355
+ maintained by audit, not a proof of completeness.
356
+
357
+ **Reviewer re-read after the resolutions: concur**
358
+
359
+ Each resolution verified from the code, not the description; the walk and unit files re-run
360
+ green together (26/26).
361
+ - Concern 1/2 closed: `remediation-keys.age` and `telemetry/local-secret` are in
362
+ `KEY_MATERIAL_PATHS` (`src/core/keyMaterialPaths.ts:69,73`) and `KEY_MATERIAL_FILES`
363
+ (`:99,100`); the walk drives `RemediationKeyVault.forStateDir(..., forceBackend:
364
+ 'env-passphrase')` and `new TelemetryAuth(stateDir).provision()`
365
+ (`tests/integration/file-routes-never-served-walk.test.ts:164-165`) and asserts both files
366
+ are found (`:220-221`). `telemetry/install-id` is excused only while its live bytes match the
367
+ UUID-shape predicate (`:71`, re-run at `:192-195`), so the excuse cannot rot into hiding a key.
368
+ - Concern 3 closed: `machine/secrets-master.key` and `secrets/passkeys/store.enc` are in
369
+ `KEY_MATERIAL_FILES` (`:97-98`); `openCheckedDescriptor` now iterates
370
+ `keyInodeCandidates(stateDir)` (`src/server/fileRoutes.ts:339`, builder at `:365-378`) = the
371
+ static list + a `readdir` of `machine-ssh/` (`KEY_MATERIAL_DYNAMIC_DIRS`,
372
+ `keyMaterialPaths.ts:111`) + stateDir-root names starting `origin-sessions-`
373
+ (`KEY_MATERIAL_ROOT_NAME_PREFIXES`, `:112`), on the `nlink > 1` path only; a missing dir is
374
+ skipped, never a 500.
375
+ - Concern 4 closed: §2 now names the driven producers and calls the list audit-maintained,
376
+ not a completeness proof.
377
+ - Non-blocking: the `machine-ssh/` readdir also enumerates its non-key files
378
+ (`directional-proofs.json`, `peer-admissions.json`, `identity.json` metadata) as inode
379
+ candidates — a hard link to one of those is refused too. That is an over-refusal on a
380
+ never-served directory, not an admit, so it is within the stated floor.
381
+
382
+ ---
383
+
384
+ ## Evidence pointers
385
+
386
+ - `tests/unit/file-routes-never-served-key-material.test.ts` — static parity + route behaviour.
387
+ - `tests/integration/file-routes-never-served-walk.test.ts` — the §5.4 walk over a real init + pair (7 tests).
388
+ - `tests/e2e/files-never-serve-key-material-alive.test.ts` — production boot, feature-is-alive, migration idempotency.
389
+ - Existing coverage re-run green: `fileRoutes-never-served`, `fileRoutes-link-allowed-paths`,
390
+ `file-editor-never-editable-bypasses`, `identity-protected-files-routes`, `file-viewer-e2e`,
391
+ `BackupManager-*`, `backup-manager`, `file-classifier`, `PostUpdateMigrator-gitignore`.
392
+
393
+ ---
394
+
395
+ ## Class-Closure Declaration (display-only mirror)
396
+
397
+ No agent-authored-artifact defect — not applicable. This change adds no self-triggered
398
+ controller (no loop, monitor, sentinel, reaper, scheduler or recovery path; it only refuses
399
+ requests synchronously), so the `unbounded-self-action` class is not touched either.
@@ -0,0 +1,151 @@
1
+ # Side-Effects Review — Telegram origin: a reply from a non-lease machine is forwarded to the holder; a failed forward is held durably and reported (ACT-058)
2
+
3
+ **Version / slug:** `telegram-origin-forward-to-holder`
4
+ **Date:** `2026-10-09`
5
+ **Author:** Echo (builder for CMT-706, §4 of `docs/specs/a2a-single-agent-identity.md`)
6
+ **Second-pass reviewer:** Echo review subagent (independent read; verdict appended below)
7
+
8
+ **Spec:** `docs/specs/a2a-single-agent-identity.md` §4 (converged + approved, ELI16 beside it). This PR builds §4 ONLY; §1/§2/§3/§5 are other builders' PRs on the same spec.
9
+ **Files:** `src/messaging/telegram-origin/OriginForwardToHolder.ts` (new), `OriginMesh.ts`, `OriginMeshRelay.ts`, `OriginStore.ts`, `OriginStore.worker.ts`, `OriginStoreBackend.ts`, `StoreTypes.ts`, `TelegramOriginBoot.ts`, `TelegramOriginRuntime.ts`, `TelegramOriginService.ts`, `OriginConfig.ts`, `OriginAwareness.ts`, `types.ts`, `src/messaging/TelegramAdapter.ts`, `src/commands/server.ts`, `src/server/routes.ts`, `src/scaffold/templates.ts`, `src/core/PostUpdateMigrator.ts`, `src/testing/selfActionRegistry.ts`, three new test files + one updated migration test.
10
+
11
+ ## Summary of the change
12
+
13
+ Today a Telegram reply authored on a machine that holds the bot token but NOT the serving lease is refused by `authorize` and parked in an in-memory `destination-not-authorized` hold: silent to the user, lost on restart, and replayed every 15 min only to be refused again while the machine stays standby (the 2026-10-08 incident). §4 makes that machine FORWARD the sealed operation to the lease holder over the existing signed origin mesh (`capabilities` + `submit`), with `executionOwnerMachineId = holder`; the holder now runs the SAME lease check the direct path runs and answers a typed `not-lease-holder`. The reply request runs a bounded lease-settling window (≤15 s) plus ONE forward attempt (≤30 s); any failure becomes a DURABLE hold through the existing `recordOperationState('held')` with a new `hold_reason = lease-not-held` (+ a `holdDetail` carrying what recovery needs), the fixed outage notice goes THROUGH the holder, and ONE `high` attention item `telegram-origin-held:<topic>` is raised (three topics on one machine within an hour collapse to one). Recovery re-forwards the SAME operation (quick ladder at +10 s/+20 s from the 5 s boot tick, then the existing 15-min schedule); an attempt the holder never confirmed is resolved at that holder's `receipt`, never re-sent blind; after a lease move the old record is durably SUPERSEDED and a new one bound to the new holder lands once. Expiry goes through the existing `expireOperation` and is reported once with honest wording. `/health → telegramOrigin.heldForward`, `/telegram/origins/status.held[]` (now a union of the in-memory cache and the durable rows, each with its reason) are the read surfaces. The forward is switchable (`messaging[].config.messageOrigin.forwardToHolder.enabled`, default true, live); the durable held state, its reason and the notice are NOT behind the flag.
14
+
15
+ A latent bug found by the integration tier and fixed here: the store's recovery reconstruction added `allowedDerivations: []` to an ordinary child, which the original sealed admission omits, so every store-reconstructed replay failed the holder's seal check (`origin-plan-invalid`). The reconstruction now reproduces the sealed shape exactly.
16
+
17
+ ## Decision-point inventory
18
+
19
+ - `TelegramAdapter.sendToTopic` / `willRelay()` — **modify** — the send decision becomes "no usable token OR (pool enabled AND this machine does not hold the lease)". `willRelay()` is true only for a DEFINITE forward (a healthy peer holds the lease); a still-settling lease keeps the local tone gate.
20
+ - `OriginMesh.handleOriginMesh` (holder-side `submit`) — **modify** — adds the lease check (`not-lease-holder`, typed, retryable) AFTER `execution-owner-mismatch` and BEFORE any store write. It executed unchecked before.
21
+ - `TelegramOriginService.#holdUnauthorized` — **add** — names WHY `authorize` refused: `lease-not-held` (durable, recoverable, notified) vs `destination-not-authorized` (foreign chat — unchanged, never forwarded).
22
+ - `OriginForwardToHolder.forwardReplyToHolder` — **add** — settle → one attempt → durable hold. `recoverForwardedHold` — **add** — receipt-first on outcome-unknown; same-operation re-forward; supersede on holder change. Pure orchestration; every side effect is injected.
23
+ - `OriginStoreBackend.supersedeOperation` / `recordForwardedAcceptance` / `listHeldOperations` / `heldForwardAdmissions` — **add** — durable state transitions; `supersede` refuses a row with any child not `queued` or any claimed entry.
24
+ - `TelegramOriginRuntime.runForwardLadder` — **add** — the 5 s tick driver, single-flight with `recoverHeld`.
25
+ - Attention item raise (`telegram-origin-held:<topic>` / aggregate) — **add** — signal only; hub-only, collapsed.
26
+
27
+ ---
28
+
29
+ ## 1. Over-block
30
+
31
+ - **A standby whose lease view is briefly stale** (holder expired but not yet re-elected) is `settling`: the request waits up to 15 s before anything is written. A legitimate local send on the machine that is ABOUT to win the lease is delayed by at most that window, never refused: a window that settles to self sends locally (`OriginForwardSettledLocallyError` → the adapter continues the local path).
32
+ - **A standby that cannot name any holder for 15 s** holds durably (`lastAttempt: unsettled`) rather than sending locally. This is the spec's deliberate choice: a dead holder whose lease has not expired must not be bypassed by a bootleg local send. The hold is reported and the ladder/schedule carry it; the first real holder is bound by the supersede path.
33
+ - **`willRelay()` returns true for a DEFINITE forward**, which skips the LOCAL tone gate (the holder's full outbound gate still runs on every forwarded operation — `authorizeSendPolicyDispatch` + `runtime.service.admit` + the holder's own policy). If the lease then settles on THIS machine (or the decision flips between the route's read and the send), the adapter refuses to send locally with a typed `OriginForwardSettledLocallyError` when the caller passed `gateSkippedForRelay`; the route catches it, runs the gate, and re-sends. No message is gated zero times (second-pass finding 3).
34
+ - **Media, edits and pins from a standby** are not forwarded in v1 (spec §4.1): they take the durable, reported hold. Over-block by design; the spec names them.
35
+
36
+ ## 2. Under-block
37
+
38
+ - **A holder that accepts a forward but whose Telegram call fails after admit** is the holder's own existing outbox problem (its recovery), not this layer's; the standby's `receipt` RPC will read the holder's state honestly.
39
+ - **A transport timeout on `submit` is `outcome-unknown`**: never retried, resolved at the SAME holder's `receipt` on the next step. If the holder stays unreachable for the whole 6 h deadline, the row expires `effect-unknown` and is reported with the "may or may not have been delivered" wording. Exactly one copy may have reached the user; a second is structurally impossible on this path.
40
+ - **The in-memory `heldStatus` cache can list a row the store has since resolved** for one refresh interval (≤30 s). The union dedupes on operation id and the durable row wins the `hold_reason`; the dashboard read is observability only.
41
+ - **A standby with the forward switched OFF, a pool-dark two-machine agent, or a lease lost between the adapter's decision and `authorize`** takes the DIRECT-path hold: `#holdUnauthorized` records the row `held` with `lease-not-held`. Its sealed owner is this machine, so it cannot be forwarded; `recoverHeld` RE-ADMITS it the moment `holdsLease()` is true again and the ordinary replay delivers it (the claim fence admits `admitted|partial` only — without the re-admit this was a guaranteed 6 h loss, second-pass finding 1). While not the holder it waits (no reserve spent), and it is reported ONCE via `onLeaseHoldObserved` (notice through the holder if one is reachable, else the collapsed item). The kill switch therefore restores today's local wait minus the silence (the spec's point).
42
+ - **A refusal that names our operation id** (the holder's `admit` ran, or its outbox/credential refused after it) is custody at the holder: treated as `outcome-unknown`, resolved at that holder's `receipt`, never re-forwarded; a receipt of `admitted`/`held`/`partial` there is `owned-by-holder` (its own recovery delivers) and keeps the row without re-sending (finding 4). Only `no-record` (a new distinct receipt answer) / `not-admitted` / terminal non-delivery frees a re-forward; `origin-receipt-unavailable` is ambiguous (a store outage looks like absence) and keeps waiting.
43
+
44
+ ## 3. Level-of-abstraction fit
45
+
46
+ Right layer. The forward rides the EXISTING signed origin mesh (`capabilities` + `submit` + `receipt`) and the EXISTING durable operation-state writer, recovery scheduler, expiry path and attention queue; nothing is re-implemented. `submitOriginToHolder` is ONE transport shared by the request-time relay (`relayOriginBot`) and the recovery re-forward, so "what a refusal means" cannot drift between the two. The holder-side lease check is the SAME predicate the direct path uses (`holdsLease` threaded from boot). The quick ladder is scheduling state stored in the row's own `holdDetail`, not a second queue.
47
+
48
+ ## 4. Signal vs authority compliance
49
+
50
+ **Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
51
+
52
+ - [x] No — this change produces a signal consumed by an existing smart gate.
53
+ - [ ] No — this change has no block/allow surface.
54
+ - [ ] Yes — but the logic is a smart gate with full conversational context.
55
+ - [ ] ⚠️ Yes, with brittle logic.
56
+
57
+ The only new refusal (`not-lease-holder`) is a deterministic ownership invariant (the lease), not a content judgment, and it is TYPED and RETRYABLE — the forwarding side re-resolves the holder and tries again; it never drops the message. Everything content-related (tone gate, send policy, credential wall) stays where it was: on the holder's full gate for every forwarded operation. The attention item and the outage notice are signals. No brittle logic holds blocking authority.
58
+
59
+ ## 4b. Judgment-point check (Judgment Within Floors standard)
60
+
61
+ No new static heuristic at a competing-signals decision point. The lease is an invariant (one holder, fenced epoch), the settle window and the ladder delays are safety bounds on an irreversible action (a user-visible send), and the ONLY competing-signal choice — "did the old holder deliver it?" — is resolved by asking that holder (`receipt`), never by a heuristic; an unanswerable question stays held, never guessed.
62
+
63
+ ---
64
+
65
+ ## 5. Interactions
66
+
67
+ - **Shadowing:** the holder-side lease check runs AFTER `execution-owner-mismatch` (kept — the receipt dedupe depends on it) and BEFORE `origin-plan-invalid` / store writes, so a refused submit leaves NO record at the holder. The standby's `#holdUnauthorized` runs only where `destination-not-authorized` ran before; with `holdsLease()` true (single machine, no coordinator) behaviour is byte-identical.
68
+ - **Double-fire:** `runForwardLadder` (5 s tick) and `recoverHeld` (sentinel, ~5 min) share the runtime's `recovering` flag (single-flight). Both can select the same `held` row (the ladder never calls `reserveRecoveryAttempt`), but the holder dedupes on operation id (idempotent `admitInternal` + its claim fence), so a double visit costs one refused RPC at most, never a second post. The LOCAL send path (lease came back here) has no holder fence, so it writes a durable `local-sent` marker BEFORE sending and refuses any later send of that row — a failed `resolve` or a crash after the send can only ever leave an honest "may or may not" expiry, never a second copy (finding 2). The notice is sent through the holder and is itself an origin operation (dedup + policy at the holder).
69
+ - **Races:** the holder change is ONE store transaction (`supersedeWithAdmission`): refuse unless the old row is still plainly held (every child `queued`, nothing `claimed`), admit the NEW sealed admission, record it `held` with its reason + detail, mark the old row `superseded` linked to the new id. Either both rows move or neither does; a refused supersede admits the new operation NOWHERE and forwards nothing (finding 5). `recordForwardedAcceptance` refuses non-queued children. The in-request `hold` returns the state writer's boolean; a `false` (claimed entry) is audited `hold-not-recorded` and the row stays `admitted` for the ordinary replay (finding 6).
70
+ - **Feedback loops:** a held forward raises one deduped item and the fixed notice ONCE at hold time (never on each ladder step). Attempts are bounded by the ladder (2) + the 15-min schedule + the 6 h deadline (modeled in `selfActionRegistry` as `telegram-origin-forward-ladder`, bound 27 per operation, horizon-independent).
71
+
72
+ ## 6. External surfaces
73
+
74
+ - **Other agents / users:** a user on a standby-owned topic now receives their reply (via the holder) instead of silence; on failure they receive the fixed notice (through the holder, so it carries the normal origin footer), never free text.
75
+ - **External systems:** Telegram sees the SAME single `sendMessage`, executed by the holder's token. A new mesh refusal reason (`not-lease-holder`) and two new response fields (`deliveryMachineId`, `forwardedFromMachine`) are ADDITIVE; an older peer ignores them. An older standby that never sends `expectedOwner` is still answered correctly.
76
+ - **Persistent state:** three additive nullable columns on `telegram_origin_operations` (`hold_reason`, `hold_detail_json`, `hold_expiry_reported_at`; `ALTER TABLE … ADD COLUMN` guarded by `PRAGMA table_info`), a new terminal operation state `superseded` (archivable/cleanable like `expired`), new child/entry state `superseded`. An older reader treats a held row with an unknown reason as the ordinary hold (additive). Audit file `logs/telegram-origin-held.jsonl` (content-free rows: ids, reasons, phases).
77
+ - **Timing:** the reply request is bounded at settle (≤15 s) + one attempt (≤30 s: per-RPC timeout clamped to 15 s so `capabilities`+`submit` fit) + the notice through the holder (three RPCs at 5 s each — `relayOriginBot`'s own `capabilities` plus `submitOriginToHolder`'s `capabilities`+`submit` — ≤15 s): worst case ≈60 s, under the route's 120 s, so a caller never times out before the hold is written and re-runs with a NEW operation id (finding 7).
78
+ - **Operator surface (Mobile-Complete):** no new operator ACTION. The read surfaces are the existing dashboard Message-origins tab (held listing now carries `hold_reason`) and the attention hub item. Nothing PIN-gated was added.
79
+
80
+ ## 6b. Operator-surface quality
81
+
82
+ No operator surface file touched — not applicable (no `dashboard/*` markup changed; the held listing's new fields ride the existing status JSON the tab already renders).
83
+
84
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
85
+
86
+ **proxied-on-read + machine-local durable state by design.** The held row lives on the machine that authored the reply (it is that machine's outbox; the sealed payload is there), and the pool-wide answer is the existing `?scope=pool` origin status/audit merge plus the attention hub. The forward itself IS the multi-machine mechanism. One-voice: the notice and the item are raised by the holding standby ONCE per hold (collapsed per machine per hour); the holder sends nothing of its own. Topic transfer: a held row is bound to an execution owner, and a lease move is handled by the supersede path (old row terminal + linked, new row bound to the new owner); it does not strand. URLs: none generated. Single machine: `holdsLease()` is true with no coordinator, `holderForwardPolicy` is only wired when a mesh identity exists → strict no-op.
87
+
88
+ ## 8. Rollback cost
89
+
90
+ - **Kill switch (live, no restart):** `messaging[].config.messageOrigin.forwardToHolder.enabled: false` → no forward; a non-holder reply becomes today's local wait, now DURABLE (`held`, `lease-not-held`), reported once, and RE-ADMITTED + replayed when the lease returns (the pre-PR row was replayed every 15 min and refused while standby; the behaviour is unchanged, the silence is gone). The holder-side lease check stays (it is a safety floor; the direct path already ran it).
91
+ - **Code revert:** pure revert + patch release. The three columns are nullable and ignored by older code; `superseded` rows are terminal and archived by the existing retention; `logs/telegram-origin-held.jsonl` is append-only observability. No data migration, no agent state repair. Rows held `lease-not-held` at revert time stay listed in the durable held listing and expire on their normal deadline.
92
+
93
+ ---
94
+
95
+ ## Conclusion
96
+
97
+ The review changed two things in the inherited draft: (1) the in-request ladder (3×10 s inside the reply request) was moved OUT of the request to the recovery tick, per the converged §4.2 wording (one attempt in-request, +10 s/+20 s from the tick, then the schedule), so the route can never time out before the hold is written; (2) the holder-change path was changed from "re-bind the same operation id" to the spec's supersede, which keeps the holder-side `execution-owner-mismatch` guard meaningful. The integration tier surfaced a pre-existing reconstruction bug that made every store-replayed forward fail the holder's seal; fixed. The second-pass review then found three blocking defects and three cheap ones (below); all six were fixed before commit: the direct-path hold is re-admitted when the lease returns and reported once; the local re-send is fenced by a durable `local-sent` marker; a skipped gate is re-run through a typed error; holder custody is receipt-first with a distinct `no-record` answer; the supersede is one atomic store transaction that forwards nothing when refused; the hold boolean is honoured and audited; the notice RPCs are shortened. Tests cover each (unit + the integration direct-path/supersede/custody scenarios).
98
+
99
+ ---
100
+
101
+ ## Second-pass review (if required)
102
+
103
+ **Reviewer:** Echo review subagent (independent read)
104
+ **Independent read of the artifact: concur**
105
+
106
+ - **CONCERN — a direct-path `lease-not-held` hold is now a guaranteed 6 h loss (Q2, §2 bullet 4, §8).** `TelegramOriginService.ts:400-404` `#holdUnauthorized` flips the local row to `state='held'`; `OriginStoreBackend.ts` `claim()` (~:534) admits only `admitted|partial`, and nothing ever writes `admitted` back (grep: zero callers). The local replay (`TelegramOriginRuntime.ts:452-466` → `OriginBotEgress.ts:114` → `executePreparedBot` → claim) therefore answers `outbox-not-ready` every tick until the 6 h expiry. This path is taken with the kill switch OFF, on a DEFAULT pool-dark two-machine agent (`server.ts:26242` returns `'local'` when `_sessionPoolStage()==='dark'`), and when the lease is lost between the adapter decision and `authorize`. Before this PR the row stayed `admitted` and was delivered once the lease returned (the old reason was in the recoverable list, `TelegramOriginService.ts:416` pre-diff). No notice/item fires on that path either: `TelegramOriginRuntime.ts:137-139` excludes `lease-not-held`. Resolution: in `recoverHeld`'s local branch re-admit a `lease-not-held` owner-self row (`recordOperationState({state:'admitted'})`) once `holdsLease()` is true (or do not flip state in `#holdUnauthorized`), and raise the notice/item there.
107
+ - **CONCERN — the local-send recovery path can double-post (Q1).** `OriginForwardToHolder.ts:299-304`: route `self` → `sendLocal` = a NEW origin op via `telegram.sendToTopic` (`server.ts:26274-26277`, random op id, no logical-send key) → `resolve`, whose result is ignored (:302). `runForwardLadder` (`TelegramOriginRuntime.ts:321-331`) reserves nothing before the attempt — `nextAt` advances only inside `retain` — so if `resolve` throws (store worker restart) or returns false the next 5 s tick sends again. The holder's claim fence protects forwards; nothing fences the local send. Resolution: derive the local op id from the held row via the logical-send key so a repeat replays the receipt (`TelegramOriginService.ts:486-492`), or rehold `lastAttempt:'local-sent'` BEFORE the send and refuse a second.
108
+ - **CONCERN — a message can be gated zero times (Q4, §1 bullet 3).** `routes.ts:17377-17384` skips `checkOutboundMessage` AND the credential wall when `willRelay()` is true; `TelegramAdapter.ts:1413` re-reads `decide()` later and the relay may throw `OriginForwardSettledLocallyError` (`server.ts:26294`), after which the send proceeds locally (`TelegramAdapter.ts:1444-1447`) with no gate. A lease move between the two reads is the ancestor incident's own shape. Resolution: the route passes `gatedLocally:false`; the adapter refuses a local fallback in that case with a typed error the route answers by gating and retrying.
109
+ - **False expiry report.** A holder post-admit refusal (`credential-capacity-unavailable`, `outbox-not-ready`) leaves the holder's row `admitted`; the holder's OWN `recoverHeld` delivers it (`TelegramOriginRuntime.ts:452-466`), but the standby consults `receipt` only when `lastAttempt==='outcome-unknown'` (`OriginForwardToHolder.ts:286`); every re-forward then hits the claim fence until expiry → "I could not deliver my reply" for a delivered reply, and `/health.heldForward` stays non-zero 6 h. Resolution: receipt-first for any attempt that reached the holder's `admit` (its response carries `operationId`, `OriginMesh.ts:140`).
110
+ - `recoverForwardedHold` forwards `next` even when `supersede` returned false (`OriginForwardToHolder.ts:328-330`); a refusal means the old row left `queued` or is claimed, i.e. may be delivered. Abort the forward of `next` on `superseded===false`.
111
+ - `hold` ignores `recordOperationState`'s boolean (`server.ts:26232`); a `false` (claimed entry) leaves an `admitted` row with no reason or detail and returns as if held.
112
+ - §6 timing is understated: the notice via `relayOriginBot` (`OriginMeshRelay.ts:24,57`) adds up to three more 15 s RPCs (its own `capabilities` is repeated inside `submitOriginToHolder`), worst case ≈90 s — still under the route's 120 s.
113
+ - Summary's "never retried" is inaccurate: the old `destination-not-authorized` row was `admitted` and replayed every 15 min (retried, refused while standby) — which is exactly what makes bullet 1 a regression.
114
+ - **Verified true:** Q3 — lease check `OriginMesh.ts:118` sits after `execution-owner-mismatch` (:112) and before `putVerifiedEvidence`/`admit` (:128-129). Holder dedupe = idempotent `admitInternal` (`inserted:false`) + the claim fence reached through `OriginBotEgress.ts:114`. Q5 — `MultiMachineCoordinator.ts:1018` falls back to `_role==='awake'` (:401 on a single machine); `holderForwardPolicy` is wired only with `meshSelfId` (`server.ts:26240`) and returns `'local'` when the pool is dark. Q6 — the only consumer of reconstructed admissions is `recoverHeld`; `allowedDerivations` readers use `?.includes` (`OriginBrowserRenewal.ts:52`, `OriginBotCompanion.ts:15`) and non-empty arrays round-trip unchanged — safe.
115
+ - **Recommendation:** do not ship until bullets 1-3 are fixed (1 is a regression on the default config; 2 and 3 break the "second copy structurally impossible" and "never gated zero times" claims); 4-6 are cheap and should ride the same PR.
116
+
117
+ **Author's resolution (all findings fixed in this PR before commit):**
118
+ - Finding 1 → `TelegramOriginRuntime.recoverHeld` local branch: a durable `lease-not-held` owner-self row is skipped while `holdsLease()` is false (no reserve spent) and re-admitted (`recordOperationState({state:'admitted'})`) the moment it is true, then replayed through the ordinary path (`authorize` afresh). `refreshHeldForward` reports each such row ONCE through the new `onLeaseHoldObserved` hook (server: notice via the holder when reachable, else the collapsed item). Integration: "a DIRECT-path lease hold is re-admitted and delivered … once its lease returns" (+ once-only hook).
119
+ - Finding 2 → `recoverForwardedHold` writes `lastAttempt:'local-sent'` via `rehold` BEFORE `sendLocal`, refuses any later send of a `local-sent` row, honours `resolve`'s boolean in the audit; expiry wording treats `local-sent` as "may or may not". Unit: the two local-sent tests.
120
+ - Finding 3 → `TelegramAdapter.sendToTopic` option `gateSkippedForRelay`: throws `OriginForwardSettledLocallyError` instead of sending locally when the route skipped its gate; `/telegram/reply` catches it, runs `gateLocally()` (tone gate + wall, releasing the in-flight reservation on a block) and re-sends with the flag off. Unit: the gateSkippedForRelay adapter test.
121
+ - Finding 4 → `submitOriginToHolder`: a refusal naming our operation id is `outcome-unknown` + `admittedAtHolder`; `not-lease-holder` no longer carries an operation id; the holder's `receipt` answers `no-record` for an unknown id (distinct from `origin-receipt-unavailable`); the standby maps `admitted/held/partial` to `owned-by-holder` (retain) and only definite non-admission to a re-forward. Unit: custody + owned-by-holder tests; integration: `no-record`.
122
+ - Finding 5 → `OriginStoreBackend.supersedeWithAdmission` (one transaction; refused ⇒ nothing admitted, nothing forwarded). Unit: store + "REFUSED supersede forwards NOTHING".
123
+ - Finding 6 → `hold` returns the boolean; `forwardReplyToHolder` audits `hold-not-recorded`.
124
+ - Finding 7 → notice RPCs at 5 s (§6 timing corrected to ≈60 s worst case). Finding 8 → Summary corrected.
125
+
126
+ **Re-review (after the author's fixes) — verified against the working tree:**
127
+ - F1 ✓ `TelegramOriginRuntime.ts:478-485`: a durable `lease-not-held` owner-self row is skipped (no reserve) while `holdsLease()` is false and re-admitted (`recordOperationState({state:'admitted'})`, which clears the reason) when true, then replayed through `authorize`; `refreshHeldForward` (:310-321) reports each direct row once via `onLeaseHoldObserved`; `server.ts:26292-26303` sends the notice through the holder when a peer holds the lease and the switch is on, else the collapsed item.
128
+ - F2 ✓ `OriginForwardToHolder.ts` recovery: `rehold(lastAttempt:'local-sent')` lands BEFORE `sendLocal`; a `local-sent` row is retained and never sent again; `resolve`'s boolean is audited; `expiredForwardWording` treats `local-sent` as "may or may not".
129
+ - F3 ✓ `TelegramAdapter.ts:1421` throws `OriginForwardSettledLocallyError` when `gateSkippedForRelay` and the route is now `'local'`; :1453 rethrows a settle-to-self; `routes.ts:17455-17464` catches it, runs `gateLocally()`, releases the content reservation on a block, re-sends with the flag off.
130
+ - F4 ✓ `submitOriginToHolder`: a refusal carrying our operation id → `outcome-unknown` + `admittedAtHolder`; `OriginMesh.ts:124-127` `not-lease-holder` carries no id; receipt answers `no-record` for an unknown id (:55-59); `server.ts:26266-26273` maps `admitted/held/partial/accepted-unconfirmed` → `owned-by-holder` (retain), only definite non-admission → re-forward, `origin-receipt-unavailable` → unreachable.
131
+ - F5 ✓ `OriginStoreBackend.supersedeWithAdmission` is one `db.transaction` (preconditions → `admitInternal` + hold → retire old); `recoverForwardedHold` returns `'retained'` without submitting when it is false. F6 ✓ `server.ts:26231-26234` returns the boolean; `OriginForwardToHolder.ts:243-244` audits `hold-not-recorded`. F7 ✓ `server.ts:26242` notice RPCs at `sendToHolder(holder, 5_000)` → ≈60 s worst case.
132
+ - Residual (non-blocking): a `sendLocal` that throws BEFORE the ordinary path admits its own durable op (advisory refusal, pre-admit hold) leaves the row `local-sent` → expires "may or may not" though nothing was sent (safe direction, reported); `#observedLeaseHolds` is in-memory, so a still-held direct row is re-reported once per restart (item id dedups); `supersedeWithAdmission` bypasses `service.admit`'s `putVerifiedEvidence` for the new self-sealed row (audit-only).
133
+ - Final verdict: **concur** — no double-post path, no silent loss, no zero-gate path remains that I could trace.
134
+
135
+ ---
136
+
137
+ ## Evidence pointers
138
+
139
+ - `tests/unit/telegram-origin-forward-on-standby.test.ts` — 46 tests: settling states, settle window, one-attempt request, ladder state, recovery (same op / supersede / receipt-first), transport meanings, item collapse, expiry wording, config switch + array-aware migration, durable store rows (reason, acceptance, expiry-reported-once, ladder candidates, supersede), service `lease-not-held` vs `destination-not-authorized`, adapter send decision.
140
+ - `tests/integration/telegram-origin-forward-on-standby.test.ts` — 4 tests over the REAL signed mesh HTTP pipeline + real stores/routes: lands via holder (`forwardedFromMachine`); `not-lease-holder` → one attempt → ladder step lands once; lease move → superseded + lands once via the new holder; holder down → 409 `telegram-origin-held {lease-not-held}` on status + `/health` → delivered once by the schedule; notice via holder.
141
+ - `tests/e2e/telegram-origin-forward-lifecycle.test.ts` — 3 tests on the production init path (`bootTelegramOrigin` + `createRoutes`): feature alive (200, never 503), `lease-not-held` durable across a runtime restart, wiring integrity of the lease predicate; migration parity lands once + idempotent; operator opt-out survives.
142
+ - `tests/unit/self-action-convergence.test.ts` — the ratchet proves `telegram-origin-forward-ladder` settles (≤27 per operation, horizon-independent, restart-safe).
143
+
144
+ ---
145
+
146
+ ## Class-Closure Declaration (display-only mirror)
147
+
148
+ - **`defectClass`:** `unbounded-self-action` — this change ADDS a self-triggered retry controller (the forward ladder + schedule re-forward).
149
+ - **`closure`:** `guard`
150
+ - **`guardEvidence`:** `{ enforcementType: ratchet, citation: tests/unit/self-action-convergence.test.ts, howCaught: "control-loop edge = a held forward row whose holdDetail.ladder.nextAt is due (5 s tick) or whose 15-min reserveRecoveryAttempt interval elapsed; steady-state bound = 1 in-request + 2 ladder + ⌊6 h / 15 min⌋ = 27 attempts per operation, then expireOperation makes the row terminal so nothing fires again (count does not scale with the horizon); settling brake = the ladder state and the schedule interval are persisted in the row (restart-safe), the holder dedupes on operation id so a double visit never double-posts, and a holder change supersedes (one durable row at a time) instead of fanning out" }`
151
+ - **`gap`:** not applicable.