instar 1.2.36 → 1.2.38

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 (47) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +13 -0
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/AutoUpdater.d.ts.map +1 -1
  5. package/dist/core/AutoUpdater.js +25 -1
  6. package/dist/core/AutoUpdater.js.map +1 -1
  7. package/dist/core/PostUpdateMigrator.d.ts +1 -0
  8. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  9. package/dist/core/PostUpdateMigrator.js +91 -0
  10. package/dist/core/PostUpdateMigrator.js.map +1 -1
  11. package/dist/core/version-skew.d.ts +77 -0
  12. package/dist/core/version-skew.d.ts.map +1 -0
  13. package/dist/core/version-skew.js +144 -0
  14. package/dist/core/version-skew.js.map +1 -0
  15. package/dist/lifeline/RestartOrchestrator.d.ts +1 -1
  16. package/dist/lifeline/RestartOrchestrator.d.ts.map +1 -1
  17. package/dist/lifeline/TelegramLifeline.d.ts +14 -0
  18. package/dist/lifeline/TelegramLifeline.d.ts.map +1 -1
  19. package/dist/lifeline/TelegramLifeline.js +60 -0
  20. package/dist/lifeline/TelegramLifeline.js.map +1 -1
  21. package/dist/lifeline/rateLimitState.d.ts +1 -1
  22. package/dist/lifeline/rateLimitState.d.ts.map +1 -1
  23. package/dist/lifeline/rateLimitState.js +16 -11
  24. package/dist/lifeline/rateLimitState.js.map +1 -1
  25. package/dist/messaging/TelegramAdapter.d.ts +45 -0
  26. package/dist/messaging/TelegramAdapter.d.ts.map +1 -1
  27. package/dist/messaging/TelegramAdapter.js +52 -0
  28. package/dist/messaging/TelegramAdapter.js.map +1 -1
  29. package/dist/server/routes.d.ts.map +1 -1
  30. package/dist/server/routes.js +20 -0
  31. package/dist/server/routes.js.map +1 -1
  32. package/dist/tunnel/TunnelManager.d.ts +32 -1
  33. package/dist/tunnel/TunnelManager.d.ts.map +1 -1
  34. package/dist/tunnel/TunnelManager.js +36 -0
  35. package/dist/tunnel/TunnelManager.js.map +1 -1
  36. package/dist/tunnel/TunnelNotifier.d.ts +28 -0
  37. package/dist/tunnel/TunnelNotifier.d.ts.map +1 -1
  38. package/dist/tunnel/TunnelNotifier.js +38 -4
  39. package/dist/tunnel/TunnelNotifier.js.map +1 -1
  40. package/package.json +1 -1
  41. package/scripts/instar-dev-precommit.js +128 -0
  42. package/src/data/builtin-manifest.json +64 -64
  43. package/src/templates/scripts/instar-watchdog.sh +80 -3
  44. package/upgrades/1.2.37.md +53 -0
  45. package/upgrades/1.2.38.md +103 -0
  46. package/upgrades/side-effects/auto-updater-lifeline-coordination.md +199 -0
  47. package/upgrades/side-effects/owner-dm-channel.md +152 -0
@@ -0,0 +1,53 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- bump: patch -->
4
+
5
+ ## What Changed
6
+
7
+ Closes the failure class that produced the b2lead-insights regression on 2026-05-22 — a recurrence of the 2026-05-20 incident two days after PR #284 shipped four of five fixes and explicitly deferred the fifth. The deferral was *"lifeline auto-restart on server upgrade — out of scope today."* This release ships that missing fifth fix, plus a structural rule that prevents future PRs from leaving similar gaps.
8
+
9
+ The mechanic: when the AutoUpdater applies a server update that crosses a major.minor boundary, the running lifeline process is on the OLD major.minor and will be rejected by the new server's `/internal/telegram-forward` with HTTP 426 on every forward. Until now, only the lifeline-internal handler reacted to that 426 — but only if the lifeline was already running the post-PR-#284 code. Lifelines started before PR #284 had no such handler and crashed silently into the rate-limit cooldown.
10
+
11
+ This release adds three independent channels that all write the same coordinated-restart signal at `state/lifeline-restart-requested.json`:
12
+
13
+ 1. **AutoUpdater** — when `crossesBreaking(previousVersion, targetVersion)` is true, writes the signal alongside the existing `restart-requested.json` server-restart flag.
14
+ 2. **Server `/internal/telegram-forward` 426 handler** — writes the same signal directly. Belt-and-suspenders: covers the case where AutoUpdater is in a deferred / weird state but the server has direct evidence of skew.
15
+ 3. **PostUpdateMigrator one-time bootstrap** — on first update post-this-release, any agent whose `lifeline-started-at.json` records a major.minor older than the installed version gets the signal written once. Unsticks every currently-stuck agent without manual SIGKILL.
16
+
17
+ The signal is read by THREE independent consumers (also for redundancy):
18
+
19
+ - **TelegramLifeline tick** — primary consumer; checks every 30s and calls `initiateRestart('plannedUpgrade', ...)`. New `plannedUpgrade` bucket in `rateLimitState.decide` bypasses the watchdog cooldown identically to `versionSkew`, and counts toward the shared 3-per-24h hard-skew cap.
20
+ - **Fleet watchdog (out-of-process)** — checks every 5 min for signals older than 60s. If the lifeline's own tick failed to act, the watchdog force-restarts via `launchctl bootout/bootstrap`. This is the only channel that can break a wedged event loop (the supervisor lives inside the lifeline and shares the loop).
21
+ - **(Future) v3 Remediator Tier-3 probe** — explicitly designed to absorb this signal-file orchestration when Tier 3 lands. <!-- tracked: topic-3079-v3-remediator -->
22
+
23
+ A separate, structural fix ships alongside: the `instar-dev` pre-commit hook (`scripts/instar-dev-precommit.js`) now scans staged specs for orphan deferral language (`deferred / out of scope today / not in this PR / preemptive fix / follow-up`) and blocks the commit unless each occurrence is linked to a tracked marker (`<!-- tracked: <id> -->`) or the spec's frontmatter explicitly waves it through via `deferrals-tracked: <affirmation>`. The override `INSTAR_DEV_ALLOW_ORPHAN_DEFERRALS=1` is logged + audited.
24
+
25
+ CLAUDE.md template gains a "Version-Skew Self-Recovery" section so future agents understand the "Heads up: my server auto-updated…" alert as a non-action-needed event.
26
+
27
+ ## What to Tell Your User
28
+
29
+ - "If your agent's server auto-updates across a breaking version boundary, the lifeline now restarts automatically to match. You may see a one-time *'Heads up: my server auto-updated… ingress is paused…'* Telegram message — no action needed; recovery is automatic within 30s to 5 min. Queued messages replay on recovery."
30
+ - "Anything currently stuck on a pre-PR-#284 lifeline self-recovers on the next update without manual intervention."
31
+ - "Future PRs cannot ship 'tactical fix + deferred follow-ups' without explicit tracking — the pre-commit hook blocks it."
32
+
33
+ ## Summary of New Capabilities
34
+
35
+ | Capability | How to Use |
36
+ |-----------|-----------|
37
+ | Coordinated lifeline restart on major.minor server bump | Automatic; AutoUpdater writes the signal, lifeline consumes it within 30s |
38
+ | Server-side 426 belt-and-suspenders | Automatic; server writes the same signal on its own evidence of skew |
39
+ | PostUpdateMigrator stale-lifeline bootstrap | Automatic; one-time nudge per agent on first update post-release |
40
+ | Fleet watchdog force-restart on wedged lifeline | Automatic; signals >60s old trigger out-of-process restart |
41
+ | Structural no-deferrals enforcement | Automatic; pre-commit blocks unlinked deferral language in specs |
42
+ | Version-Skew Self-Recovery awareness in CLAUDE.md | Automatic via PostUpdateMigrator on next update |
43
+
44
+ ## Evidence
45
+
46
+ - Spec: `docs/specs/auto-updater-lifeline-coordination.md` (with ELI16 companion).
47
+ - Side-effects review: `upgrades/side-effects/auto-updater-lifeline-coordination.md`.
48
+ - Incident reference: b2lead-insights regression 2026-05-22, ~46h silent Telegram ingress drop. Lifeline pinned at v1.1.0 while server auto-updated through 27 minor releases to v1.2.28.
49
+ - Tests: 43 new tests across 5 files (17 unit on version-skew module, 5 unit on rate-limit plannedUpgrade bucket, 5 unit on PostUpdateMigrator nudge, 9 unit on deferrals-check, 7 integration on the full coordinated-restart pipeline).
50
+
51
+ ## Rollback
52
+
53
+ Every layer is independently revertable. The signal-file format is additive (writers + readers; no existing format changes). New `plannedUpgrade` bucket adds a string union member; revert renames it out. The deferrals-check defaults to mandatory in this release; reverting drops the gate without affecting any other commit path. PostUpdateMigrator's nudge writes a signal that's harmless if left behind (24h cleanup already covers it).
@@ -0,0 +1,103 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- bump: patch -->
4
+ <!-- patch = owner-DM channel + notifier sink wiring; no public API breakage -->
5
+
6
+ ## What Changed
7
+
8
+ **feat(tunnel,telegram): owner-DM channel + two-channel notifier wired into the tunnel manager.**
9
+
10
+ PR 3 of the tunnel-failure-resilience chain. Lands the owner-DM
11
+ messaging surface that the upcoming consent UX + relay providers
12
+ (PR 4) and credential rotation lifecycle (PR 5) depend on. Spec
13
+ `specs/dev-infrastructure/tunnel-failure-resilience.md` is converged
14
+ and approved on main.
15
+
16
+ What's new:
17
+
18
+ - `TelegramAdapter.ownerUserId` config field — the single Telegram
19
+ user id who controls security-sensitive decisions for this
20
+ instance (consent gate for backup tunnels, credential exposure).
21
+ Separate from the broader `authorizedUserIds` allowlist per the
22
+ GPT external review's CRITICAL finding that consent decisions
23
+ must not trust the broader set. Falls back to
24
+ `promptGate.ownerId` for back-compat when not set explicitly.
25
+ - `TelegramAdapter.getOwnerUserId()` — returns the configured owner
26
+ principal (or the back-compat fallback).
27
+ - `TelegramAdapter.sendToOwnerDM(text)` — sends a private DM to the
28
+ owner's direct bot chat. Failure modes ("no owner configured",
29
+ "owner hasn't messaged the bot yet", network error) log a warning
30
+ and return null; never throw into the caller. Telegram bots can
31
+ only initiate a DM after the user has messaged the bot at least
32
+ once, so an early forbidden response is expected and non-fatal.
33
+ - `TunnelManager.attachTelegram(adapter, dashboardPin)` — wires the
34
+ notifier sink. Group messages route to the Dashboard topic (with
35
+ Lifeline fallback); owner-DM messages route to
36
+ `sendToOwnerDM`. A credentialProvider callback returns the live
37
+ URL + current PIN at compose time, so the notifier never holds
38
+ stale credentials in its own state. `server.ts` calls
39
+ `attachTelegram` after the Dashboard topic is ensured.
40
+ - `TunnelNotifier` composers — the four owner-DM messages
41
+ (recovered, restored, consent prompt, relay activated) now render
42
+ real English text with credentials substituted. Group-channel
43
+ messages continue to carry status text only — credentials NEVER
44
+ appear in group messages.
45
+
46
+ What's NOT new yet (deliberate, future PRs in this chain):
47
+
48
+ - Inline-button consent UX + callback handler (PR 4).
49
+ - Tier-2 relay providers (localtunnel) + the actual consent-driven
50
+ transition into `relay-active` (PR 4).
51
+ - `authToken` / PIN rotation on relay-episode end + boot recovery
52
+ (PR 5).
53
+ - Full N-consecutive-success self-heal stability gate (PR 6).
54
+ - The auth-gated `/tunnel` route (PR 7).
55
+
56
+ The lifecycle state machine already supports all of these; the
57
+ manager just doesn't transition into the relevant states yet.
58
+
59
+ ## Evidence
60
+
61
+ 3 new unit tests in `tests/unit/tunnel-manager-rewrite.test.ts`:
62
+
63
+ - `attachTelegram` routes the "couldn't reach" group message to the
64
+ Dashboard topic id.
65
+ - `attachTelegram` falls back to the Lifeline topic when no Dashboard
66
+ topic is configured.
67
+ - `attachTelegram` owner-DM message carries the live URL and current
68
+ PIN (credential substitution at compose time).
69
+
70
+ 1 new + updated tests in `tests/unit/tunnel-notifier.test.ts`:
71
+
72
+ - The credential-snapshot test now uses a real credentialProvider and
73
+ asserts the URL + PIN appear in owner-DM messages AND that they
74
+ NEVER appear in group messages.
75
+ - New "renders a graceful 'link not available' placeholder when no
76
+ credentialProvider is wired" test for the test-injection /
77
+ partial-wire-up path.
78
+
79
+ All 71 tunnel-related unit tests pass (32 lifecycle + 10 providers +
80
+ 13 notifier + 19 manager). Typescript and lint clean.
81
+
82
+ ## What to Tell Your User
83
+
84
+ This release adds the plumbing for owner-only private messages from
85
+ the agent to you, separate from the group topics where everyone
86
+ sees the same messages. Nothing visible to you yet — the upcoming
87
+ release uses this plumbing to message you directly when Cloudflare
88
+ goes down and asks whether to use a backup. The release after that
89
+ uses the same channel to send you new dashboard credentials after a
90
+ backup tunnel finishes its work and your auth token rotates.
91
+
92
+ If you want to set the owner explicitly, the relevant config field
93
+ is your Telegram user id. The agent falls back to your prompt-gate
94
+ owner id if it's already set, so most existing installs don't need
95
+ to change anything.
96
+
97
+ ## Summary of New Capabilities
98
+
99
+ | Capability | How to Use |
100
+ |-----------|-----------|
101
+ | Owner-DM channel | Automatic — the agent picks up your owner id from existing config and uses it for security-sensitive messages |
102
+ | Live credential substitution in DMs | Automatic — when the tunnel sends you a link, the URL + PIN are always the current ones |
103
+ | Group-vs-DM channel separation | Structural — credentials never appear in group messages regardless of how the tunnel layer wires up |
@@ -0,0 +1,199 @@
1
+ # Side-Effects Review — Auto-updater ↔ lifeline coordination + no-deferrals enforcement
2
+
3
+ **Version / slug:** `auto-updater-lifeline-coordination`
4
+ **Date:** 2026-05-22
5
+ **Author:** echo
6
+ **Second-pass reviewer:** subagent (high-risk: AutoUpdater + lifecycle + watchdog)
7
+ **Spec:** [docs/specs/auto-updater-lifeline-coordination.md](../../docs/specs/auto-updater-lifeline-coordination.md)
8
+ **ELI16:** [docs/specs/auto-updater-lifeline-coordination.eli16.md](../../docs/specs/auto-updater-lifeline-coordination.eli16.md)
9
+
10
+ ## Summary of the change
11
+
12
+ Closes the failure class produced by today's b2lead-insights regression — a recurrence of the 2026-05-20 incident two days after PR #284 shipped four of five fixes and explicitly deferred the fifth. The fifth fix — *"lifeline auto-restart on server upgrade"* — is the actual prevention layer, and its deferral allowed the same outage to recur.
13
+
14
+ This PR ships the missing fix in three writer channels and three reader channels (each independent for redundancy), plus a structural enforcement that prevents future deferrals from rotting.
15
+
16
+ **Writers** (all write to `state/lifeline-restart-requested.json`):
17
+ - `AutoUpdater.requestRestart` — when `crossesBreaking(prev, next)` is true, writes the signal alongside the existing server-restart signal.
18
+ - `routes.ts /internal/telegram-forward` 426 path — writes the signal on direct evidence of skew.
19
+ - `PostUpdateMigrator.migrateStaleLifelineSignal` — one-time bootstrap for currently-stuck agents.
20
+
21
+ **Readers**:
22
+ - `TelegramLifeline.checkLifelineRestartSignal` — primary; checks every 30s, calls `initiateRestart('plannedUpgrade', ...)`. New `plannedUpgrade` bucket in `rateLimitState.decide` bypasses watchdog cooldown identically to the existing `versionSkew` bucket.
23
+ - Fleet watchdog (`src/templates/scripts/instar-watchdog.sh`) — checks every 5 min; force-restarts via launchctl if signal is >60s old. Out-of-process, so it can break a wedged event loop.
24
+ - (Future) v3 Remediator Tier-3 probe — explicit absorption point, tracked separately. <!-- tracked: topic-3079-v3-remediator -->
25
+
26
+ **Structural meta-fix**: `scripts/instar-dev-precommit.js` adds an orphan-deferrals scan. Every "deferred / out of scope today / NOT in this PR / preemptive fix / follow-up" mention in a spec must be linked (`<!-- tracked: <id> -->`) or the spec's frontmatter must explicitly wave it through (`deferrals-tracked: <affirmation>`). Override via `INSTAR_DEV_ALLOW_ORPHAN_DEFERRALS=1`, logged + audited.
27
+
28
+ **Agent awareness**: CLAUDE.md template gains a "Version-Skew Self-Recovery" section migrated to existing agents.
29
+
30
+ ## Decision-point inventory
31
+
32
+ - `crossesBreaking(prev, next)` — **add** — semver predicate; mechanic, no judgment.
33
+ - `writeLifelineRestartSignal` — **add** — atomic write + idempotent skip-fresh.
34
+ - `readLifelineRestartSignal` / `clearLifelineRestartSignal` — **add** — read with expiry check, atomic delete.
35
+ - `AutoUpdater.requestRestart` lifeline-signal branch — **add** — fires on major.minor crossing.
36
+ - `routes.ts /internal/telegram-forward` 426 lifeline-signal write — **add** — belt-and-suspenders.
37
+ - `PostUpdateMigrator.migrateStaleLifelineSignal` — **add** — one-time bootstrap.
38
+ - `TelegramLifeline.checkLifelineRestartSignal` — **add** — per-tick consumer.
39
+ - `rateLimitState.decide` `plannedUpgrade` bucket — **add** — shares cooldown-bypass + daily-cap with `versionSkew`.
40
+ - Fleet watchdog `check_stale_lifeline_signal` — **add** — out-of-process consumer.
41
+ - `instar-dev-precommit.js` orphan-deferrals scan — **add** — structural enforcement.
42
+ - CLAUDE.md "Version-Skew Self-Recovery" section — **add** (template + migrator).
43
+
44
+ ## Deferrals tracked
45
+
46
+ Per the new enforcement rule, every potential deferral in this PR is either in-scope (and thus not a deferral) or explicitly tracked:
47
+
48
+ - v3 Remediator absorption — tracked at topic 3079 with active development; absorption is mechanical when Tier 3 lands. <!-- tracked: topic-3079-v3-remediator -->
49
+
50
+ No other deferrals. Tests, migration, CLAUDE.md awareness, cross-platform fleet watchdog wiring — all in this PR.
51
+
52
+ ---
53
+
54
+ ## 1. Over-block
55
+
56
+ **What legitimate inputs does this change reject that it shouldn't?**
57
+
58
+ - **`crossesBreaking` fail-safe.** Returns `true` on malformed inputs (missing version, non-semver string). A false-positive triggers a single harmless lifeline restart — strictly better than a false-negative which is the exact incident class. Documented in unit test.
59
+ - **PostUpdateMigrator nudge — agent that legitimately wants the old lifeline.** If a user has pinned their lifeline at an older version on purpose (e.g. for compatibility testing), the migrator will signal a restart against their will on next update. Mitigation: a pinned-version operator would also pin the server. Same-major.minor → no signal. The corner case is "user pinned the lifeline only" — explicitly out of supported configurations.
60
+ - **Deferrals-check false-positives on technical writing.** A spec might legitimately use "deferred" / "follow-up" in a non-prescriptive context (e.g. quoting an older spec for historical comparison). The override flag `INSTAR_DEV_ALLOW_ORPHAN_DEFERRALS=1` exists for this case, with mandatory audit logging. Practical false-positive rate from the test fixtures: zero (the regex's negation guard handles "no deferrals" / "non-deferred").
61
+ - **Server-side 426 writer over-firing on dev/test deployments.** If a dev runs the server with the version handshake disabled (no `authToken`), the 426 path doesn't fire. If they enable auth but mismatch versions intentionally, the signal write happens — but the lifeline's tick consumer is the only one that can act on it, and a dev's lifeline can read it just as well.
62
+
63
+ ## 2. Under-block
64
+
65
+ **What failure modes does this still miss?**
66
+
67
+ - **Server downgrade (reviewer nit 2026-05-22, addressed).** `crossesBreaking('1.2.28', '1.1.0')` returns true (the major.minor genuinely changed). The cited "lifeline reads its own running version and skips" pathway (`TelegramLifeline.checkLifelineRestartSignal:1338`) only no-ops when `signal.targetVersion === this.lifelineVersion`. In a genuine server-downgrade scenario the lifeline is on the HIGHER version, the signal targets the LOWER version, and `lifelineVersion !== targetVersion` so the lifeline WOULD restart. After restart, the new lifeline matches the new lower server version — recovery is correct, just an extra restart on an intentional downgrade. Acceptable because downgrades through the auto-updater are not a supported flow; manual-install downgrades trip this once per downgrade, no worse than the manual-replace operator would already expect.
68
+ - **Lifeline that's both wedged AND offline from the fleet watchdog.** If `launchctl bootout` itself hangs (rare but possible under macOS sandbox bugs), the watchdog's force-restart doesn't land. No recovery within this PR. The fleet watchdog's existing 5-min retry plus the eventual launchd ThrottleInterval re-spawn provide eventual recovery; not always sub-15-minute.
69
+ - **Two AutoUpdater instances racing.** Idempotency-by-targetVersion handles the common case. If two AutoUpdaters write SIMULTANEOUSLY with the same target, the atomic rename ensures one wins cleanly. Different targets → second write replaces the first; either way the lifeline restarts to a coherent target.
70
+ - **Signal corruption (truncated write, half-written JSON).** Reader returns null for corrupt JSON; next writer replaces. Worst case: one missed cycle (~30s for lifeline tick, ~5 min for watchdog).
71
+ - **Tone gate B12 on the "Heads up" alert.** PR #284 already shipped this alert via the existing tone gate. This PR doesn't change the alert content; if a future change introduces jargon, the existing B12 ruleset blocks it and falls back to the safe template.
72
+
73
+ ## 3. Level-of-abstraction fit
74
+
75
+ **Is this at the right layer?**
76
+
77
+ - **AutoUpdater is the right writer for the proactive signal.** It KNOWS it's about to bump major.minor (it's about to install a new version). Pushing this knowledge to the lifeline via a signal file is structurally cleaner than expecting the lifeline to infer skew from forward failures.
78
+ - **Server-side 426 writer is a defensible second channel.** The server has direct evidence (the lifeline forwarded a wrong-version request). Even if AutoUpdater somehow missed the boundary, the server can repair the situation.
79
+ - **PostUpdateMigrator is the right home for the one-time bootstrap.** Updates flow through it; the bootstrap is a one-time correction, not an ongoing concern.
80
+ - **Fleet watchdog (out-of-process) for the wedged-lifeline case.** The original ServerSupervisor proposal lived inside the lifeline — wrong layer, would share the wedge. Fleet watchdog has independent process lifetime + already iterates all agents.
81
+ - **`plannedUpgrade` bucket alongside `versionSkew`.** Both are hard-incompatibility signals. Sharing the daily cap (versus separate caps) prevents bucket-hopping abuse.
82
+ - **Deferrals check in the pre-commit hook (not in CI).** Pre-commit catches it before any commit lands; CI is the second-pass authority. Structurally consistent with the other gates in the same hook (ELI16, tracked spec, artifact integrity).
83
+
84
+ ## 4. Signal vs authority compliance
85
+
86
+ **Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)
87
+
88
+ **Does this change hold blocking authority with brittle logic?**
89
+
90
+ - [x] **No — this change produces signals consumed by existing smart gates.**
91
+
92
+ Detailed:
93
+ - `crossesBreaking` is a deterministic predicate (mechanic, not judgment).
94
+ - All three signal writers produce a candidate file; consumers decide.
95
+ - `TelegramLifeline.checkLifelineRestartSignal` calls `initiateRestart` — the existing authority — which goes through `rateLimitState.decide`, `RestartOrchestrator.requestRestart`, etc.
96
+ - The fleet watchdog's force-restart is a bounded recovery primitive (the principle's "Safety guards on irreversible actions" exemption — `launchctl bootout/bootstrap` is reversible by reboot but the mechanical action itself is bounded).
97
+ - The new `plannedUpgrade` bucket bypasses watchdog cooldown — but the daily cap (3-per-24h, shared with versionSkew) is the loop-safety backstop, identical to the existing versionSkew design from PR #284.
98
+ - The deferrals-check regex is a hard-invariant validator at the boundary (the principle's "Hard-invariant validation" exemption). Patterns are explicit, narrow, and the override flag exists for legitimate edge cases.
99
+
100
+ No new judgmental gates over message content or agent intent.
101
+
102
+ ## 5. Interactions
103
+
104
+ **Does this interact with existing checks, recovery paths, or infrastructure?**
105
+
106
+ - **Shadowing:**
107
+ - AutoUpdater writes both `restart-requested.json` (existing) and `lifeline-restart-requested.json` (new). Different consumers, different files. No shadow.
108
+ - The lifeline's existing in-process versionSkew handler (PR #284) fires on 426 response. This PR's per-tick signal-file consumer is independent — it fires on the SIGNAL FILE. Both fire on the b2lead scenario; first-wins via `initiateRestart` rate-limit and the orchestrator's pid-based serialization.
109
+ - Watchdog's `check_stale_lifeline_signal` runs in the healthy-PID branch before the bind-probe; on signal-hit it does an early `continue` so the bind-probe doesn't double-react.
110
+
111
+ - **Double-fire:**
112
+ - All three writers can fire on the same boundary crossing. The `skipped-fresh` outcome in `writeLifelineRestartSignal` prevents duplicate audit-log entries.
113
+ - The lifeline's per-tick consumer + the watchdog's per-cycle consumer can both see the same signal. The lifeline acts first (30s cadence vs 5min); on success, it `clearLifelineRestartSignal`'s first. The watchdog's signal-readability check returns null after clear → no double-restart.
114
+
115
+ - **Races:**
116
+ - File-write race: tmp + rename is atomic on POSIX; concurrent writers see one win cleanly.
117
+ - Read-then-delete race in the lifeline: a process exits between read and delete → respawned lifeline sees the signal on next tick → checks `targetVersion === lifelineVersion` → clears and no-ops. Documented in test.
118
+ - Fleet watchdog vs lifeline-tick race: watchdog only acts when signal is >60s old. The lifeline's 30s tick has two cracks before the watchdog touches anything.
119
+
120
+ - **Feedback loops:**
121
+ - The "Heads up" Telegram alert (PR #284 wiring) fires once per skew episode; the lifeline restart that follows doesn't reset the alert dedupe (per-PR-#284 semantics). On recovery, no extra alert.
122
+ - PostUpdateMigrator runs on every update. Its bootstrap is idempotent (skip-fresh) and only writes when major.minor mismatches the just-installed version. No loop.
123
+
124
+ ## 6. External surfaces
125
+
126
+ - **Other agents on the same machine:** YES — the fleet watchdog now reads each agent's signal file. Read-only, no cross-agent state. The same fleet watchdog landed in PR #245 / #272 and is already cross-agent-aware.
127
+ - **Other users of the install base:** YES — ships to every macOS agent via PostUpdateMigrator on next update. Linux/Windows users: AutoUpdater and lifeline-tick channels work the same (file-based, OS-agnostic); fleet watchdog channel is macOS launchctl-specific, so Linux users rely on the in-process consumers + the v3 Remediator absorption.
128
+ - **External systems:**
129
+ - Telegram: one extra topic per affected agent during the rollout (the existing "Heads up" alert from PR #284). After this PR, that alert fires LESS often because the AutoUpdater coordination prevents many episodes from happening in the first place.
130
+ - launchctl: fleet watchdog now occasionally calls `bootout/bootstrap` on lifeline plists. Already-shipped behavior; this PR adds one more trigger condition.
131
+ - **Persistent state:**
132
+ - New `state/lifeline-restart-requested.json` per agent — single-line JSON, max ~500 bytes, expires after 1h, cleared by readers on action. Harmless if left behind.
133
+ - `lifeline-started-at.json` is now READ by the migrator (in addition to its existing write by the lifeline). Format unchanged.
134
+ - **Timing:**
135
+ - AutoUpdater apply gains one `writeLifelineRestartSignal` call (~5ms file write).
136
+ - Lifeline gains a 30s-interval `fs.statSync` + occasional JSON parse (~1ms).
137
+ - Server 426 path gains one signal write (~5ms) — only fires on actual version mismatches.
138
+
139
+ ## 7. Rollback cost
140
+
141
+ - **Hot-fix:** revert each of the seven source files + the watchdog template. Independently revertable. Ship as a patch.
142
+ - **Data migration:** none. Signal files left behind: harmless (expire in 1h, cleared by readers).
143
+ - **Agent state repair:** none required. Reverting just disables the new behavior; existing agents continue to work via PR #284's in-process handler (which now requires post-PR-#284 lifeline code — i.e., the very thing this PR was meant to deliver).
144
+ - **User visibility:** none. The "Heads up" alert is unchanged in tone and frequency.
145
+ - **Estimated total:** ~30 min revert + release cycle.
146
+
147
+ ---
148
+
149
+ ## Addendum 2026-05-22 — Reviewer findings + fixes
150
+
151
+ Second-pass reviewer found one structural defect plus two minor items. All three addressed before merge:
152
+
153
+ **Structural — frontmatter wave-through was a loophole** (`scripts/instar-dev-precommit.js`). The original implementation honored a `deferrals-tracked:` frontmatter field as a whole-spec wave-through. A future author could write `deferrals-tracked: see below` and ship orphan deferrals undetected. **Closed:** the frontmatter wave-through is REMOVED entirely. Every body deferral mention must have its own `<!-- tracked: <id> -->` marker within 200 chars, with no spec-level escape hatch. Test added that asserts the loophole no longer exists (`tests/unit/instar-dev-precommit-deferrals.test.ts:"frontmatter deferrals-tracked is NO LONGER a wave-through"`).
154
+
155
+ The bootstrap-commit-that-introduces-the-rule itself (this very PR) uses the `INSTAR_DEV_ALLOW_ORPHAN_DEFERRALS=1` env override because the spec describes the rule's vocabulary extensively for documentation purposes — and the audit log entry is the structural visibility. This pattern is documented in `skills/instar-dev/SKILL.md` Phase 4.5 as "the bootstrap-commit exception."
156
+
157
+ **Minor 1 — narrow `follow-ups` negation regex.** The prior pattern excluded only "follow-ups (are) tracked" and false-alarmed on natural variants. **Broadened:** the regex now matches all "follow-up" / "follow-ups" / "followups" mentions and lets the 200-char tracker-marker check do the work uniformly. Plural "deferrals" added to coverage too — reviewer noted the singular-only pattern would miss it.
158
+
159
+ **Minor 2 — Server downgrade doc nit.** The §"Under-block" paragraph cited a mitigation pathway that doesn't actually handle the downgrade case. Reasoning corrected above with the file:line reference.
160
+
161
+ **Confirmed sound (no change needed):** signal-vs-authority compliance; shared `versionSkew`+`plannedUpgrade` daily cap; PR #284 + this PR's signal-consumer coexistence under the orchestrator + rate-limit; watchdog signal-deletion ordering race-safety; `crossesBreaking` fail-safe choice; the one tracked deferral (v3 Remediator at topic 3079) is genuinely separable.
162
+
163
+ ## Conclusion
164
+
165
+ Closes the deferral that produced today's regression. Adds three writers, three readers, one structural meta-fix, and CLAUDE.md awareness — all in this PR per Justin's 2026-05-22 directive. 43 new tests cover the full pipeline. The structural deferrals-check makes the rule durable across future PRs.
166
+
167
+ Clear to ship pending second-pass review.
168
+
169
+ ---
170
+
171
+ ## Second-pass review (if required)
172
+
173
+ **Reviewer:** echo (second-pass subagent)
174
+ **Independent read of the artifact: concern (minor — 2 items, 1 nit). Design is sound; ship after addressing item 1.**
175
+
176
+ - **Frontmatter wave-through is whole-spec, not per-hit.** The artifact and spec both phrase the rule as "each instance is linked OR the frontmatter waves it through." But `scripts/instar-dev-precommit.js:372` short-circuits the entire body scan when `deferrals-tracked:` is present (`if (hasFrontmatterDeferralsTracked(fmText)) return [];`). A future author can put `deferrals-tracked: see below` in frontmatter and ship a spec full of orphan deferrals with zero per-hit verification. This spec relies on exactly that path — without it, the body's 8+ uses of "deferred" couldn't commit. **Resolution:** when frontmatter wave is present, still require the affirmation to either (a) enumerate each deferral hit's tracker ID explicitly, or (b) reference a `## Deferrals tracked` section that the gate verifies contains a `<!-- tracked: ... -->` marker for each unique deferral phrase the body emits. This spec already has §"Deferrals tracked" structured correctly; the gate just doesn't validate it.
177
+
178
+ - **`follow-ups` negation is narrow.** `scripts/instar-dev-precommit.js:359` only excludes "follow-ups (are )?tracked". Natural variants — "follow-up is tracked", "follow-up has an owner", "follow-up at topic-XYZ" — false-alarm. Low harm (override flag exists), but adds friction. **Resolution:** broaden the lookahead to also accept "is tracked", "owned by", or any `<!-- tracked: ... -->` within the same line, OR drop the negation and rely solely on the within-200-char tracker-marker check that's already running for every other pattern.
179
+
180
+ - **Nit — §"Under-block: Server downgrade" reasoning is wrong.** The artifact says targetVersion===currentVersion skip handles downgrades. It doesn't — for a 1.2.28 lifeline against a 1.1.0 install, those differ, and the signal fires. The actual behavior is fine (it re-aligns toward the new install), but the stated mitigation isn't the one that runs. `TelegramLifeline.ts:1338` only skips on identity.
181
+
182
+ **Concur on the rest:**
183
+ - Signal-vs-authority compliance: all three writers + `crossesBreaking` are pure mechanics; `initiateRestart` and the shared `rateLimitState.decide` are the single authority (`src/lifeline/rateLimitState.ts:121-135` correctly shares the daily cap across `versionSkew` and `plannedUpgrade` — no bucket-hopping bypass).
184
+ - PR #284 absorption: `handleVersionSkew` (TelegramLifeline.ts:1275) and `checkLifelineRestartSignal` (TelegramLifeline.ts:1327) can both fire on the same skew episode, but the orchestrator serializes via PID and the shared 3-per-24h cap is the loop backstop. No harmful double-fire.
185
+ - Watchdog ordering: signal deletion after `bootstrap` (instar-watchdog.sh:589) is race-safe — respawned lifeline's same-version no-op (TelegramLifeline.ts:1338) handles any read-then-delete window.
186
+ - `crossesBreaking` fail-safe to `true` for malformed inputs is the correct choice given the false-positive cost (one harmless restart) vs false-negative cost (the exact incident class).
187
+ - Non-goals list is genuinely separable; the one tracked deferral (v3 Remediator absorption) has a real marker (`<!-- tracked: topic-3079-v3-remediator -->`) and the absorption point is mechanical.
188
+ - Linux/Windows coverage via in-process consumers (lifeline tick) is honest — the macOS-only fleet watchdog is the third channel, not the only one. Acceptable.
189
+
190
+ Items 2 and 3 are non-blocking. Item 1 is a structural loophole in the very gate being introduced — recommend tightening before merge so the rule the artifact promises is the rule the code enforces.
191
+
192
+ ---
193
+
194
+ ## Evidence pointers
195
+
196
+ - Spec: `docs/specs/auto-updater-lifeline-coordination.md` (with ELI16 companion).
197
+ - Tests: 5 new test files, 43 new tests.
198
+ - Incident reference: b2lead-insights regression 2026-05-22, ~46h silent Telegram ingress drop. Lifeline pinned at v1.1.0 while server auto-updated through 27 minor releases to v1.2.28.
199
+ - PR #284's spec explicitly deferred "lifeline auto-restart on server upgrade" as Forward note (NOT in this PR) — this PR closes that forward note.
@@ -0,0 +1,152 @@
1
+ # Side-effects review — owner-DM channel + notifier sink wiring (PR 3 of tunnel-failure-resilience chain)
2
+
3
+ **Scope (PR 3 of the chain):** Add the owner-DM messaging surface and
4
+ wire it into the tunnel manager's two-channel notifier. Spec
5
+ `specs/dev-infrastructure/tunnel-failure-resilience.md` is converged
6
+ + approved on main. Public API: `TelegramAdapter.getOwnerUserId()` and
7
+ `TelegramAdapter.sendToOwnerDM(text)` are new; `TunnelManager.attachTelegram()`
8
+ is new; the notifier now substitutes live URL + PIN credentials into
9
+ owner-DM messages via a `credentialProvider` callback.
10
+
11
+ **Files touched:**
12
+ - `src/messaging/TelegramAdapter.ts` — adds `ownerUserId?: number` to
13
+ `TelegramConfig`; adds `getOwnerUserId()` accessor (falls back to
14
+ `promptGate?.ownerId` for back-compat); adds `sendToOwnerDM(text)`
15
+ which posts to the owner's private bot chat via `sendMessage` with
16
+ `chat_id: ownerUserId`. The DM send is fire-and-forget: failure paths
17
+ ("no owner configured", "owner hasn't messaged the bot", network
18
+ error) log-not-throw and return `null` so callers can degrade
19
+ gracefully.
20
+ - `src/tunnel/TunnelNotifier.ts` — adds `credentialProvider?: () =>
21
+ CredentialSnapshot` to `TunnelNotifierOptions`. The four owner-DM
22
+ message variants (recovered, restored, consent prompt, relay
23
+ activated) now compose real English text with the credentials
24
+ substituted at compose time. The four group-channel variants
25
+ continue to carry status text only — credentials NEVER appear in
26
+ group messages.
27
+ - `src/tunnel/TunnelManager.ts` — new `attachTelegram(adapter,
28
+ dashboardPin)` method builds a notifier sink from a duck-typed
29
+ `TunnelMessagingAdapter` (group → Dashboard topic with Lifeline
30
+ fallback; owner DM → `sendToOwnerDM`) and a credentialProvider that
31
+ returns `{ url: this.url, pin: dashboardPin() }`. Holds the notifier
32
+ in a mutable field so callers can wire it AFTER tunnel construction
33
+ (the messaging adapter is constructed later in server.ts).
34
+ - `src/commands/server.ts` — calls `tunnel.attachTelegram(telegram,
35
+ () => liveConfig.get(...) || config.dashboardPin)` after the
36
+ Dashboard topic is ensured. The wire is non-fatal: if attachTelegram
37
+ throws, the catch around `ensureDashboardTopic` already swallows it.
38
+ - `tests/unit/tunnel-notifier.test.ts` — replaces the placeholder
39
+ assertions with real-credential assertions: a DM with a credential
40
+ provider contains the URL + PIN; a DM without a provider renders a
41
+ graceful "link not available yet" placeholder; group messages NEVER
42
+ contain credentials.
43
+ - `tests/unit/tunnel-manager-rewrite.test.ts` — 3 new tests for
44
+ `attachTelegram`: Dashboard-topic routing, Lifeline fallback when
45
+ Dashboard isn't ensured, owner-DM credential snapshot includes the
46
+ live URL and current PIN.
47
+
48
+ **Under-block**: None. The owner-DM channel adds NEW capability;
49
+ existing behavior is unchanged when no `ownerUserId` is configured
50
+ (`sendToOwnerDM` logs a warning and returns null; the notifier's DM
51
+ sink call is a no-op). Existing Telegram group flows are untouched.
52
+
53
+ **Over-block**: None — but note the existing
54
+ `config.promptGate?.ownerId` continues to serve as a back-compat
55
+ fallback when `ownerUserId` isn't set explicitly. Operators who used
56
+ `promptGate.ownerId` to mark their session-prompt owner will see that
57
+ same user receiving the tunnel-DM messages, which is consistent with
58
+ the spec's "owner principal" intent. New deployments should set
59
+ `ownerUserId` explicitly.
60
+
61
+ **Level-of-abstraction fit**:
62
+ - TunnelManager remains the single owner of the lifecycle; the
63
+ notifier sink is a thin pass-through to the messaging adapter.
64
+ - The `TunnelMessagingAdapter` duck-typed interface keeps the tunnel
65
+ module from importing `TelegramAdapter` directly — preserves the
66
+ layered architecture (tunnel does not depend on messaging type).
67
+ - Credential substitution happens in the notifier's composer methods.
68
+ The notifier never holds credentials in its own state; it calls
69
+ the provider at compose time so the credentials are always fresh.
70
+
71
+ **Signal vs authority**: Compliant. The credentialProvider returns a
72
+ SIGNAL (current URL + PIN); the notifier remains the AUTHORITY for
73
+ deciding when an owner-DM message goes out and what it says. The
74
+ sink is the routing layer (executes the send); the messaging adapter
75
+ is the platform layer. No new authority introduced at the wrong
76
+ layer.
77
+
78
+ **Interactions**:
79
+ - `TunnelManager` constructed in server.ts BEFORE telegram, so the
80
+ manager initially has no notifier. The wire-up happens AFTER
81
+ telegram + Dashboard topic are ready (one-shot call to
82
+ `attachTelegram`). Until then, transitions go through the lifecycle
83
+ but no user-facing messages fire — preserving the boot-time
84
+ ordering established in PR 2.
85
+ - The credentialProvider closes over `this._legacyState.url` and
86
+ the dashboardPin accessor; both are read at each compose, so a
87
+ PIN rotation (PR 5) will be visible to subsequent messages without
88
+ re-attaching.
89
+ - `sendToOwnerDM` failure modes are absorbed inside the adapter — the
90
+ sink wrapper never throws into the notifier path. Preserves the
91
+ fire-and-forget invariant.
92
+
93
+ **External surfaces**:
94
+ - New config field: `messaging.config.ownerUserId` (number). Optional.
95
+ Falls back to `messaging.config.promptGate.ownerId`. Documented in
96
+ the spec.
97
+ - No new API endpoint. No new CLI command.
98
+ - `TelegramAdapter.sendToOwnerDM(text)` is a new public method but
99
+ intended for in-process callers only (the tunnel manager is the
100
+ first caller; future PRs may add more).
101
+
102
+ **Migration parity**:
103
+ - No agent-installed file change in this PR. Existing agents pick up
104
+ the new behavior the moment they update — the `ownerUserId` field
105
+ remains optional with the documented fallback.
106
+ - A future PR in the chain (the ConfigDefaults / migration PR) will
107
+ add a ConfigDefaults entry for `ownerUserId` and a CLAUDE.md
108
+ template note so the agent surfaces the new capability
109
+ conversationally. For now the manager's behavior degrades
110
+ gracefully when the field is absent.
111
+
112
+ **Rollback cost**: Trivial. Revert four source files + the test
113
+ updates. The two new public methods (`getOwnerUserId`,
114
+ `sendToOwnerDM`, `attachTelegram`) become unused; the
115
+ `TunnelMessagingAdapter` interface becomes dead code. Nothing else
116
+ breaks.
117
+
118
+ **Tests**:
119
+ - 19/19 `tests/unit/tunnel-manager-rewrite.test.ts` (3 new for
120
+ `attachTelegram`).
121
+ - 13/13 `tests/unit/tunnel-notifier.test.ts` (placeholder assertions
122
+ replaced with credential assertions; 1 new "no-credential
123
+ gracefully renders 'link not available'" test).
124
+ - 32/32 `tests/unit/tunnel-lifecycle.test.ts` (no change).
125
+ - 10/10 `tests/unit/tunnel-providers.test.ts` (no change).
126
+ - `tsc --noEmit` clean. `npm run lint` clean.
127
+
128
+ **Decision-point inventory**:
129
+ 1. Owner principal modeled as `ownerUserId` separate from
130
+ `authorizedUserIds` (vs. reusing the broader set) — the GPT
131
+ external review specifically flagged that consent / credential
132
+ exposure decisions must NOT trust the broader authorized-users
133
+ set. A separate single-principal field is the structural fix.
134
+ 2. `sendToOwnerDM` returns null on failure (vs. throwing) — preserves
135
+ the fire-and-forget contract callers expect for outbound
136
+ messaging. The bot can't initiate a DM until the user has messaged
137
+ the bot first, so an early "forbidden" response is expected during
138
+ onboarding; treating it as fatal would block tunnel notifications
139
+ indefinitely.
140
+ 3. `attachTelegram` is a post-construction method (vs. constructor
141
+ parameter) — the messaging adapter is constructed later than the
142
+ tunnel manager in server.ts. Putting the sink in the constructor
143
+ would require restructuring boot order. The post-attach pattern
144
+ keeps the existing boot sequence and is testable.
145
+ 4. `TunnelMessagingAdapter` duck-typed interface (vs. direct
146
+ `TelegramAdapter` import) — keeps the tunnel module from depending
147
+ on the messaging layer's concrete types; future PRs can add Slack
148
+ / iMessage variants without modifying tunnel/.
149
+ 5. credentialProvider returns a snapshot (vs. holding the URL/PIN as
150
+ notifier state) — the notifier composes messages over time; a
151
+ stale PIN cached in notifier state would leak after rotation. The
152
+ provider pattern keeps credential freshness in one place.