instar 1.3.1348 → 1.3.1349

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 (67) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +15 -2
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/LifelineIngressSecret.d.ts +83 -0
  5. package/dist/core/LifelineIngressSecret.d.ts.map +1 -0
  6. package/dist/core/LifelineIngressSecret.js +203 -0
  7. package/dist/core/LifelineIngressSecret.js.map +1 -0
  8. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  9. package/dist/core/PostUpdateMigrator.js +15 -0
  10. package/dist/core/PostUpdateMigrator.js.map +1 -1
  11. package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
  12. package/dist/core/WriteDomainRegistry.js +17 -0
  13. package/dist/core/WriteDomainRegistry.js.map +1 -1
  14. package/dist/core/devGatedFeatures.d.ts.map +1 -1
  15. package/dist/core/devGatedFeatures.js +6 -0
  16. package/dist/core/devGatedFeatures.js.map +1 -1
  17. package/dist/core/keyMaterialPaths.d.ts.map +1 -1
  18. package/dist/core/keyMaterialPaths.js +16 -0
  19. package/dist/core/keyMaterialPaths.js.map +1 -1
  20. package/dist/core/types.d.ts +23 -0
  21. package/dist/core/types.d.ts.map +1 -1
  22. package/dist/core/types.js.map +1 -1
  23. package/dist/core/version-skew.d.ts +4 -2
  24. package/dist/core/version-skew.d.ts.map +1 -1
  25. package/dist/core/version-skew.js +3 -1
  26. package/dist/core/version-skew.js.map +1 -1
  27. package/dist/data/standards-guard-index.json +1 -1
  28. package/dist/data/standards-guard-index.meta.json +2 -2
  29. package/dist/data/standards-registry.meta.json +1 -1
  30. package/dist/lifeline/TelegramLifeline.d.ts +49 -0
  31. package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
  32. package/dist/lifeline/TelegramLifeline.js +202 -17
  33. package/dist/lifeline/TelegramLifeline.js.map +1 -1
  34. package/dist/lifeline/forwardErrors.d.ts +11 -0
  35. package/dist/lifeline/forwardErrors.d.ts.map +1 -1
  36. package/dist/lifeline/forwardErrors.js +15 -1
  37. package/dist/lifeline/forwardErrors.js.map +1 -1
  38. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  39. package/dist/monitoring/guardManifest.js +24 -0
  40. package/dist/monitoring/guardManifest.js.map +1 -1
  41. package/dist/scaffold/templates.d.ts.map +1 -1
  42. package/dist/scaffold/templates.js +4 -0
  43. package/dist/scaffold/templates.js.map +1 -1
  44. package/dist/server/LifelineIngressGuard.d.ts +326 -0
  45. package/dist/server/LifelineIngressGuard.d.ts.map +1 -0
  46. package/dist/server/LifelineIngressGuard.js +713 -0
  47. package/dist/server/LifelineIngressGuard.js.map +1 -0
  48. package/dist/server/TelegramCallbackDedupe.d.ts +58 -0
  49. package/dist/server/TelegramCallbackDedupe.d.ts.map +1 -0
  50. package/dist/server/TelegramCallbackDedupe.js +151 -0
  51. package/dist/server/TelegramCallbackDedupe.js.map +1 -0
  52. package/dist/server/routes.d.ts.map +1 -1
  53. package/dist/server/routes.js +218 -10
  54. package/dist/server/routes.js.map +1 -1
  55. package/dist/testing/selfActionRegistry.d.ts.map +1 -1
  56. package/dist/testing/selfActionRegistry.js +98 -0
  57. package/dist/testing/selfActionRegistry.js.map +1 -1
  58. package/package.json +1 -1
  59. package/src/data/builtin-manifest.json +64 -64
  60. package/src/data/standards-guard-index.json +1 -1
  61. package/src/data/standards-guard-index.meta.json +2 -2
  62. package/src/data/standards-registry.meta.json +1 -1
  63. package/src/data/state-coherence-registry.json +19 -0
  64. package/src/scaffold/templates.ts +4 -0
  65. package/upgrades/1.3.1349.md +59 -0
  66. package/upgrades/side-effects/telegram-internal-route-lifeline-secret.md +187 -0
  67. package/upgrades/1.3.1348.md +0 -24
@@ -0,0 +1,59 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ - `SingleInstanceLock` decided whether the recorded lock holder was still running from the pid alone. On a host that recycles pids every few minutes, a dead holder's pid is re-issued to unrelated live processes, the lock reads LIVE indefinitely, and the agent's server refuses to boot until a spawn happens to land on a free pid. The lock record now also carries the holder's absolute process start time (`ps -o lstart=`, via the primitive `hostTestRunnerSemaphore` already uses for the same corroboration); a candidate pid whose start postdates the record by more than the shared 120s skew is treated as a different process and the stale lock is reclaimed. One-sided on purpose; every uncertain case (no recorded start, unreadable candidate start) keeps the previous pid-only verdict.
9
+ - `ServerSupervisor` now appends the spawned server's stderr to `logs/server-stderr.log` (`tee -a`) instead of truncating it on every spawn. Past 4 MB the newest 512 KB is kept. The crash-output reader takes a bounded tail rather than reading the whole file.
10
+
11
+ `POST /internal/telegram-forward` (messages) and `POST /internal/telegram-callback` (inline-button taps) carry Telegram input from the lifeline into the server and take the sender's Telegram user id from the request body. They were protected only by the ordinary Bearer token, which every spawned session holds as `INSTAR_AUTH_TOKEN`. Any session could therefore post a message or an Approve tap that named the operator as the sender, which undermined the operator auto-bind, sender re-validation and every inline-button approval.
12
+
13
+ - **A lifeline-only secret.** 32 random bytes (hex) in `.instar/state/lifeline-ingress-secret`, mode 0600, created atomically and exclusively by whichever of the lifeline or the server needs it first (`src/core/LifelineIngressSecret.ts`). It is held in the file and in process memory only: never in `process.env`, never a tmux `-e` flag, never logged, never returned by a route. A file that cannot be read is never replaced, and a failed re-read keeps the last good value.
14
+ - **The routes require it** in the `X-Instar-Lifeline-Secret` header, compared in constant time (`src/server/LifelineIngressGuard.ts`). The Bearer check is unchanged and still required.
15
+ - **Callers updated:** the lifeline's message forward (including the legacy-strict retry and queue replay), the lifeline's callback forward, and the server's own re-injection call (which labels itself `X-Instar-Ingress-Caller: server-self`).
16
+ - **No message is refused without proof.** The guard refuses no message until it is armed: the running lifeline has sent the valid secret at least once (persisted), or there is no lifeline to wait for (the server polls Telegram itself, or Telegram is not configured). A current lifeline proves itself through the new `POST /internal/lifeline-proof`, on server-up and on its 15-second tick. Sixty seconds after the server starts, a guard that is still unarmed writes the existing lifeline-restart signal once, so a lifeline on older code restarts onto current code without waiting for a message.
17
+ - **While unarmed, an unproven request is handled exactly as before.** In observe, for as long as the guard stays unarmed (a fleet agent's real traffic is never degraded before a development agent has run the strict behaviour); the unarmed guard is reported hourly, raises an attention item after a day, and reads off on `/guards`. In enforce, for at most 24 hours from when the feature first ran on the machine.
18
+ - **Once armed (either mode), or in enforce after those 24 hours,** an unproven button tap is refused and an unproven message is delivered with no sender identity.
19
+ - **"No sender identity" is applied in one place.** The forward route drops the body's `fromUserId`, `fromUsername`, bot fields and name (it becomes "Unverified sender") right after the check, flags the message `senderUnverified`, and does not claim an exactly-once ledger key for it. Operator auto-bind (both sites), the inbound log's operator uid, the ratifiers, command and topic-profile authority and the agent-to-agent hook therefore see no first-party identity.
20
+ - **Two modes**, `telegramIngress.lifelineSecret.enforce` (read live, shipped omitted). `observe` delivers an unproven message that way and counts a `would-refuse`. `enforce` refuses it with HTTP 401 `{ ok: false, error: 'lifeline-secret-required', refused: true, reason }`. Default: enforce on a development agent and where there is no lifeline ingress; observe on every other agent.
21
+ - **Observe has an exit.** With the setting omitted on a non-development agent, observe switches to enforce after 24 hours with zero would-refuse and at least 10 verified Telegram requests from the lifeline, or 7 days after arming, whichever comes first. The counters and timestamps are persisted in `.instar/state/lifeline-ingress-secret.guard.json` and survive a restart. `enforce: false` holds observe.
22
+ - **A full rollback key.** `telegramIngress.lifelineSecret.bypass: true` restores the previous handling of every request. Both keys are config-file edits only; they cannot be changed over HTTP.
23
+ - **A refused message is held, not lost.** A current lifeline re-reads the secret file on a 401 and retries. If the server still refuses, the message stays in the lifeline's durable queue with no drop budget burned (new `ForwardSecretRefusedError`); replay is single-flight, backs off from one minute to fifteen, and after ten minutes the lifeline tells the user once that messages are being held.
24
+ - **Restart-signal budget.** Signal writes caused by an unverified request (the guard's, and the forward route's 426 handler) share a persisted budget: one per 12 hours before arming, one per 24 hours after, never with an unknown target version.
25
+ - **One tap, one effect.** `TelegramCallbackDedupe` claims each `callback_query.id` before handling and records the outcome. A second delivery of a handled tap returns `{ ok: true, deduped: true }`; of a tap whose handler failed, or whose outcome is unknown, 409. Proven taps are persisted (bounded to 2000 ids, 7 days); unproven ones are claimed in memory.
26
+ - **Never served or editable.** The secret, the guard's state file, the tap claim store and the lifeline-restart signal file are in `KEY_MATERIAL_PATHS`, so the file routes refuse to read or save them, backups exclude them, and init plus the existing post-update migration gitignore them. Registered machine-local in the state-coherence registry.
27
+ - **Observability.** The authed `/health` gains `telegramIngress.lifelineSecret` (mode, `armed`, `refusing`, `refusingTaps`, `secretAvailable`, exit-rule progress, persisted counters) and `telegramIngress.callbackDedupe`. The guard is a load-bearing row on `/guards` (`telegramIngress.lifelineSecret`). Degradation reports under feature `TelegramIngress.lifelineSecret`; an unproven request after arming, a bypassed guard, and a lifeline still not updated after a day each raise an operator attention item (at most one a day per kind).
28
+ - CLAUDE.md template and `migrateClaudeMd` gain "Telegram internal routes need the lifeline secret" (framework-shadowed). Registered in `DEV_GATED_FEATURES`.
29
+
30
+ **Limit:** no tool hook refuses a read of the secret file today; the protection is the 0600 mode plus the key-material path list (file viewer, backups, git sync). Everything runs as one OS user, so this is a cooperative boundary. It stops a session forging through its normal token. It does not stop a session that deliberately reads the secret file. A read-refusing hook covering this file, `.instar/identity.json` and the dashboard PIN is tracked as ACT-099.
31
+
32
+ **Rollback note:** rolling the lifeline back by itself to a version from before this change, under a server that has already armed and enforces, gets that lifeline refused. Set `telegramIngress.lifelineSecret.bypass: true` first.
33
+
34
+ ## What to Tell Your User
35
+
36
+ If one of your agents went quiet for hours and its log showed a long run of "spawns failed before binding", this is the fix. The agent believed a copy of itself was already running because it was identifying that copy by an operating-system ID number the machine had since handed to something else. It now also checks when that process started, so a stale note can no longer keep the agent off. Nothing to configure. Separately, the agent's crash log now keeps the reasons for every failed start instead of only the last one, so a repeat of this kind of outage can be explained in minutes rather than hours.
37
+
38
+ Messages and button taps from Telegram reach me through a small helper process. Until now, any of my own working sessions could have imitated that helper and sent me a message, or an Approve tap, that looked like it came from you. That door is now closed: the helper and my main process share a private key that my sessions are not given, and I only treat Telegram input as yours when it arrives with that key. A button tap that reaches me twice is now acted on once. Nothing changes in how you talk to me, and no message is lost during the update: I only start refusing messages once the helper has shown it is running the new version. If the helper somehow stays on the old version for more than a day, I will tell you; your messages still reach me either way. One honest limit: a session that deliberately went looking for the key file on the machine could still read it; this stops accidental or casual misuse, not a determined one.
39
+
40
+ ## Summary of New Capabilities
41
+
42
+ None — this is a reliability fix to an existing guard. No new setting, route, or surface.
43
+
44
+ - Telegram messages and button taps are accepted as coming from a person only when they arrive through the lifeline, not from a session using the ordinary API token.
45
+ - The same button tap delivered twice is applied once.
46
+ - The health report shows whether this protection is watching or refusing, and the saved counts behind that.
47
+ - The watch-only mode ends by itself after a clean day of real traffic, or after a week at the latest; one setting holds it or forces it.
48
+
49
+ ## Evidence
50
+
51
+ - Measured on the `inspec` agent (monroe-workspace, laptop) 2026-10-12: 48 consecutive `Bind-failure escalation armed: N consecutive spawns failed before binding` in `lifeline-launchd.log`; outage windows from `activity-*.jsonl` `scheduler_start` events of ~35h (Oct 10 22:06 → Oct 12 08:53 PDT) and ~3.5h (Oct 12 12:58 → 16:30 PDT); the recovering boot logged `reclaimed a stale same-host lock (dead pid 86892)`.
52
+ - `server-stderr.log` on that host held three lines from an unrelated earlier boot — the truncation defect in one observation.
53
+ - Tests: `tests/unit/single-instance-lock.test.ts` (29, including a defaults-only round trip that exercises the real `lstart` write and read together) and `tests/unit/supervisor-crashlog-append.test.ts` (4).
54
+ - Side-effects review with an independent second pass: `upgrades/side-effects/single-instance-lock-pid-reuse.md`.
55
+
56
+ - Unit: `tests/unit/lifeline-ingress-secret.test.ts` (0600, atomic exclusive create, an unreadable file is never replaced, a failed re-read keeps the last value, backoff, constant-time compare, never in `process.env`, registered as key material), `tests/unit/lifeline-ingress-guard.test.ts` (modes on both agent classes, arming by proof and by no-lifeline-ingress, the boot nudge, the 24-hour allowance at minus 1 ms and after a lost state file, sender trust, taps, bypass, the quiet window at 24 h minus 1 ms and at 9 verified, the deadline at 7 days minus 1 ms and under a secretless request every 6 hours, persistence across a restart, the signal budget, each report's threshold, the attention item and its throttle, the posture row, write coalescing), `tests/unit/telegram-callback-dedupe.test.ts`, `tests/unit/session-spawn-no-lifeline-secret.test.ts` (a real `SessionManager` spawn: the secret is in no tmux argument, no child env object, not in `process.env`), `tests/unit/PostUpdateMigrator-lifelineIngressSecret.test.ts`, `tests/unit/lifeline/forwardErrors.test.ts`.
57
+ - Integration: `tests/integration/telegram-internal-routes-lifeline-secret.test.ts` (real `createRoutes`: observe accepts and counts; once armed an unproven message loses every identity claim, binds no operator, cannot pre-claim a ledger key, and an unproven tap is refused; enforce refuses only once armed, with the typed 401, the restart signal and no side effect; the startup proof; arming with no lifeline ingress; bypass; the stale-secret hint; a refused message replayed with the secret is applied once; a tap delivered twice has one effect, also across a restart; a failed tap answers 409), `tests/integration/telegram-unverified-sender-pipeline.test.ts` (the real message pipeline binds no operator and gives commands sender id 0 for a flagged message).
58
+ - E2E: `tests/e2e/telegram-internal-routes-lifeline-secret-lifecycle.test.ts` (a real `AgentServer` with middleware and a real `TelegramLifeline`: the startup proof arms the server; a Bearer-only request gets the typed 401; the secret without the Bearer token is still refused by the middleware; a lifeline with the header removed is refused, keeps the message in its on-disk queue with no budget burned, receives the restart request, and the restarted lifeline delivers it exactly once; a lifeline that stays refused backs off, tells the user once after ten minutes, and recovers; a session cannot drive further restart signals; the same tap twice has one effect; the file routes refuse to read or save the secret and the guard's files; `/health` shows the persisted counters and never the secret).
59
+ - Not covered: a lifeline process built from a previous release. Its 401 handling was checked by reading the code on `main`.
@@ -0,0 +1,187 @@
1
+ # Side-Effects Review — Internal Telegram routes require a lifeline-only secret
2
+
3
+ **Version / slug:** `telegram-internal-route-lifeline-secret`
4
+ **Date:** `2026-10-12`
5
+ **Author:** `echo`
6
+ **Second-pass reviewer:** `independent reviewer subagent (see the end of this file)`
7
+
8
+ ## Summary of the change
9
+
10
+ `POST /internal/telegram-forward` and `POST /internal/telegram-callback` take the sender's Telegram id from the request body and were protected only by the Bearer token every session holds, so any session could post a message or an Approve tap as the operator. This change adds a secret only the lifeline and the server hold (`state/lifeline-ingress-secret`, 0600, never in any process environment), requires it on those routes and on a new `POST /internal/lifeline-proof`, and decides what an unproven request gets through `LifelineIngressGuard`.
11
+
12
+ Files: `src/core/LifelineIngressSecret.ts` (new), `src/server/LifelineIngressGuard.ts` (new), `src/server/TelegramCallbackDedupe.ts` (new), `src/server/routes.ts`, `src/commands/server.ts`, `src/lifeline/TelegramLifeline.ts`, `src/lifeline/forwardErrors.ts`, `src/core/version-skew.ts`, `src/core/keyMaterialPaths.ts`, `src/core/WriteDomainRegistry.ts`, `src/core/devGatedFeatures.ts`, `src/core/types.ts`, `src/monitoring/guardManifest.ts`, `src/data/state-coherence-registry.json`, `src/scaffold/templates.ts`, `src/core/PostUpdateMigrator.ts`, plus tests, the spec, its ELI16 and the release-note fragment.
13
+
14
+ ## Decision-point inventory
15
+
16
+ - `LifelineIngressGuard.evaluate` (forward, callback, proof routes) — **add** — accept or refuse a request by whether it carries the lifeline secret; decide sender trust.
17
+ - Operator auto-bind in the forward route and in the message pipeline — **modify** — skipped for a request whose sender is not trusted.
18
+ - `/internal/telegram-forward` 426 handler's restart-signal write — **modify** — budgeted for an unverified request.
19
+ - `TelegramCallbackDedupe.claim` (callback route) — **add** — a tap id already claimed is not handled again.
20
+ - Lifeline `forwardToServerClassified` / `replayQueue` — **modify** — a secret refusal re-queues with no budget burned, backs off, and notifies after ten minutes.
21
+ - Lifeline restart signal — **pass-through** for existing writers; **add** one budgeted writer.
22
+
23
+ ---
24
+
25
+ ## 1. Over-block
26
+
27
+ **What legitimate inputs does this change reject that it shouldn't?**
28
+
29
+ - A lifeline still on pre-change code sends no secret. Its **messages are never refused**: no message is refused until the guard is armed, and it arms only when a lifeline has presented the secret (or there is no lifeline). Sixty seconds after the routes are built, an unarmed guard writes the restart signal once, so that lifeline is deliberately restarted onto current code; apart from that restart its requests are handled as before while the guard is unarmed.
30
+ - In **observe** (the fleet default) that lasts as long as the guard stays unarmed. Nothing is degraded.
31
+ - In **enforce** (development agents) it lasts 24 hours. After that, with the lifeline still not updated, its messages are still delivered but as from "Unverified sender", and its button taps are refused. Deliberate (it bounds the original hole) and loud (hourly report, attention item, `/guards` row off).
32
+ - A lifeline rolled back alone to pre-change code under a server that has armed and enforces is refused: its messages queue and are dropped with the existing notice after about 25 minutes, and its taps fail. This is the one real over-block of messages; it needs a manual lifeline-only rollback, and `bypass: true` prevents it. Stated in the spec's Rollback section and in the release note.
33
+ - **The server cannot read or create its own secret and has never held one in this process** (for example the file is unreadable at boot). Then every tap from a healthy, current lifeline gets 503 `lifeline-secret-unavailable`, and every real message is delivered as "Unverified sender": no operator bind, no command authority. The load is retried every 30 seconds and a degradation is reported after a second failed load. Refusing messages too would make the agent unreachable; trusting them would restore the hole.
34
+ - A current lifeline whose copy of the secret is stale gets one 401, re-reads the file and succeeds on the retry (end-to-end test). One that cannot read the file at all has its messages held (not dropped), retried with a backoff that doubles from one minute to fifteen, and tells the user at the first refused attempt after ten minutes. Held messages can therefore wait up to fifteen minutes after the cause clears, unless a new message arrives or the server restarts.
35
+ - An operator's own script that calls these routes with the Bearer token (none exists in the repo) would be counted and reported in observe, and refused once the agent enforces. The typed 401 names the cause.
36
+ - A button tap that arrives a second time is not handled again. That is the intent. A tap whose handler threw, or whose first handling was cut off, is not retried on redelivery and answers 409; the user taps again, which is a new id.
37
+ - A tap whose `callbackQueryId` is not a string or a number now gets 400. Telegram always sends a string.
38
+
39
+ ---
40
+
41
+ ## 2. Under-block
42
+
43
+ **What failure modes does this still miss?**
44
+
45
+ - A session that deliberately reads the secret file, rewrites the guard state on disk, or edits the config file. No tool hook refuses that read today; the protection is 0600 plus the key-material list (file viewer, backups, git sync), on one OS user. Stated as the limit in the spec, the CLAUDE.md text, the release note and the PR; the hook is tracked as ACT-099.
46
+ - **An unarmed guard.** While unarmed a forged request is handled as before this change: in observe for as long as the lifeline has not updated, in enforce for at most 24 hours. In practice until the boot nudge restarts the lifeline (about a minute).
47
+ - **An unproven message can still trigger the emergency stop.** The sentinel intercept runs on any accepted forward and acts on the message text, so an "Unverified sender" message saying "stop everything" can still kill the topic's session and pause the resume queue. That is unchanged from before and deliberate: skipping the intercept for unverified requests would stop a real operator's "stop" from working through a lifeline that has not updated. A few slash commands that ignore the sender id (`/interrupt`, `/restart`, `/link`, `/unlink`, `/claim`, `/local-model`) are in the same position. A Bearer holder already has route equivalents for all of these.
48
+ - `POST /sessions/:name/input` still lets a Bearer holder type text shaped like a Telegram message into a session, and `POST /a2a/inbox` still lets one hand the agent-to-agent hook a message with a body-supplied bot claim. Neither reaches operator binding, the inbound log, the ratifiers or the tap handlers. Unchanged, and stated.
49
+ - In observe after arming, an unproven message is still delivered to the session (with no identity and the sender name "Unverified sender"). A session can still put text in front of another session through this route; it can no longer do it as the operator.
50
+ - Two machines: a tap handled on machine A whose poll offset A never confirmed can be applied again on machine B after a seat move. Narrow, stated, flagged for the operator.
51
+ - A message that was accepted and whose response was lost can be delivered twice (existing exactly-once-ingress concern). This change widens that in one case: an untrusted message does not claim an exactly-once ledger key, so a real lifeline whose messages are being treated as untrusted (enforce with the allowance over, or the server holding no secret) loses that protection. Conversely, while the guard is unarmed an unverified caller is still trusted and can still pre-claim a real message's ledger key, as before.
52
+ - The tap guarantee is bounded by retention (2000 ids, 7 days).
53
+
54
+ ---
55
+
56
+ ## 3. Level-of-abstraction fit
57
+
58
+ **Is this at the right layer?**
59
+
60
+ Yes. The identity claim enters the system at these two routes, so the proof is checked there, before any effect, and the identity is removed there in one place rather than at each of the seven consumers. The lifeline restart coordination reuses the existing signal file and its existing readers. The never-served / never-backed-up / gitignored floor reuses the one key-material list. The guard reports through the existing `DegradationReporter`, the attention queue and the guard-posture manifest rather than a new surface. The Bearer middleware is untouched and still required.
61
+
62
+ ---
63
+
64
+ ## 4. Signal vs authority compliance
65
+
66
+ **Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
67
+
68
+ **Does this change hold blocking authority with brittle logic?**
69
+
70
+ - [ ] No — this change produces a signal consumed by an existing smart gate.
71
+ - [ ] No — this change has no block/allow surface.
72
+ - [x] Yes — and the logic is an exact credential compare, which the principle exempts.
73
+ - [ ] ⚠️ Yes, with brittle logic.
74
+
75
+ The guard blocks on one thing: whether a header equals a random secret, compared in constant time. That is a mechanical credential check, not a judgment about meaning, and the principle's own carve-out for authentication applies; an LLM here would only add a way to be talked into accepting a forgery. Everything else the guard decides (armed, the exit rule, the signal budget, duplicate ids) is arithmetic over recorded facts. No keyword, phrase or heuristic holds authority.
76
+
77
+ ---
78
+
79
+ ## 4b. Judgment-point check (Judgment Within Floors standard)
80
+
81
+ **Does this change add a static heuristic at a competing-signals decision point?**
82
+
83
+ No new static heuristic at a competing-signals decision point. Every decision point is classified `invariant` in the spec's `## Decision points touched`, with its justification; the three review rounds contested each row in both directions and they held. The closest to a judgment is the observe exit, and it is fixed arithmetic with a hard deadline precisely so nothing can argue it out of enforcing.
84
+
85
+ ---
86
+
87
+ ## 5. Interactions
88
+
89
+ - **Shadowing:** the secret check runs before the forward route's version handshake. A pre-change lifeline across a major.minor boundary, under an armed enforcing server, gets the 401 before the 426, so its own skew handler does not run. That needs a lifeline-only rollback; stated under Rollback. In the ordinary case (unarmed, or a current lifeline) the handshake runs as before.
90
+ - **Sentinel intercept:** runs after the secret check and before identity is used. A refused request never reaches it. An accepted but untrusted message does (see Under-block).
91
+ - **Exactly-once ledger:** an untrusted message does not claim a key, so it can neither be deduplicated nor pre-claim a real message's id (see Under-block for both consequences).
92
+ - **Double-fire on the restart signal:** the guard and the forward route's 426 handler write the same file. For an unverified request they share one persisted budget, and the guard evaluates first, so it normally spends the budget and the 426 handler's own write does not happen. The signal the guard writes is equivalent (same file, same target version), but it is now written at most once per 12 hours while unarmed instead of on every 426, and the lifeline deletes the signal before it attempts the restart, so a restart that does not go through is not re-nudged by the server for 12 hours. The 426 response itself still drives that lifeline's in-process restart on every forward, as before. A verified lifeline's 426 is unbudgeted, as before.
93
+ - **Operator auto-bind:** the route's bind and the pipeline's bind both skip for an untrusted sender (the second by the missing id and by the explicit flag).
94
+ - **Races:** the secret file is created with temp + hard link, so two processes creating it at once cannot produce two values or a half-written file. Two processes replacing a malformed file at once can briefly hold different values; both re-read on the next mismatch and converge on the file. Guard state and the tap store each have one writer (the server), written synchronously with temp + rename. Lifeline queue replay is now single-flight.
95
+ - **Feedback loops:** an unverified request, or the boot nudge, can cause a restart signal, which can cause a lifeline restart, which causes a startup proof. Bounded by the persisted 12 h / 24 h budget and by the lifeline ignoring a signal that targets its own version. A secret refusal causes a retry; bounded by the backoff (one minute doubling to fifteen), with no drop budget burned and one user notice per episode. The backoff gates queue replay only; a newly arrived message is forwarded at once and is itself the probe.
96
+ - **Standby write admission:** the new proof route is classified machine-local in the write-domain registry, so a standby admits its own lifeline's proof.
97
+ - **Guard-posture probe:** the new manifest row derives off-runtime-divergent for an unarmed (lifeline expected) or bypassed guard, and alerts after two probe ticks.
98
+
99
+ ---
100
+
101
+ ## 6. External surfaces
102
+
103
+ - **Other agents on the same machine:** none. Each agent has its own state dir and secret.
104
+ - **Install base:** existing agents get the gitignore entries and the CLAUDE.md section through `PostUpdateMigrator`. Development agents start in enforce. Fleet agents with a lifeline start in observe. An agent with no lifeline ingress (its server polls Telegram itself, or it has no Telegram) arms at once and enforces on every agent class, because there is no lifeline traffic to observe; that is persisted. No message is refused on any agent with a lifeline until that lifeline has proven itself.
105
+ - **External systems:** Telegram only through the lifeline's existing notices plus one new fixed notice (messages held, sent at the first refused attempt after ten minutes of secret refusal). Three report kinds raise an operator attention item, each at most once per 24 hours, raised by each machine for itself.
106
+ - **Persistent state:** three new files under `state/` (secret, guard state, tap claims), all bounded, all machine-local, none in backups or git. The existing restart-signal file also joins the never-served list.
107
+ - **Timing:** arming depends on the lifeline's first proof (within one 15-second tick of a current lifeline being up). Until then the guard is unarmed and refuses no message. All time rules read the wall clock.
108
+ - **Operator surface (Mobile-Complete Operator Actions):** the two config keys (`enforce`, `bypass`) are config-file edits only, on purpose: a Bearer holder must not be able to turn the guard off over HTTP. There is no dashboard control. The operator's phone-reachable path is conversational: they ask the agent, and the agent edits its own config file. No PIN-gated or approval-class route is added.
109
+
110
+ ---
111
+
112
+ ## 6b. Operator-surface quality (Operator-Surface Quality standard)
113
+
114
+ No operator surface — not applicable. No dashboard renderer, approval page or form is touched.
115
+
116
+ ---
117
+
118
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
119
+
120
+ **Machine-local BY DESIGN.** Each machine runs its own lifeline and server; these routes are the loopback hop between them and cross-machine forwarding does not use them.
121
+
122
+ - Secret: a credential shared by two processes on one disk. Replicating it would only widen who can read it.
123
+ - Guard state: whether this machine's lifeline has proven itself with this disk's secret. Meaningless on another machine.
124
+ - Tap claim store: channel-seat state, like the poll offset the registry already classes machine-local. This is the contestable one, and it has a real residual (a tap can be applied on a second machine if the first stopped between handling it and its next poll). Declared in the spec and flagged in the PR for the operator.
125
+
126
+ A current standby lifeline never polls but still proves itself, so that machine arms. A machine with Telegram configured, a send-only server and no lifeline process at all never arms: it reports `unarmed` hourly when unverified requests arrive, its guard row reads off, and it writes a restart signal every 12 hours that nothing reads. User-facing notices: only the lifeline that holds undelivered messages emits the new lifeline notice, which is the poll owner, so one voice for that. The server-side attention items are raised per machine. No durable state is keyed on a topic, so nothing strands on a topic transfer. No URL is generated.
127
+
128
+ ---
129
+
130
+ ## 8. Rollback cost
131
+
132
+ - **Live, no restart (where the live config reader is wired, as it is in the running server):** `telegramIngress.lifelineSecret.bypass: true` in the config file makes every request accepted with its sender trusted and taps accepted. Two things from this change stay on under bypass: a tap id must be a string or a number, and a tap delivered twice is applied once (with 409 for a redelivery after a failed or unknown first attempt). `enforce: false` stops refusing messages only. A message the lifeline is already holding is delivered at its next retry, up to fifteen minutes away.
133
+ - **Hot-fix release:** revert the code; an older server ignores the header and the three files.
134
+ - **Data migration:** none. The three state files can be left in place or deleted.
135
+ - **Agent state repair:** none for a full revert. If only the lifeline is rolled back while the server stays armed and enforcing, set `bypass: true` first.
136
+ - **User visibility:** none during a full revert.
137
+
138
+ ---
139
+
140
+ ## Conclusion
141
+
142
+ The review changed the design substantially over four rounds. The first version refused on a bare mode flag; the reviews drove: arming only on proof and a startup proof route; dropping the sender identity in one place instead of skipping one auto-bind; refusing unproven taps in either mode; a 7-day deadline so observe cannot be held open, and in enforce a 24-hour pre-arm allowance so the migration window cannot either (in observe an unarmed guard deliberately changes nothing: fleet traffic is not degraded before development agents have run the strict behaviour); a boot nudge so a quiet machine's lifeline is restarted without waiting for a message; a persisted restart-signal budget; a no-budget, backed-off, user-visible path for a refused lifeline; protection of the guard's own files from the Bearer-only file editor; a true rollback key; and registration in the guard-posture manifest. What remains open is stated rather than hidden: the one-OS-user limit, the two-machine tap window, and the absence of a test against a lifeline built from a previous release.
143
+
144
+ ---
145
+
146
+ ## Second-pass review (if required)
147
+
148
+ Required: this change adds block/allow decisions on inbound messaging.
149
+
150
+ **Reviewer:** independent reviewer subagent (read-only; read the diff and the new files, not only this artifact)
151
+ **Independent read of the artifact: concur** (on the second read)
152
+
153
+ First read: "Concern raised". No blocking defect in the code; seven places where this artifact omitted something or said more than the code supports: the emergency-stop intercept acting on an unproven message; the server's own secret being unreadable; the exactly-once ledger; the 426 signal writer being effectively replaced for unverified callers; "one attempt a minute" (the backoff doubles to fifteen); `bypass` not restoring everything; and three statements that assumed a lifeline on every machine. Each was corrected in sections 1, 2, 5, 6, 7 and 8 above.
154
+
155
+ Second read, after those corrections and seven small code changes (the pre-arm allowance expiring only in enforce; a per-kind attention throttle; independent unarmed reports; a `no-lifeline-ingress` mode source; the backoff counter incrementing per replay pass; the restart-signal file joining the never-served list; the boot-nudge timer disabled under the test runner): "Concur with the review. All seven earlier concerns are addressed in the revised artifact, and the seven code changes match their descriptions." One cosmetic point was raised (a stale code comment about taps) and fixed.
156
+
157
+ ---
158
+
159
+ ## Addendum — second commit (catch sites)
160
+
161
+ After the second-pass review, the `no-silent-fallbacks` ratchet counted four new catch blocks in `LifelineIngressGuard.ts` and `LifelineIngressSecret.ts`. Two now log (the boot-nudge signal write and the attention raise); all four carry a stated reason why the fallback direction is the safe one (a config read fault never switches the guard off; a failed temp-file cleanup happens after the secret is already published). No behaviour change, so none of the sections above changes. The second-pass reviewer did not see this commit.
162
+
163
+ ---
164
+
165
+ ## Addendum — third commit (source-pin ordering)
166
+
167
+ The full local suite found `tests/unit/signed-inbound-label.test.ts` failing: it pins the exact operator-bind condition lines in `server.ts` and `routes.ts` to prove the agent-signed verdict is resolved before the bind, and the first commit had inserted the `senderUnverified` / `senderUntrusted` condition into the middle of both pinned strings. The condition is now placed outside the pinned substring in each file (before the pinned clause in `server.ts`, after it in `routes.ts`). Same checks, same effect; the ordering proof the pin exists for still holds. The two bind behaviour tests (`topic-operator-autobind-route`, `topic-operator-polling-bind`) and this change's own tests pass unchanged.
168
+
169
+ ---
170
+
171
+ ## Evidence pointers
172
+
173
+ - `tests/unit/lifeline-ingress-secret.test.ts`, `tests/unit/lifeline-ingress-guard.test.ts`, `tests/unit/telegram-callback-dedupe.test.ts`, `tests/unit/session-spawn-no-lifeline-secret.test.ts`, `tests/unit/PostUpdateMigrator-lifelineIngressSecret.test.ts`, `tests/unit/lifeline/forwardErrors.test.ts`
174
+ - `tests/integration/telegram-internal-routes-lifeline-secret.test.ts`, `tests/integration/telegram-unverified-sender-pipeline.test.ts`
175
+ - `tests/e2e/telegram-internal-routes-lifeline-secret-lifecycle.test.ts`
176
+ - `docs/specs/reports/telegram-internal-route-lifeline-secret-convergence.md`
177
+
178
+ ---
179
+
180
+ ## Class-Closure Declaration (display-only mirror)
181
+
182
+ This change adds self-triggered behaviour in the `unbounded-self-action` class in two places, and neither fixes a defect in an agent-authored artifact.
183
+
184
+ - **Server restart-signal writer** (`LifelineIngressGuard`): control-loop edge = an unverified request, or the boot nudge while unarmed, causes a signal write, which can restart the lifeline. Steady-state bound = one write per 12 hours before arming and one per 24 hours after, persisted across server restarts, never with an unknown target; a lifeline on the server's version ignores the signal. Settling brake = arming (a restarted current lifeline proves itself, after which a secretless request is no longer a version problem).
185
+ - **Lifeline secret-refusal retry** (`TelegramLifeline.replayQueue`): control-loop edge = a refused forward is retried. Steady-state bound = single-flight, the pass stops at the first refusal, the interval between passes doubles from one minute to a fifteen-minute cap, no drop budget burned, one user notice per episode. Settling brake = the first delivered forward clears the episode.
186
+
187
+ `defectClass`: not applicable (no agent-authored-artifact defect fixed). The convergence arguments above are the self-action declaration.
@@ -1,24 +0,0 @@
1
- # Upgrade Guide — vNEXT
2
-
3
- <!-- assembled-by: assemble-next-md -->
4
- <!-- bump: patch -->
5
-
6
- ## What Changed
7
-
8
- - `SingleInstanceLock` decided whether the recorded lock holder was still running from the pid alone. On a host that recycles pids every few minutes, a dead holder's pid is re-issued to unrelated live processes, the lock reads LIVE indefinitely, and the agent's server refuses to boot until a spawn happens to land on a free pid. The lock record now also carries the holder's absolute process start time (`ps -o lstart=`, via the primitive `hostTestRunnerSemaphore` already uses for the same corroboration); a candidate pid whose start postdates the record by more than the shared 120s skew is treated as a different process and the stale lock is reclaimed. One-sided on purpose; every uncertain case (no recorded start, unreadable candidate start) keeps the previous pid-only verdict.
9
- - `ServerSupervisor` now appends the spawned server's stderr to `logs/server-stderr.log` (`tee -a`) instead of truncating it on every spawn. Past 4 MB the newest 512 KB is kept. The crash-output reader takes a bounded tail rather than reading the whole file.
10
-
11
- ## What to Tell Your User
12
-
13
- If one of your agents went quiet for hours and its log showed a long run of "spawns failed before binding", this is the fix. The agent believed a copy of itself was already running because it was identifying that copy by an operating-system ID number the machine had since handed to something else. It now also checks when that process started, so a stale note can no longer keep the agent off. Nothing to configure. Separately, the agent's crash log now keeps the reasons for every failed start instead of only the last one, so a repeat of this kind of outage can be explained in minutes rather than hours.
14
-
15
- ## Summary of New Capabilities
16
-
17
- None — this is a reliability fix to an existing guard. No new setting, route, or surface.
18
-
19
- ## Evidence
20
-
21
- - Measured on the `inspec` agent (monroe-workspace, laptop) 2026-10-12: 48 consecutive `Bind-failure escalation armed: N consecutive spawns failed before binding` in `lifeline-launchd.log`; outage windows from `activity-*.jsonl` `scheduler_start` events of ~35h (Oct 10 22:06 → Oct 12 08:53 PDT) and ~3.5h (Oct 12 12:58 → 16:30 PDT); the recovering boot logged `reclaimed a stale same-host lock (dead pid 86892)`.
22
- - `server-stderr.log` on that host held three lines from an unrelated earlier boot — the truncation defect in one observation.
23
- - Tests: `tests/unit/single-instance-lock.test.ts` (29, including a defaults-only round trip that exercises the real `lstart` write and read together) and `tests/unit/supervisor-crashlog-append.test.ts` (4).
24
- - Side-effects review with an independent second pass: `upgrades/side-effects/single-instance-lock-pid-reuse.md`.