@catalyst-cloud/schema 0.1.44 → 0.1.46

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.44",
3
+ "version": "0.1.46",
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",
@@ -76,6 +76,27 @@ export const durableEventTypes = {
76
76
  ingestVerb: "coordination.publish",
77
77
  wakes: false,
78
78
  },
79
+ // CTC-838 — a long-lived coordination agent (concierge/steward/worker) announcing which ROLE it
80
+ // holds, so the fleet's own agent_roster (apps/mirror/src/do/agent-roster.ts) can answer "who holds
81
+ // which role, and are they alive" without inventing a heartbeat-shaped table of its own. See
82
+ // ADR-20260903T011701 for why this is observational only.
83
+ "agent.role.started": {
84
+ rationale:
85
+ "A long-lived coordination agent announced the ROLE it holds (concierge / steward / worker) and, when it has one, its parent — a discrete boundary fact, not a sampled reading. The paired terminal fact is agent.role.stopped.",
86
+ ingestVerb: "coordination.publish",
87
+ // ⛔ CTC-838 / ADR-20260903T011701 — a roster row is observational only. Waking a dispatcher on it would
88
+ // make a heartbeat-derived signal load-bearing for scheduling, which this repo's lease and
89
+ // entitlement planes explicitly refuse (lease-verbs.ts:10-13).
90
+ wakes: false,
91
+ entity: "agent",
92
+ },
93
+ "agent.role.stopped": {
94
+ rationale:
95
+ "A coordination agent released its role — the paired terminal fact to agent.role.started, and the only thing that distinguishes 'went away cleanly' from 'stopped pulsing'.",
96
+ ingestVerb: "coordination.publish",
97
+ wakes: false,
98
+ entity: "agent",
99
+ },
79
100
  "agent.waiting-on-user": {
80
101
  rationale:
81
102
  "The agent is now blocked awaiting human input — a coordination fact a host must act on.",
@@ -770,6 +791,20 @@ export const durableEventTypes = {
770
791
  wakes: false,
771
792
  entity: "ticket",
772
793
  },
794
+ "phase.merge.complete": {
795
+ rationale:
796
+ "ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the phase reported success.",
797
+ ingestVerb: "coordination.publish",
798
+ wakes: false,
799
+ entity: "ticket",
800
+ },
801
+ "phase.merge.failed": {
802
+ rationale:
803
+ "ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the phase reported failure.",
804
+ ingestVerb: "coordination.publish",
805
+ wakes: false,
806
+ entity: "ticket",
807
+ },
773
808
  "phase.merge.park": {
774
809
  rationale:
775
810
  "ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the merge phase was PARKED after N consecutive failures (CTC-1270) — a terminal-until-released outcome, distinct from `phase.merge.failed`, which a reader must be able to tell apart. Paired with `phase.merge.revive`.",
@@ -1141,6 +1176,13 @@ export const durableEventTypes = {
1141
1176
  wakes: false,
1142
1177
  entity: "ticket",
1143
1178
  },
1179
+ "phase.remediate.blocked": {
1180
+ rationale:
1181
+ "ADR-0039 durable-fact example: an escalated remediate round's exhaustion (or its own declared block) raised a catalyst-ask decision ticket that BLOCKS the remediated ticket (CTC-1373) — a confirmed, terminal-until-answered designation, distinct from `phase.remediate.escalation-exhausted`, which merely requested it.",
1182
+ ingestVerb: "coordination.publish",
1183
+ wakes: false,
1184
+ entity: "ticket",
1185
+ },
1144
1186
  "phase.remediate.boot-resume": {
1145
1187
  rationale:
1146
1188
  "ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the daemon restarted and resumed this phase — a real coordination fact about continuity across a restart.",
@@ -1162,6 +1204,20 @@ export const durableEventTypes = {
1162
1204
  wakes: false,
1163
1205
  entity: "ticket",
1164
1206
  },
1207
+ "phase.remediate.escalated": {
1208
+ rationale:
1209
+ "ADR-0039 durable-fact example: a remediate round's cap-exhaustion escalated to a second, broader-purview attempt instead of parking (CTC-1373) — a distinct lifecycle transition from an ordinary `phase.remediate.failed`, and one that creates newly claimable work.",
1210
+ ingestVerb: "coordination.publish",
1211
+ wakes: { consumerClass: "dispatcher" },
1212
+ entity: "ticket",
1213
+ },
1214
+ "phase.remediate.escalation-exhausted": {
1215
+ rationale:
1216
+ "ADR-0039 durable-fact example: the escalated (broader-purview) remediate round also failed, or declared itself blocked (CTC-1373) — the terminal signal that requests a blocking ASK ticket be raised; see the paired `phase.remediate.blocked` once one is confirmed.",
1217
+ ingestVerb: "coordination.publish",
1218
+ wakes: false,
1219
+ entity: "ticket",
1220
+ },
1165
1221
  "phase.remediate.failed": {
1166
1222
  rationale:
1167
1223
  "ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the phase reported failure.",
@@ -1211,6 +1267,20 @@ export const durableEventTypes = {
1211
1267
  wakes: false,
1212
1268
  entity: "ticket",
1213
1269
  },
1270
+ "phase.remediate.rewind": {
1271
+ rationale:
1272
+ "ADR-0039 durable-fact example: a successful remediate round's own ladder-rewind request was applied (CTC-1373) — earlier relay_phase_state rows were reset to `ready`, creating newly claimable work for the reset span.",
1273
+ ingestVerb: "coordination.publish",
1274
+ wakes: { consumerClass: "dispatcher" },
1275
+ entity: "ticket",
1276
+ },
1277
+ "phase.remediate.rewind-requested": {
1278
+ rationale:
1279
+ "ADR-0039 durable-fact example: a successful remediate round recommended the ladder start over rather than advance (CTC-1373's adjudication) — a request the mirror applies under its own fence and budget; see the paired `phase.remediate.rewind` once applied.",
1280
+ ingestVerb: "coordination.publish",
1281
+ wakes: false,
1282
+ entity: "ticket",
1283
+ },
1214
1284
  "phase.rescue.dispatched": {
1215
1285
  rationale:
1216
1286
  "ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): work became eligible and was dispatched to a worker.",
@@ -1512,6 +1582,20 @@ export const durableEventTypes = {
1512
1582
  wakes: false,
1513
1583
  entity: "ticket",
1514
1584
  },
1585
+ "phase.validate.complete": {
1586
+ rationale:
1587
+ "ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the phase reported success.",
1588
+ ingestVerb: "coordination.publish",
1589
+ wakes: false,
1590
+ entity: "ticket",
1591
+ },
1592
+ "phase.validate.failed": {
1593
+ rationale:
1594
+ "ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the phase reported failure. CTC-1541 — absent until now, so the record the runner has always published was dropped at deriveDurableCoordinationEvents's classification filter and never reached phase-failure-trigger.ts; ADR-0069 D3's 'every per-ticket failure drives the trigger' was not true for validate.",
1595
+ ingestVerb: "coordination.publish",
1596
+ wakes: false,
1597
+ entity: "ticket",
1598
+ },
1515
1599
  "phase.validate.park": {
1516
1600
  rationale:
1517
1601
  "ADR-0039 durable-fact example ('a phase started, completed, failed, or was preempted'): the validate phase was PARKED after N consecutive failures (CTC-1270) — a terminal-until-released outcome, distinct from `phase.validate.failed`, which a reader must be able to tell apart. Paired with `phase.validate.revive`.",
@@ -1861,6 +1945,13 @@ export interface CurrentStateEventSpec {
1861
1945
  * for zero replay value.
1862
1946
  */
1863
1947
  export const currentStateEventTypes = {
1948
+ // CTC-838 — the pulse half of the agent-role pair above: updates the agent_roster row's
1949
+ // last_seen_at in place rather than growing the durable history on every pulse.
1950
+ "agent.role.heartbeat": {
1951
+ rationale:
1952
+ "A coordination agent's role liveness pulse — updates mutable NOW (the agent_roster row's last_seen_at); agent.role.started/stopped are the durable boundary facts.",
1953
+ boundaryEvents: ["agent.role.started", "agent.role.stopped"],
1954
+ },
1864
1955
  "broker.daemon.heartbeat": {
1865
1956
  rationale:
1866
1957
  "The coordination broker daemon's own liveness pulse — updates mutable NOW; startup/shutdown are the durable boundary facts.",
@@ -1978,6 +2069,11 @@ export const telemetryEventTypes = {
1978
2069
  reason:
1979
2070
  "A replica read falling back to a secondary path — an internal routing diagnostic, not a ticket/phase coordination fact.",
1980
2071
  },
2072
+ "catalyst.runner.egress.policy.applied": {
2073
+ reason:
2074
+ "CTC-1501 — one point per RunnerDispatchDO `/ensure` egress-policy application, `outcome` carrying ok (applied) / error (observe-mode swallow, `detail` naming the classified reason) / skip (RUNNER_EGRESS_MODE=off). Gauge/ratio-shaped: the whole value is the rollup of error-over-total, which is the denominator the ticket's own 'how often, versus container starts per hour' question needs — no individual /ensure poll has identity. The durable fact a failed application produces elsewhere is the phase outcome's egress: unpoliced attribute (loop.ts), not this point.",
2075
+ sampling: { kind: "counted" },
2076
+ },
1981
2077
  "catalyst.service.health": { reason: "A periodic service health-check gauge." },
1982
2078
  "ci.run.completed": {
1983
2079
  reason:
@@ -45,6 +45,21 @@ export interface PhaseLifecycleFields {
45
45
  artifactKey?: string;
46
46
  durationMs?: number;
47
47
  reason?: string;
48
+ /** CTC-1417 — the Linear team key. Optional here (an ergonomic caller-facing input, unlike
49
+ * TelemetryPoint's wire shape): when omitted, {@link buildPhaseLifecyclePoint} derives it from
50
+ * `ticket` via {@link teamFromTicket}. An explicit value always wins over the derivation. */
51
+ team?: string;
52
+ /** CTC-1417 — `owner/name`. Optional; a caller with none in scope leaves the point's `repo: null`
53
+ * rather than fabricating one. */
54
+ repo?: string;
55
+ }
56
+
57
+ /** The Linear team key is the ticket identifier's prefix by construction (`CTC-1390` → `CTC`) — the
58
+ * same convention apps/mirror/src/do/ci-run-telemetry.ts's TICKET_SHAPE already depends on. Returns
59
+ * null, NEVER a guess, for anything that is not identifier-shaped. */
60
+ export function teamFromTicket(ticket: string): string | null {
61
+ const m = /^([A-Z][A-Z0-9]{1,9})-\d+$/.exec(ticket);
62
+ return m === null ? null : m[1]!;
48
63
  }
49
64
 
50
65
  /**
@@ -75,5 +90,8 @@ export function buildPhaseLifecyclePoint(fields: PhaseLifecycleFields): Telemetr
75
90
  traceId: `${fields.ticket}:${fields.phase}:${fields.nonce}`,
76
91
  durationMs: fields.durationMs ?? null,
77
92
  count: 1,
93
+ projectId: null,
94
+ repo: fields.repo ?? null,
95
+ team: fields.team ?? teamFromTicket(fields.ticket),
78
96
  };
79
97
  }
@@ -18,26 +18,31 @@ export const MAX_POINTS_PER_BATCH = 25;
18
18
  export const MAX_INDEX_BYTES = 96;
19
19
  /** ClickHouse/AE per-blob byte cap. The bounded excerpt (`detail`) is budgeted against this. */
20
20
  export const MAX_EXCERPT_BYTES = 1_024;
21
+ /** AE caps each blob at 1 KiB individually and the blob SUM at ~5 KiB. `detail` owns the 1 KiB budget;
22
+ * a dimension blob (`project_id`/`repo`/`team`) gets 256 — far above any real `owner/name` (GitHub:
23
+ * 39 + 100 chars) or Linear team key, and small enough that three of them cannot push a row over the
24
+ * sum cap (CTC-1417 D4). */
25
+ export const MAX_DIMENSION_BYTES = 256;
21
26
 
22
27
  function byteLen(s: string): number {
23
28
  return new TextEncoder().encode(s).length;
24
29
  }
25
30
 
26
31
  /**
27
- * Bound a would-be-blob string to the AE per-blob cap, appending a stable elision marker that names
28
- * how many bytes were dropped. Truncation is on a UTF-8 CHARACTER boundary: a naive byte slice can
29
- * split a multi-byte sequence, so the kept bytes are decoded leniently and a trailing partial
30
- * (U+FFFD) is dropped so the excerpt is always valid text AND stays under the byte budget. Lifted
31
- * here (CTC-962) from apps/mirror/src/event-stream/validate.ts, which now imports this copy instead
32
- * of carrying its own — one truncator, not two that could drift.
32
+ * Bound a would-be-blob string to `cap` (default the AE per-blob cap), appending a stable elision
33
+ * marker that names how many bytes were dropped. Truncation is on a UTF-8 CHARACTER boundary: a naive
34
+ * byte slice can split a multi-byte sequence, so the kept bytes are decoded leniently and a trailing
35
+ * partial (U+FFFD) is dropped so the excerpt is always valid text AND stays under the byte budget.
36
+ * Lifted here (CTC-962) from apps/mirror/src/event-stream/validate.ts, which now imports this copy
37
+ * instead of carrying its own — one truncator, not two that could drift.
33
38
  */
34
- export function boundExcerpt(raw: string): string {
39
+ export function boundExcerpt(raw: string, cap: number = MAX_EXCERPT_BYTES): string {
35
40
  const bytes = byteLen(raw);
36
- if (bytes <= MAX_EXCERPT_BYTES) return raw;
41
+ if (bytes <= cap) return raw;
37
42
  // Reserve marker room using the WORST-CASE omitted count (the whole payload); the real marker is
38
- // never longer, so the final excerpt is guaranteed ≤ MAX_EXCERPT_BYTES.
43
+ // never longer, so the final excerpt is guaranteed ≤ cap.
39
44
  const markerReserve = byteLen(`…[+${bytes} bytes]`);
40
- const keepBudget = Math.max(0, MAX_EXCERPT_BYTES - markerReserve);
45
+ const keepBudget = Math.max(0, cap - markerReserve);
41
46
  const kept = new TextEncoder().encode(raw).slice(0, keepBudget);
42
47
  // Lenient decode → a split trailing multi-byte becomes U+FFFD; strip it so output is valid + smaller.
43
48
  let decoded = new TextDecoder().decode(kept);
@@ -46,6 +51,20 @@ export function boundExcerpt(raw: string): string {
46
51
  return `${decoded}…[+${omitted} bytes]`;
47
52
  }
48
53
 
54
+ /**
55
+ * The ONE builder for an AE `indexes` array in this repo (CTC-1417 D8). Every lane fences on exactly
56
+ * one low-cardinality value — the tenant — so "every stream is indexed by the same account_id" is a
57
+ * property of CONSTRUCTION, not a convention five modules separately honour (which is how
58
+ * catalyst_runner_egress once shipped with no index at all, CTC-1528). THROWS on an over-cap id rather
59
+ * than truncating: a truncated tenant id is a plausible-looking WRONG fence.
60
+ */
61
+ export function aeAccountIndexes(accountId: string): string[] {
62
+ if (byteLen(accountId) > MAX_INDEX_BYTES) {
63
+ throw new Error(`accountId exceeds ${MAX_INDEX_BYTES}-byte AE index cap`);
64
+ }
65
+ return [accountId];
66
+ }
67
+
49
68
  /** Conservative default for a telemetry name with no explicit `sampling` spec — never "unbounded".
50
69
  * 70 pre-existing fleet/orchestrator names carry no per-name budget yet (see registry.ts); defaulting
51
70
  * them to `always` would import their full volume into the hot tier the moment anything emits them. */
@@ -76,6 +95,13 @@ export interface TelemetryPoint {
76
95
  readonly durationMs: number | null;
77
96
  /** >1 when this point is a collapsed repeat (see `collapse`). */
78
97
  readonly count: number;
98
+ /** ADR-0011 dimensions, promoted to typed fields so BOTH legs carry them (ADR-0048 D3: one
99
+ * vocabulary, two volumes; CTC-1417). `null` — never absent, never the string "null" — when the
100
+ * emitting surface genuinely has no such value. Required-but-nullable ON PURPOSE: an optional field
101
+ * lets a call site forget one; a nullable one makes it STATE that it has none. */
102
+ readonly projectId: string | null;
103
+ readonly repo: string | null;
104
+ readonly team: string | null;
79
105
  }
80
106
 
81
107
  /**
@@ -114,14 +140,26 @@ export interface TelemetryAeDataPoint {
114
140
  * event-stream/sql-proxy.ts's `doubles:[seq]` already uses. Encoding an unknown duration as `0`
115
141
  * (the previous behavior) would have AE and the hot-leg feed report a false zero-latency instead of
116
142
  * "no duration recorded", indistinguishable from a real fast operation.
143
+ *
144
+ * Blobs 1-5 (`service, name, outcome, traceId, detail`) are FROZEN — the read proxy is positional.
145
+ * Blobs 6-8 (`project_id, repo, team`, CTC-1417) are APPENDED: an absent dimension emits an empty
146
+ * string, never a dropped slot, so position never shifts.
117
147
  */
118
148
  export function toTelemetryDataPoint(p: TelemetryPoint, accountId: string): TelemetryAeDataPoint {
119
- if (byteLen(accountId) > MAX_INDEX_BYTES) {
120
- throw new Error(`accountId exceeds ${MAX_INDEX_BYTES}-byte AE index cap`);
121
- }
149
+ const dimBlob = (v: string | null): string =>
150
+ v === null ? "" : boundExcerpt(v, MAX_DIMENSION_BYTES);
122
151
  return {
123
- indexes: [accountId],
124
- blobs: [p.service, p.name, p.outcome, p.traceId, boundExcerpt(p.detail)],
152
+ indexes: aeAccountIndexes(accountId),
153
+ blobs: [
154
+ p.service,
155
+ p.name,
156
+ p.outcome,
157
+ p.traceId,
158
+ boundExcerpt(p.detail),
159
+ dimBlob(p.projectId),
160
+ dimBlob(p.repo),
161
+ dimBlob(p.team),
162
+ ],
125
163
  doubles: p.durationMs == null ? [p.count] : [p.count, p.durationMs],
126
164
  };
127
165
  }
@@ -142,6 +180,9 @@ export interface TelemetryStreamRecord {
142
180
  readonly detail: string | null;
143
181
  readonly duration_ms: number | null;
144
182
  readonly count: number;
183
+ readonly project_id: string | null;
184
+ readonly repo: string | null;
185
+ readonly team: string | null;
145
186
  readonly [key: string]: unknown;
146
187
  }
147
188
 
@@ -162,21 +203,29 @@ export function toTelemetryStreamRecord(
162
203
  detail: p.detail ? boundExcerpt(p.detail) : null,
163
204
  duration_ms: p.durationMs,
164
205
  count: p.count,
206
+ project_id: p.projectId,
207
+ repo: p.repo,
208
+ team: p.team,
165
209
  };
166
210
  }
167
211
 
168
212
  /**
169
- * Fold points sharing an identical (name, outcome) pair into a single point carrying the summed
170
- * count and the FIRST traceId seen for that pair — the direct answer to ADR-0039's measurement (one
171
- * fact logged 14,256 times). Order-preserving on first occurrence. Applies identically regardless of
172
- * sampling class, to BOTH legs (D3/D4) — a `counted` name's "never stored individually" guarantee
173
- * comes from this stage, not from `applySampling`.
213
+ * Fold points sharing an identical (name, outcome, projectId, repo, team) tuple into a single point
214
+ * carrying the summed count and the FIRST traceId seen for that tuple — the direct answer to
215
+ * ADR-0039's measurement (one fact logged 14,256 times). Order-preserving on first occurrence.
216
+ * Applies identically regardless of sampling class, to BOTH legs (D3/D4) — a `counted` name's
217
+ * "never stored individually" guarantee comes from this stage, not from `applySampling`.
218
+ *
219
+ * The key is widened beyond (name, outcome) to include projectId/repo/team (CTC-1417): once
220
+ * dimensions live on the point, folding on (name, outcome) alone while spreading the FIRST point
221
+ * would merge two different repos' identically-named/outcome points into one row carrying the first
222
+ * repo's name and the SUMMED count — one repo's volume silently attributed to another.
174
223
  */
175
224
  export function collapse(points: readonly TelemetryPoint[]): TelemetryPoint[] {
176
225
  const order: string[] = [];
177
226
  const byKey = new Map<string, TelemetryPoint>();
178
227
  for (const p of points) {
179
- const key = `${p.name}\u0000${p.outcome}`;
228
+ const key = `${p.name}\u0000${p.outcome}\u0000${p.projectId}\u0000${p.repo}\u0000${p.team}`;
180
229
  const existing = byKey.get(key);
181
230
  if (existing) {
182
231
  byKey.set(key, { ...existing, count: existing.count + p.count });