@catalyst-cloud/schema 0.1.3 → 0.1.9

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.3",
3
+ "version": "0.1.9",
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",
@@ -30,7 +30,7 @@
30
30
  "drizzle-orm": "0.44.7"
31
31
  },
32
32
  "devDependencies": {
33
- "@catalyst-cloud/typescript-config": "workspace:*",
33
+ "@catalyst-cloud/typescript-config": "0.0.0",
34
34
  "@types/node": "^25.9.3",
35
35
  "drizzle-kit": "^0.31.0",
36
36
  "prettier": "^3.8.4",
package/src/index.ts CHANGED
@@ -27,7 +27,13 @@ import {
27
27
  reviews,
28
28
  processed_events,
29
29
  change_log,
30
+ event_log,
30
31
  sync_cursors,
32
+ fleet_activity,
33
+ agent_sessions,
34
+ agent_activities,
35
+ fleet_activity_pruned,
36
+ pull_sweeps,
31
37
  } from "./mirror.js";
32
38
 
33
39
  export * from "./mirror.js";
@@ -42,6 +48,18 @@ export {
42
48
  type MigrationJournalEntry,
43
49
  } from "./migrate.js";
44
50
 
51
+ // CTC-471 — schema-skew detection. A consumer reports the bundle it LOADED (not the one its lockfile
52
+ // claims) and the hub places that tail in its own journal. Exported here so the SDK, the replica and
53
+ // the Worker all compare the same way; see ./skew.ts for why a version string cannot do this job.
54
+ export {
55
+ loadedSchemaIdentity,
56
+ schemaSkew,
57
+ schemaSkewNeedsAttention,
58
+ schemaIncludesMigration,
59
+ type SchemaIdentity,
60
+ type SchemaSkew,
61
+ } from "./skew.js";
62
+
45
63
  /** Every table in the Mirror DO store — pass as `drizzle(storage, { schema: mirrorSchema })`. */
46
64
  export const mirrorSchema = {
47
65
  issues,
@@ -61,13 +79,36 @@ export const mirrorSchema = {
61
79
  reviews,
62
80
  processed_events,
63
81
  change_log,
82
+ event_log,
64
83
  sync_cursors,
84
+ fleet_activity,
85
+ // The per-PR enrichment receipt. In `mirrorSchema` (it is a real table the DO creates and the
86
+ // migration bundle must produce) but DELIBERATELY ABSENT from `mirrorEntityTables` below — see the
87
+ // no-feed arithmetic on the table definition in ./mirror.ts.
88
+ pull_sweeps,
89
+ // CTC-393: the fleet_activity anti-resurrection tombstone. Same posture as pull_sweeps — a real
90
+ // table the DO creates, but pure internal bookkeeping that must NEVER ride the change-feed (a
91
+ // consumer has no use for "a row was once terminal here"; see the doc on the table itself).
92
+ // CTC-524: agent sessions + their activity stream.
93
+ agent_sessions,
94
+ agent_activities,
95
+ fleet_activity_pruned,
65
96
  } as const;
66
97
 
67
98
  /**
68
99
  * The domain-entity tables that flow over the change-feed (the keys are the wire `entity` names). The
69
- * infra tables — processed_events / change_log / sync_cursors — are deliberately excluded: they never
70
- * ride a delta.
100
+ * infra tables — processed_events / change_log / event_log / sync_cursors — are deliberately excluded:
101
+ * they never ride a delta.
102
+ *
103
+ * `event_log` in particular must NEVER appear here. It is the raw provider-event passthrough feed
104
+ * (CTC-294 / ADR-0017), served on its own route and its own cursor; leaking it into the entity feed
105
+ * would push an unknown `entity` at every already-deployed replica consumer, which is exactly the
106
+ * fleet-wide breakage this cutover exists to avoid.
107
+ *
108
+ * ⛔ `pull_sweeps` must never appear here either, for a different reason: its clock advances on every
109
+ * reconcile pass, so as an entity it would emit ~60k deltas/day (7 repos × 30 open PRs × 288 passes)
110
+ * into a 200k/7-day ring — the ring would hold under a day and every replica cursor would fall off
111
+ * the end. The arithmetic and the rest of the posture live on the table definition in ./mirror.ts.
71
112
  */
72
113
  export const mirrorEntityTables = {
73
114
  issues,
@@ -85,6 +126,12 @@ export const mirrorEntityTables = {
85
126
  check_runs,
86
127
  commit_statuses,
87
128
  reviews,
129
+ // CTC-350: fleet coordination current-state — replicates like any other entity (one row per
130
+ // host_id/ticket), unlike the infra tables above which never ride a delta.
131
+ fleet_activity,
132
+ // CTC-524: agent sessions + their activities.
133
+ agent_sessions,
134
+ agent_activities,
88
135
  } as const;
89
136
 
90
137
  /** The wire `entity` name of a change-feed-carried table (keys of mirrorEntityTables). */
package/src/migrate.ts CHANGED
@@ -118,8 +118,11 @@ export function applyMigrations(
118
118
  * non-replay-safe when the run-once ledger guard is bypassed — e.g. a crash between a statement
119
119
  * succeeding and the tag being recorded, which reruns the whole migration on the next boot:
120
120
  * • "duplicate column name" on `ALTER TABLE ... ADD COLUMN` — the column is already present.
121
- * • "index ... already exists" on `CREATE INDEX` — the index is already present (drizzle-kit emits
122
- * bare `CREATE INDEX`, not `IF NOT EXISTS`, so the runner makes the replay idempotent here).
121
+ * • "index ... already exists" on `CREATE INDEX` **or `CREATE UNIQUE INDEX`** — the index is already
122
+ * present (drizzle-kit emits a bare form, not `IF NOT EXISTS`, so the runner makes the replay
123
+ * idempotent here). The `UNIQUE` variant is NOT optional to handle: the pattern must match the
124
+ * statement drizzle-kit actually emitted, and a unique index that fell through this guard would
125
+ * throw on replay and leave the DO unable to boot (CTC-294 added the first such index).
123
126
  * • "table ... already exists" on `CREATE TABLE` — a NEW entity table added in a LATER migration
124
127
  * (CTC-USERS): drizzle-kit emits a bare `CREATE TABLE` (no IF NOT EXISTS), so a live DB whose
125
128
  * tables were already created from a newer `schema.sql` (which carries the new table) would throw
@@ -135,7 +138,7 @@ function execReplaySafe(db: MigrationDb, stmt: string): void {
135
138
  } catch (err) {
136
139
  const msg = err instanceof Error ? err.message : String(err);
137
140
  if (/duplicate column name/i.test(msg) && /\bADD\b/i.test(stmt)) return;
138
- if (/already exists/i.test(msg) && /\bCREATE\s+INDEX\b/i.test(stmt)) return;
141
+ if (/already exists/i.test(msg) && /\bCREATE\s+(UNIQUE\s+)?INDEX\b/i.test(stmt)) return;
139
142
  if (/already exists/i.test(msg) && /\bCREATE\s+TABLE\b/i.test(stmt)) return;
140
143
  throw err;
141
144
  }
@@ -74,6 +74,76 @@ export const MIRROR_MIGRATIONS = {
74
74
  tag: "0008_optimal_rattler",
75
75
  breakpoints: true,
76
76
  },
77
+ {
78
+ idx: 9,
79
+ version: "6",
80
+ when: 1784832568791,
81
+ tag: "0009_deep_bill_hollister",
82
+ breakpoints: true,
83
+ },
84
+ {
85
+ idx: 10,
86
+ version: "6",
87
+ when: 1785084189458,
88
+ tag: "0010_red_abomination",
89
+ breakpoints: true,
90
+ },
91
+ {
92
+ idx: 11,
93
+ version: "6",
94
+ when: 1785090228974,
95
+ tag: "0011_hesitant_korath",
96
+ breakpoints: true,
97
+ },
98
+ {
99
+ idx: 12,
100
+ version: "6",
101
+ when: 1786097717165,
102
+ tag: "0012_ordinary_infant_terrible",
103
+ breakpoints: true,
104
+ },
105
+ {
106
+ idx: 13,
107
+ version: "6",
108
+ when: 1786133360284,
109
+ tag: "0013_fair_mach_iv",
110
+ breakpoints: true,
111
+ },
112
+ {
113
+ idx: 14,
114
+ version: "6",
115
+ when: 1786583196993,
116
+ tag: "0014_reviews_state_canonical",
117
+ breakpoints: true,
118
+ },
119
+ {
120
+ idx: 15,
121
+ version: "6",
122
+ when: 1786594623244,
123
+ tag: "0015_brainy_lady_ursula",
124
+ breakpoints: true,
125
+ },
126
+ {
127
+ idx: 16,
128
+ version: "6",
129
+ when: 1786723845155,
130
+ tag: "0016_vengeful_the_stranger",
131
+ breakpoints: true,
132
+ },
133
+ {
134
+ idx: 17,
135
+ version: "6",
136
+ when: 1786770512483,
137
+ tag: "0017_clammy_doctor_octopus",
138
+ breakpoints: true,
139
+ },
140
+ {
141
+ idx: 18,
142
+ version: "6",
143
+ when: 1786783490609,
144
+ tag: "0018_brainy_clint_barton",
145
+ breakpoints: true,
146
+ },
77
147
  ],
78
148
  },
79
149
  migrations: {
@@ -95,5 +165,23 @@ export const MIRROR_MIGRATIONS = {
95
165
  "CREATE TABLE `issue_history` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`issue_id` text NOT NULL,\n\t`actor_id` text,\n\t`created_at` integer,\n\t`updated_at` integer,\n\t`from_state` text,\n\t`to_state` text,\n\t`from_assignee_id` text,\n\t`to_assignee_id` text,\n\t`from_priority` integer,\n\t`to_priority` integer,\n\t`from_estimate` real,\n\t`to_estimate` real,\n\t`from_title` text,\n\t`to_title` text,\n\t`from_cycle_id` text,\n\t`to_cycle_id` text,\n\t`from_project_id` text,\n\t`to_project_id` text,\n\t`from_parent_id` text,\n\t`to_parent_id` text,\n\t`from_team_id` text,\n\t`to_team_id` text,\n\t`from_due_date` text,\n\t`to_due_date` text,\n\t`added_label_ids` text,\n\t`removed_label_ids` text,\n\t`updated_description` integer,\n\t`archived` integer,\n\t`auto_archived` integer,\n\t`auto_closed` integer,\n\t`trashed` integer,\n\t`raw` text\n);\n--> statement-breakpoint\nCREATE INDEX `idx_issue_history_issue_created` ON `issue_history` (`issue_id`,`created_at`);",
96
166
  "0008_optimal_rattler":
97
167
  "ALTER TABLE `issues` ADD `state_id` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `team_key` text;--> statement-breakpoint\nALTER TABLE `issues` ADD `team_name` text;",
168
+ "0009_deep_bill_hollister":
169
+ "ALTER TABLE `pull_requests` ADD `head_ref` text;--> statement-breakpoint\nALTER TABLE `pull_requests` ADD `linear_issue_identifier` text;--> statement-breakpoint\nCREATE INDEX `idx_pulls_linear_ident` ON `pull_requests` (`linear_issue_identifier`);",
170
+ "0010_red_abomination":
171
+ "CREATE TABLE `event_log` (\n\t`seq` integer PRIMARY KEY AUTOINCREMENT NOT NULL,\n\t`delivery_id` text NOT NULL,\n\t`source` text NOT NULL,\n\t`event_type` text NOT NULL,\n\t`action` text,\n\t`received_at` integer NOT NULL,\n\t`backend_ts` integer,\n\t`payload` text NOT NULL,\n\t`payload_omitted_bytes` integer\n);\n--> statement-breakpoint\nCREATE UNIQUE INDEX `idx_event_log_delivery` ON `event_log` (`delivery_id`);",
172
+ "0011_hesitant_korath": "ALTER TABLE `event_log` ADD `identity` text;",
173
+ "0012_ordinary_infant_terrible":
174
+ "ALTER TABLE `cycles` ADD `team_id` text;--> statement-breakpoint\nALTER TABLE `cycles` ADD `team_key` text;--> statement-breakpoint\nALTER TABLE `cycles` ADD `team_name` text;",
175
+ "0013_fair_mach_iv":
176
+ "CREATE TABLE `fleet_activity` (\n\t`host_id` text NOT NULL,\n\t`ticket` text NOT NULL,\n\t`phase` text,\n\t`status` text,\n\t`disposition` text,\n\t`pr_number` integer,\n\t`pr_url` text,\n\t`orch_id` text,\n\t`last_event_name` text,\n\t`last_event_ts` integer,\n\t`updated_at` integer,\n\tPRIMARY KEY(`host_id`, `ticket`)\n);\n",
177
+ "0014_reviews_state_canonical":
178
+ "-- CTC-451 — canonicalise `reviews.state` to UPPERCASE.\n--\n-- The two ingestion legs disagreed at the source: REST (`/pulls/{n}/reviews`, the reconcile) returns\n-- `COMMENTED`, while the `pull_request_review` webhook delivers `commented`. Both legs stored the\n-- value verbatim, so the column carried BOTH casings of the same state. Measured on the live corpus\n-- before this migration: COMMENTED 3957 / commented 921 — a raw `WHERE state = 'COMMENTED'` missed\n-- 19% of reviews. Three read paths worked around it in application code, and none of those\n-- workarounds travel to the host replica, which is the fleet's only data path.\n--\n-- Both writers are fixed (apps/mirror/src/normalize/github.ts buildReviewRow), but that alone\n-- converges only rows the reconcile re-sweeps — and the reconcile walks OPEN PRs only, so every\n-- lowercase row on an already-closed PR would keep its casing forever. This backfill is what makes\n-- the corpus canonical rather than just the new writes.\n--\n-- ⭐ Deliberately a DATA migration in the SHARED bundle: the DO and every host replica apply the\n-- same MIRROR_MIGRATIONS through the same runner, so each side canonicalises its OWN rows locally.\n-- That converges the replica WITHOUT emitting ~921 change_log deltas, which would otherwise evict\n-- real deltas from the 200k/7d ring (the CTC-279 flood).\n--\n-- Idempotent by construction: upper() of an already-uppercase value is a no-op, and the WHERE clause\n-- makes the write a no-op too. Safe to re-run.\nUPDATE `reviews` SET `state` = upper(`state`) WHERE `state` IS NOT NULL AND `state` <> upper(`state`);\n",
179
+ "0015_brainy_lady_ursula":
180
+ "CREATE TABLE `pull_sweeps` (\n\t`repo_id` text NOT NULL,\n\t`pr_number` integer NOT NULL,\n\t`head_sha` text NOT NULL,\n\t`swept_at` integer NOT NULL,\n\t`check_run_count` integer,\n\t`checks_error` integer,\n\t`commit_status_count` integer,\n\t`statuses_error` integer,\n\t`review_count` integer,\n\t`reviews_error` integer,\n\tPRIMARY KEY(`repo_id`, `pr_number`)\n);\n",
181
+ "0016_vengeful_the_stranger":
182
+ "CREATE TABLE `fleet_activity_pruned` (\n\t`host_id` text NOT NULL,\n\t`ticket` text NOT NULL,\n\t`last_event_ts` integer NOT NULL,\n\t`pruned_at` integer NOT NULL,\n\tPRIMARY KEY(`host_id`, `ticket`)\n);\n",
183
+ "0017_clammy_doctor_octopus": "ALTER TABLE `users` ADD `email` text;",
184
+ "0018_brainy_clint_barton":
185
+ "CREATE TABLE `agent_activities` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`session_id` text NOT NULL,\n\t`type` text,\n\t`body` text,\n\t`signal` text,\n\t`created_at` integer,\n\t`updated_at` integer,\n\t`raw` text\n);\n--> statement-breakpoint\nCREATE INDEX `idx_agent_activities_session_created` ON `agent_activities` (`session_id`,`created_at`);--> statement-breakpoint\nCREATE TABLE `agent_sessions` (\n\t`id` text PRIMARY KEY NOT NULL,\n\t`issue_id` text,\n\t`status` text,\n\t`app_user_id` text,\n\t`creator_id` text,\n\t`created_at` integer,\n\t`updated_at` integer,\n\t`removed_at` integer,\n\t`raw` text\n);\n--> statement-breakpoint\nCREATE INDEX `idx_agent_sessions_issue` ON `agent_sessions` (`issue_id`,`updated_at`);",
98
186
  },
99
187
  } as const;
package/src/mirror.ts CHANGED
@@ -16,7 +16,15 @@
16
16
  // • `check_runs` join a PR by head_sha, NOT PR number.
17
17
  // • PR mergeable / mergeable_state are nullable until a reconcile resolves them.
18
18
 
19
- import { sqliteTable, text, integer, real, primaryKey, index } from "drizzle-orm/sqlite-core";
19
+ import {
20
+ sqliteTable,
21
+ text,
22
+ integer,
23
+ real,
24
+ primaryKey,
25
+ index,
26
+ uniqueIndex,
27
+ } from "drizzle-orm/sqlite-core";
20
28
 
21
29
  // ── Linear ───────────────────────────────────────────────────────────────────────────────────────
22
30
 
@@ -108,6 +116,10 @@ export const users = sqliteTable("users", {
108
116
  avatar_url: text("avatar_url"),
109
117
  type: text("type"), // human | agent | bot | app
110
118
  source: text("source"), // linear | github
119
+ // CTC-506 — only ever populated for source='linear' rows (the roster sweep is the one caller that
120
+ // fetches it; every other upsert site omits the column entirely rather than writing null over it,
121
+ // see users/identity.ts's userRow()). Feeds the registry-side identity join, never read for github.
122
+ email: text("email"),
111
123
  updated_at: integer("updated_at"),
112
124
  });
113
125
 
@@ -189,6 +201,65 @@ export const issue_history = sqliteTable(
189
201
  // progress, health, and presentation (icon/color). `target_date` / `start_date` are Linear
190
202
  // TimelessDates ("YYYY-MM-DD") — stored AS-IS as TEXT, NEVER through isoToMs (the same trap as
191
203
  // issues.due_date). `progress` is a Float (0..1).
204
+ /**
205
+ * CTC-524 — a Linear agent session: one delegated unit of agent work on an issue.
206
+ *
207
+ * ⛔ WHY THIS TABLE EXISTS AT ALL. CTC-499's responder ACKNOWLEDGES an `AgentSessionEvent` inside
208
+ * Linear's 10-second deadline and enqueues it — and the queue then dropped it, because
209
+ * `normalizeLinearEvent`'s switch had no case and fell through to `default: return []`. Acknowledged,
210
+ * queued, normalized to nothing, discarded. So nothing could render a session: CTC-503's own first
211
+ * payload — 14 dead sessions since Aug 10 — lived only in Linear.
212
+ *
213
+ * ⚠️ `status` IS LINEAR'S, NOT OURS. `active` / `awaitingInput` / `complete` / `error` — stored as the
214
+ * provider sends it rather than mapped into a local vocabulary, so a reader can tell "Linear says
215
+ * awaitingInput" from any judgement we layer on top. The abandoned-vs-awaiting distinction CTC-503
216
+ * needs is DERIVED from `status` + `updated_at`, and derivation belongs above this row, not in it.
217
+ */
218
+ export const agent_sessions = sqliteTable(
219
+ "agent_sessions",
220
+ {
221
+ id: text("id").primaryKey(), // Linear agentSession id
222
+ issue_id: text("issue_id"), // FK → issues.id; the session is issue-scoped (no session above it)
223
+ /** Linear's own session status, verbatim. */
224
+ status: text("status"),
225
+ /** The app/user the session belongs to (FK → users.id) — the agent that was delegated to. */
226
+ app_user_id: text("app_user_id"),
227
+ /** Who created it. ⚠️ MEASURED NULL in practice (ARCHITECT-7) — a session created by the app
228
+ * itself has no human creator, which is exactly why CTC-500 needs an explicit target. */
229
+ creator_id: text("creator_id"),
230
+ created_at: integer("created_at"),
231
+ updated_at: integer("updated_at"), // drives the last-write-wins upsert guard
232
+ removed_at: integer("removed_at"),
233
+ raw: text("raw"), // full fidelity / forward-compat, like issues.raw
234
+ },
235
+ (t) => [index("idx_agent_sessions_issue").on(t.issue_id, t.updated_at)],
236
+ );
237
+
238
+ /**
239
+ * CTC-524 — one activity within a session: the thought/action/response/elicitation stream.
240
+ *
241
+ * ⚠️ `type` CARRIES THE NOTIFY SEMANTICS and must not be flattened. `elicitation` and `error` reach a
242
+ * human's Inbox; `thought`/`action`/`response` do not (CTC-501, and Linear's behaviour rather than our
243
+ * preference). A reader that cannot tell an elicitation from a thought cannot tell a question from
244
+ * narration.
245
+ */
246
+ export const agent_activities = sqliteTable(
247
+ "agent_activities",
248
+ {
249
+ id: text("id").primaryKey(),
250
+ session_id: text("session_id").notNull(), // FK → agent_sessions.id
251
+ /** thought | action | response | elicitation | error — Linear's content type, verbatim. */
252
+ type: text("type"),
253
+ body: text("body"),
254
+ /** Linear's `signal` on the activity (e.g. stop), when present. */
255
+ signal: text("signal"),
256
+ created_at: integer("created_at"),
257
+ updated_at: integer("updated_at"),
258
+ raw: text("raw"),
259
+ },
260
+ (t) => [index("idx_agent_activities_session_created").on(t.session_id, t.created_at)],
261
+ );
262
+
192
263
  export const projects = sqliteTable("projects", {
193
264
  id: text("id").primaryKey(),
194
265
  name: text("name"),
@@ -217,6 +288,12 @@ export const cycles = sqliteTable("cycles", {
217
288
  scope: real("scope"), // last(scopeHistory) — current total scope points.
218
289
  completed_issue_count: integer("completed_issue_count"), // last(completedIssueCountHistory).
219
290
  issue_count: integer("issue_count"), // last(issueCountHistory).
291
+ team_id: text("team_id"),
292
+ // CTC-358: denormalized team key + name (off the cycle's `team` ref) so a cycle labels its team —
293
+ // several teams' "Cycle 7" rows are otherwise indistinguishable. Mirrors the issues precedent
294
+ // (issues.team_key/team_name). team_id is KEPT.
295
+ team_key: text("team_key"),
296
+ team_name: text("team_name"),
220
297
  starts_at: integer("starts_at"),
221
298
  ends_at: integer("ends_at"),
222
299
  updated_at: integer("updated_at"),
@@ -300,11 +377,20 @@ export const pull_requests = sqliteTable(
300
377
  milestone_title: text("milestone_title"),
301
378
  updated_at: integer("updated_at"),
302
379
  synced_at: integer("synced_at"),
380
+ // CTC-269 ticket↔PR link: the PR's source branch (now persisted — previously read only for
381
+ // head_sha and dropped at normalize) and the Linear ticket key parsed from the PR title/body
382
+ // (e.g. "CTC-268"). Both nullable (a pre-backfill row, or a PR whose text names no ticket). The
383
+ // identifier backs the reverse lookup that surfaces a ticket's PRs and a PR's ticket — existence
384
+ // is gated at READ time (a false-positive parse never surfaces because no issue matches it).
385
+ head_ref: text("head_ref"),
386
+ linear_issue_identifier: text("linear_issue_identifier"),
303
387
  },
304
388
  (t) => [
305
389
  primaryKey({ columns: [t.repo_id, t.number] }),
306
390
  // CTC-59: the pulls list — `ORDER BY updated_at DESC` (buildPullsView).
307
391
  index("idx_pulls_updated").on(t.updated_at),
392
+ // CTC-269: the ticket→PR reverse lookup — `WHERE linear_issue_identifier = ?` (buildIssueDetail).
393
+ index("idx_pulls_linear_ident").on(t.linear_issue_identifier),
308
394
  ],
309
395
  );
310
396
 
@@ -363,6 +449,38 @@ export const change_log = sqliteTable("change_log", {
363
449
  ts: integer("ts"),
364
450
  });
365
451
 
452
+ /**
453
+ * CTC-294 / ADR-0017: the raw provider-webhook passthrough log — the event-delivery feed that
454
+ * replaces the per-host smee tunnels. DELIBERATELY NOT an entity table: it never rides the
455
+ * change-feed, never appears in /snapshot, and carries its OWN `seq` sequence (consumers of
456
+ * /events/stream track a cursor SEPARATE from the change_log cursor — crossing them is a bug).
457
+ *
458
+ * `payload` is the provider's JSON re-serialized VERBATIM — deliberately un-normalized, so a host
459
+ * can feed it through the same mapper it already applies to a direct webhook. `delivery_id` is the
460
+ * provider's own idempotency key (x-github-delivery / linear-delivery) and is UNIQUE, so a queue
461
+ * re-delivery is an INSERT OR IGNORE no-op rather than a duplicate event on the wire.
462
+ */
463
+ export const event_log = sqliteTable(
464
+ "event_log",
465
+ {
466
+ seq: integer("seq").primaryKey({ autoIncrement: true }),
467
+ delivery_id: text("delivery_id").notNull(),
468
+ source: text("source").notNull(), // github | linear
469
+ event_type: text("event_type").notNull(), // x-github-event value | Linear payload.type
470
+ action: text("action"), // payload.action when the provider sends one
471
+ received_at: integer("received_at").notNull(), // webhook receipt ms (edge clock)
472
+ backend_ts: integer("backend_ts"), // provider event time when supplied (Linear webhookTimestamp)
473
+ payload: text("payload").notNull(), // RAW provider JSON, verbatim ("null" when elided)
474
+ // NULL = payload stored intact. Non-null = the payload exceeded the cap and was ELIDED, and this
475
+ // is its original byte size; the record still ships so the consumer sees an attributable gap.
476
+ payload_omitted_bytes: integer("payload_omitted_bytes"),
477
+ // CTC-296: JSON identity tuple extracted BEFORE an over-cap payload is discarded, so the host
478
+ // can scope its reconcile to one entity instead of sweeping the board. Null unless elided.
479
+ identity: text("identity"),
480
+ },
481
+ (t) => [uniqueIndex("idx_event_log_delivery").on(t.delivery_id)],
482
+ );
483
+
366
484
  export const sync_cursors = sqliteTable(
367
485
  "sync_cursors",
368
486
  {
@@ -373,3 +491,124 @@ export const sync_cursors = sqliteTable(
373
491
  },
374
492
  (t) => [primaryKey({ columns: [t.source, t.entity] })],
375
493
  );
494
+
495
+ /**
496
+ * CTC-350: bounded current-state projection of fleet coordination events — one row per
497
+ * (host_id, ticket). Rides change_log (NOT a new cursor); NOT an infra table (it replicates, so it is
498
+ * in mirrorEntityTables). Folded by reduceFleetActivity (apps/mirror/src/do/fleet-activity.ts);
499
+ * terminal rows pruned on ingest. `updated_at` = the event's last_event_ts, so upsertRow's
500
+ * last-write-wins guard (`excluded.updated_at > stored`) drops stale/replayed events by construction
501
+ * (out-of-order tolerance).
502
+ */
503
+ export const fleet_activity = sqliteTable(
504
+ "fleet_activity",
505
+ {
506
+ host_id: text("host_id").notNull(),
507
+ ticket: text("ticket").notNull(),
508
+ phase: text("phase"),
509
+ status: text("status"),
510
+ disposition: text("disposition"), // non-null == terminal (drives ingest-time prune)
511
+ pr_number: integer("pr_number"),
512
+ pr_url: text("pr_url"),
513
+ orch_id: text("orch_id"),
514
+ last_event_name: text("last_event_name"),
515
+ last_event_ts: integer("last_event_ts"), // ms epoch — the reducer watermark
516
+ updated_at: integer("updated_at"), // ms epoch — mirrors last_event_ts; upsertRow guard column
517
+ },
518
+ (t) => [primaryKey({ columns: [t.host_id, t.ticket] })],
519
+ );
520
+
521
+ /**
522
+ * CTC-393 — a tombstone of the last `last_event_ts` a (host_id, ticket) reached TERMINAL disposition
523
+ * at, kept for a while AFTER `fleet_activity`'s own row is hard-pruned.
524
+ *
525
+ * ⛔ THE BUG THIS CLOSES. `fleet_activity` has no soft-delete column — a terminal row past its
526
+ * retention window is genuinely DELETEd, not marked removed. Without this table, a stale/delayed
527
+ * pre-terminal event arriving AFTER that deletion finds no existing row (`readFleetRow` → null), the
528
+ * reducer treats that as "first seen", and a DEAD ticket is silently RESURRECTED as an active row
529
+ * with no disposition — orphaned forever, since nothing prunes an active row.
530
+ *
531
+ * Bounded the same way `fleet_activity` itself is bounded: a tombstone is written once per prune and
532
+ * pruned again after its own retention window (see `fleet-activity.ts`'s prune step), so this table
533
+ * never grows past "recently terminal tickets, twice over" — never proportional to all-time history.
534
+ */
535
+ export const fleet_activity_pruned = sqliteTable(
536
+ "fleet_activity_pruned",
537
+ {
538
+ host_id: text("host_id").notNull(),
539
+ ticket: text("ticket").notNull(),
540
+ // The watermark the deleted row reached terminal at — a later event must beat THIS, not just
541
+ // "any row", to be accepted as a genuine reopening rather than a stale replay.
542
+ last_event_ts: integer("last_event_ts").notNull(),
543
+ pruned_at: integer("pruned_at").notNull(), // ms epoch — when the live row was deleted; ages this out
544
+ },
545
+ (t) => [primaryKey({ columns: [t.host_id, t.ticket] })],
546
+ );
547
+
548
+ /**
549
+ * ⭐ THE PER-PR ENRICHMENT RECEIPT — the row that makes "this PR has no CI" distinguishable from
550
+ * "nobody ever swept this PR". CTC-436 follow-up.
551
+ *
552
+ * ⛔ THE DEFECT. The GitHub reconcile writes one `check_runs` row per run GitHub returns and writes
553
+ * NOTHING when it returns `[]`. So a PR with no CI configured and a PR the walk never reached produce
554
+ * the IDENTICAL observation — zero rows — and no column anywhere separates them. Measured: 4 of a
555
+ * repo's 20 newest open PRs had zero check rows and nothing could say which kind of zero they were.
556
+ * `synced_at` does not help: it lives on `pull_requests` and is stamped by step 1's PR sweep, which
557
+ * runs for EVERY PR whether or not the enrichment walk ever reached it.
558
+ *
559
+ * ⭐ HOW A READER DECODES IT — three states, and the third is the one that used to be unrepresentable:
560
+ * • no row for (repo_id, pr_number) → NEVER swept. Not "no CI".
561
+ * • row.head_sha <> pull_requests.head_sha → never swept AT THIS SHA; a push invalidated the
562
+ * receipt. (This is why a bare timestamp is a LIE:
563
+ * step 1 advances head_sha every pass, so a stale
564
+ * per-PR clock reads as "swept" at a SHA nobody
565
+ * ever looked at.)
566
+ * • row.head_sha matches AND check_run_count = 0 → SWEPT, and GitHub genuinely reported zero.
567
+ * • row.head_sha matches AND check_run_count IS NULL
568
+ * AND checks_error = 422 → the fetch FAILED. ⛔ NOT a zero.
569
+ *
570
+ * ⛔ A COUNT COLUMN IS NULLABLE ON PURPOSE AND NULL IS NEVER 0. After CTC-436 each enrichment stage
571
+ * CATCHES an object-scoped 4xx and continues with the empty array it was initialised to, and the
572
+ * resume cursor advances anyway. A receipt that recorded `0` there would be a FORGED PROOF — "swept,
573
+ * source reported zero", with a fresh clock, for a PR nobody successfully swept — which is strictly
574
+ * worse than the missing receipt, because a merge gate would read it as verified-clean. The `*_error`
575
+ * columns carry the HTTP status so the absence states its reason instead of just being absent.
576
+ *
577
+ * ⛔ THIS TABLE MUST NEVER RIDE THE CHANGE FEED — a load-bearing decision, with arithmetic. The sweep
578
+ * clock advances on EVERY pass, so feeding it would emit one delta per open PR per pass:
579
+ * ~7 repos × ~30 open PRs × ~288 passes/day ≈ 60k deltas/day against a 200k-row / 7-day change_log
580
+ * ring. The ring would hold under a day, every host replica's `/changes` cursor would fall off the
581
+ * end, and the fleet would resync-storm. It is therefore deliberately NOT in `mirrorEntityTables`
582
+ * (so it has no MIRROR_TABLE_META, no `EntityName`, no PK_COLUMNS entry), NOT in SNAPSHOT_TABLES,
583
+ * NOT in GITHUB_ENTITIES, and nothing ever calls `appendChange` for it. It is a HUB-READ-ONLY table,
584
+ * the same posture as `sync_cursors` / `event_log`. Do not "finish the job" by wiring it to the feed.
585
+ *
586
+ * No pruner: it is self-limiting at |pull_requests| — one row per PR, overwritten in place.
587
+ *
588
+ * ⚠️ NO `updated_at` COLUMN, DELIBERATELY. `upsertRow` applies its last-write-wins guard
589
+ * (`WHERE excluded.updated_at > stored.updated_at`) iff the row object carries an `updated_at` key.
590
+ * Here the advancing clock IS the payload, so a guard could only ever reject the write that proves
591
+ * freshness — and two sweeps landing in the same millisecond would silently drop the second, which is
592
+ * exactly the write that carries a new `head_sha`. The clock is named `swept_at` so no future edit
593
+ * re-acquires that guard by renaming a column.
594
+ */
595
+ export const pull_sweeps = sqliteTable(
596
+ "pull_sweeps",
597
+ {
598
+ repo_id: text("repo_id").notNull(),
599
+ pr_number: integer("pr_number").notNull(),
600
+ /** The head SHA the enrichment walk actually swept — NOT re-read from pull_requests. */
601
+ head_sha: text("head_sha").notNull(),
602
+ /** When the walk reached this PR (ms). Advances only when a walk actually ran. */
603
+ swept_at: integer("swept_at").notNull(),
604
+ /** Runs GitHub RETURNED (not rows changed). NULL ⇒ the fetch did not succeed — never 0. */
605
+ check_run_count: integer("check_run_count"),
606
+ /** HTTP status when the check-runs fetch failed; NULL when it succeeded. */
607
+ checks_error: integer("checks_error"),
608
+ commit_status_count: integer("commit_status_count"),
609
+ statuses_error: integer("statuses_error"),
610
+ review_count: integer("review_count"),
611
+ reviews_error: integer("reviews_error"),
612
+ },
613
+ (t) => [primaryKey({ columns: [t.repo_id, t.pr_number] })],
614
+ );
package/src/skew.ts ADDED
@@ -0,0 +1,181 @@
1
+ // CTC-471 — "is this consumer's schema bundle older than mine?", answered from the bundle a consumer
2
+ // ACTUALLY LOADED rather than from what its lockfile claims.
3
+ //
4
+ // THE DEFECT THIS CLOSES. A host ran `@catalyst-cloud/schema@0.1.3` for 21+ days while the published
5
+ // latest was 0.1.5. Every column added since 0.1.3 — `head_ref`, `linear_issue_identifier` — was
6
+ // dropped from every replica upsert, and the host reported healthy the entire time: the replica's
7
+ // migration tail and the installed bundle's journal tail agreed (`0008_optimal_rattler`), because the
8
+ // host was faithfully applying the stale bundle it had. Nothing anywhere compared that tail to the
9
+ // hub's.
10
+ //
11
+ // ⛔ WHY A VERSION STRING CANNOT BE THE ANSWER. The obvious fix — report `package.json`'s version and
12
+ // compare semver — fails on the exact fleet that motivated the ticket, for three independent reasons:
13
+ //
14
+ // 1. `bun.lock` pins a RESOLUTION, not a range. `^0.1.3` would admit 0.1.5, but the lock records
15
+ // 0.1.3 exactly, so `bun install` re-installs 0.1.3 deterministically, forever.
16
+ // 2. A nested `node_modules` SHADOWS the root install. This fleet has already run one SDK version
17
+ // while the lockfile claimed another — so an install-time check would have PASSED while the
18
+ // replica sat at 0008.
19
+ // 3. The daemon holds old code in memory; anything on disk is inert until restart.
20
+ //
21
+ // Every one of those is a gap between LOCKED and LOADED. So the reported value here is derived from
22
+ // the `MIRROR_MIGRATIONS` array **at runtime**, in the same module graph that will do the applying. A
23
+ // shadowed install reports the shadowed bundle's tail, because that is genuinely the bundle whose
24
+ // columns the replica will have. That property is the whole point — see {@link loadedSchemaIdentity}.
25
+ //
26
+ // ⛔ AND "DID NOT REPORT" IS NOT "UP TO DATE". `unreported` is its own state and must never fold into
27
+ // `current`. CTC-479 cost 27 hours precisely because a null read as a clean bill of health: an
28
+ // absence with no reason is indistinguishable from proven-healthy, and an operator (and an agent
29
+ // actively hunting the bug) both read it as fine. The invariant `team-liveness.ts` states for the
30
+ // Linear legs — "every way of not proving liveness resolves to a RECORDED REASON, never to a clean
31
+ // bill of health inferred from silence" — is the same invariant, one layer up.
32
+
33
+ import { MIRROR_MIGRATIONS } from "./migrations.generated.js";
34
+ import type { MigrationBundle } from "./migrate.js";
35
+
36
+ /**
37
+ * What a consumer reports about the migration bundle it LOADED — not the one it installed, locked, or
38
+ * intended.
39
+ *
40
+ * `count` is not redundant with `tail`: it is what distinguishes a consumer running AHEAD of the hub
41
+ * (a newer bundle, whose tail the hub has never heard of) from a consumer reporting garbage (a tail
42
+ * the hub has never heard of, from a bundle no bigger than its own). Without it both collapse into
43
+ * "unrecognized" and a mid-deploy fleet looks broken.
44
+ */
45
+ export interface SchemaIdentity {
46
+ /** The last journal tag in the loaded bundle, e.g. `0015_brainy_lady_ursula`. Null iff empty. */
47
+ tail: string | null;
48
+ /** How many migrations the loaded bundle carries. */
49
+ count: number;
50
+ }
51
+
52
+ /**
53
+ * The hub's verdict on one consumer's reported identity.
54
+ *
55
+ * ⚠️ Five states, deliberately. Collapsing any of them into a boolean recreates the defect: a
56
+ * consumer that is behind, a consumer that never reported, and a consumer whose bundle the hub cannot
57
+ * place are three DIFFERENT operational situations, and exactly one of them (`current`) is healthy.
58
+ */
59
+ export type SchemaSkew =
60
+ /** The consumer's tail is the hub's tail. The only healthy state. */
61
+ | { state: "current"; tail: string }
62
+ /** The consumer is missing migrations the hub already has — it WILL drop those columns. */
63
+ | { state: "behind"; tail: string; missing: string[] }
64
+ /**
65
+ * The consumer carries migrations the hub does not — normal and transient mid-deploy (a host
66
+ * upgraded first), so it is NOT an error, but it is not `current` either and must stay visible.
67
+ */
68
+ | { state: "ahead"; tail: string; extra: number }
69
+ /**
70
+ * The consumer reported a tail the hub cannot place, from a bundle no larger than its own. Neither
71
+ * behind nor ahead: the question is unanswerable, and the unanswerable-ness is what gets recorded.
72
+ */
73
+ | { state: "unrecognized"; tail: string }
74
+ /**
75
+ * ⛔ The consumer reported nothing. NOT healthy, NOT `current` — see the header. Pre-CTC-471 SDKs
76
+ * land here, which is correct: the hub genuinely does not know what they are running.
77
+ */
78
+ | { state: "unreported" };
79
+
80
+ /**
81
+ * The identity of the bundle THIS process actually loaded.
82
+ *
83
+ * ⭐ Derived from the imported `MIRROR_MIGRATIONS` array, never from `package.json`, a lockfile, or a
84
+ * build-time constant. That is what makes it a LOADED fact rather than a LOCKED claim: if a nested
85
+ * `node_modules` shadows the root install, this returns the shadowing bundle's tail — which is
86
+ * genuinely the schema the replica will have, and genuinely what the hub needs to know.
87
+ *
88
+ * The default argument exists so tests can pass a synthetic bundle; production callers pass nothing.
89
+ */
90
+ export function loadedSchemaIdentity(bundle: MigrationBundle = MIRROR_MIGRATIONS): SchemaIdentity {
91
+ const entries = bundle.journal.entries;
92
+ const last = entries.length > 0 ? entries[entries.length - 1] : undefined;
93
+ return { tail: last?.tag ?? null, count: entries.length };
94
+ }
95
+
96
+ /**
97
+ * Compare a consumer's reported identity against the hub's own bundle.
98
+ *
99
+ * `reported` is deliberately typed to accept `null | undefined`: a consumer that predates CTC-471
100
+ * sends nothing, and the caller must not have to invent a value for it. That path returns
101
+ * `unreported`, never `current`.
102
+ */
103
+ export function schemaSkew(
104
+ reported: SchemaIdentity | null | undefined,
105
+ bundle: MigrationBundle = MIRROR_MIGRATIONS,
106
+ ): SchemaSkew {
107
+ if (reported == null || reported.tail == null) return { state: "unreported" };
108
+
109
+ const tags = bundle.journal.entries.map((e) => e.tag);
110
+ const idx = tags.indexOf(reported.tail);
111
+
112
+ if (idx === -1) {
113
+ // Not in our journal. A LARGER bundle than ours is a consumer that upgraded first — expected
114
+ // during a rolling deploy. A same-or-smaller bundle we cannot place is genuinely unrecognized.
115
+ if (reported.count > tags.length) {
116
+ return { state: "ahead", tail: reported.tail, extra: reported.count - tags.length };
117
+ }
118
+ return { state: "unrecognized", tail: reported.tail };
119
+ }
120
+
121
+ // ⚠️ The tail alone is not enough to trust `current`/`behind` — see the doc on `SchemaIdentity.count`.
122
+ // A tail that matches our journal but a count that DISAGREES with that tail's position is an
123
+ // internally-inconsistent report (a corrupted client, or a future bug elsewhere), not a verified
124
+ // one: it cannot have come from actually walking this bundle. Codex flagged this — a mismatched
125
+ // count at the LAST tag would otherwise still read `current`, recreating the exact false-clean-bill
126
+ // condition this ticket exists to close.
127
+ if (reported.count !== idx + 1) return { state: "unrecognized", tail: reported.tail };
128
+
129
+ if (idx === tags.length - 1) return { state: "current", tail: reported.tail };
130
+
131
+ return { state: "behind", tail: reported.tail, missing: tags.slice(idx + 1) };
132
+ }
133
+
134
+ /**
135
+ * Is this verdict one an operator must act on? `behind` and `unrecognized` mean the replica cannot
136
+ * represent what the hub is sending. `unreported` is included ON PURPOSE — an unknown consumer is a
137
+ * thing to chase, not a thing to assume fine.
138
+ *
139
+ * ⚠️ `ahead` is excluded: it is expected mid-deploy and alerting on it would train operators to
140
+ * ignore this signal, which is strictly worse than the silence it replaces (the same argument
141
+ * `team-liveness.ts` makes for not black-flagging a quiet workspace).
142
+ */
143
+ export function schemaSkewNeedsAttention(skew: SchemaSkew): boolean {
144
+ return skew.state === "behind" || skew.state === "unrecognized" || skew.state === "unreported";
145
+ }
146
+
147
+ /**
148
+ * CTC-393 — does a consumer's LOADED bundle include a specific migration (by journal tag)?
149
+ *
150
+ * ⭐ This is a different, more precise question than {@link schemaSkew}'s five-state verdict. A host
151
+ * can be `behind` the hub's exact tail (missing a LATER, unrelated migration) while still safely
152
+ * having every column a SPECIFIC earlier migration added — `schemaSkew` alone would wrongly treat it
153
+ * as ineligible. Use this when the question is "does this consumer support entity X", not "is this
154
+ * consumer on our exact tail" — e.g. gating a newly-replicated entity to only the hosts whose bundle
155
+ * already contains the migration that created its table, regardless of what shipped after.
156
+ *
157
+ * Reuses the identical journal-position lookup `schemaSkew` uses internally, so a host that reports
158
+ * nothing (`reported == null`) or a tail this hub cannot place resolves to `false` — the safe
159
+ * default-EXCLUDE this ticket requires, with no separate "unreported" branch to get wrong twice.
160
+ *
161
+ * `requiredTag` is asserted to exist in `bundle`'s own journal — a typo'd or since-renumbered tag
162
+ * would otherwise silently exclude EVERY consumer forever with no signal anything is wrong.
163
+ */
164
+ export function schemaIncludesMigration(
165
+ reported: SchemaIdentity | null | undefined,
166
+ requiredTag: string,
167
+ bundle: MigrationBundle = MIRROR_MIGRATIONS,
168
+ ): boolean {
169
+ const tags = bundle.journal.entries.map((e) => e.tag);
170
+ const requiredIdx = tags.indexOf(requiredTag);
171
+ if (requiredIdx === -1) {
172
+ throw new Error(`schemaIncludesMigration: "${requiredTag}" is not in this bundle's journal`);
173
+ }
174
+ if (reported == null || reported.tail == null) return false;
175
+ const reportedIdx = tags.indexOf(reported.tail);
176
+ // Not in our journal at all: only a LARGER unrecognized bundle (a rolling-deploy "ahead" consumer,
177
+ // per schemaSkew's own reasoning) can be trusted to include an old migration; a same-or-smaller
178
+ // unplaceable tail is unrecognized garbage, never assumed to include anything.
179
+ if (reportedIdx === -1) return reported.count > tags.length;
180
+ return reportedIdx >= requiredIdx;
181
+ }