@tokenoftrust/storefront-runner 1.4.0 → 1.4.1

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 (51) hide show
  1. package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
  2. package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
  3. package/apps/storefront/migrations-apps/meta/_journal.json +7 -0
  4. package/apps/storefront/package.json +0 -1
  5. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1735 -132
  6. package/apps/storefront/src/lib/activity/alerts.ts +428 -0
  7. package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
  8. package/apps/storefront/src/lib/activity/deployVersion.ts +127 -0
  9. package/apps/storefront/src/lib/activity/ingest.ts +188 -0
  10. package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
  11. package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
  12. package/apps/storefront/src/lib/activity/query.ts +403 -0
  13. package/apps/storefront/src/lib/activity/recordActivity.ts +105 -0
  14. package/apps/storefront/src/lib/activity/store.ts +122 -0
  15. package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
  16. package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
  17. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +4 -0
  18. package/apps/storefront/src/lib/auth/route.ts +44 -4
  19. package/apps/storefront/src/lib/d1/schema-apps.ts +54 -0
  20. package/apps/storefront/src/lib/dev/vcBinding.ts +80 -0
  21. package/apps/storefront/src/lib/env.ts +51 -0
  22. package/apps/storefront/src/lib/publish/shipWorkspace.ts +15 -8
  23. package/apps/storefront/src/middleware/index.ts +17 -0
  24. package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
  25. package/apps/storefront/src/pages/admin.astro +154 -6
  26. package/apps/storefront/src/pages/api/activity.ts +137 -0
  27. package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
  28. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
  29. package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
  30. package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
  31. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
  32. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
  33. package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
  34. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
  35. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
  36. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
  37. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
  38. package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
  39. package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
  40. package/apps/storefront/src/pages/auth/login.astro +5 -4
  41. package/apps/storefront/src/pages/cockpit.astro +40 -3
  42. package/package.json +1 -1
  43. package/packages/public-runtime/src/activity/README.md +146 -0
  44. package/packages/public-runtime/src/activity/catalog.ts +501 -0
  45. package/packages/public-runtime/src/activity/event.ts +168 -0
  46. package/packages/public-runtime/src/activity/index.ts +16 -0
  47. package/packages/public-runtime/src/activity/redaction.ts +263 -0
  48. package/packages/public-runtime/src/candidate-index.ts +13 -0
  49. package/packages/public-runtime/src/index.ts +6 -0
  50. package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
  51. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Operational-activity ALERT conditions (card D7) — the detection layer on top of
3
+ * the D5 query surface.
4
+ *
5
+ * D1–D5 make operational activity queryable; D7 turns four specific, high-value
6
+ * conditions in that stream into ALERTS. It deliberately follows the SAME shape as
7
+ * this repo's only other alert contract, F14's `analytics/rumAlert.ts`: a set of
8
+ * PURE, transport-agnostic detectors that take a window of activity and return a
9
+ * structured `ActivityAlert[]`. WHERE the alerts go (a log line, a future
10
+ * PagerDuty/Slack sink, a polled endpoint) is the sink's job — there is NO outbound
11
+ * notification transport in this repo today (rumAlert is likewise detection-only),
12
+ * so D7 does not invent one. It also does NOT add a cron: the checker is on-demand
13
+ * (computed when `/admin/ops-timeline` loads, or when the read-only alerts endpoint
14
+ * is polled), exactly as the card scopes it.
15
+ *
16
+ * THE FOUR CONDITIONS (and how each avoids false-positive noise):
17
+ *
18
+ * 1. silent-terminal — a `reconcile.terminal` with status `failed` (errorClass
19
+ * `reconcile_fault` = an unexpected crash → critical, or `reconcile_blocked` =
20
+ * a safety gate refused the candidate → warning). A single failure is worth an
21
+ * alert (a reviewer's PR silently didn't materialize), so there is no count
22
+ * threshold; noise is bounded by (a) status = `failed` only, never `succeeded`,
23
+ * and (b) collapsing repeat failures for one changeId into one alert.
24
+ *
25
+ * 2. recurring-preview-miss — a REPEATED `preview.serve`/`failed`/`miss_no_env`
26
+ * for the SAME changeId. One miss is an expected race (a reviewer opened the
27
+ * preview before its ReviewEnvironment finished materializing), so a single
28
+ * miss NEVER alerts; only `threshold`+ misses (default 3) for one changeId
29
+ * inside a short `window` (default 15m) do — that pattern means a LIVE PR whose
30
+ * preview never came up, not a transient.
31
+ *
32
+ * 3. reconcile-hang — a `webhook.reconcile.received` for a changeId with NO
33
+ * `reconcile.terminal` (any status) at-or-after it, once a `grace` period
34
+ * (default 10m) has elapsed. This is an ABSENCE detector (architecturally
35
+ * unlike 1/2, which fire on a bad event's PRESENCE): it correlates two action
36
+ * types by changeId + time. Noise is bounded by the grace window — a reconcile
37
+ * still legitimately in flight (received < grace ago) never fires; a terminal
38
+ * after the received (any status) clears it.
39
+ *
40
+ * 4. deploy-skew — promotes D5's visibility-only `deriveDeploySkew()` into an
41
+ * alert. `skewDetected` is true on EVERY normal rollout (two isolates coexist
42
+ * briefly), so alerting on it directly would page on every deploy. Instead this
43
+ * fires ONLY when the build serving this request differs from the newest
44
+ * observed build AND that newest build has been latest for longer than a `grace`
45
+ * period (default 15m) — i.e. THIS isolate failed to roll to a deploy that
46
+ * finished long ago (a persistent stale isolate), not a rollout in progress.
47
+ *
48
+ * DATA DEPENDENCY (honest wiring status, mirroring F16's `WiringStatus`). The
49
+ * detectors read the D1 `activity_events` timeline through the D5 `queryActivity`
50
+ * seam, so they alert on whatever is in that timeline. Getting SERVER-emitted
51
+ * lifecycle events INTO D1 is the D1/D4 fan-out wiring (today `recordActivity()` is
52
+ * logs-only; `deployVersion.ts` notes the swap to a timeline-fanning emitter is
53
+ * pending) — NOT D7's job. `preview.serve` stays `analytics`-sink by design (too
54
+ * high-volume to timeline wholesale); detector 2 is source-agnostic (the rumAlert
55
+ * precedent) and fires against an Analytics-Engine-sourced or seeded feed. See
56
+ * `ALERT_CONDITIONS` for the per-condition source + wiring note.
57
+ */
58
+ import {
59
+ queryActivity,
60
+ deriveDeploySkew,
61
+ type ActivityTimelineRow,
62
+ type DeploySkewSummary,
63
+ } from "./query.js";
64
+ import type { Queryable } from "../d1/catalog.js";
65
+
66
+ /** Severity, mirroring F14's `rumAlert.AlertSeverity` / F16's `manifest.Severity`. */
67
+ export type AlertSeverity = "page" | "critical" | "warning" | "info";
68
+
69
+ /** The four D7 conditions. Stable ids — a downstream sink/dashboard keys on them. */
70
+ export type ActivityAlertType =
71
+ | "silent-terminal"
72
+ | "recurring-preview-miss"
73
+ | "reconcile-hang"
74
+ | "deploy-skew";
75
+
76
+ /** One active alert. `details` is low-cardinality + already-redacted-safe (it is
77
+ * built only from CORE fields — action/status/errorClass/changeId/at/version —
78
+ * never from the payload zone), so it is safe to log or serialize verbatim. */
79
+ export interface ActivityAlert {
80
+ type: ActivityAlertType;
81
+ severity: AlertSeverity;
82
+ /** Stable human title for the condition. */
83
+ title: string;
84
+ /** One-line human summary for a banner / log line / notification body. */
85
+ summary: string;
86
+ /** The change this alert concerns, when it is change-scoped. */
87
+ changeId?: string;
88
+ /** Structured, low-cardinality specifics (safe to serialize). */
89
+ details: Record<string, string | number>;
90
+ /** ISO-8601 instant the condition has been active SINCE (earliest evidence). */
91
+ since: string;
92
+ }
93
+
94
+ // ─── Thresholds (the launch defaults; every one is overridable per call) ──────
95
+ export const DEFAULTS = {
96
+ /** How far back the on-demand checker scans the timeline. */
97
+ lookbackMs: 24 * 60 * 60 * 1000,
98
+ /** recurring-preview-miss: misses of the SAME changeId before it alerts. */
99
+ previewMissThreshold: 3,
100
+ /** recurring-preview-miss: the window those misses must fall inside. */
101
+ previewMissWindowMs: 15 * 60 * 1000,
102
+ /** reconcile-hang: how long a `received` may sit un-terminated before alerting. */
103
+ reconcileHangGraceMs: 10 * 60 * 1000,
104
+ /** deploy-skew: how long the newest build must have been latest before a
105
+ * still-stale serving isolate is a persistent (not rollout-transient) skew. */
106
+ deploySkewGraceMs: 15 * 60 * 1000,
107
+ } as const;
108
+
109
+ const SEVERITY_RANK: Record<AlertSeverity, number> = { page: 0, critical: 1, warning: 2, info: 3 };
110
+
111
+ /** Deterministic ordering: most-severe first, then most-recently-active first. */
112
+ export function sortAlerts(alerts: ActivityAlert[]): ActivityAlert[] {
113
+ return [...alerts].sort(
114
+ (a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || (a.since < b.since ? 1 : a.since > b.since ? -1 : 0),
115
+ );
116
+ }
117
+
118
+ // ─── Detector 1: silent-terminal ─────────────────────────────────────────────
119
+
120
+ /**
121
+ * Alert on `reconcile.terminal`/`failed` rows. Pass ONLY failed terminals (the
122
+ * orchestrator queries `status: "failed"`). Repeat failures for one changeId
123
+ * collapse into a single alert carrying the count, so a flapping PR does not spam.
124
+ */
125
+ export function detectSilentTerminals(failedTerminals: readonly ActivityTimelineRow[]): ActivityAlert[] {
126
+ const byChange = new Map<string, ActivityTimelineRow[]>();
127
+ for (const r of failedTerminals) {
128
+ if (r.status !== "failed") continue; // defensive: only failures
129
+ const key = r.changeId ?? r.id;
130
+ (byChange.get(key) ?? byChange.set(key, []).get(key)!).push(r);
131
+ }
132
+ const out: ActivityAlert[] = [];
133
+ for (const [key, group] of byChange) {
134
+ const sorted = [...group].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
135
+ const latest = sorted[sorted.length - 1]!;
136
+ const errorClass = latest.errorClass ?? "unknown";
137
+ // reconcile_fault = an UNEXPECTED crash of the reconcile → critical; a
138
+ // reconcile_blocked = a safety gate correctly refused the candidate, an
139
+ // operator should look but nothing crashed → warning.
140
+ const severity: AlertSeverity = errorClass === "reconcile_fault" ? "critical" : "warning";
141
+ out.push({
142
+ type: "silent-terminal",
143
+ severity,
144
+ title: "Reconcile terminated in failure",
145
+ summary:
146
+ `Candidate ${latest.changeId ?? key} reconcile ${errorClass}` +
147
+ (group.length > 1 ? ` (${group.length}× in window)` : "") +
148
+ ` — its preview did not materialize.`,
149
+ ...(latest.changeId ? { changeId: latest.changeId } : {}),
150
+ details: { errorClass, failures: group.length, lastAt: latest.at },
151
+ since: sorted[0]!.at,
152
+ });
153
+ }
154
+ return out;
155
+ }
156
+
157
+ // ─── Detector 2: recurring preview.serve miss_no_env ─────────────────────────
158
+
159
+ export interface PreviewMissOptions {
160
+ /** "now" as epoch ms (injected for testing). */
161
+ now: number;
162
+ threshold?: number;
163
+ windowMs?: number;
164
+ }
165
+
166
+ /**
167
+ * Alert when the SAME changeId has `threshold`+ `preview.serve`/`failed`/
168
+ * `miss_no_env` events inside `windowMs`. Pass `preview.serve`/`failed` rows (the
169
+ * orchestrator queries them); this filters to `miss_no_env` and groups by changeId.
170
+ * A single miss (an expected pre-materialization race) is below threshold → silent.
171
+ */
172
+ export function detectRecurringPreviewMisses(
173
+ serveFailures: readonly ActivityTimelineRow[],
174
+ opts: PreviewMissOptions,
175
+ ): ActivityAlert[] {
176
+ const threshold = opts.threshold ?? DEFAULTS.previewMissThreshold;
177
+ const windowMs = opts.windowMs ?? DEFAULTS.previewMissWindowMs;
178
+ const floor = opts.now - windowMs;
179
+
180
+ const byChange = new Map<string, ActivityTimelineRow[]>();
181
+ for (const r of serveFailures) {
182
+ if (r.errorClass !== "miss_no_env" || !r.changeId) continue;
183
+ const t = Date.parse(r.at);
184
+ if (Number.isNaN(t) || t < floor || t > opts.now) continue; // only misses inside the window
185
+ (byChange.get(r.changeId) ?? byChange.set(r.changeId, []).get(r.changeId)!).push(r);
186
+ }
187
+
188
+ const out: ActivityAlert[] = [];
189
+ for (const [changeId, misses] of byChange) {
190
+ if (misses.length < threshold) continue; // below threshold ⇒ transient, not an alert
191
+ const sorted = [...misses].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
192
+ out.push({
193
+ type: "recurring-preview-miss",
194
+ severity: "warning",
195
+ title: "Preview repeatedly missing for a live change",
196
+ summary:
197
+ `Change ${changeId} has ${misses.length} preview.serve misses (miss_no_env) in ` +
198
+ `${Math.round(windowMs / 60000)}m — reviewers are hitting a preview that never came up.`,
199
+ changeId,
200
+ details: { misses: misses.length, windowMinutes: Math.round(windowMs / 60000), lastAt: sorted[sorted.length - 1]!.at },
201
+ since: sorted[0]!.at,
202
+ });
203
+ }
204
+ return out;
205
+ }
206
+
207
+ // ─── Detector 3: reconcile-hang watchdog (ABSENCE correlation) ───────────────
208
+
209
+ export interface ReconcileHangOptions {
210
+ /** "now" as epoch ms (injected for testing). */
211
+ now: number;
212
+ graceMs?: number;
213
+ }
214
+
215
+ /**
216
+ * Alert on a `webhook.reconcile.received` for a changeId that has NO
217
+ * `reconcile.terminal` at-or-after it, once `graceMs` has elapsed. Correlates the
218
+ * two action streams by changeId + time and reports the ABSENCE of a terminal.
219
+ *
220
+ * Per changeId we take the LATEST received (a PR pushed N times → only the newest
221
+ * cycle matters) and look for ANY terminal (succeeded/failed/blocked) with
222
+ * `at >= received.at`. None + past-grace ⇒ hang. A terminal after the received, OR
223
+ * a received still inside the grace window, clears it (the true-negative cases).
224
+ */
225
+ export function detectReconcileHangs(
226
+ received: readonly ActivityTimelineRow[],
227
+ terminals: readonly ActivityTimelineRow[],
228
+ opts: ReconcileHangOptions,
229
+ ): ActivityAlert[] {
230
+ const graceMs = opts.graceMs ?? DEFAULTS.reconcileHangGraceMs;
231
+
232
+ // Latest `received` per changeId.
233
+ const latestReceived = new Map<string, ActivityTimelineRow>();
234
+ for (const r of received) {
235
+ if (!r.changeId) continue;
236
+ const prev = latestReceived.get(r.changeId);
237
+ if (!prev || r.at > prev.at) latestReceived.set(r.changeId, r);
238
+ }
239
+ // Latest terminal `at` per changeId (any status).
240
+ const latestTerminalAt = new Map<string, string>();
241
+ for (const t of terminals) {
242
+ if (!t.changeId) continue;
243
+ const prev = latestTerminalAt.get(t.changeId);
244
+ if (!prev || t.at > prev) latestTerminalAt.set(t.changeId, t.at);
245
+ }
246
+
247
+ const out: ActivityAlert[] = [];
248
+ for (const [changeId, r] of latestReceived) {
249
+ const terminalAt = latestTerminalAt.get(changeId);
250
+ if (terminalAt && terminalAt >= r.at) continue; // terminated after this receive ⇒ not hung
251
+ const receivedMs = Date.parse(r.at);
252
+ if (Number.isNaN(receivedMs)) continue;
253
+ const elapsedMs = opts.now - receivedMs;
254
+ if (elapsedMs <= graceMs) continue; // still legitimately in flight ⇒ no alert
255
+ out.push({
256
+ type: "reconcile-hang",
257
+ severity: "critical",
258
+ title: "Reconcile received but never terminated",
259
+ summary:
260
+ `Change ${changeId} got a reconcile webhook ${Math.round(elapsedMs / 60000)}m ago with no ` +
261
+ `terminal state — the review loop is stuck.`,
262
+ changeId,
263
+ details: { receivedAt: r.at, minutesElapsed: Math.round(elapsedMs / 60000), graceMinutes: Math.round(graceMs / 60000) },
264
+ since: r.at,
265
+ });
266
+ }
267
+ return out;
268
+ }
269
+
270
+ // ─── Detector 4: deploy-skew (persistent, not rollout-transient) ─────────────
271
+
272
+ export interface DeploySkewAlertOptions {
273
+ /** "now" as epoch ms (injected for testing). */
274
+ now: number;
275
+ graceMs?: number;
276
+ }
277
+
278
+ /**
279
+ * Promote D5's visibility-only `DeploySkewSummary` into an alert, WITHOUT firing on
280
+ * every normal rollout. Fires only when the build serving this request differs from
281
+ * the newest observed build AND that newest build has been latest for longer than
282
+ * `graceMs` — i.e. THIS isolate never rolled to a deploy that finished long ago.
283
+ * A fresh rollout (newest observed < grace old) is the expected transient → silent.
284
+ */
285
+ export function detectDeploySkew(skew: DeploySkewSummary, opts: DeploySkewAlertOptions): ActivityAlert[] {
286
+ const graceMs = opts.graceMs ?? DEFAULTS.deploySkewGraceMs;
287
+ const { runningVersion, latestObservedVersion } = skew;
288
+ // Need to know BOTH the serving build and the newest observed build to judge skew.
289
+ if (!runningVersion || !latestObservedVersion) return [];
290
+ if (runningVersion === latestObservedVersion) return []; // this isolate is current ⇒ fine
291
+ const latest = skew.observedVersions[0];
292
+ if (!latest) return [];
293
+ const latestAgeMs = opts.now - Date.parse(latest.lastObservedAt);
294
+ if (Number.isNaN(latestAgeMs) || latestAgeMs <= graceMs) return []; // rollout still in its grace window
295
+ return [
296
+ {
297
+ type: "deploy-skew",
298
+ severity: "warning",
299
+ title: "Stale build still serving after rollout",
300
+ summary:
301
+ `This isolate serves build ${runningVersion} but ${latestObservedVersion} has been the ` +
302
+ `latest for ${Math.round(latestAgeMs / 60000)}m — a stale isolate never rolled forward.`,
303
+ details: {
304
+ runningVersion,
305
+ latestObservedVersion,
306
+ minutesStale: Math.round(latestAgeMs / 60000),
307
+ graceMinutes: Math.round(graceMs / 60000),
308
+ },
309
+ since: latest.lastObservedAt,
310
+ },
311
+ ];
312
+ }
313
+
314
+ // ─── The on-demand orchestrator ──────────────────────────────────────────────
315
+
316
+ export interface CheckAlertsOptions {
317
+ /** Injected clock (tests). Defaults to `new Date()`. */
318
+ now?: Date;
319
+ lookbackMs?: number;
320
+ previewMissThreshold?: number;
321
+ previewMissWindowMs?: number;
322
+ reconcileHangGraceMs?: number;
323
+ deploySkewGraceMs?: number;
324
+ /** Pin the serving build for deploy-skew (tests); real callers omit it. */
325
+ runningOverride?: string;
326
+ }
327
+
328
+ /**
329
+ * Run all four detectors for a tenant against the D1 timeline and return the active
330
+ * alerts, most-severe-first. Tenant-scoped through `queryActivity`/`deriveDeploySkew`
331
+ * (the same isolation invariant covers the whole check). On-demand: called when the
332
+ * ops-timeline page loads and by the read-only alerts endpoint — no cron.
333
+ */
334
+ export async function checkActivityAlerts(
335
+ db: Queryable,
336
+ tenantId: string,
337
+ opts: CheckAlertsOptions = {},
338
+ ): Promise<ActivityAlert[]> {
339
+ const now = opts.now ?? new Date();
340
+ const nowMs = now.getTime();
341
+ const lookbackMs = opts.lookbackMs ?? DEFAULTS.lookbackMs;
342
+ const since = new Date(nowMs - lookbackMs).toISOString();
343
+
344
+ const [failedTerminals, allTerminals, received, serveFailures, skew] = await Promise.all([
345
+ queryActivity(db, { tenantId, action: "reconcile.terminal", status: "failed", since, order: "desc" }),
346
+ queryActivity(db, { tenantId, action: "reconcile.terminal", since, order: "desc" }),
347
+ queryActivity(db, { tenantId, action: "webhook.reconcile.received", since, order: "desc" }),
348
+ queryActivity(db, { tenantId, action: "preview.serve", status: "failed", since, order: "desc" }),
349
+ deriveDeploySkew(db, tenantId, { since, ...(opts.runningOverride ? { runningOverride: opts.runningOverride } : {}) }),
350
+ ]);
351
+
352
+ return sortAlerts([
353
+ ...detectSilentTerminals(failedTerminals),
354
+ ...detectRecurringPreviewMisses(serveFailures, {
355
+ now: nowMs,
356
+ ...(opts.previewMissThreshold != null ? { threshold: opts.previewMissThreshold } : {}),
357
+ ...(opts.previewMissWindowMs != null ? { windowMs: opts.previewMissWindowMs } : {}),
358
+ }),
359
+ ...detectReconcileHangs(received, allTerminals, {
360
+ now: nowMs,
361
+ ...(opts.reconcileHangGraceMs != null ? { graceMs: opts.reconcileHangGraceMs } : {}),
362
+ }),
363
+ ...detectDeploySkew(skew, {
364
+ now: nowMs,
365
+ ...(opts.deploySkewGraceMs != null ? { graceMs: opts.deploySkewGraceMs } : {}),
366
+ }),
367
+ ]);
368
+ }
369
+
370
+ /** One-line alert summary for a log line / notification body (rumAlert parity). */
371
+ export function formatActivityAlert(a: ActivityAlert): string {
372
+ return `[${a.severity}] ${a.type}: ${a.summary} (since ${a.since})`;
373
+ }
374
+
375
+ // ─── Coherent surface descriptor (F16 `MONITORS` parity) ─────────────────────
376
+
377
+ /** Honest per-condition source + wiring note — the audit trail for "is this
378
+ * actually alerting?", mirroring F16's `WiringStatus`. */
379
+ export interface AlertCondition {
380
+ type: ActivityAlertType;
381
+ title: string;
382
+ defaultSeverity: AlertSeverity;
383
+ threshold: string;
384
+ /** Which timeline signal(s) it reads. */
385
+ source: string;
386
+ /** Honest wiring status. */
387
+ wiring: "in-code" | "needs-timeline-fanout" | "needs-external";
388
+ notes?: string;
389
+ }
390
+
391
+ export const ALERT_CONDITIONS: readonly AlertCondition[] = [
392
+ {
393
+ type: "silent-terminal",
394
+ title: "Reconcile terminated in failure",
395
+ defaultSeverity: "critical",
396
+ threshold: "any reconcile.terminal/failed (reconcile_fault ⇒ critical, reconcile_blocked ⇒ warning)",
397
+ source: "reconcile.terminal (timeline sink)",
398
+ wiring: "needs-timeline-fanout",
399
+ notes: "Emitted by candidate-reconcile.ts via recordActivity() (logs-only today); reaches D1 once the D1/D4 server→timeline fan-out lands.",
400
+ },
401
+ {
402
+ type: "recurring-preview-miss",
403
+ title: "Preview repeatedly missing for a live change",
404
+ defaultSeverity: "warning",
405
+ threshold: "≥3 preview.serve/failed/miss_no_env for one changeId within 15m",
406
+ source: "preview.serve (analytics sink — high volume)",
407
+ wiring: "needs-external",
408
+ notes: "preview.serve stays analytics-sink by design; detector is source-agnostic (rumAlert precedent) and fires against an Analytics-Engine-sourced or seeded feed.",
409
+ },
410
+ {
411
+ type: "reconcile-hang",
412
+ title: "Reconcile received but never terminated",
413
+ defaultSeverity: "critical",
414
+ threshold: "webhook.reconcile.received with no reconcile.terminal at-or-after it, past a 10m grace",
415
+ source: "webhook.reconcile.received + reconcile.terminal (both timeline sink)",
416
+ wiring: "needs-timeline-fanout",
417
+ notes: "D7 promoted webhook.reconcile.received to the timeline sink and emits it at reconcile start; reaches D1 once server→timeline fan-out lands.",
418
+ },
419
+ {
420
+ type: "deploy-skew",
421
+ title: "Stale build still serving after rollout",
422
+ defaultSeverity: "warning",
423
+ threshold: "serving build ≠ newest observed AND newest has been latest > 15m",
424
+ source: "deploy.version_observed (timeline sink) + this build's __GIT_SHA__",
425
+ wiring: "needs-timeline-fanout",
426
+ notes: "Promotes D5's deriveDeploySkew from visibility to an alert; grace period suppresses normal-rollout noise.",
427
+ },
428
+ ] as const;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Owner-facing "who did this" attribution for a candidate change or aggregate —
3
+ * DELIBERATELY separate from the anonymized activity spine. `ActivityActor.id`
4
+ * is a salted, non-reversible hash by design (see `recordActivity.ts`'s header:
5
+ * a raw PII id in the core zone would defeat the D0 contract), so it can never
6
+ * answer "who submitted/accepted/rejected/shipped this" for an owner reviewing
7
+ * `/admin#publish`. This persists the real email (never joined into the general
8
+ * `queryActivity()` surface, never cross-tenant) alongside the existing hashed
9
+ * event, so the two concerns stay separate: analytics stays anonymized, and
10
+ * this stays a narrow, owner-only-readable accountability record.
11
+ *
12
+ * `sub` (the tot20-issued, stable user id) would be a better key than email —
13
+ * email is a mutable account attribute, not the identity itself — but `sub`
14
+ * is dropped before `SessionRecord` today (see `auth/session.ts`). Threading
15
+ * it through is a real follow-up, not done here; email is what every call site
16
+ * already has.
17
+ */
18
+ import { displayNameFromEmail } from "./recordActivity";
19
+
20
+ export type ChangeActorAction = "submit" | "reject" | "accept" | "ship" | "build";
21
+
22
+ export interface ChangeActorRecord {
23
+ email: string;
24
+ /** Best-effort display name — no real name is captured anywhere today, so
25
+ * this is always {@link displayNameFromEmail}'s local-part guess. Swap the
26
+ * source here (not the callers) if a real name ever becomes available. */
27
+ name: string;
28
+ at: string;
29
+ }
30
+
31
+ export interface ChangeActorKv {
32
+ get(key: string): Promise<string | null>;
33
+ put(key: string, value: string): Promise<void>;
34
+ }
35
+
36
+ function attributionKey(tenantId: string, subjectId: string, action: ChangeActorAction): string {
37
+ return `cust:change-actor:${tenantId}:${subjectId}:${action}`;
38
+ }
39
+
40
+ /**
41
+ * Persist who took `action` on `subjectId` (a `changeId` for reject/accept/build,
42
+ * or the aggregate id for ship). Best-effort — never blocks or fails the
43
+ * mutation it describes; a write fault is swallowed, mirroring `recordActivity`.
44
+ */
45
+ export async function recordChangeActor(
46
+ kv: ChangeActorKv,
47
+ tenantId: string,
48
+ subjectId: string,
49
+ action: ChangeActorAction,
50
+ email: string,
51
+ ): Promise<void> {
52
+ try {
53
+ const record: ChangeActorRecord = {
54
+ email,
55
+ name: displayNameFromEmail(email),
56
+ at: new Date().toISOString(),
57
+ };
58
+ await kv.put(attributionKey(tenantId, subjectId, action), JSON.stringify(record));
59
+ } catch {
60
+ /* best-effort — the mutation it describes already succeeded */
61
+ }
62
+ }
63
+
64
+ /** Read every recorded action's attribution for one subject. Best-effort per
65
+ * action: a missing/corrupt record is simply absent from the result, never
66
+ * a thrown error. */
67
+ export async function readChangeActors(
68
+ kv: ChangeActorKv,
69
+ tenantId: string,
70
+ subjectId: string,
71
+ ): Promise<Partial<Record<ChangeActorAction, ChangeActorRecord>>> {
72
+ const actions: ChangeActorAction[] = ["submit", "reject", "accept", "ship", "build"];
73
+ const out: Partial<Record<ChangeActorAction, ChangeActorRecord>> = {};
74
+ await Promise.all(
75
+ actions.map(async (action) => {
76
+ try {
77
+ const raw = await kv.get(attributionKey(tenantId, subjectId, action));
78
+ if (raw) out[action] = JSON.parse(raw) as ChangeActorRecord;
79
+ } catch {
80
+ /* a corrupt/missing record just isn't shown */
81
+ }
82
+ }),
83
+ );
84
+ return out;
85
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * deploy.version_observed — the deploy-provenance / deploy-skew signal (card D2).
3
+ *
4
+ * "A deployed worker version/commit was observed." This is the DERIVED signal D5's
5
+ * query surface reads to answer *deploy skew* — is the build serving traffic the
6
+ * pushed umbrella tip, or is a stale isolate still live? D2's job is just to make
7
+ * `worker_commit` reliably present on server events and to EMIT this observation at
8
+ * least once per running build; the actual "== umbrella tip?" comparison and the
9
+ * timeline query are D5's (this module intentionally does no comparison — it has no
10
+ * notion of what the tip is, only what THIS build is).
11
+ *
12
+ * WHAT IT PRODUCES. A catalog `deploy.version_observed` event (ACTION_CATALOG:
13
+ * actors [system|dev], source server, sinks logs+analytics+timeline). The FULL
14
+ * 40-char commit rides `scope.workerCommit` — the CORE zone, which createActivityEvent
15
+ * never redacts — because that is where the authoritative, unscrubbed sha belongs.
16
+ *
17
+ * DELIBERATELY the full sha does NOT go in `payload`. The action's argsAllow names
18
+ * `commit`, but a 40-char hex sha trips D0's `high_entropy` VALUE canary (redaction.ts:
19
+ * ≥28 chars of base64url) — it would be scrubbed to «redacted» AND set
20
+ * `report.canaryTripped`, which D6/D7 alert on, firing a FALSE PII alarm on every cold
21
+ * start. So payload carries only the SHORT `version` (7 chars, safe) plus `env` +
22
+ * `observedFrom`; a reader wanting the full sha reads scope.workerCommit (a git sha is
23
+ * public, so the core zone is its correct, alert-free home). See the D2 report note for
24
+ * D5/D8: treat scope.workerCommit as the authoritative provenance, payload.version as a
25
+ * human-eyeball convenience.
26
+ *
27
+ * WHERE IT'S EMITTED. observeDeployVersion() fires ONCE PER WORKER ISOLATE (guarded
28
+ * by a module-level flag) from the middleware's front door — a cold start emits one
29
+ * observation, not one per request, so the stream is low-volume and honest. Until
30
+ * D1's recordActivity()/D4's ingest+store land, the emission goes to the universal
31
+ * `logs` sink (structured `[activity]` JSON to Workers Logs — the same pipeline the
32
+ * interim recordActivity.ts uses, already provisioned via `[observability]`). When
33
+ * D1's recordActivity() exists, swap `emitToLogs` for it (it will fan out to the
34
+ * analytics+timeline sinks the action declares); the EVENT this module builds is
35
+ * unchanged.
36
+ */
37
+ import { createActivityEvent } from "@tot/public-runtime";
38
+ import type { ActivityEvent, RedactionReport } from "@tot/public-runtime";
39
+ import { workerCommit, workerCommitShort, UNKNOWN_COMMIT } from "./workerCommit.js";
40
+ import { isActivityDisabled } from "./killSwitch.js";
41
+
42
+ export interface DeployVersionObservedInput {
43
+ /** Deploy env/target name (e.g. "preview", "production"), when known. Optional. */
44
+ env?: string;
45
+ /** What surfaced the observation (e.g. "startup", "health"). Defaults "startup". */
46
+ observedFrom?: string;
47
+ /** Opaque actor id. Defaults "system" (the platform observed its own build). */
48
+ actorId?: string;
49
+ /** Override the build commit — tests only. Real callers omit (reads __GIT_SHA__). */
50
+ commit?: string;
51
+ }
52
+
53
+ /**
54
+ * Build (do not emit) the `deploy.version_observed` ActivityEvent for the running
55
+ * build. Pure + side-effect-free — the piece D5 ultimately consumes and the piece
56
+ * tests assert against. Returns the safe-constructed event plus its redaction report.
57
+ */
58
+ export function buildDeployVersionObservedEvent(
59
+ input: DeployVersionObservedInput = {},
60
+ ): { event: ActivityEvent; report: RedactionReport } {
61
+ const commit = workerCommit(input.commit);
62
+ const version = workerCommitShort(input.commit);
63
+ return createActivityEvent({
64
+ action: "deploy.version_observed",
65
+ actor: { kind: "system", id: input.actorId ?? "system" },
66
+ source: "server",
67
+ outcome: { status: "succeeded" },
68
+ // worker_commit (full sha) in the CORE zone — never redacted; the authoritative
69
+ // provenance every server event carries and D5 derives skew from.
70
+ scope: commit !== UNKNOWN_COMMIT ? { workerCommit: commit } : {},
71
+ // Payload carries the SHORT version (safe from the high_entropy canary that would
72
+ // scrub a full sha) + env + observedFrom. Unknown builds omit `version` rather than
73
+ // store the sentinel, but still emit the observation.
74
+ payload: {
75
+ args: {
76
+ ...(commit !== UNKNOWN_COMMIT ? { version } : {}),
77
+ ...(input.env ? { env: input.env } : {}),
78
+ observedFrom: input.observedFrom ?? "startup",
79
+ },
80
+ },
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Serialize an ActivityEvent to the universal `logs` sink (Workers Logs). Interim
86
+ * transport until D1's recordActivity() fans out to all of the action's sinks;
87
+ * mirrors the `[activity]` tag the existing recordActivity.ts slice already emits so
88
+ * one grep finds both. Best-effort, never throws.
89
+ */
90
+ function emitToLogs(event: ActivityEvent): void {
91
+ try {
92
+ console.log(`[activity] ${JSON.stringify(event)}`);
93
+ } catch {
94
+ // best-effort only — telemetry must never break a request
95
+ }
96
+ }
97
+
98
+ /** Per-isolate guard: emit the observation once per cold start, not per request. */
99
+ let observedThisIsolate = false;
100
+
101
+ /**
102
+ * Emit `deploy.version_observed` ONCE for this worker isolate. Idempotent within an
103
+ * isolate (subsequent calls are no-ops), best-effort, never throws — safe to call on
104
+ * every request from the middleware front door. Returns the event it emitted (or
105
+ * `undefined` if it already fired this isolate) so callers/tests can inspect it.
106
+ */
107
+ export function observeDeployVersion(
108
+ input: DeployVersionObservedInput = {},
109
+ ): ActivityEvent | undefined {
110
+ if (observedThisIsolate) return undefined;
111
+ // Kill switch (D8): telemetry off ⇒ skip the observation entirely (fail-open). Do
112
+ // NOT set the per-isolate guard, so re-enabling emits the observation on the next call.
113
+ if (isActivityDisabled()) return undefined;
114
+ observedThisIsolate = true;
115
+ try {
116
+ const { event } = buildDeployVersionObservedEvent(input);
117
+ emitToLogs(event);
118
+ return event;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ /** Test-only: reset the per-isolate guard so a test can re-exercise the first-fire path. */
125
+ export function __resetDeployVersionObservedForTest(): void {
126
+ observedThisIsolate = false;
127
+ }