@catalyst-cloud/schema 0.1.45 → 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.45",
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",
@@ -791,6 +791,20 @@ export const durableEventTypes = {
791
791
  wakes: false,
792
792
  entity: "ticket",
793
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
+ },
794
808
  "phase.merge.park": {
795
809
  rationale:
796
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`.",
@@ -1568,6 +1582,20 @@ export const durableEventTypes = {
1568
1582
  wakes: false,
1569
1583
  entity: "ticket",
1570
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
+ },
1571
1599
  "phase.validate.park": {
1572
1600
  rationale:
1573
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`.",
@@ -2041,6 +2069,11 @@ export const telemetryEventTypes = {
2041
2069
  reason:
2042
2070
  "A replica read falling back to a secondary path — an internal routing diagnostic, not a ticket/phase coordination fact.",
2043
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
+ },
2044
2077
  "catalyst.service.health": { reason: "A periodic service health-check gauge." },
2045
2078
  "ci.run.completed": {
2046
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 });