@catalyst-cloud/schema 0.1.40 → 0.1.41

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catalyst-cloud/schema",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
4
4
  "type": "module",
5
5
  "description": "Typed Drizzle schema = single source of truth for the per-tenant Mirror DO SQLite store (CTC-13 / ADR-0002). Shared by the mirror Worker, the host-sync replica, and the browser OPFS replica.",
6
6
  "license": "MIT",
@@ -0,0 +1,31 @@
1
+ // current-state.ts — CTC-355. The THIRD admission gate, and the reason it did not exist.
2
+ //
3
+ // `classifyEventType` sorts every declared name into exactly one of three disjoint buckets, and until
4
+ // now only two of them had a door: `appendEventWithOutbox` (apps/mirror/src/do/event-backbone.ts)
5
+ // admits `durable`, `assertEmittable` (./telemetry/point.ts) admits `telemetry`, and `current-state`
6
+ // was admitted by NOTHING — declared, unproduced, unconsumed, the "declared but unfed lane" shape
7
+ // ADR-0029 names. This is that door: the fleet-liveness heartbeat ingest lane
8
+ // (apps/mirror/src/coordination/heartbeat-validate.ts) calls this to refuse anything that is not a
9
+ // heartbeat-shaped current-state name, symmetrically with how the durable backbone and the telemetry
10
+ // sink already refuse everything that is NOT theirs.
11
+ //
12
+ // Three disjoint buckets, three gates, one per bucket — the symmetry is the point. The other two gates
13
+ // stay exactly as they are: a heartbeat is still refused by the durable backbone and by the telemetry
14
+ // sink, which is correct — updating mutable NOW is this gate's job alone.
15
+
16
+ import { classifyEventType } from "./registry.js";
17
+
18
+ /**
19
+ * Refuse to admit a name that is not `classifyEventType(name) === "current-state"`. Throws — never
20
+ * returns a falsy sentinel (AGENTS.md: a helper that degrades to "fine" hides the defect, and a caller
21
+ * cannot degrade a refusal into a silent skip when the refusal is an exception).
22
+ */
23
+ export function assertCurrentState(name: string): void {
24
+ const klass = classifyEventType(name);
25
+ if (klass !== "current-state") {
26
+ throw new Error(
27
+ `event type ${JSON.stringify(name)} is classified "${klass}" — only current-state (heartbeat) ` +
28
+ `names may update mutable NOW. See packages/schema/src/events/registry.ts.`,
29
+ );
30
+ }
31
+ }
@@ -6,3 +6,7 @@ export * from "./types.js";
6
6
  export * from "./type-grammar.js";
7
7
  export * from "./registry.js";
8
8
  export * from "./retention.js";
9
+ // CTC-355 — the THIRD admission gate: durable/current-state/telemetry are the three disjoint buckets
10
+ // classifyEventType partitions the vocabulary into; the other two already had a door
11
+ // (appendEventWithOutbox, ../telemetry/point.ts's assertEmittable). This is current-state's.
12
+ export * from "./current-state.js";
@@ -185,12 +185,14 @@ export const durableEventTypes = {
185
185
  wakes: { consumerClass: "dispatcher" },
186
186
  },
187
187
  "fleet.health.degraded": {
188
- rationale: "A fleet-wide health boundary crossing — paired with fleet.health.recovered.",
188
+ rationale:
189
+ "A fleet-wide health boundary crossing — paired with fleet.health.recovered. ⭐ CTC-355 is its producer: the per-tenant absence-based liveness evaluator (apps/mirror/src/do/fleet-liveness-evaluate.ts's evaluateLiveness, invoked from MirrorDO.alarm()) derives it from monitor.heartbeat ABSENCE past a per-host grace window — exactly the derivation currentStateEventTypes' boundaryEvents list above was typed against. ⚠️ `ingestVerb` below is unchanged and now under-describes this name: the producer is an in-DO evaluator, not an ingest verb. The field is descriptive metadata with no runtime consumer (verified: nothing outside this registry file reads it) — widening its closed union for one unread field would touch ~90 spec literals.",
189
190
  ingestVerb: "coordination.publish",
190
191
  wakes: { consumerClass: "host-channel" },
191
192
  },
192
193
  "fleet.health.recovered": {
193
- rationale: "The paired boundary-crossing fact to fleet.health.degraded.",
194
+ rationale:
195
+ "The paired boundary-crossing fact to fleet.health.degraded. Same producer (CTC-355's liveness evaluator) — fired when a degraded host's heartbeat resumes inside a later evaluation pass.",
194
196
  ingestVerb: "coordination.publish",
195
197
  wakes: false,
196
198
  },
@@ -1910,6 +1912,16 @@ export const telemetryEventTypes = {
1910
1912
  "account.ratelimit.sampled": {
1911
1913
  reason: "A periodic provider rate-limit budget reading — a gauge, not a state transition.",
1912
1914
  },
1915
+ "catalyst.fleet.liveness.evaluated": {
1916
+ reason:
1917
+ "CTC-355 — one point per per-tenant liveness evaluation pass, carrying the live/degraded/unknown counts. Gauge-shaped: the rollup is the signal, no individual pass has identity. The BOUNDARY crossings are durable (fleet.health.degraded/recovered below); this is the pass itself.",
1918
+ sampling: { kind: "counted" },
1919
+ },
1920
+ "catalyst.fleet.anomaly.detected": {
1921
+ reason:
1922
+ "CTC-355 — one point per NEWLY-OPENED fleet anomaly (rate_spike/flatline), never per re-detection of an already-open one. The anomaly ROW (fleet_anomalies) is the durable record of what is wrong now; this is the detection event.",
1923
+ sampling: { kind: "counted" },
1924
+ },
1913
1925
  "account.ratelimit.unsampled": {
1914
1926
  reason:
1915
1927
  "The raw (pre-sampling) rate-limit reading behind account.ratelimit.sampled — same gauge family.",
package/src/index.ts CHANGED
@@ -50,6 +50,10 @@ import {
50
50
  deployment_statuses,
51
51
  pr_review_threads,
52
52
  check_suites,
53
+ fleet_host_liveness,
54
+ fleet_anomalies,
55
+ fleet_activity_rate,
56
+ fleet_health_sends,
53
57
  } from "./mirror.js";
54
58
 
55
59
  export * from "./mirror.js";
@@ -152,6 +156,17 @@ export const mirrorSchema = {
152
156
  // CTC-667 item 4: the check SUITE's own rollup — the highest-volume signal in CTL's census and the
153
157
  // one every phase agent's CI wait blocks on.
154
158
  check_suites,
159
+ // CTC-355: per-host fleet liveness (mutable NOW) + open anomaly signals. Real tables the DO creates
160
+ // (migrations must produce them) and change-feed entities (see feedEntityTables below) — the
161
+ // dashboard is a live consumer of both.
162
+ fleet_host_liveness,
163
+ fleet_anomalies,
164
+ // CTC-355: the rolling activity-rate bucket table (anomaly baseline source) and the fleet-health
165
+ // push re-fire gate. Same posture as fleet_activity_pruned / pull_sweeps — real tables the DO
166
+ // creates, deliberately absent from feedEntityTables/nonFeedEntityTables (pure bookkeeping no
167
+ // consumer should ever see over the feed).
168
+ fleet_activity_rate,
169
+ fleet_health_sends,
155
170
  } as const;
156
171
 
157
172
  /**
@@ -225,6 +240,12 @@ export const feedEntityTables = {
225
240
  // CTC-667 item 4: GitHub's OWN check-suite rollup. ⛔ The row is contractual; a derivation from
226
241
  // `check_runs` is NOT — see the table's own header for the two reasons it cannot agree with GitHub.
227
242
  check_suites,
243
+ // CTC-355: per-host fleet liveness + open anomaly signals. On this plane deliberately — the
244
+ // dashboard's fleet-liveness panel is a legitimate account-wide reader, same posture as
245
+ // fleet_activity directly above (both are BROWSER_ONLY at the changefeed layer — see
246
+ // apps/mirror/src/do/changefeed.ts's BROWSER_ONLY_ENTITIES).
247
+ fleet_host_liveness,
248
+ fleet_anomalies,
228
249
  } as const;
229
250
 
230
251
  /**
@@ -263,6 +263,13 @@ export const MIRROR_MIGRATIONS = {
263
263
  tag: "0035_tricky_swarm",
264
264
  breakpoints: true,
265
265
  },
266
+ {
267
+ idx: 36,
268
+ version: "6",
269
+ when: 1788333712249,
270
+ tag: "0036_watery_hercules",
271
+ breakpoints: true,
272
+ },
266
273
  ],
267
274
  },
268
275
  migrations: {
@@ -335,5 +342,7 @@ export const MIRROR_MIGRATIONS = {
335
342
  "CREATE INDEX `idx_processed_events_source_received` ON `processed_events` (`source`,`received_at`);",
336
343
  "0035_tricky_swarm":
337
344
  "ALTER TABLE `agent_activities` ADD `action` text;--> statement-breakpoint\nALTER TABLE `agent_activities` ADD `parameter` text;--> statement-breakpoint\nALTER TABLE `agent_activities` ADD `result` text;--> statement-breakpoint\nUPDATE agent_activities\n SET action = json_extract(raw, '$.content.action'),\n parameter = json_extract(raw, '$.content.parameter'),\n result = json_extract(raw, '$.content.result')\n WHERE raw IS NOT NULL\n AND json_valid(raw)\n AND json_extract(raw, '$.content.type') = 'action';\n",
345
+ "0036_watery_hercules":
346
+ "CREATE TABLE `fleet_activity_rate` (\n\t`host_id` text NOT NULL,\n\t`ticket` text NOT NULL,\n\t`bucket_start_ms` integer NOT NULL,\n\t`events` integer NOT NULL,\n\tPRIMARY KEY(`host_id`, `ticket`, `bucket_start_ms`)\n);\n--> statement-breakpoint\nCREATE TABLE `fleet_anomalies` (\n\t`kind` text NOT NULL,\n\t`host_id` text NOT NULL,\n\t`ticket` text NOT NULL,\n\t`detected_at_ms` integer NOT NULL,\n\t`observed` integer NOT NULL,\n\t`baseline` integer NOT NULL,\n\t`detail` text,\n\t`updated_at` integer NOT NULL,\n\tPRIMARY KEY(`kind`, `host_id`, `ticket`)\n);\n--> statement-breakpoint\nCREATE TABLE `fleet_health_sends` (\n\t`host_id` text NOT NULL,\n\t`status` text NOT NULL,\n\t`status_changed_at_ms` integer NOT NULL,\n\t`sent_at_ms` integer NOT NULL,\n\t`delivered` integer NOT NULL,\n\t`failed` integer NOT NULL,\n\tPRIMARY KEY(`host_id`, `status`, `status_changed_at_ms`)\n);\n--> statement-breakpoint\nCREATE TABLE `fleet_host_liveness` (\n\t`host_id` text PRIMARY KEY NOT NULL,\n\t`status` text NOT NULL,\n\t`reason` text,\n\t`last_heartbeat_ms` integer,\n\t`last_activity_ms` integer,\n\t`last_seen_ms` integer,\n\t`period_ms` integer,\n\t`grace_ms` integer,\n\t`agent_version` text,\n\t`status_changed_at_ms` integer,\n\t`updated_at` integer NOT NULL\n);\n",
338
347
  },
339
348
  } as const;
package/src/mirror.ts CHANGED
@@ -1366,3 +1366,109 @@ export const push_subscriptions = sqliteTable(
1366
1366
  // PK), keyed off issues.assignee_id — the same shape idx_workflow_states_team indexes on team_id.
1367
1367
  (t) => [index("idx_push_subscriptions_member").on(t.member_id)],
1368
1368
  );
1369
+
1370
+ /**
1371
+ * CTC-355 — MUTABLE NOW for one fleet host's liveness. One row per host_id.
1372
+ *
1373
+ * ⛔ THIS IS NOT `fleet_activity`, AND THE DIFFERENCE IS THE WHOLE POINT. `fleet_activity` advances
1374
+ * only when a host does TICKET WORK, so an alive-but-idle host freezes its watermark — the exact trap
1375
+ * `apps/web/src/lib/sync-health.ts` documents for `last_ingest_ms` (CTC-144 added a heartbeat
1376
+ * precisely because of it). Liveness is asserted by a HEARTBEAT and by nothing else.
1377
+ *
1378
+ * ⚠️ `last_activity_ms` folds in ONE-WAY. Publishing a coordination event is positive proof of life,
1379
+ * so it advances `last_seen_ms`; its ABSENCE proves nothing and never drives a verdict.
1380
+ *
1381
+ * ⭐ `status` is TRI-STATE and `unknown` is load-bearing. A host that has never heartbeated is
1382
+ * `unknown` regardless of how much activity it has produced — because on the day this shipped NO host
1383
+ * emitted `monitor.heartbeat`, and a binary model would have marked the entire fleet degraded and
1384
+ * pushed a notification for every one of them.
1385
+ */
1386
+ export const fleet_host_liveness = sqliteTable("fleet_host_liveness", {
1387
+ host_id: text("host_id").primaryKey(),
1388
+ /** 'live' | 'degraded' | 'unknown' — see FleetLivenessStatus (do/fleet-liveness-evaluate.ts). */
1389
+ status: text("status").notNull(),
1390
+ /** Why, when status is 'unknown': 'no_heartbeat_lane' | 'clock_skew'. Null otherwise. */
1391
+ reason: text("reason"),
1392
+ /** Last accepted heartbeat receipt (the DO's OWN clock). NULL ⇒ never heartbeated ⇒ unknown. */
1393
+ last_heartbeat_ms: integer("last_heartbeat_ms"),
1394
+ /** Last accepted coordination-event receipt for this host (DO clock). Positive proof only. */
1395
+ last_activity_ms: integer("last_activity_ms"),
1396
+ /** max(last_heartbeat_ms, last_activity_ms) — the value the grace window is measured against. */
1397
+ last_seen_ms: integer("last_seen_ms"),
1398
+ /** The cadence the host DECLARED, clamped on ingest. Drives the per-host grace window. */
1399
+ period_ms: integer("period_ms"),
1400
+ /** The window in force for this host, materialized so the UI reports the SAME number the evaluator
1401
+ * used rather than re-deriving one that could disagree. */
1402
+ grace_ms: integer("grace_ms"),
1403
+ /** The host's self-reported agent/SDK version, verbatim. Display only. */
1404
+ agent_version: text("agent_version"),
1405
+ /** When `status` last CHANGED — not when the row was last touched. The two differ on every
1406
+ * heartbeat of a healthy host, and the UI needs the former ("degraded for 20 minutes"). */
1407
+ status_changed_at_ms: integer("status_changed_at_ms"),
1408
+ updated_at: integer("updated_at").notNull(), // upsertRow's last-write-wins guard column
1409
+ });
1410
+
1411
+ /**
1412
+ * CTC-355 — an open anomaly signal. One row per (kind, host_id, ticket); DELETED when the condition
1413
+ * clears, so this table is "what is wrong now", never a history.
1414
+ *
1415
+ * ⚠️ TICKET IS `''` FOR TENANT-SCOPE KINDS (flatline), never NULL — it is a PK column, and SQLite
1416
+ * treats NULLs as distinct in a UNIQUE index, so a nullable PK member would let the same flatline
1417
+ * insert unboundedly many times.
1418
+ */
1419
+ export const fleet_anomalies = sqliteTable(
1420
+ "fleet_anomalies",
1421
+ {
1422
+ kind: text("kind").notNull(), // 'rate_spike' | 'flatline'
1423
+ host_id: text("host_id").notNull(), // '' for tenant-scope kinds
1424
+ ticket: text("ticket").notNull(), // '' for tenant-scope kinds
1425
+ detected_at_ms: integer("detected_at_ms").notNull(),
1426
+ /** The measured value and the baseline it was judged against — so the UI can SHOW the arithmetic
1427
+ * instead of asserting a verdict the reader has to trust. */
1428
+ observed: integer("observed").notNull(),
1429
+ baseline: integer("baseline").notNull(),
1430
+ detail: text("detail"),
1431
+ updated_at: integer("updated_at").notNull(),
1432
+ },
1433
+ (t) => [primaryKey({ columns: [t.kind, t.host_id, t.ticket] })],
1434
+ );
1435
+
1436
+ /**
1437
+ * CTC-355 — bounded rolling activity counters, the ONLY baseline source for anomaly detection. 15-min
1438
+ * buckets, 24h retention: ≤96 rows per active (host, ticket), and a pair stops producing rows the
1439
+ * moment it stops publishing. Bookkeeping — on NEITHER entity list, like fleet_activity_pruned.
1440
+ *
1441
+ * ⛔ NOT Analytics Engine. AE applies query-time sampling, and a sampled-away point is
1442
+ * indistinguishable from an absent one — which for an absence-based detector is a FALSE ALARM, not a
1443
+ * rounding error. This table is how it is dodged rather than compensated for.
1444
+ */
1445
+ export const fleet_activity_rate = sqliteTable(
1446
+ "fleet_activity_rate",
1447
+ {
1448
+ host_id: text("host_id").notNull(),
1449
+ ticket: text("ticket").notNull(),
1450
+ bucket_start_ms: integer("bucket_start_ms").notNull(),
1451
+ events: integer("events").notNull(),
1452
+ },
1453
+ (t) => [primaryKey({ columns: [t.host_id, t.ticket, t.bucket_start_ms] })],
1454
+ );
1455
+
1456
+ /**
1457
+ * CTC-355 — the durable "has anybody been told?" record for fleet-health notifications, modelled on
1458
+ * the CTC-764 ask-push re-fire gate. Keyed by the transition, so a re-evaluation that re-derives the
1459
+ * same verdict cannot re-notify, and a genuine flap (degraded → live → degraded) can.
1460
+ */
1461
+ export const fleet_health_sends = sqliteTable(
1462
+ "fleet_health_sends",
1463
+ {
1464
+ host_id: text("host_id").notNull(),
1465
+ /** The status transitioned INTO. */
1466
+ status: text("status").notNull(),
1467
+ /** The status_changed_at_ms this send was for — what makes a flap re-notifiable. */
1468
+ status_changed_at_ms: integer("status_changed_at_ms").notNull(),
1469
+ sent_at_ms: integer("sent_at_ms").notNull(),
1470
+ delivered: integer("delivered").notNull(), // count of endpoints that accepted
1471
+ failed: integer("failed").notNull(),
1472
+ },
1473
+ (t) => [primaryKey({ columns: [t.host_id, t.status, t.status_changed_at_ms] })],
1474
+ );