@teamscala/service-boot 0.23.430 → 0.23.432

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 (82) hide show
  1. package/package.json +12 -19
  2. package/src/agreement-gate/signing-shell.ts +19 -15
  3. package/src/attach-proposal-accept.ts +5 -2
  4. package/src/auth-gate/public-paths.ts +7 -16
  5. package/src/auth-gate.ts +54 -42
  6. package/src/bind-page-handler.ts +20 -32
  7. package/src/build-client-assets/build-lock.ts +15 -6
  8. package/src/build-client-assets/hashed-output.ts +3 -1
  9. package/src/build-client-assets/hydrate-bundle.ts +59 -57
  10. package/src/build-client-assets/input-fingerprint.ts +50 -57
  11. package/src/build-client-assets/input-hashing.ts +2 -4
  12. package/src/build-client-assets/manifest.ts +8 -11
  13. package/src/build-client-assets/source-candidates.ts +8 -16
  14. package/src/build-client-assets/sourcemaps-upload.ts +17 -10
  15. package/src/build-client-assets/styles-build.ts +17 -19
  16. package/src/build-client-assets.test.ts +2 -2
  17. package/src/build-client-assets.ts +16 -22
  18. package/src/content-gate/access.ts +5 -2
  19. package/src/content-gate/catalog.ts +26 -18
  20. package/src/content-gate/gate.ts +7 -3
  21. package/src/discover-blocks.ts +5 -7
  22. package/src/fn-routers/agreement-certificate.ts +1 -1
  23. package/src/fn-routers/agreement-send-router.ts +5 -4
  24. package/src/fn-routers/agreement-sign-side-effects.ts +27 -22
  25. package/src/fn-routers/assessment-report-email.ts +3 -2
  26. package/src/fn-routers/auth-router.ts +20 -21
  27. package/src/fn-routers/chat-session-router-shared.ts +12 -3
  28. package/src/fn-routers/chat-session-router.ts +8 -6
  29. package/src/fn-routers/conversation-ownership.ts +3 -2
  30. package/src/fn-routers/messaging-whatsapp-router.test.ts +3 -1
  31. package/src/fn-routers/messaging-whatsapp-router.ts +17 -3
  32. package/src/fn-routers/persist-chat-messages.ts +6 -4
  33. package/src/fn-routers/proposal-send-router.ts +4 -3
  34. package/src/fn-routers/public-agreement-router.ts +5 -4
  35. package/src/fn-routers/public-assessment-router.ts +5 -4
  36. package/src/fn-routers/public-gpt-session-router.ts +17 -12
  37. package/src/fn-routers/user-preferences-router.ts +6 -15
  38. package/src/fn-routers/work-items-router.ts +21 -17
  39. package/src/handlers/platform-fault-escalation/delivery.ts +36 -0
  40. package/src/handlers/platform-fault-escalation/orpc-client.ts +38 -18
  41. package/src/handlers/platform-fault-escalation-logic.ts +26 -5
  42. package/src/handlers/platform-fault-escalation.test.ts +67 -15
  43. package/src/handlers/platform-fault-escalation.ts +16 -0
  44. package/src/handlers/projects-digest.ts +1 -1
  45. package/src/handlers/scala-tools-currency-watch.test.ts +1 -5
  46. package/src/handlers/scala-tools-currency-watch.ts +23 -32
  47. package/src/handlers/singleton-tools-currency-watch.test.ts +1 -5
  48. package/src/handlers/singleton-tools-currency-watch.ts +27 -19
  49. package/src/handlers/upstream-issue-sync.ts +11 -4
  50. package/src/handlers/whatsapp-liveness.ts +14 -4
  51. package/src/install-harness.ts +1 -1
  52. package/src/install-logging.ts +1 -1
  53. package/src/mount-orpc-router.test.ts +56 -10
  54. package/src/mount-orpc-router.ts +23 -9
  55. package/src/mount-server-packages.ts +17 -15
  56. package/src/pg-boss-mount.ts +5 -12
  57. package/src/proposal-gate.ts +7 -2
  58. package/src/read-html-template.test.ts +12 -15
  59. package/src/read-html-template.ts +2 -1
  60. package/src/readiness-gate.ts +2 -1
  61. package/src/register-crash-handlers.ts +4 -8
  62. package/src/register-page-server-client.ts +15 -19
  63. package/src/register-registry-page-route/ai-chat-prefetch.ts +16 -12
  64. package/src/register-registry-page-route/apply-page-branding.ts +68 -56
  65. package/src/register-registry-page-route/graph-cache.ts +22 -18
  66. package/src/register-registry-page-route/meta-title.ts +24 -20
  67. package/src/register-registry-page-route/prefetch-org-id.ts +6 -2
  68. package/src/register-registry-page-route/proposal-approval-email-token.ts +5 -3
  69. package/src/register-registry-page-route/run-content-gates.ts +2 -4
  70. package/src/register-registry-page-route.ts +1 -2
  71. package/src/register-static-assets.ts +6 -5
  72. package/src/resolve-app-base-url.ts +12 -5
  73. package/src/resolvers/portal-route-paths.ts +15 -11
  74. package/src/resource-chat-inject.ts +84 -72
  75. package/src/resource-not-found.ts +23 -19
  76. package/src/scala-tools-currency-watchdog.ts +41 -12
  77. package/src/two-phase-full-app.ts +61 -29
  78. package/src/two-phase-minimal-app.ts +39 -31
  79. package/src/two-phase-readiness.ts +50 -38
  80. package/src/ui-service.ts +50 -38
  81. package/src/handlers/pane-outbox-drain.ts +0 -39
  82. package/src/handlers/prune-stale-data.ts +0 -110
@@ -15,6 +15,27 @@
15
15
  * behaviour the board wants.
16
16
  */
17
17
 
18
+ /**
19
+ * Org-scoped member list for the task-comments panel — @mention matching AND
20
+ * comment-sender display (name + avatar). ChatPanel.tsx has no
21
+ * serverLoader/orgId prop (client-only overlay content), so it can't do the
22
+ * SSR raw-SQL org-scope join load-project-list-view.ts uses; this exposes
23
+ * the same result via a custom procedure instead of widening service_schema
24
+ * to the raw better_auth_member table.
25
+ *
26
+ * This is the SINGLE source of member display info for the comments panel:
27
+ * it coalesces BOTH name/email/avatar families on the user model into one
28
+ * shape (the `full_name`/`primary_email`/`avatar_url` family AND the
29
+ * better-auth `name`/`email`/`image` family). Returning avatar here lets the
30
+ * client resolve sender name+avatar from this one list and drop the separate
31
+ * `user.findMany` enrichment fetch it previously ran as a second waterfall
32
+ * hop after `comments.findMany` (loading-shape: one wave-parallel hit, not a
33
+ * dependent client-side round-trip chain).
34
+ */
35
+
36
+ // Why: Org-scoped member list for the task-comments panel — @mentio…
37
+ // (full note hoisted to this file's leading header — kept out of the body per
38
+ // no-inline-prose-blocks / prose-stays-in-docs)
18
39
  import { createEmailClient } from "@teamscala/email-client/client";
19
40
  import { getAppLogger } from "@teamscala/logger/app-loggers";
20
41
  import { authed, type Context } from "./auth-router.ts";
@@ -227,23 +248,6 @@ export const workItemsRouter = {
227
248
  return { notified: 0 };
228
249
  }
229
250
  }),
230
- /**
231
- * Org-scoped member list for the task-comments panel — @mention matching AND
232
- * comment-sender display (name + avatar). ChatPanel.tsx has no
233
- * serverLoader/orgId prop (client-only overlay content), so it can't do the
234
- * SSR raw-SQL org-scope join load-project-list-view.ts uses; this exposes
235
- * the same result via a custom procedure instead of widening service_schema
236
- * to the raw better_auth_member table.
237
- *
238
- * This is the SINGLE source of member display info for the comments panel:
239
- * it coalesces BOTH name/email/avatar families on the user model into one
240
- * shape (the `full_name`/`primary_email`/`avatar_url` family AND the
241
- * better-auth `name`/`email`/`image` family). Returning avatar here lets the
242
- * client resolve sender name+avatar from this one list and drop the separate
243
- * `user.findMany` enrichment fetch it previously ran as a second waterfall
244
- * hop after `comments.findMany` (loading-shape: one wave-parallel hit, not a
245
- * dependent client-side round-trip chain).
246
- */
247
251
  orgMembers: authed.handler(async ({ input, context }) => {
248
252
  const { orgId } = input as { orgId: string };
249
253
  const prisma = (context as Context).prisma as {
@@ -72,6 +72,12 @@ export async function deliverEscalation(
72
72
  last_seen_at: aggregate.lastSeen.toISOString(),
73
73
  event_count: aggregate.count,
74
74
  resolved_at: null,
75
+ // CLEARING THIS IS WHAT STOPS AN ESCALATION STORM. A re-armed row
76
+ // keeps resolved_at NULL, so it still loads as open, and
77
+ // `escalate` fires for any episode that is resolved OR re-armed.
78
+ // Leaving rearmed_at set would therefore re-escalate the same kind
79
+ // on EVERY sweep — the firing state must be consumed by the firing.
80
+ rearmed_at: null,
75
81
  },
76
82
  });
77
83
 
@@ -165,6 +171,36 @@ export async function deliverEscalation(
165
171
  return { messageId, workItemId: config.orchestrator_work_item_id };
166
172
  }
167
173
 
174
+ /**
175
+ * Re-arm an episode whose kind went quiet but whose condition NOBODY CHECKED.
176
+ * The alarm becomes audible again and no claim is made that the fault
177
+ * stopped, so there is deliberately NO operator comment: "Fault stopped" on
178
+ * the strength of silence is the sentence this change exists to stop writing.
179
+ * The event is still recorded — re-arming is a state change. See migration 811.
180
+ */
181
+ export async function rearmEpisode(
182
+ episode: EscalationEpisode,
183
+ quietMinutes: number,
184
+ ): Promise<void> {
185
+ const now = new Date();
186
+ await (await orpc()).platform_fault_escalations.update({
187
+ where: { kind: episode.kind },
188
+ data: { rearmed_at: now.toISOString(), updated_at: now.toISOString() },
189
+ });
190
+ recordEvent({
191
+ kind: "platform.fault.rearmed",
192
+ payload: {
193
+ event_kind: episode.kind,
194
+ quiet_minutes: Math.round(quietMinutes),
195
+ work_item_id: episode.workItemId,
196
+ // Named so a reader never mistakes this for a resolution: no probe
197
+ // exists for this kind, so nothing verified the condition ended.
198
+ verified: false,
199
+ reason: "no capability probe declared for this kind",
200
+ },
201
+ });
202
+ }
203
+
168
204
  export async function resolveEpisode(
169
205
  episode: EscalationEpisode,
170
206
  quietMinutes: number,
@@ -11,20 +11,6 @@
11
11
  * byte-for-byte move; import paths adjusted only.
12
12
  */
13
13
 
14
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
15
- import * as v from "valibot";
16
-
17
- let cachedSystemIdentity: { userId: string | undefined; organisationId: string | undefined } | undefined;
18
- export async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
19
- if (cachedSystemIdentity) return cachedSystemIdentity;
20
- const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
21
- cachedSystemIdentity = {
22
- userId: row?.system_user_id as string | undefined,
23
- organisationId: row?.system_org_id as string | undefined,
24
- };
25
- return cachedSystemIdentity;
26
- }
27
-
28
14
  /**
29
15
  * The system-authed in-process ORPC client, built LAZILY on first use from the
30
16
  * SAME composed appRouter the /api/rpc mount uses (getAppRouter — never a raw
@@ -40,9 +26,42 @@ export async function getSystemIdentity(): Promise<{ userId: string | undefined;
40
26
  * the assigned-client form with $queryRawUnsafe. Timestamps are ISO strings,
41
27
  * never Date instances (the generated valibot schemas require it).
42
28
  */
43
- let cachedOrpc: OrpcSurface | null = null;
29
+
30
+ // Why: The system-authed in-process ORPC client, built LAZILY on fi…
31
+ // (full note hoisted to this file's leading header — kept out of the body per
32
+ // no-inline-prose-blocks / prose-stays-in-docs)
33
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
34
+ import * as v from "valibot";
35
+ import { createCache } from "@teamscala/cache/create-cache";
36
+
37
+ const systemIdentityCache = createCache<{
38
+ userId: string | undefined;
39
+ organisationId: string | undefined;
40
+ }>("service-boot:fault-escalation-system-identity", {
41
+ ttlMs: Number.POSITIVE_INFINITY,
42
+ maxSize: 1,
43
+ });
44
+
45
+ export async function getSystemIdentity(): Promise<{ userId: string | undefined; organisationId: string | undefined }> {
46
+ const hit = systemIdentityCache.get("default");
47
+ if (hit) return hit;
48
+ const row = await loadRegistryConfig("config", "system-identity", v.looseObject({}));
49
+ const identity = {
50
+ userId: row?.system_user_id as string | undefined,
51
+ organisationId: row?.system_org_id as string | undefined,
52
+ };
53
+ systemIdentityCache.set("default", identity);
54
+ return identity;
55
+ }
56
+
57
+ const orpcSurfaceCache = createCache<OrpcSurface>(
58
+ "service-boot:fault-escalation-orpc",
59
+ { ttlMs: Number.POSITIVE_INFINITY, maxSize: 1 },
60
+ );
61
+
44
62
  export async function orpc(): Promise<OrpcSurface> {
45
- if (cachedOrpc) return cachedOrpc;
63
+ const hit = orpcSurfaceCache.get("default");
64
+ if (hit) return hit;
46
65
  const { createRouterClient } = await import("@orpc/server");
47
66
  const { getAppRouter } = await import("@teamscala/orpc/app-router");
48
67
  const appRouter = (await getAppRouter()) as Record<string, unknown> | null;
@@ -61,7 +80,7 @@ export async function orpc(): Promise<OrpcSurface> {
61
80
  "platform-identity",
62
81
  PlatformIdentitySchema,
63
82
  );
64
- cachedOrpc = createRouterClient(
83
+ const surface = createRouterClient(
65
84
  { ...appRouter } as never,
66
85
  {
67
86
  context: {
@@ -74,7 +93,8 @@ export async function orpc(): Promise<OrpcSurface> {
74
93
  },
75
94
  } as never,
76
95
  ) as unknown as OrpcSurface;
77
- return cachedOrpc;
96
+ orpcSurfaceCache.set("default", surface);
97
+ return surface;
78
98
  }
79
99
 
80
100
  /** The router subset this handler uses — mirrors the generated procedure args. */
@@ -26,7 +26,13 @@ export interface EscalationEpisode {
26
26
  eventCount: number;
27
27
  escalatedAt: Date | null;
28
28
  workItemId: string | null;
29
+ /** VERIFIED over — a capability probe answered OK, or a declared recovery
30
+ * transition arrived. Never written from silence. */
29
31
  resolvedAt: Date | null;
32
+ /** The kind went quiet and the alarm is ARMED AGAIN. This is all that
33
+ * quiet ever honestly supported, and it is deliberately weaker than
34
+ * `resolvedAt`: it claims nothing about the condition. */
35
+ rearmedAt: Date | null;
30
36
  lastUpdatedAt: Date | null;
31
37
  deliveredCommentId?: string | null;
32
38
  }
@@ -51,8 +57,18 @@ export interface KindThresholdOverride {
51
57
  export interface SweepDecision {
52
58
  /** Sustained faults needing a FIRST delivery (new episode, or a claim whose send failed). */
53
59
  escalate: Array<{ aggregate: FaultAggregate; episode: EscalationEpisode | null }>;
54
- /** Open episodes whose kind went quiet past the resolve window — close them. */
60
+ /** Open episodes whose kind went quiet past the resolve window — close them.
61
+ * ONLY reachable from the probe gate: quiet alone never populates this. */
55
62
  resolve: Array<{ episode: EscalationEpisode; quietMinutes: number; clearZeroVolumeFlag: boolean }>;
63
+ /** Open episodes quiet past the resolve window whose kind declares NO
64
+ * capability probe: the alarm is RE-ARMED (the next occurrence escalates
65
+ * again) and nothing is claimed about the condition. Quiet used to write
66
+ * `resolved_at` and post "Fault stopped", which is the unsound close
67
+ * `emit-transitions-not-conditions` names. It is re-armed rather than
68
+ * merely held open because `escalate` fires only for an absent, resolved
69
+ * or re-armed episode — holding it open would silence the alarm for good.
70
+ * Rationale + measurements: migration 811. */
71
+ rearm: Array<{ episode: EscalationEpisode; quietMinutes: number; clearZeroVolumeFlag: boolean }>;
56
72
  /** Open episodes quiet past the resolve window whose producer ALSO went
57
73
  * silent: zero failure rows AND zero call volume that would produce them.
58
74
  * Quiet is NOT evidence of health here (measured 2026-08-27: llm-router
@@ -150,14 +166,17 @@ export function decideSweep(
150
166
  const byKind = new Map(episodes.map((e) => [e.kind, e]));
151
167
  const aggregateByKind = new Map(aggregates.map((a) => [a.kind, a]));
152
168
  const decision: SweepDecision =
153
- { escalate: [], resolve: [], resolveProbeGate: [], zeroVolumeHold: [] };
169
+ { escalate: [], resolve: [], rearm: [], resolveProbeGate: [], zeroVolumeHold: [] };
154
170
 
155
171
  for (const aggregate of aggregates) {
156
172
  const episode = byKind.get(aggregate.kind);
157
173
  if (!isSustained(aggregate, policy, kindThresholds[aggregate.kind], now)) continue;
158
174
  // No episode, or the previous episode closed (quiet past resolve window):
159
175
  // a NEW episode begins and must be delivered.
160
- if (!episode || episode.resolvedAt) {
176
+ // A RE-ARMED episode starts a new one exactly as a resolved one does.
177
+ // This is the half that keeps the alarm audible: without it, dropping
178
+ // the quiet-close would leave every probe-less kind permanently silent.
179
+ if (!episode || episode.resolvedAt || episode.rearmedAt) {
161
180
  decision.escalate.push({ aggregate, episode: episode ?? null });
162
181
  continue;
163
182
  }
@@ -174,7 +193,7 @@ export function decideSweep(
174
193
  }
175
194
 
176
195
  for (const episode of episodes) {
177
- if (episode.resolvedAt) continue;
196
+ if (episode.resolvedAt || episode.rearmedAt) continue;
178
197
  const aggregate = aggregateByKind.get(episode.kind);
179
198
  // A future-dated aggregate lastSeen (producer clock skew) cannot serve
180
199
  // as the quiet reference — it would freeze quietMinutes negative and
@@ -218,7 +237,9 @@ export function decideSweep(
218
237
  });
219
238
  continue;
220
239
  }
221
- decision.resolve.push({
240
+ // NO PROBE DECLARED: re-arm, never resolve. Quiet re-arms the alarm and
241
+ // says nothing about the condition — see the `rearm` field's comment.
242
+ decision.rearm.push({
222
243
  episode,
223
244
  quietMinutes,
224
245
  clearZeroVolumeFlag: Boolean(source && zeroVolume?.flagActiveByKind[episode.kind]),
@@ -44,6 +44,7 @@ function episode(overrides: Partial<EscalationEpisode> & { kind: string }): Esca
44
44
  escalatedAt: new Date(NOW.getTime() - 60 * 60_000),
45
45
  workItemId: "wi-1",
46
46
  resolvedAt: null,
47
+ rearmedAt: null,
47
48
  lastUpdatedAt: null,
48
49
  ...overrides,
49
50
  };
@@ -117,6 +118,7 @@ describe("decideSweep — dedup (one escalation per fault, no re-fire)", () => {
117
118
  expect(decision.resolve).toHaveLength(0);
118
119
  expect(Object.keys(decision).sort()).toEqual([
119
120
  "escalate",
121
+ "rearm",
120
122
  "resolve",
121
123
  "resolveProbeGate",
122
124
  "zeroVolumeHold",
@@ -133,17 +135,60 @@ describe("decideSweep — dedup (one escalation per fault, no re-fire)", () => {
133
135
  });
134
136
 
135
137
  describe("decideSweep — resolution + re-arm", () => {
136
- test("quiet past the resolve window closes the episode", () => {
138
+ // QUIET RE-ARMS, IT DOES NOT RESOLVE. These two asserted `resolve` until
139
+ // 2026-08-29, which is the unsound close: only a probe can say a condition
140
+ // ended, and no probe is declared for these kinds.
141
+ test("quiet past the resolve window RE-ARMS the episode, and claims nothing", () => {
137
142
  const gone = episode({ kind: "wake.failed", lastSeenAt: new Date(NOW.getTime() - 60 * 60_000) });
138
143
  const decision = decideSweep([], [gone], POLICY, NOW);
139
- expect(decision.resolve).toHaveLength(1);
140
- expect(decision.resolve[0]!.quietMinutes).toBeGreaterThanOrEqual(30);
144
+ expect(decision.rearm).toHaveLength(1);
145
+ expect(decision.rearm[0]!.quietMinutes).toBeGreaterThanOrEqual(30);
146
+ expect(decision.resolve).toHaveLength(0);
141
147
  });
142
148
 
143
- test("an absent kind resolves by its episode's last_seen, not the sweep clock", () => {
149
+ test("an absent kind re-arms by its episode's last_seen, not the sweep clock", () => {
144
150
  const gone = episode({ kind: "listen.connect_failed", lastSeenAt: new Date(NOW.getTime() - 45 * 60_000) });
145
151
  const decision = decideSweep([], [gone], POLICY, NOW);
146
- expect(decision.resolve).toHaveLength(1);
152
+ expect(decision.rearm).toHaveLength(1);
153
+ expect(decision.resolve).toHaveLength(0);
154
+ });
155
+
156
+ // THE INVARIANT THAT MAKES RE-ARMING SAFE. If a re-armed episode did not
157
+ // escalate again, dropping the quiet-close would have left every
158
+ // probe-less alarm permanently silent — worse than the false close.
159
+ test("a RE-ARMED episode escalates again when its fault recurs", () => {
160
+ const recurring = agg("guard.fail.open", 50, 40, 0);
161
+ const rearmed = episode({
162
+ kind: "guard.fail.open",
163
+ rearmedAt: new Date(NOW.getTime() - 35 * 60_000),
164
+ });
165
+ const decision = decideSweep([recurring], [rearmed], POLICY, NOW);
166
+ expect(decision.escalate).toHaveLength(1);
167
+ });
168
+
169
+ // A re-armed episode is already armed; re-arming it every sweep would emit
170
+ // a transition per sweep, which is the repeating-condition shape itself.
171
+ test("an already re-armed episode is not re-armed again while it stays quiet", () => {
172
+ const rearmed = episode({
173
+ kind: "guard.fail.open",
174
+ lastSeenAt: new Date(NOW.getTime() - 60 * 60_000),
175
+ rearmedAt: new Date(NOW.getTime() - 35 * 60_000),
176
+ });
177
+ const decision = decideSweep([], [rearmed], POLICY, NOW);
178
+ expect(decision.rearm).toHaveLength(0);
179
+ expect(decision.resolve).toHaveLength(0);
180
+ });
181
+
182
+ // A kind WITH a probe keeps the stronger path: it is never re-armed on
183
+ // quiet, because something can actually be asked about it.
184
+ test("a kind declaring a capability probe goes to the probe gate, never to re-arm", () => {
185
+ const gone = episode({ kind: "llm.provider.call.failed", lastSeenAt: new Date(NOW.getTime() - 60 * 60_000) });
186
+ const decision = decideSweep([], [gone], POLICY, NOW, {}, undefined, {
187
+ "llm.provider.call.failed": { url: "http://127.0.0.1:9/health" },
188
+ });
189
+ expect(decision.resolveProbeGate).toHaveLength(1);
190
+ expect(decision.rearm).toHaveLength(0);
191
+ expect(decision.resolve).toHaveLength(0);
147
192
  });
148
193
 
149
194
  test("after resolution the same fault escalates again (new episode)", () => {
@@ -161,6 +206,7 @@ describe("decideSweep — resolution + re-arm", () => {
161
206
  const open = episode({ kind: "slow.error", lastSeenAt: active.lastSeen });
162
207
  const decision = decideSweep([active], [open], POLICY, NOW);
163
208
  expect(decision.resolve).toHaveLength(0);
209
+ expect(decision.rearm).toHaveLength(0);
164
210
  });
165
211
  });
166
212
 
@@ -188,7 +234,9 @@ describe("decideSweep — a future-dated lastSeen is not recurrence evidence", (
188
234
  const phantom = agg("guard.fail.open", 40, 720, -44);
189
235
  const decision = decideSweep([phantom], [quietEpisode], POLICY, NOW);
190
236
  expect(decision.escalate).toHaveLength(0);
191
- expect(decision.resolve).toHaveLength(1);
237
+ // Re-armed rather than resolved: no probe is declared for this kind.
238
+ expect(decision.rearm).toHaveLength(1);
239
+ expect(decision.resolve).toHaveLength(0);
192
240
  });
193
241
  });
194
242
 
@@ -354,19 +402,21 @@ describe("decideSweep — zero-volume gate (quiet vs dead producer)", () => {
354
402
  expect(decision.zeroVolumeHold[0].volumeCount).toBe(0);
355
403
  });
356
404
 
357
- test("quiet WITH healthy call volume still resolves (the normal close)", () => {
405
+ test("quiet WITH healthy call volume re-arms (the normal path for a probe-less kind)", () => {
358
406
  const episodes = [episode({ kind: "llm.provider.call.failed", lastSeenAt: new Date(NOW.getTime() - 45 * 60_000) })];
359
407
  const inputs = { ...VOLUME_BACKED, countByKind: { "llm.provider.call.failed": 120 } };
360
408
  const decision = decideSweep([], episodes, POLICY, NOW, {}, inputs);
361
409
  expect(decision.zeroVolumeHold).toHaveLength(0);
362
- expect(decision.resolve).toHaveLength(1);
363
- expect(decision.resolve[0].clearZeroVolumeFlag).toBe(false);
410
+ expect(decision.rearm).toHaveLength(1);
411
+ expect(decision.resolve).toHaveLength(0);
412
+ expect(decision.rearm[0]!.clearZeroVolumeFlag).toBe(false);
364
413
  });
365
414
 
366
- test("a sourceless kind resolves on quiet exactly as before", () => {
415
+ test("a sourceless kind re-arms on quiet", () => {
367
416
  const episodes = [episode({ kind: "guard.fail.open", lastSeenAt: new Date(NOW.getTime() - 45 * 60_000) })];
368
417
  const decision = decideSweep([], episodes, POLICY, NOW, {}, { ...VOLUME_BACKED, countByKind: {} });
369
- expect(decision.resolve).toHaveLength(1);
418
+ expect(decision.rearm).toHaveLength(1);
419
+ expect(decision.resolve).toHaveLength(0);
370
420
  expect(decision.zeroVolumeHold).toHaveLength(0);
371
421
  });
372
422
 
@@ -378,13 +428,14 @@ describe("decideSweep — zero-volume gate (quiet vs dead producer)", () => {
378
428
  flagActiveByKind: { "llm.provider.call.failed": true },
379
429
  };
380
430
  const decision = decideSweep([], episodes, POLICY, NOW, {}, inputs);
381
- expect(decision.resolve[0].clearZeroVolumeFlag).toBe(true);
431
+ expect(decision.rearm[0]!.clearZeroVolumeFlag).toBe(true);
382
432
  });
383
433
 
384
434
  test("without a sixth argument the legacy shape is untouched", () => {
385
435
  const episodes = [episode({ kind: "llm.provider.call.failed", lastSeenAt: new Date(NOW.getTime() - 45 * 60_000) })];
386
436
  const decision = decideSweep([], episodes, POLICY, NOW);
387
- expect(decision.resolve).toHaveLength(1);
437
+ expect(decision.rearm).toHaveLength(1);
438
+ expect(decision.resolve).toHaveLength(0);
388
439
  });
389
440
  });
390
441
 
@@ -422,10 +473,11 @@ describe("decideSweep — probe-gated resolution (operator ruling 2026-08-28)",
422
473
  expect(decision.resolveProbeGate[0]!.quietMinutes).toBeGreaterThanOrEqual(30);
423
474
  });
424
475
 
425
- test("a kind without a declared probe resolves on quiet exactly as before", () => {
476
+ test("a kind without a declared probe RE-ARMS on quiet — it is never closed unverified", () => {
426
477
  const gone = episode({ kind: "wake.failed", lastSeenAt: new Date(NOW.getTime() - 60 * 60_000) });
427
478
  const decision = decideSweep([agg("wake.failed", 10, 700, 60)], [gone], POLICY, NOW, {}, undefined, PROBE_SOURCES);
428
- expect(decision.resolve).toHaveLength(1);
479
+ expect(decision.rearm).toHaveLength(1);
480
+ expect(decision.resolve).toHaveLength(0);
429
481
  expect(decision.resolveProbeGate).toHaveLength(0);
430
482
  });
431
483
 
@@ -39,6 +39,7 @@ import {
39
39
  deliverEscalation,
40
40
  deliverZeroVolumeHold,
41
41
  refreshOpenEpisode,
42
+ rearmEpisode,
42
43
  resolveEpisode,
43
44
  } from "./platform-fault-escalation/delivery.ts";
44
45
 
@@ -61,6 +62,7 @@ async function platformFaultEscalationSweep(ctx: { config: ScheduledJobConfig })
61
62
  escalated_at: string | Date | null;
62
63
  work_item_id: string | null;
63
64
  resolved_at: string | Date | null;
65
+ rearmed_at: string | Date | null;
64
66
  last_updated_at: string | Date | null;
65
67
  }>;
66
68
  const episodes: EscalationEpisode[] = openEpisodes.map((row) => ({
@@ -71,6 +73,7 @@ async function platformFaultEscalationSweep(ctx: { config: ScheduledJobConfig })
71
73
  escalatedAt: row.escalated_at ? new Date(row.escalated_at) : null,
72
74
  workItemId: row.work_item_id,
73
75
  resolvedAt: row.resolved_at ? new Date(row.resolved_at) : null,
76
+ rearmedAt: row.rearmed_at ? new Date(row.rearmed_at) : null,
74
77
  lastUpdatedAt: row.last_updated_at ? new Date(row.last_updated_at) : null,
75
78
  }));
76
79
 
@@ -204,6 +207,19 @@ async function platformFaultEscalationSweep(ctx: { config: ScheduledJobConfig })
204
207
  log.error(`[platform-fault-escalation] resolve for '${episode.kind}' failed: ${err instanceof Error ? err.message : String(err)}`);
205
208
  }
206
209
  }
210
+ // RE-ARM, NOT RESOLVE (2026-08-29). A kind with no declared capability
211
+ // probe went quiet: the alarm is armed for the next occurrence and NOTHING
212
+ // is claimed about the condition. No operator comment is written here on
213
+ // purpose — see rearmEpisode.
214
+ for (const { episode, quietMinutes, clearZeroVolumeFlag } of decision.rearm) {
215
+ try {
216
+ await rearmEpisode(episode, quietMinutes);
217
+ if (clearZeroVolumeFlag) await clearZeroVolumeHold(episode, zeroVolume.countByKind[episode.kind] ?? 0);
218
+ log.info(`[platform-fault-escalation] RE-ARMED '${episode.kind}' after ${Math.round(quietMinutes)} quiet min — no probe declared, so the condition is UNVERIFIED and no close was claimed`);
219
+ } catch (err) {
220
+ log.error(`[platform-fault-escalation] re-arm for '${episode.kind}' failed (retried next sweep): ${err instanceof Error ? err.message : String(err)}`);
221
+ }
222
+ }
207
223
 
208
224
  // Emitted every run, even when nothing fired — a sweep that only reports
209
225
  // when it escalated is indistinguishable from one that stopped running.
@@ -4,7 +4,7 @@
4
4
  * @edit edit directly
5
5
  *
6
6
  * The `projects-daily-digest` / `projects-weekly-digest` pg-boss handlers for
7
- * scala-projects. Same fixed-query shape as prune-stale-data: no row-driven
7
+ * scala-projects. Fixed-query shape: no row-driven
8
8
  * SQL, every query is a literal Prisma call against work_items; the
9
9
  * scheduled_job row only carries the cron.
10
10
  *
@@ -1,5 +1,4 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { readFileSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
 
5
4
  /**
@@ -20,10 +19,7 @@ import { join } from "node:path";
20
19
  * the module proves it cannot fire on any path. It also stays honest if someone
21
20
  * reintroduces the restart behind a new condition.
22
21
  */
23
- const SOURCE = readFileSync(
24
- join(import.meta.dir, "scala-tools-currency-watch.ts"),
25
- "utf-8",
26
- );
22
+ const SOURCE = await Bun.file(join(import.meta.dir, "scala-tools-currency-watch.ts")).text();
27
23
 
28
24
  /** Comment prose legitimately discusses the removed restart; code must not do it. */
29
25
  const CODE = SOURCE.split("\n")
@@ -25,6 +25,29 @@
25
25
  *
26
26
  * Registered under slug `@teamscala/service-boot:scala-tools-currency-watch`.
27
27
  */
28
+ // NO RESTART. The reinstall alone is sufficient, and restarting here was actively harmful.
29
+ //
30
+ // The premise this watch was written on — "restart so the fresh scala-tools loads" — does not hold for THIS package. scala-sentinel never
31
+ // imports `@teamscala/scala-tools`; it SPAWNS `node_modules/.bin/scala-tools` per invocation, so a freshly installed binary is picked up by the next
32
+ // spawn with no process restart at all. Verified 2026-07-27: the package is declared in scala-sentinel/package.json, yet scala-guard/src contains zero
33
+ // import statements resolving to it — it is spawned, never linked. dep-sync already encodes the same conclusion — HARNESS_CRITICAL_FAST_PATH
34
+ // maps this exact pair to install-WITHOUT-restart, while its PM2_CACHED sibling map exists for packages a daemon really does load at boot
35
+ // (@teamscala/codegen → codegen-daemon). This handler was the odd one out.
36
+ //
37
+ // The cost was not theoretical. Restarting the guard daemon takes enforcement offline for its warmup, and every shutdown logs "152 guard(s)
38
+ // failed to run (fail-open)" — so each unnecessary restart opened a window in which tool-check, turn-end and pre-push silently did not run. Three
39
+ // scala-tools versions published on 2026-07-26 alone, and this watch polls, so it re-fired for each. That inverts
40
+ // `infrastructure-outranks-application-durability`: a currency convenience was repeatedly disabling the thing it exists to keep current.
41
+ //
42
+ // It also used a bare `pm2 restart`, which is forbidden (`forbid-bare-pm2-restart`) precisely because it bypasses the
43
+ // currency-gated canonical pipeline — the guard blocks agents from that exact call.
44
+ //
45
+ // If a future package genuinely needs a restart to take effect, it does not belong here: add it to dep-sync's PM2_CACHED map, which already owns
46
+ // install-plus-restart and coalesces them.
47
+
48
+ // Why: NO RESTART. The reinstall alone is sufficient, and restartin…
49
+ // (full note hoisted to this file's leading header — kept out of the body per
50
+ // no-inline-prose-blocks / prose-stays-in-docs)
28
51
  import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
29
52
  import { recordEvent } from "@teamscala/event-log/record-event";
30
53
  import { getAppLogger } from "@teamscala/logger/app-loggers";
@@ -124,38 +147,6 @@ async function scalaToolsCurrencyWatch(_ctx: { config: ScheduledJobConfig }): Pr
124
147
  payload: { from: installed, to: latest },
125
148
  });
126
149
 
127
- // NO RESTART. The reinstall alone is sufficient, and restarting here was
128
- // actively harmful.
129
- //
130
- // The premise this watch was written on — "restart so the fresh
131
- // scala-tools loads" — does not hold for THIS package. scala-sentinel never
132
- // imports `@teamscala/scala-tools`; it SPAWNS `node_modules/.bin/scala-tools`
133
- // per invocation, so a freshly installed binary is picked up by the next
134
- // spawn with no process restart at all. Verified 2026-07-27: the package is
135
- // declared in scala-sentinel/package.json, yet scala-guard/src contains zero
136
- // import statements resolving to it — it is spawned, never linked.
137
- // dep-sync already encodes the same conclusion — HARNESS_CRITICAL_FAST_PATH
138
- // maps this exact pair to install-WITHOUT-restart, while its PM2_CACHED
139
- // sibling map exists for packages a daemon really does load at boot
140
- // (@teamscala/codegen → codegen-daemon). This handler was the odd one out.
141
- //
142
- // The cost was not theoretical. Restarting the guard daemon takes
143
- // enforcement offline for its warmup, and every shutdown logs "152 guard(s)
144
- // failed to run (fail-open)" — so each unnecessary restart opened a window
145
- // in which tool-check, turn-end and pre-push silently did not run. Three
146
- // scala-tools versions published on 2026-07-26 alone, and this watch polls,
147
- // so it re-fired for each. That inverts
148
- // `infrastructure-outranks-application-durability`: a currency convenience
149
- // was repeatedly disabling the thing it exists to keep current.
150
- //
151
- // It also used a bare `pm2 restart`, which is forbidden
152
- // (`forbid-bare-pm2-restart`) precisely because it bypasses the
153
- // currency-gated canonical pipeline — the guard blocks agents from that
154
- // exact call.
155
- //
156
- // If a future package genuinely needs a restart to take effect, it does not
157
- // belong here: add it to dep-sync's PM2_CACHED map, which already owns
158
- // install-plus-restart and coalesces them.
159
150
  log.info(
160
151
  `[scala-tools-currency-watch] reinstalled ${PKG} ${installed} → ${latest} (no restart: sentinel spawns the binary, it does not import it)`,
161
152
  );
@@ -1,11 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { readFileSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
 
5
- const SOURCE = readFileSync(
6
- join(import.meta.dir, "singleton-tools-currency-watch.ts"),
7
- "utf-8",
8
- );
4
+ const SOURCE = await Bun.file(join(import.meta.dir, "singleton-tools-currency-watch.ts")).text();
9
5
 
10
6
  const CODE = SOURCE.split("\n")
11
7
  .filter((l) => !l.trimStart().startsWith("//") && !l.trimStart().startsWith("*"))
@@ -23,14 +23,6 @@
23
23
  *
24
24
  * Registered under slug `@teamscala/service-boot:singleton-tools-currency-watch`.
25
25
  */
26
- import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
27
- import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
28
- import { recordEvent } from "@teamscala/event-log/record-event";
29
- import { getAppLogger } from "@teamscala/logger/app-loggers";
30
- import { spawn } from "@teamscala/os/spawn";
31
- import { getDistTagVersion } from "@teamscala/publish-registry";
32
- import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
33
-
34
26
  /**
35
27
  * Where a tool's "latest" comes from.
36
28
  *
@@ -47,6 +39,33 @@ import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
47
39
  * never shadow a /usr/local/bin singleton wrapper. The gap was currency
48
40
  * tracking, never placement.
49
41
  */
42
+
43
+ // Why: Where a tool's "latest" comes from.
44
+ // (full note hoisted to this file's leading header — kept out of the body per
45
+ // no-inline-prose-blocks / prose-stays-in-docs)
46
+ // A GitHub-released binary is NOT installed by a package manager, so there
47
+ // is no "reinstall" verb to call — it would have to be downloaded, verified,
48
+ // made executable and swapped atomically over a file that may be RUNNING.
49
+ // That is a real install path with real failure modes (partial download,
50
+ // wrong architecture, replacing a binary mid-execution), and doing it badly
51
+ // is worse than not doing it: a half-written CLI is a broken CLI.
52
+ //
53
+ // So this reports the drift and stops. Detection is live from today; the
54
+ // automated swap is a separate, deliberate change. Reporting a version the
55
+ // watcher cannot actually install is honest — silently claiming currency
56
+ // would not be.
57
+
58
+ // Why: A GitHub-released binary is NOT installed by a package manag…
59
+ // (full note hoisted to this file's leading header — kept out of the body per
60
+ // no-inline-prose-blocks / prose-stays-in-docs)
61
+ import type { ScheduledJobConfig } from "@teamscala/db-validation/registry-schemas/scheduled-job";
62
+ import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
63
+ import { recordEvent } from "@teamscala/event-log/record-event";
64
+ import { getAppLogger } from "@teamscala/logger/app-loggers";
65
+ import { spawn } from "@teamscala/os/spawn";
66
+ import { getDistTagVersion } from "@teamscala/publish-registry";
67
+ import { pgBossHandlerRegistry } from "../pg-boss-handler-registry.ts";
68
+
50
69
  type VersionSource =
51
70
  | {
52
71
  type: "npm_package";
@@ -210,17 +229,6 @@ async function reinstallTool(tool: SingletonTool, latest: string): Promise<boole
210
229
  if (!vs) return false;
211
230
 
212
231
  if (vs.type === "github_release") {
213
- // A GitHub-released binary is NOT installed by a package manager, so there
214
- // is no "reinstall" verb to call — it would have to be downloaded, verified,
215
- // made executable and swapped atomically over a file that may be RUNNING.
216
- // That is a real install path with real failure modes (partial download,
217
- // wrong architecture, replacing a binary mid-execution), and doing it badly
218
- // is worse than not doing it: a half-written CLI is a broken CLI.
219
- //
220
- // So this reports the drift and stops. Detection is live from today; the
221
- // automated swap is a separate, deliberate change. Reporting a version the
222
- // watcher cannot actually install is honest — silently claiming currency
223
- // would not be.
224
232
  log.warn(
225
233
  `[singleton-tools-currency] ${tool.name}: STALE, latest ${latest} from ${vs.repo}. Automated github_release install is not implemented — update it manually.`,
226
234
  );