approval-md 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/README.md +63 -24
  2. package/SPEC.md +57 -11
  3. package/dist/src/channels/contract.d.ts +34 -1
  4. package/dist/src/channels/contract.js +200 -7
  5. package/dist/src/channels/contract.js.map +1 -1
  6. package/dist/src/channels/telegram.d.ts +123 -11
  7. package/dist/src/channels/telegram.js +218 -23
  8. package/dist/src/channels/telegram.js.map +1 -1
  9. package/dist/src/channels/web.d.ts +9 -0
  10. package/dist/src/channels/web.js +17 -0
  11. package/dist/src/channels/web.js.map +1 -1
  12. package/dist/src/cli/amend.js +214 -30
  13. package/dist/src/cli/amend.js.map +1 -1
  14. package/dist/src/cli/attest.d.ts +9 -0
  15. package/dist/src/cli/attest.js +134 -7
  16. package/dist/src/cli/attest.js.map +1 -1
  17. package/dist/src/cli/channel-telegram.d.ts +99 -26
  18. package/dist/src/cli/channel-telegram.js +311 -13
  19. package/dist/src/cli/channel-telegram.js.map +1 -1
  20. package/dist/src/cli/channel.d.ts +9 -0
  21. package/dist/src/cli/channel.js +9 -0
  22. package/dist/src/cli/channel.js.map +1 -1
  23. package/dist/src/cli/codex-bridge.d.ts +819 -0
  24. package/dist/src/cli/codex-bridge.js +1607 -0
  25. package/dist/src/cli/codex-bridge.js.map +1 -0
  26. package/dist/src/cli/codex.d.ts +1 -1
  27. package/dist/src/cli/codex.js +304 -7
  28. package/dist/src/cli/codex.js.map +1 -1
  29. package/dist/src/cli/daemon.js +4 -1
  30. package/dist/src/cli/daemon.js.map +1 -1
  31. package/dist/src/cli/doctor.js +467 -12
  32. package/dist/src/cli/doctor.js.map +1 -1
  33. package/dist/src/cli/execute.js +25 -2
  34. package/dist/src/cli/execute.js.map +1 -1
  35. package/dist/src/cli/help.d.ts +6 -2
  36. package/dist/src/cli/help.js +165 -60
  37. package/dist/src/cli/help.js.map +1 -1
  38. package/dist/src/cli/hook-codex.d.ts +49 -1
  39. package/dist/src/cli/hook-codex.js +60 -1
  40. package/dist/src/cli/hook-codex.js.map +1 -1
  41. package/dist/src/cli/hook.d.ts +459 -3
  42. package/dist/src/cli/hook.js +1062 -114
  43. package/dist/src/cli/hook.js.map +1 -1
  44. package/dist/src/cli/import.js +1 -1
  45. package/dist/src/cli/import.js.map +1 -1
  46. package/dist/src/cli/main.js +5 -3
  47. package/dist/src/cli/main.js.map +1 -1
  48. package/dist/src/cli/policy-apply.d.ts +195 -0
  49. package/dist/src/cli/policy-apply.js +573 -0
  50. package/dist/src/cli/policy-apply.js.map +1 -0
  51. package/dist/src/cli/policy.js +14 -1
  52. package/dist/src/cli/policy.js.map +1 -1
  53. package/dist/src/cli/preflight.d.ts +151 -13
  54. package/dist/src/cli/preflight.js +398 -41
  55. package/dist/src/cli/preflight.js.map +1 -1
  56. package/dist/src/cli/sandbox.js +17 -1
  57. package/dist/src/cli/sandbox.js.map +1 -1
  58. package/dist/src/cli/scaffold.d.ts +1 -1
  59. package/dist/src/cli/scaffold.js +1 -1
  60. package/dist/src/cli/setup-channel.d.ts +9 -0
  61. package/dist/src/cli/setup-channel.js +28 -1
  62. package/dist/src/cli/setup-channel.js.map +1 -1
  63. package/dist/src/cli/setup-common.d.ts +3 -1
  64. package/dist/src/cli/setup-common.js +3 -2
  65. package/dist/src/cli/setup-common.js.map +1 -1
  66. package/dist/src/cli/setup.d.ts +2 -0
  67. package/dist/src/cli/setup.js +94 -2
  68. package/dist/src/cli/setup.js.map +1 -1
  69. package/dist/src/cli/up.js +115 -51
  70. package/dist/src/cli/up.js.map +1 -1
  71. package/dist/src/cli/values.js +3 -4
  72. package/dist/src/cli/values.js.map +1 -1
  73. package/dist/src/cli/verb-registry.js +174 -9
  74. package/dist/src/cli/verb-registry.js.map +1 -1
  75. package/dist/src/cli/wordmark.d.ts +2 -2
  76. package/dist/src/cli/wordmark.js +2 -2
  77. package/dist/src/codex/broker.d.ts +229 -0
  78. package/dist/src/codex/broker.js +548 -0
  79. package/dist/src/codex/broker.js.map +1 -0
  80. package/dist/src/codex/runner.d.ts +178 -0
  81. package/dist/src/codex/runner.js +231 -0
  82. package/dist/src/codex/runner.js.map +1 -0
  83. package/dist/src/codex/serve.d.ts +56 -0
  84. package/dist/src/codex/serve.js +98 -0
  85. package/dist/src/codex/serve.js.map +1 -0
  86. package/dist/src/codex/workspace-commit.d.ts +219 -0
  87. package/dist/src/codex/workspace-commit.js +549 -0
  88. package/dist/src/codex/workspace-commit.js.map +1 -0
  89. package/dist/src/core/advance-cycle.d.ts +51 -0
  90. package/dist/src/core/advance-cycle.js +66 -2
  91. package/dist/src/core/advance-cycle.js.map +1 -1
  92. package/dist/src/core/agents-md.d.ts +20 -18
  93. package/dist/src/core/agents-md.js +33 -31
  94. package/dist/src/core/agents-md.js.map +1 -1
  95. package/dist/src/core/attest.d.ts +215 -0
  96. package/dist/src/core/attest.js +317 -7
  97. package/dist/src/core/attest.js.map +1 -1
  98. package/dist/src/core/audit.d.ts +18 -0
  99. package/dist/src/core/audit.js +13 -0
  100. package/dist/src/core/audit.js.map +1 -1
  101. package/dist/src/core/channel-owner.d.ts +213 -0
  102. package/dist/src/core/channel-owner.js +358 -0
  103. package/dist/src/core/channel-owner.js.map +1 -0
  104. package/dist/src/core/command-class.d.ts +154 -0
  105. package/dist/src/core/command-class.js +673 -20
  106. package/dist/src/core/command-class.js.map +1 -1
  107. package/dist/src/core/commit-guard.d.ts +272 -0
  108. package/dist/src/core/commit-guard.js +424 -0
  109. package/dist/src/core/commit-guard.js.map +1 -0
  110. package/dist/src/core/daemon-actor.d.ts +45 -0
  111. package/dist/src/core/daemon-actor.js +54 -0
  112. package/dist/src/core/daemon-actor.js.map +1 -0
  113. package/dist/src/core/dark-session.d.ts +109 -8
  114. package/dist/src/core/dark-session.js +266 -82
  115. package/dist/src/core/dark-session.js.map +1 -1
  116. package/dist/src/core/decision-refusal.d.ts +23 -2
  117. package/dist/src/core/decision-refusal.js +24 -2
  118. package/dist/src/core/decision-refusal.js.map +1 -1
  119. package/dist/src/core/env-file.d.ts +5 -0
  120. package/dist/src/core/env-file.js +60 -1
  121. package/dist/src/core/env-file.js.map +1 -1
  122. package/dist/src/core/execute.d.ts +15 -2
  123. package/dist/src/core/execute.js +15 -2
  124. package/dist/src/core/execute.js.map +1 -1
  125. package/dist/src/core/gate.d.ts +86 -1
  126. package/dist/src/core/gate.js +81 -1
  127. package/dist/src/core/gate.js.map +1 -1
  128. package/dist/src/core/gesture-refusal.d.ts +166 -0
  129. package/dist/src/core/gesture-refusal.js +188 -0
  130. package/dist/src/core/gesture-refusal.js.map +1 -0
  131. package/dist/src/core/harness-version.d.ts +1 -1
  132. package/dist/src/core/harness-version.js +3 -1
  133. package/dist/src/core/harness-version.js.map +1 -1
  134. package/dist/src/core/instance.d.ts +59 -2
  135. package/dist/src/core/instance.js +113 -0
  136. package/dist/src/core/instance.js.map +1 -1
  137. package/dist/src/core/log.d.ts +39 -1
  138. package/dist/src/core/log.js.map +1 -1
  139. package/dist/src/core/policy-explain.d.ts +10 -0
  140. package/dist/src/core/policy-explain.js +32 -0
  141. package/dist/src/core/policy-explain.js.map +1 -1
  142. package/dist/src/core/policy-load.d.ts +41 -1
  143. package/dist/src/core/policy-load.js +21 -3
  144. package/dist/src/core/policy-load.js.map +1 -1
  145. package/dist/src/core/policy-match.d.ts +43 -0
  146. package/dist/src/core/policy-match.js +52 -0
  147. package/dist/src/core/policy-match.js.map +1 -1
  148. package/dist/src/core/policy-proposal.d.ts +52 -0
  149. package/dist/src/core/policy-proposal.js +102 -2
  150. package/dist/src/core/policy-proposal.js.map +1 -1
  151. package/dist/src/core/protected-path-guard.d.ts +117 -4
  152. package/dist/src/core/protected-path-guard.js +362 -48
  153. package/dist/src/core/protected-path-guard.js.map +1 -1
  154. package/dist/src/core/question-preempted.d.ts +141 -0
  155. package/dist/src/core/question-preempted.js +152 -0
  156. package/dist/src/core/question-preempted.js.map +1 -0
  157. package/dist/src/core/read-scope.d.ts +172 -0
  158. package/dist/src/core/read-scope.js +252 -0
  159. package/dist/src/core/read-scope.js.map +1 -0
  160. package/dist/src/core/sandbox.d.ts +81 -0
  161. package/dist/src/core/sandbox.js +190 -1
  162. package/dist/src/core/sandbox.js.map +1 -1
  163. package/dist/src/core/sender-identity.d.ts +476 -0
  164. package/dist/src/core/sender-identity.js +572 -0
  165. package/dist/src/core/sender-identity.js.map +1 -0
  166. package/dist/src/core/shlex.d.ts +102 -0
  167. package/dist/src/core/shlex.js +159 -0
  168. package/dist/src/core/shlex.js.map +1 -0
  169. package/dist/src/core/values.d.ts +18 -8
  170. package/dist/src/core/values.js +36 -1
  171. package/dist/src/core/values.js.map +1 -1
  172. package/dist/src/daemon/advance.d.ts +10 -0
  173. package/dist/src/daemon/advance.js +25 -4
  174. package/dist/src/daemon/advance.js.map +1 -1
  175. package/dist/src/daemon/daemon.js +9 -0
  176. package/dist/src/daemon/daemon.js.map +1 -1
  177. package/dist/src/daemon/git-evidence.d.ts +2 -2
  178. package/dist/src/daemon/git-evidence.js +1 -1
  179. package/dist/src/mcp/server.js +8 -0
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/docs/cli-reference.md +932 -32
  182. package/docs/codex-enforced-session.md +75 -2
  183. package/docs/codex-workspace-broker.md +118 -0
  184. package/package.json +3 -1
  185. package/schema/event.schema.json +538 -9
  186. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  187. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  188. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  189. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  190. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  191. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  192. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  193. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  194. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  195. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  196. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  197. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  198. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  199. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  200. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  201. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  202. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  203. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  204. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  205. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  206. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  207. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  208. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  209. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  210. package/schema/fixtures/policy/valid/canonical.json +1 -1
  211. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  212. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  213. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  214. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  215. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  216. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  217. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  218. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  219. package/schema/fixtures/values/invalid/version-float.json +1 -0
  220. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  221. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  222. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  223. package/schema/fixtures/values/valid/full.json +5 -7
  224. package/schema/fixtures/values/valid/minimal.json +1 -1
  225. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  226. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  227. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  228. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  229. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  230. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  231. package/schema/fixtures/values-md/valid/absent.md +1 -1
  232. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  233. package/schema/policy.schema.json +54 -2
  234. package/schema/values.schema.json +7 -11
  235. package/schema/fixtures/values/invalid/version-string.json +0 -1
@@ -14,12 +14,18 @@
14
14
  * are read here and passed to the channel as values (SPEC.md §5.1: policy
15
15
  * carries the env-var *names*, never the secrets). Nothing in `channels/`
16
16
  * touches `process.env`.
17
- * 2. **It declares who is approving.** The decision is recorded against the
18
- * human actor from `--as` / `APPROVAL_HUMAN`, never against anything the
19
- * callback carried. SPEC.md §11: identity in v0.1 is config-declared, the
20
- * trust boundary is the local machine, and everyone who can reach the
21
- * configured chat can approve as that actor. This is stated in `--help`
22
- * because an operator has to be able to see it without reading the source.
17
+ * 2. **It declares the identity the process itself acts under.** `--as` /
18
+ * `APPROVAL_HUMAN` is what this listener is, and it is what a decision is
19
+ * recorded as whenever the policy maps no sender for this channel: SPEC.md
20
+ * §11's config-declared identity, where the trust boundary is the local
21
+ * machine and everyone who can reach the configured chat can approve as that
22
+ * actor. Since APRV-324 a policy MAY map Telegram account ids to approvers,
23
+ * and then the decision is recorded against the person the operator attested
24
+ * the tapping account to, and an unmapped account is refused rather than
25
+ * recorded as this listener. Either way the choosing happens in
26
+ * `channels/contract.ts` against the attested policy, never here and never
27
+ * in the channel. Both settings are stated in `--help` because an operator
28
+ * has to be able to see which one they are in without reading the source.
23
29
  * 3. **It holds the token.** A grant mints a single-use execution token;
24
30
  * `recordChannelDecision` returns it to *this* handler, which prints it on
25
31
  * **stdout** and never hands it back to the channel. It is never sent to
@@ -107,6 +113,8 @@ import { type ChannelTagRefusalCode, type TagOptions } from "../channels/tagging
107
113
  import { type CheckpointTap } from "./checkpoint-tap.js";
108
114
  import { TelegramChannel, type CheckpointTapResponse, type ReviewCard, type ReviewTapResponse, type TelegramCommand, type TelegramTerminalState } from "../channels/telegram.js";
109
115
  import { type TelegramDelivery } from "../core/telegram-config.js";
116
+ import { type InstanceFinding } from "../core/instance.js";
117
+ import { type ChannelSender } from "../core/sender-identity.js";
110
118
  import { type ParsedFlags } from "./args.js";
111
119
  import { type GlossRunner } from "./gloss.js";
112
120
  import { type GlossRunnerFactoryOptions } from "./gloss-options.js";
@@ -151,6 +159,32 @@ export interface ListenSetup {
151
159
  * `core/telegram-config.ts` beside the other Telegram policy readings.
152
160
  */
153
161
  delivery: TelegramDelivery;
162
+ /**
163
+ * Cross-instance findings `--allow-cross-instance` let through (APRV-390).
164
+ *
165
+ * Empty on every ordinary start, because a finding without the flag is a
166
+ * refusal. Carried on the setup rather than printed inside `prepareListen`
167
+ * for the reason that function prints nothing at all: `approval up` and
168
+ * `approval channel telegram listen` choose their own stream, and this is
169
+ * the one thing an overridden refusal still owes the operator.
170
+ */
171
+ crossInstance: readonly InstanceFinding[];
172
+ /**
173
+ * `--allow-cross-instance` as passed (APRV-390).
174
+ *
175
+ * Carried as well as applied, because the flag overrides TWO refusals and
176
+ * only one of them can be decided synchronously. The credential check is
177
+ * `prepareListen`'s; the bot-ownership claim needs a `getMe`, so it happens
178
+ * later and has to be able to ask whether the operator already said yes.
179
+ */
180
+ allowCrossInstance: boolean;
181
+ /**
182
+ * The Bot API base this listener talks to (APRV-390).
183
+ *
184
+ * Part of the bot's identity in the ownership registry, because a bot id is
185
+ * unique within one Bot API deployment and nothing more.
186
+ */
187
+ apiBase: string;
154
188
  /**
155
189
  * How the one-sentence model gloss is obtained (APRV-144).
156
190
  *
@@ -178,6 +212,42 @@ export interface ListenSetup {
178
212
  */
179
213
  checkpoint: CheckpointTap;
180
214
  }
215
+ /**
216
+ * Claim this listener's bot before it polls (APRV-390).
217
+ *
218
+ * ONE `getMe`, once per process, before the first `getUpdates`. Two things
219
+ * come out of it: the bot's identity, which is the only thing that can tell
220
+ * two instances holding two differently-named copies of one token apart; and
221
+ * a claim in the per-machine registry, which is what makes the SECOND such
222
+ * listener refuse instead of joining a 409 loop.
223
+ *
224
+ * ## Why an unreachable Bot API is not a refusal
225
+ *
226
+ * A `getMe` that fails says nothing about ownership. The machine may be behind
227
+ * a captive portal, the API may be rate-limiting, the network may be down for
228
+ * four seconds. Refusing to start on that would mean a transient network fault
229
+ * took the phone channel down until somebody noticed, in exchange for no
230
+ * safety: an unreachable Bot API is also a `getUpdates` that cannot conflict
231
+ * with anything. So the preflight says what happened and lets the listener
232
+ * start, where the existing retry loop is already the right behaviour. The
233
+ * refusal is reserved for the case this task is about, which is a bot that is
234
+ * demonstrably somebody else's.
235
+ */
236
+ export declare function claimListenerBot(setup: ListenSetup, report: (message: string) => void): Promise<{
237
+ ok: true;
238
+ } | {
239
+ ok: false;
240
+ code: ListenRefusalCode;
241
+ message: string;
242
+ }>;
243
+ /**
244
+ * What to append to a runtime 409, naming the other instance if one is known.
245
+ *
246
+ * Read at the moment of the conflict rather than captured at start-up, because
247
+ * the other gate may have claimed the bot in between: the registry is the only
248
+ * thing that knows, and it is cheap to re-read.
249
+ */
250
+ export declare function conflictAdviceFor(setup: ListenSetup): () => string | null;
181
251
  /**
182
252
  * Why a listener could not be built. A closed union, because more than one
183
253
  * caller now branches on it: the verb turns each into an exit code, and
@@ -194,7 +264,22 @@ export declare const LISTEN_REFUSAL_CODES: readonly [
194
264
  /** The log could not be read (or its directory does not exist). */
195
265
  "log-unreadable",
196
266
  /** `--payloads` did not hold a JSON object of action key -> payload. */
197
- "payloads-unreadable"];
267
+ "payloads-unreadable",
268
+ /**
269
+ * A credential variable holds a value this instance did not configure
270
+ * (APRV-390): it was exported before the process started and is not this
271
+ * instance's own `approval env` export, or `.approval/env` names another
272
+ * instance's keystore item. Refused rather than warned since APRV-390 — the
273
+ * warning is what let a demo gate spend an evening on the production bot.
274
+ * `--allow-cross-instance` is the deliberate case.
275
+ */
276
+ "cross-instance-credential",
277
+ /**
278
+ * `getMe` named a bot another instance on this machine has already claimed
279
+ * (APRV-390). Refused BEFORE the first `getUpdates`, so the operator reads
280
+ * which two gates are involved instead of an HTTP 409 loop.
281
+ */
282
+ "bot-owned-elsewhere"];
198
283
  export type ListenRefusalCode = (typeof LISTEN_REFUSAL_CODES)[number];
199
284
  /** Everything {@link prepareListen} needs, already resolved to absolute paths. */
200
285
  export interface ListenRequest {
@@ -215,6 +300,11 @@ export interface ListenRequest {
215
300
  pollTimeout: string | null;
216
301
  once: boolean;
217
302
  json: boolean;
303
+ /**
304
+ * `--allow-cross-instance` (APRV-390): start on a credential this instance
305
+ * did not configure, saying so, instead of refusing.
306
+ */
307
+ allowCrossInstance?: boolean;
218
308
  /** Where the channel's operational complaints go. Ordinarily stderr. */
219
309
  log(message: string): void;
220
310
  /** The gloss runner, if the caller wants one. See {@link ListenSetup.gloss}. */
@@ -660,31 +750,13 @@ export declare function dispatchPending(setup: ListenSetup, streams: Streams, st
660
750
  export declare const COLLAPSE_STALE_AFTER_MS: number;
661
751
  /** The computed lines a collapsed re-delivery leads with (APRV-287). */
662
752
  export declare function staleLines(requests: ChannelRequest[], now: string): string[];
663
- /**
664
- * What a checkpoint tap does, on the machine the listener runs on (APRV-257).
665
- *
666
- * The signing happens HERE, in the listener's process, and that is the whole
667
- * point of the tap: this process holds the vault passphrase because a HUMAN
668
- * exported it into the shell they started it from, and `core/child-env.ts`
669
- * strips that variable from every child an agent's session spawns. No agent can
670
- * arrange for a process that reaches this function with a key.
671
- *
672
- * Nothing about the head is re-derived. The `(seq, hash)` comes back from the
673
- * channel exactly as it was put on the screen, and
674
- * {@link ../core/checkpoint.js appendCheckpointAt} signs that and checks the
675
- * log still carries it. A handler that quietly re-read the head would be
676
- * putting a human's key over bytes nobody looked at.
677
- *
678
- * `Not now` appends nothing and says so. It is not a rejection: there is no
679
- * request here to reject, and a checkpoint that is owed is a warning at every
680
- * layer and a refusal at none.
681
- */
682
753
  export declare function checkpointHandlerFor(setup: ListenSetup, streams: Streams): (tap: {
683
754
  sign: boolean;
684
755
  head: {
685
756
  seq: number;
686
757
  hash: string;
687
758
  };
759
+ sender?: ChannelSender;
688
760
  }) => CheckpointTapResponse;
689
761
  /**
690
762
  * What a review tap does: the human-only `reviewSample`, and nothing else
@@ -712,6 +784,7 @@ export declare function reviewHandlerFor(setup: ListenSetup, streams: Streams):
712
784
  verdict: "ok" | "denied";
713
785
  reaction?: "disliked" | "indifferent" | "liked" | "loved";
714
786
  note?: string;
787
+ sender?: ChannelSender;
715
788
  }) => ReviewTapResponse;
716
789
  /**
717
790
  * `/queue`, `/skip`, `/next` — the paced walkthrough's three verbs (APRV-216).
@@ -14,12 +14,18 @@
14
14
  * are read here and passed to the channel as values (SPEC.md §5.1: policy
15
15
  * carries the env-var *names*, never the secrets). Nothing in `channels/`
16
16
  * touches `process.env`.
17
- * 2. **It declares who is approving.** The decision is recorded against the
18
- * human actor from `--as` / `APPROVAL_HUMAN`, never against anything the
19
- * callback carried. SPEC.md §11: identity in v0.1 is config-declared, the
20
- * trust boundary is the local machine, and everyone who can reach the
21
- * configured chat can approve as that actor. This is stated in `--help`
22
- * because an operator has to be able to see it without reading the source.
17
+ * 2. **It declares the identity the process itself acts under.** `--as` /
18
+ * `APPROVAL_HUMAN` is what this listener is, and it is what a decision is
19
+ * recorded as whenever the policy maps no sender for this channel: SPEC.md
20
+ * §11's config-declared identity, where the trust boundary is the local
21
+ * machine and everyone who can reach the configured chat can approve as that
22
+ * actor. Since APRV-324 a policy MAY map Telegram account ids to approvers,
23
+ * and then the decision is recorded against the person the operator attested
24
+ * the tapping account to, and an unmapped account is refused rather than
25
+ * recorded as this listener. Either way the choosing happens in
26
+ * `channels/contract.ts` against the attested policy, never here and never
27
+ * in the channel. Both settings are stated in `--help` because an operator
28
+ * has to be able to see which one they are in without reading the source.
23
29
  * 3. **It holds the token.** A grant mints a single-use execution token;
24
30
  * `recordChannelDecision` returns it to *this* handler, which prints it on
25
31
  * **stdout** and never hands it back to the channel. It is never sent to
@@ -105,15 +111,21 @@ import { readFileSync, statSync } from "node:fs";
105
111
  import { isAbsolute, resolve as resolvePathSegments } from "node:path";
106
112
  import { HUMAN_ACTOR_ENV, resolveHumanActor } from "../core/attest.js";
107
113
  import { assembleBatch } from "../channels/batch.js";
108
- import { recordChannelDecision, } from "../channels/contract.js";
114
+ import { recordChannelDecision, refusedDecisionLine, } from "../channels/contract.js";
109
115
  import { ageText, buildPendingQueue, } from "../channels/tagging.js";
110
116
  import { checkpointOfferFor, checkpointPromptLines, checkpointSignedLines, signCheckpointOffer, } from "./checkpoint-tap.js";
111
- import { actionRefOf, decidedLine, groupForDigest, isMessageNotModified, isTelegramTerminalState, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
117
+ import { actionRefOf, decidedLine, groupForDigest, isMessageNotModified, isTelegramTerminalState, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_DEFAULT_API_BASE, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
112
118
  import { openReviewCards } from "./audit-card.js";
113
119
  import { reviewSample } from "../core/audit.js";
114
120
  import { abandonedAfterMs, HOOK_DEFAULT_WAIT_MS, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
115
121
  import { telegramDeliveryFor } from "../core/telegram-config.js";
116
122
  import { loadPolicy } from "../core/policy-load.js";
123
+ import { valueFindings } from "../core/instance.js";
124
+ import { defaultSourceRunner } from "../core/env-file.js";
125
+ import { claimBot, describeOwners, otherOwnersOf, ownedBot, } from "../core/channel-owner.js";
126
+ import { actorForSender, recordedSenderFor, senderKeyFrom, } from "../core/sender-identity.js";
127
+ import { recordRefusedGesture, } from "../core/gesture-refusal.js";
128
+ import { attestationRefusal, checkAttestation } from "../core/attest.js";
117
129
  import { promptLayoutFor } from "../core/prompt-layout.js";
118
130
  import { passphraseEnvFor } from "../core/vault.js";
119
131
  import { isAttestationActionKey, proposalRecords, proposalState, } from "../core/policy-proposal.js";
@@ -136,6 +148,8 @@ const LISTEN_FLAGS = {
136
148
  "--api-base": "string",
137
149
  "--poll-timeout": "string",
138
150
  "--once": "boolean",
151
+ /** APRV-390: start on a credential this instance did not configure. */
152
+ "--allow-cross-instance": "boolean",
139
153
  "--gloss": "boolean",
140
154
  "--no-gloss": "boolean",
141
155
  "--gloss-provider": "string",
@@ -201,6 +215,77 @@ function env(name) {
201
215
  const value = process.env[name];
202
216
  return value === undefined || value.trim().length === 0 ? null : value.trim();
203
217
  }
218
+ /**
219
+ * Claim this listener's bot before it polls (APRV-390).
220
+ *
221
+ * ONE `getMe`, once per process, before the first `getUpdates`. Two things
222
+ * come out of it: the bot's identity, which is the only thing that can tell
223
+ * two instances holding two differently-named copies of one token apart; and
224
+ * a claim in the per-machine registry, which is what makes the SECOND such
225
+ * listener refuse instead of joining a 409 loop.
226
+ *
227
+ * ## Why an unreachable Bot API is not a refusal
228
+ *
229
+ * A `getMe` that fails says nothing about ownership. The machine may be behind
230
+ * a captive portal, the API may be rate-limiting, the network may be down for
231
+ * four seconds. Refusing to start on that would mean a transient network fault
232
+ * took the phone channel down until somebody noticed, in exchange for no
233
+ * safety: an unreachable Bot API is also a `getUpdates` that cannot conflict
234
+ * with anything. So the preflight says what happened and lets the listener
235
+ * start, where the existing retry loop is already the right behaviour. The
236
+ * refusal is reserved for the case this task is about, which is a bot that is
237
+ * demonstrably somebody else's.
238
+ */
239
+ export async function claimListenerBot(setup, report) {
240
+ let identity;
241
+ try {
242
+ identity = await setup.channel.identify();
243
+ }
244
+ catch (cause) {
245
+ report(`approval: telegram getMe could not be reached (${cause instanceof Error ? cause.message : String(cause)}), so which bot this token names is unknown and no ownership was recorded; starting anyway`);
246
+ return { ok: true };
247
+ }
248
+ if (identity.id.length === 0) {
249
+ report("approval: telegram getMe answered without a bot id, so no ownership was recorded; starting anyway");
250
+ return { ok: true };
251
+ }
252
+ const claim = claimBot(setup.logPath, {
253
+ channel: "telegram",
254
+ botId: identity.id,
255
+ username: identity.username,
256
+ apiBase: setup.apiBase,
257
+ });
258
+ if (!claim.ok) {
259
+ if (!setup.allowCrossInstance) {
260
+ return { ok: false, code: "bot-owned-elsewhere", message: claim.message };
261
+ }
262
+ // The deliberate case. It says what it is doing, and it does NOT record a
263
+ // second claim: the registry answers "who owns this bot", and two owners
264
+ // is the state it exists to report rather than a state to write down.
265
+ report(`approval: --allow-cross-instance: starting anyway — ${claim.message}`);
266
+ return { ok: true };
267
+ }
268
+ report(`approval: telegram ${identity.username} (bot id ${identity.id}) is this instance's own; no update was consumed by this check`);
269
+ return { ok: true };
270
+ }
271
+ /**
272
+ * What to append to a runtime 409, naming the other instance if one is known.
273
+ *
274
+ * Read at the moment of the conflict rather than captured at start-up, because
275
+ * the other gate may have claimed the bot in between: the registry is the only
276
+ * thing that knows, and it is cheap to re-read.
277
+ */
278
+ export function conflictAdviceFor(setup) {
279
+ return () => {
280
+ const mine = ownedBot(setup.logPath, "telegram");
281
+ if (mine === null)
282
+ return null;
283
+ const others = otherOwnersOf(setup.logPath, mine.botId, mine.apiBase);
284
+ return others.length === 0
285
+ ? `this instance is ${mine.instanceHome} (instance ${mine.instanceId}) on ${mine.username}, and nothing else on this machine has claimed it — the other poller is on another machine, or was started outside this runtime`
286
+ : `${mine.instanceHome} (instance ${mine.instanceId}) and ${describeOwners(others)} have both claimed ${mine.username}; stop one of them`;
287
+ };
288
+ }
204
289
  function payloadSource(resolved) {
205
290
  if (resolved === null)
206
291
  return { ok: true, source: undefined };
@@ -243,6 +328,21 @@ export const LISTEN_REFUSAL_CODES = [
243
328
  "log-unreadable",
244
329
  /** `--payloads` did not hold a JSON object of action key -> payload. */
245
330
  "payloads-unreadable",
331
+ /**
332
+ * A credential variable holds a value this instance did not configure
333
+ * (APRV-390): it was exported before the process started and is not this
334
+ * instance's own `approval env` export, or `.approval/env` names another
335
+ * instance's keystore item. Refused rather than warned since APRV-390 — the
336
+ * warning is what let a demo gate spend an evening on the production bot.
337
+ * `--allow-cross-instance` is the deliberate case.
338
+ */
339
+ "cross-instance-credential",
340
+ /**
341
+ * `getMe` named a bot another instance on this machine has already claimed
342
+ * (APRV-390). Refused BEFORE the first `getUpdates`, so the operator reads
343
+ * which two gates are involved instead of an HTTP 409 loop.
344
+ */
345
+ "bot-owned-elsewhere",
246
346
  ];
247
347
  /**
248
348
  * Everything that can fail without touching the network, in order.
@@ -280,6 +380,35 @@ export function prepareListen(request) {
280
380
  message: `telegram is not configured: ${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} unset or empty (both ${tokenEnv} and ${chatEnv} are required; APPROVAL.md carries only their names)`,
281
381
  };
282
382
  }
383
+ // APRV-390. WHOSE credentials are these? `instanceFindings` answers from
384
+ // names alone — no value is read, compared or printed — and until this task
385
+ // its answer was a warning `approval up` printed on the way past. It is a
386
+ // refusal now. The incident it is named after is a demo gate that started on
387
+ // the primary's exported token and long-polled the primary's bot: the
388
+ // warning was on the operator's terminal the whole time, above a process
389
+ // that had already started. An override exists because feeding a daemon from
390
+ // a shell profile is a deliberate, supported thing to do; what was missing
391
+ // is that it now has to be said out loud.
392
+ //
393
+ // `valueFindings` and not `instanceFindings`: this is the one caller that is
394
+ // about to USE the values, so it is allowed to resolve the file and compare
395
+ // (APRV-390, second round). Comparing is what tells a stale export — the
396
+ // shell holding a token the keychain has since replaced, which `approval env`
397
+ // will never override — from an honest one, and what stops the correct `eval
398
+ // "$(approval env)"` ritual being reported as a finding. `approval doctor`
399
+ // keeps the name-only rule, because a diagnostic may not block on an unlock
400
+ // dialog. No value is printed here or anywhere below.
401
+ const crossInstance = valueFindings(request.logPath, policyLoad, defaultSourceRunner);
402
+ if (crossInstance.length > 0 && request.allowCrossInstance !== true) {
403
+ const stale = crossInstance.some((finding) => finding.kind === "stale-export");
404
+ return {
405
+ ok: false,
406
+ code: "cross-instance-credential",
407
+ message: `${crossInstance.map((finding) => finding.detail).join("; ")}. ${stale
408
+ ? "A stale export is why a token re-stored in the keystore can still answer 401: `approval env` never overrides a variable this shell has already exported."
409
+ : "Two gates on one bot token both long-poll it, their getUpdates offsets acknowledge each other's updates, and an approval tap is answered by whichever listener asked first."} Fix it with one of: \`unset ${crossInstance.map((finding) => finding.variable).join(" ")}\` and then \`eval "$(approval env)"\` in this shell; a fresh shell that has never exported it; or \`approval setup channel telegram\` to give this instance its own bot. Pass --allow-cross-instance to start anyway`,
410
+ };
411
+ }
283
412
  const actor = resolveHumanActor(request.as === null ? {} : { actor: request.as });
284
413
  if (actor === null) {
285
414
  return {
@@ -342,6 +471,10 @@ export function prepareListen(request) {
342
471
  // asks of the policy file, and a policy that failed to load leaves the
343
472
  // default (paced) in force rather than a mode nobody chose.
344
473
  delivery: telegramDeliveryFor(policyLoad),
474
+ // APRV-390. Empty unless --allow-cross-instance let a finding through.
475
+ crossInstance,
476
+ allowCrossInstance: request.allowCrossInstance === true,
477
+ apiBase: request.apiBase ?? TELEGRAM_DEFAULT_API_BASE,
345
478
  gateOptions: { policy: request.policy },
346
479
  tagOptions: {
347
480
  policy: request.policy,
@@ -458,6 +591,8 @@ function setUp(argv, streams, cwd) {
458
591
  pollTimeout: stringFlag(flags, "--poll-timeout"),
459
592
  once: boolFlag(flags, "--once"),
460
593
  json,
594
+ // APRV-390.
595
+ allowCrossInstance: boolFlag(flags, "--allow-cross-instance"),
461
596
  log: (message) => streams.err(`${message}\n`),
462
597
  // APRV-144, on by default, `--no-gloss` to turn it off (APRV-197).
463
598
  //
@@ -1569,7 +1704,10 @@ function handlerFor(setup, streams) {
1569
1704
  })}\n`);
1570
1705
  }
1571
1706
  else if (result.outcome.ok) {
1572
- streams.out(`${decision.decision === "grant" ? "granted" : "rejected"} ${decision.action_key} (seq ${result.outcome.record.seq}) by ${setup.actor} via telegram\n`);
1707
+ // APRV-324: the actor off the RECORD, not the one this process was
1708
+ // launched with. Under a sender mapping they differ, and the line a human
1709
+ // reads on the operator's terminal has to say who the log says decided.
1710
+ streams.out(`${decision.decision === "grant" ? "granted" : "rejected"} ${decision.action_key} (seq ${result.outcome.record.seq}) by ${result.outcome.record.actor} via telegram\n`);
1573
1711
  }
1574
1712
  else {
1575
1713
  streams.err(`approval: telegram decision refused (${result.outcome.code}): ${result.outcome.message}\n`);
@@ -1606,8 +1744,101 @@ function handlerFor(setup, streams) {
1606
1744
  * request here to reject, and a checkpoint that is owed is a warning at every
1607
1745
  * layer and a refusal at none.
1608
1746
  */
1747
+ /**
1748
+ * Where this listener's policy lives, as `loadPolicy` wants it (APRV-324
1749
+ * follow-up).
1750
+ *
1751
+ * The same location every gate operation this process performs resolves,
1752
+ * because the handlers below ask the policy who a sender is and the gate asks
1753
+ * it what a class resolves to, and two different files answering those two
1754
+ * questions would be a listener enforcing one policy and attributing under
1755
+ * another.
1756
+ */
1757
+ function policyLoadOptions(setup) {
1758
+ const policy = setup.gateOptions.policy;
1759
+ if (policy?.file !== undefined)
1760
+ return { file: policy.file };
1761
+ if (policy?.dir !== undefined)
1762
+ return { dir: policy.dir };
1763
+ return {};
1764
+ }
1765
+ function senderIdentityFor(setup, sender) {
1766
+ const load = loadPolicy(policyLoadOptions(setup));
1767
+ if (sender === undefined)
1768
+ return { ok: true, actor: setup.actor };
1769
+ // APRV-370. The operator's sender key, and with it the form the two refusals
1770
+ // below record the account in. Those two never reach the in-force mapping,
1771
+ // so they take `recordedSenderFor`'s rule: the form follows the FILE, the
1772
+ // mapping follows the ATTESTATION.
1773
+ const key = senderKeyFrom();
1774
+ if (load.ok) {
1775
+ const read = readVerifiedRecords(setup.logPath);
1776
+ if (!read.ok) {
1777
+ return {
1778
+ ok: false,
1779
+ code: "policy-not-attested",
1780
+ sender: recordedSenderFor(load, sender, key),
1781
+ message: `the log could not be read to check whether ${load.source.filename} is attested (${read.code}): ${read.message}. Nothing was recorded; a decision made at a terminal carries no sender and is unaffected.`,
1782
+ };
1783
+ }
1784
+ // `core/attest.ts`'s own check and its own wording, rather than a second
1785
+ // comparison written here that could come to a different conclusion about
1786
+ // the same file than the one the gate's refusal is built on.
1787
+ const refusal = attestationRefusal(checkAttestation(read.records, load.source.path));
1788
+ if (refusal !== null) {
1789
+ return {
1790
+ ok: false,
1791
+ code: refusal.code,
1792
+ sender: recordedSenderFor(load, sender, key),
1793
+ message: `${refusal.message}. The sender mapping it declares is therefore not in force, so the account this gesture came from cannot be resolved against it and nothing was recorded. Re-attest the policy, or do this from a terminal, which authenticates no sender and is unaffected by the mapping.`,
1794
+ };
1795
+ }
1796
+ }
1797
+ return actorForSender(load, setup.actor, sender, key);
1798
+ }
1799
+ /**
1800
+ * Record that a gesture this listener refused was made (APRV-355).
1801
+ *
1802
+ * Best-effort, and after the refusal is already decided: the card the person
1803
+ * sees does not depend on this write landing. A failure is reported on stderr
1804
+ * beside the refusal it was about, because a record that silently did not land
1805
+ * is exactly the gap this task exists to close, and one that fails quietly is
1806
+ * the same gap wearing a fix.
1807
+ *
1808
+ * The runtime cannot name a person on `sender-unmapped` or `sender-ambiguous`,
1809
+ * which is the whole point of those refusals, so `actor` is the listener's
1810
+ * configured identity ONLY where the resolution produced one. The observed
1811
+ * account goes in either way; it is the only thing known about who tapped.
1812
+ */
1813
+ function recordGestureRefusal(setup, streams, gesture, refused) {
1814
+ const recorded = recordRefusedGesture(setup.logPath, {
1815
+ gesture,
1816
+ actor: null,
1817
+ channel: "telegram",
1818
+ sender: refused.sender,
1819
+ }, { code: refused.code, message: refused.message }, setup.gateOptions);
1820
+ if (!recorded.ok) {
1821
+ streams.err(`approval: telegram ${gesture} refusal could not be recorded (${recorded.code}): ${recorded.message}\n`);
1822
+ }
1823
+ }
1609
1824
  export function checkpointHandlerFor(setup, streams) {
1610
1825
  return (tap) => {
1826
+ // APRV-324 follow-up, and FIRST, before the decline branch is even
1827
+ // considered: a signature says this log's head is what this person saw, so
1828
+ // an account the ATTESTED policy does not name may not produce one. Under
1829
+ // no mapping this resolves to the configured identity and the whole branch
1830
+ // is today's behaviour.
1831
+ const resolved = senderIdentityFor(setup, tap.sender);
1832
+ if (!resolved.ok) {
1833
+ streams.err(`approval: telegram checkpoint refused (${resolved.code}): ${resolved.message}\n`);
1834
+ recordGestureRefusal(setup, streams, "checkpoint-signature", resolved);
1835
+ return {
1836
+ ok: false,
1837
+ headline: TELEGRAM_NOT_RECORDED,
1838
+ detail: [refusedDecisionLine(resolved.code)],
1839
+ toast: "Not signed.",
1840
+ };
1841
+ }
1611
1842
  if (!tap.sign) {
1612
1843
  return {
1613
1844
  ok: true,
@@ -1619,7 +1850,7 @@ export function checkpointHandlerFor(setup, streams) {
1619
1850
  toast: "Not now.",
1620
1851
  };
1621
1852
  }
1622
- const result = signCheckpointOffer(setup.checkpoint, tap.head, setup.actor, "telegram", process.cwd());
1853
+ const result = signCheckpointOffer(setup.checkpoint, tap.head, resolved.actor, "telegram", process.cwd());
1623
1854
  const lines = checkpointSignedLines(result);
1624
1855
  const [headline, ...detail] = lines;
1625
1856
  if (setup.json) {
@@ -1632,7 +1863,7 @@ export function checkpointHandlerFor(setup, streams) {
1632
1863
  })}\n`);
1633
1864
  }
1634
1865
  else if (result.ok) {
1635
- streams.out(`checkpoint ${String(result.seq)}: signed head seq ${String(result.signed.seq)} ${result.signed.hash} by ${setup.actor} via telegram\n`);
1866
+ streams.out(`checkpoint ${String(result.seq)}: signed head seq ${String(result.signed.seq)} ${result.signed.hash} by ${resolved.actor} via telegram\n`);
1636
1867
  }
1637
1868
  else {
1638
1869
  streams.err(`approval: telegram checkpoint refused (${result.code}): ${result.message}\n`);
@@ -1668,10 +1899,36 @@ export function checkpointHandlerFor(setup, streams) {
1668
1899
  */
1669
1900
  export function reviewHandlerFor(setup, streams) {
1670
1901
  return (tap) => {
1671
- const result = reviewSample(setup.logPath, { kind: "seq", seq: tap.sampleSeq }, setup.actor, tap.note ?? null, {
1902
+ // APRV-324 follow-up. A review confers no authority and is still a HUMAN's
1903
+ // observation: `approval feedback` hands it to agents as human-authored
1904
+ // guidance, so a review attributed to the wrong person is guidance in
1905
+ // somebody else's name. Resolved against the ATTESTED policy exactly as a
1906
+ // signature is, and under no mapping this is the configured identity and
1907
+ // today's behaviour.
1908
+ const resolved = senderIdentityFor(setup, tap.sender);
1909
+ if (!resolved.ok) {
1910
+ streams.err(`approval: telegram review refused (${resolved.code}): ${resolved.message}\n`);
1911
+ // `review-note` when the tap carried words: a note is attention spent
1912
+ // writing rather than attention spent tapping, and an operator reading
1913
+ // this record wants to know which they lost (APRV-355).
1914
+ recordGestureRefusal(setup, streams, tap.note === undefined || tap.note.trim().length === 0 ? "review" : "review-note", resolved);
1915
+ return {
1916
+ ok: false,
1917
+ headline: TELEGRAM_NOT_RECORDED,
1918
+ detail: [refusedDecisionLine(resolved.code)],
1919
+ toast: "Not recorded.",
1920
+ };
1921
+ }
1922
+ const result = reviewSample(setup.logPath, { kind: "seq", seq: tap.sampleSeq }, resolved.actor, tap.note ?? null, {
1672
1923
  ...(setup.gateOptions.policy === undefined ? {} : { policy: setup.gateOptions.policy }),
1673
1924
  verdict: tap.verdict,
1674
1925
  ...(tap.reaction === undefined ? {} : { reaction: tap.reaction }),
1926
+ ...(resolved.sender === undefined
1927
+ ? {}
1928
+ : {
1929
+ sender: resolved.sender,
1930
+ ...(resolved.source === undefined ? {} : { senderSource: resolved.source }),
1931
+ }),
1675
1932
  });
1676
1933
  if (!result.ok) {
1677
1934
  // SPEC.md §11.1 invariant 6: the code is machine-readable and distinct,
@@ -1927,7 +2184,11 @@ export function startListener(setup, streams) {
1927
2184
  const beforePoll = async () => {
1928
2185
  reportCycle(await dispatchPending(setup, streams, state, new Date().toISOString()), streams);
1929
2186
  };
1930
- await channel.listen(setup.once ? { once: true, beforePoll } : { beforePoll });
2187
+ // APRV-390. The 409's report reads the ownership registry at the moment
2188
+ // the conflict happens; the channel prints what this returns and knows
2189
+ // nothing about instances or files.
2190
+ const conflictAdvice = conflictAdviceFor(setup);
2191
+ await channel.listen(setup.once ? { once: true, beforePoll, conflictAdvice } : { beforePoll, conflictAdvice });
1931
2192
  if (setup.json) {
1932
2193
  streams.out(`${JSON.stringify({ event: "stopped", ...channel.stats() })}\n`);
1933
2194
  }
@@ -1936,6 +2197,16 @@ export function startListener(setup, streams) {
1936
2197
  return { done, stop };
1937
2198
  }
1938
2199
  async function runListener(setup, streams) {
2200
+ // APRV-390. Before anything is delivered and before the first poll: whose
2201
+ // bot is this? An override that got this far still owes the operator the
2202
+ // sentence, because the whole point of `--allow-cross-instance` is that the
2203
+ // deliberate case is deliberate out loud.
2204
+ for (const finding of setup.crossInstance) {
2205
+ streams.err(`approval: --allow-cross-instance: starting anyway — ${finding.detail}\n`);
2206
+ }
2207
+ const claimed = await claimListenerBot(setup, (message) => streams.err(`${message}\n`));
2208
+ if (!claimed.ok)
2209
+ return integrityError(streams, setup.json, claimed.message);
1939
2210
  const running = startListener(setup, streams);
1940
2211
  const stop = () => running.stop();
1941
2212
  process.on("SIGINT", stop);
@@ -1989,6 +2260,10 @@ export function commandTelegramHealth(argv, streams, cwd) {
1989
2260
  const parsed = parseFlags(argv, {
1990
2261
  "--policy": "string",
1991
2262
  "--dir": "string",
2263
+ // APRV-390. Which instance's ownership record to read. Accepted here for
2264
+ // the same reason `listen` accepts it: a gate may be configured with its
2265
+ // log somewhere other than beside the policy.
2266
+ "--log": "string",
1992
2267
  "--json": "boolean",
1993
2268
  "--help": "boolean",
1994
2269
  "-h": "boolean",
@@ -2013,6 +2288,19 @@ export function commandTelegramHealth(argv, streams, cwd) {
2013
2288
  const token = env(tokenEnv);
2014
2289
  const chatId = env(chatEnv);
2015
2290
  const ok = token !== null && chatId !== null;
2291
+ // APRV-390. Which bot, and whose. Read from the two ownership records and
2292
+ // never from the network: this verb makes no Bot API call on any path, and
2293
+ // the last `getMe` a listener or a setup run made is already written down.
2294
+ // An instance that has never started a listener has no record, which is a
2295
+ // state and not a fault — the row says so rather than guessing.
2296
+ const logPath = resolvePath(stringFlag(parsed.flags, "--log"), DEFAULT_LOG_PATH, dirFlag === null ? cwd : absolute(dirFlag, cwd));
2297
+ const mine = ownedBot(logPath, "telegram");
2298
+ const others = mine === null ? [] : otherOwnersOf(logPath, mine.botId, mine.apiBase);
2299
+ const ownership = mine === null
2300
+ ? "no bot recorded for this instance yet: it is written by the first `approval up` or `approval channel telegram listen` that reaches getMe"
2301
+ : others.length === 0
2302
+ ? `bot ${mine.username} (id ${mine.botId}) is owned by this instance, ${mine.instanceHome} (instance ${mine.instanceId}), recorded ${mine.claimedAt}`
2303
+ : `bot ${mine.username} (id ${mine.botId}) is claimed by this instance AND by ${describeOwners(others)}; one of them must be given its own bot`;
2016
2304
  if (json) {
2017
2305
  streams.out(`${JSON.stringify({
2018
2306
  ok,
@@ -2022,10 +2310,20 @@ export function commandTelegramHealth(argv, streams, cwd) {
2022
2310
  token_set: token !== null,
2023
2311
  chat_env: chatEnv,
2024
2312
  chat_id: chatId,
2313
+ // APRV-390. Names and ids, never a value.
2314
+ bot_username: mine?.username ?? null,
2315
+ bot_id: mine?.botId ?? null,
2316
+ owner_instance: mine?.instanceId ?? null,
2317
+ owner_home: mine?.instanceHome ?? null,
2318
+ other_owners: others.map((claim) => ({
2319
+ instance_id: claim.instanceId,
2320
+ instance_home: claim.instanceHome,
2321
+ })),
2025
2322
  })}\n`);
2026
2323
  }
2027
2324
  else if (ok) {
2028
2325
  streams.out(`telegram: configured (${tokenEnv} set, chat ${String(chatId)})\n`);
2326
+ streams.out(` ${ownership}\n`);
2029
2327
  }
2030
2328
  else {
2031
2329
  streams.err(`approval: telegram is not configured: ${[