@bli-cockpit/telemetry-core 0.1.20 → 0.1.23

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.
@@ -28,7 +28,10 @@ export declare const RawEvidenceLegacyUploadResponseSchema: z.ZodObject<{
28
28
  byte_size: z.ZodNumber;
29
29
  redaction: z.ZodOptional<z.ZodObject<{
30
30
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
31
- status: z.ZodLiteral<"sanitized">;
31
+ status: z.ZodEnum<{
32
+ sanitized: "sanitized";
33
+ scanned_clean: "scanned_clean";
34
+ }>;
32
35
  mode: z.ZodLiteral<"deterministic_text_replacement">;
33
36
  applied_by: z.ZodArray<z.ZodEnum<{
34
37
  local_collector: "local_collector";
@@ -90,7 +93,10 @@ export declare const RawEvidenceUploadBeginObjectSchema: z.ZodObject<{
90
93
  redacted_summary: z.ZodOptional<z.ZodString>;
91
94
  redaction: z.ZodOptional<z.ZodObject<{
92
95
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
93
- status: z.ZodLiteral<"sanitized">;
96
+ status: z.ZodEnum<{
97
+ sanitized: "sanitized";
98
+ scanned_clean: "scanned_clean";
99
+ }>;
94
100
  mode: z.ZodLiteral<"deterministic_text_replacement">;
95
101
  applied_by: z.ZodArray<z.ZodEnum<{
96
102
  local_collector: "local_collector";
@@ -186,7 +192,10 @@ export declare const RawEvidenceUploadBeginRequestSchema: z.ZodObject<{
186
192
  redacted_summary: z.ZodOptional<z.ZodString>;
187
193
  redaction: z.ZodOptional<z.ZodObject<{
188
194
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
189
- status: z.ZodLiteral<"sanitized">;
195
+ status: z.ZodEnum<{
196
+ sanitized: "sanitized";
197
+ scanned_clean: "scanned_clean";
198
+ }>;
190
199
  mode: z.ZodLiteral<"deterministic_text_replacement">;
191
200
  applied_by: z.ZodArray<z.ZodEnum<{
192
201
  local_collector: "local_collector";
@@ -351,7 +360,10 @@ export declare const RawEvidenceUploadCommitResponseSchema: z.ZodObject<{
351
360
  chunk_count: z.ZodNumber;
352
361
  redaction: z.ZodOptional<z.ZodObject<{
353
362
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
354
- status: z.ZodLiteral<"sanitized">;
363
+ status: z.ZodEnum<{
364
+ sanitized: "sanitized";
365
+ scanned_clean: "scanned_clean";
366
+ }>;
355
367
  mode: z.ZodLiteral<"deterministic_text_replacement">;
356
368
  applied_by: z.ZodArray<z.ZodEnum<{
357
369
  local_collector: "local_collector";
@@ -290,7 +290,10 @@ export declare const TelemetryIngestEventDtoSchema: z.ZodObject<{
290
290
  redacted_summary: z.ZodOptional<z.ZodString>;
291
291
  redaction: z.ZodOptional<z.ZodObject<{
292
292
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
293
- status: z.ZodLiteral<"sanitized">;
293
+ status: z.ZodEnum<{
294
+ sanitized: "sanitized";
295
+ scanned_clean: "scanned_clean";
296
+ }>;
294
297
  mode: z.ZodLiteral<"deterministic_text_replacement">;
295
298
  applied_by: z.ZodArray<z.ZodEnum<{
296
299
  local_collector: "local_collector";
@@ -763,7 +766,10 @@ export declare const TelemetryIngestEnvelopeSchema: z.ZodObject<{
763
766
  redacted_summary: z.ZodOptional<z.ZodString>;
764
767
  redaction: z.ZodOptional<z.ZodObject<{
765
768
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
766
- status: z.ZodLiteral<"sanitized">;
769
+ status: z.ZodEnum<{
770
+ sanitized: "sanitized";
771
+ scanned_clean: "scanned_clean";
772
+ }>;
767
773
  mode: z.ZodLiteral<"deterministic_text_replacement">;
768
774
  applied_by: z.ZodArray<z.ZodEnum<{
769
775
  local_collector: "local_collector";
@@ -913,7 +919,10 @@ export declare const TelemetryIngestEnvelopeSchema: z.ZodObject<{
913
919
  redacted_summary: z.ZodOptional<z.ZodString>;
914
920
  redaction: z.ZodOptional<z.ZodObject<{
915
921
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
916
- status: z.ZodLiteral<"sanitized">;
922
+ status: z.ZodEnum<{
923
+ sanitized: "sanitized";
924
+ scanned_clean: "scanned_clean";
925
+ }>;
917
926
  mode: z.ZodLiteral<"deterministic_text_replacement">;
918
927
  applied_by: z.ZodArray<z.ZodEnum<{
919
928
  local_collector: "local_collector";
package/dist/privacy.d.ts CHANGED
@@ -68,7 +68,10 @@ export declare const RawEvidencePointerSchema: z.ZodObject<{
68
68
  redacted_summary: z.ZodOptional<z.ZodString>;
69
69
  redaction: z.ZodOptional<z.ZodObject<{
70
70
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
71
- status: z.ZodLiteral<"sanitized">;
71
+ status: z.ZodEnum<{
72
+ sanitized: "sanitized";
73
+ scanned_clean: "scanned_clean";
74
+ }>;
72
75
  mode: z.ZodLiteral<"deterministic_text_replacement">;
73
76
  applied_by: z.ZodArray<z.ZodEnum<{
74
77
  local_collector: "local_collector";
@@ -43,9 +43,27 @@ export declare const RawEvidenceRedactionRangeSchema: z.ZodObject<{
43
43
  rule_id: z.ZodString;
44
44
  }, z.core.$strict>;
45
45
  export type RawEvidenceRedactionRange = z.infer<typeof RawEvidenceRedactionRangeSchema>;
46
+ /**
47
+ * The verdict of a secret scan — NOT "did the bytes change" (BLI-3277).
48
+ *
49
+ * `sanitized` means the scan matched and the uploaded bytes differ from what was
50
+ * read. `scanned_clean` means the same scan ran over the same bytes and matched
51
+ * nothing, so the uploaded bytes are the original ones. The second value exists
52
+ * because every reader downstream asks "was this scanned?", and for 70% of
53
+ * transcripts — the ones with no secret in them — the honest answer used to be
54
+ * an absent record, which reads identically to "nobody ever looked".
55
+ */
56
+ export declare const RawEvidenceRedactionStatusSchema: z.ZodEnum<{
57
+ sanitized: "sanitized";
58
+ scanned_clean: "scanned_clean";
59
+ }>;
60
+ export type RawEvidenceRedactionStatus = z.infer<typeof RawEvidenceRedactionStatusSchema>;
46
61
  export declare const RawEvidenceRedactionMetadataSchema: z.ZodObject<{
47
62
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
48
- status: z.ZodLiteral<"sanitized">;
63
+ status: z.ZodEnum<{
64
+ sanitized: "sanitized";
65
+ scanned_clean: "scanned_clean";
66
+ }>;
49
67
  mode: z.ZodLiteral<"deterministic_text_replacement">;
50
68
  applied_by: z.ZodArray<z.ZodEnum<{
51
69
  local_collector: "local_collector";
@@ -48,6 +48,41 @@ const SECRET_NAME_PATTERN_SOURCE = [
48
48
  "client[_-]?secret",
49
49
  "secret[_-]?access[_-]?key",
50
50
  ].join("|");
51
+ /**
52
+ * Credential names live INSIDE longer identifiers, so `\b` is the wrong fence
53
+ * (BLI-3116).
54
+ *
55
+ * Every name fragment above used to be wrapped in `\b(?:…)\b`. Underscore is a
56
+ * word character, so there is no word boundary between `AWS_` and `SECRET`:
57
+ * `AWS_SECRET_ACCESS_KEY=AKIA…` — the most common real spelling of the most
58
+ * common real leak — never matched and uploaded unmasked from every fleet
59
+ * machine, while the bare `SECRET_ACCESS_KEY=…` matched fine. The same hole hid
60
+ * `AZURE_OPENAI_API_KEY`, `VITE_SUPABASE_ANON_KEY`, `GITHUB_ACCESS_TOKEN`,
61
+ * `MY_APP_CLIENT_SECRET` and every other prefixed spelling: the bug was the
62
+ * fence, not the vocabulary, so the whole list is fenced differently now.
63
+ *
64
+ * `_`, `-` and `.` are identifier JOINERS here, not boundaries:
65
+ *
66
+ * - leading `(?<![A-Za-z0-9])` — the fragment may begin right after a joiner or
67
+ * at a real boundary, but a letter or digit immediately before it still
68
+ * blocks the match (`notapikey=…` stays out, as before).
69
+ * - trailing `[A-Za-z0-9_-]{0,40}` — a bounded identifier tail, so
70
+ * `OPENAI_API_KEY_2=…` and `AWS_SECRET_ACCESS_KEY_ID=…` are seen too.
71
+ *
72
+ * Prose is unaffected because the assignment requirement below is unchanged:
73
+ * "rotate your secret access key" has no `=`/`:` + opaque value and is not a
74
+ * leak. The guard's job is assignments, not vocabulary.
75
+ */
76
+ const SECRET_NAME_MATCH_SOURCE = `(?<![A-Za-z0-9])(?:${SECRET_NAME_PATTERN_SOURCE})[A-Za-z0-9_-]{0,40}`;
77
+ /**
78
+ * The assignment operator, with the closing quote of a JSON/YAML key allowed
79
+ * before it. Found while auditing the fence above: `{"aws_secret_access_key":
80
+ * "…"}` never matched either, because the name was followed by `"` and the
81
+ * pattern demanded `[:=]` immediately. Transcripts are JSONL, so this is the
82
+ * shape a leaked credential most often has on the way in. The sibling redactor
83
+ * in harvest-analysis (`study/prepare.ts`) already allowed it; this one did not.
84
+ */
85
+ const SECRET_ASSIGNMENT_SOURCE = `["']?\\s*[:=]\\s*["']?`;
51
86
  /**
52
87
  * Credential names that are only a leak when assigned a value (D13). The value
53
88
  * shape mirrors the long-standing generic pattern: an assignment operator
@@ -55,10 +90,18 @@ const SECRET_NAME_PATTERN_SOURCE = [
55
90
  * `GRANT ... TO service_role` (no assignment) do not match; the same name with
56
91
  * an assigned value does.
57
92
  */
58
- const SECRET_NAME_WITH_VALUE_PATTERN = new RegExp(`\\b(?:${SECRET_NAME_PATTERN_SOURCE})\\b\\s*[:=]\\s*["']?[A-Za-z0-9_./+=-]{12,}`, "i");
93
+ const SECRET_NAME_WITH_VALUE_PATTERN = new RegExp(`${SECRET_NAME_MATCH_SOURCE}${SECRET_ASSIGNMENT_SOURCE}[A-Za-z0-9_./+=-]{12,}`, "i");
59
94
  /**
60
95
  * Self-identifying secret values — blocked regardless of context because the
61
96
  * token shape itself is the credential, with no benign reading.
97
+ *
98
+ * `\b` is deliberately KEPT here, unlike the name fragments above (BLI-3116).
99
+ * These patterns are whole tokens, not fragments of a longer identifier: a
100
+ * `ghp_`/`sk-`/`AKIA…` run that continues into surrounding alphanumerics is not
101
+ * that provider's token, and unanchoring them would mask ordinary identifiers
102
+ * (`chart_m0_2024_revenue…`) for no safety gain. A real leak of any of these
103
+ * shapes is preceded by a quote, whitespace, `=`, `:` or `/` — all of which
104
+ * `\b` already admits.
62
105
  */
63
106
  const SECRET_VALUE_PATTERNS = [
64
107
  /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
@@ -110,14 +153,28 @@ export const RawEvidenceRedactionRangeSchema = z
110
153
  });
111
154
  }
112
155
  });
156
+ /**
157
+ * The verdict of a secret scan — NOT "did the bytes change" (BLI-3277).
158
+ *
159
+ * `sanitized` means the scan matched and the uploaded bytes differ from what was
160
+ * read. `scanned_clean` means the same scan ran over the same bytes and matched
161
+ * nothing, so the uploaded bytes are the original ones. The second value exists
162
+ * because every reader downstream asks "was this scanned?", and for 70% of
163
+ * transcripts — the ones with no secret in them — the honest answer used to be
164
+ * an absent record, which reads identically to "nobody ever looked".
165
+ */
166
+ export const RawEvidenceRedactionStatusSchema = z.enum([
167
+ "sanitized",
168
+ "scanned_clean",
169
+ ]);
113
170
  export const RawEvidenceRedactionMetadataSchema = z
114
171
  .object({
115
172
  schema_version: z.literal("raw-evidence-redaction.v1"),
116
- status: z.literal("sanitized"),
173
+ status: RawEvidenceRedactionStatusSchema,
117
174
  mode: z.literal("deterministic_text_replacement"),
118
175
  applied_by: z.array(RawEvidenceRedactionSourceSchema).min(1),
119
- rule_counts: z.array(RawEvidenceRedactionRuleCountSchema).min(1),
120
- secret_like_match_count: z.number().int().positive(),
176
+ rule_counts: z.array(RawEvidenceRedactionRuleCountSchema),
177
+ secret_like_match_count: z.number().int().nonnegative(),
121
178
  redacted_fields: z.array(z.string().trim().min(1).max(160)).default([]),
122
179
  redacted_ranges: z.array(RawEvidenceRedactionRangeSchema).max(200).default([]),
123
180
  original_content_hash_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
@@ -125,8 +182,81 @@ export const RawEvidenceRedactionMetadataSchema = z
125
182
  original_byte_size: z.number().int().nonnegative().optional(),
126
183
  sanitized_byte_size: z.number().int().nonnegative().optional(),
127
184
  })
128
- .strict();
129
- const SECRET_ASSIGNMENT_REDACTION_PATTERN = new RegExp(`(\\b(?:${SECRET_NAME_PATTERN_SOURCE})\\b\\s*[:=]\\s*["']?)([A-Za-z0-9_./+=-]{12,})`, "gi");
185
+ .strict()
186
+ // The per-status invariants the old `z.literal("sanitized")` + `.min(1)` +
187
+ // `.positive()` shape enforced structurally. They still hold for a sanitized
188
+ // record; a clean one has to prove the opposite — no rule fired, no range was
189
+ // replaced, no field was masked, and the bytes came out the way they went in.
190
+ .superRefine((metadata, context) => {
191
+ if (metadata.status === "sanitized") {
192
+ if (metadata.rule_counts.length === 0) {
193
+ context.addIssue({
194
+ code: "custom",
195
+ message: "a sanitized redaction must name at least one rule",
196
+ path: ["rule_counts"],
197
+ });
198
+ }
199
+ if (metadata.secret_like_match_count < 1) {
200
+ context.addIssue({
201
+ code: "custom",
202
+ message: "a sanitized redaction must count at least one match",
203
+ path: ["secret_like_match_count"],
204
+ });
205
+ }
206
+ return;
207
+ }
208
+ if (metadata.rule_counts.length > 0) {
209
+ context.addIssue({
210
+ code: "custom",
211
+ message: "a clean scan cannot name a rule that fired",
212
+ path: ["rule_counts"],
213
+ });
214
+ }
215
+ if (metadata.secret_like_match_count !== 0) {
216
+ context.addIssue({
217
+ code: "custom",
218
+ message: "a clean scan cannot count a match",
219
+ path: ["secret_like_match_count"],
220
+ });
221
+ }
222
+ if (metadata.redacted_ranges.length > 0) {
223
+ context.addIssue({
224
+ code: "custom",
225
+ message: "a clean scan cannot redact a range",
226
+ path: ["redacted_ranges"],
227
+ });
228
+ }
229
+ if (metadata.redacted_fields.length > 0) {
230
+ context.addIssue({
231
+ code: "custom",
232
+ message: "a clean scan cannot redact a field",
233
+ path: ["redacted_fields"],
234
+ });
235
+ }
236
+ if (metadata.original_content_hash_sha256 !== undefined &&
237
+ metadata.sanitized_content_hash_sha256 !== undefined &&
238
+ metadata.original_content_hash_sha256 !==
239
+ metadata.sanitized_content_hash_sha256) {
240
+ context.addIssue({
241
+ code: "custom",
242
+ message: "a clean scan cannot change the content hash",
243
+ path: ["sanitized_content_hash_sha256"],
244
+ });
245
+ }
246
+ if (metadata.original_byte_size !== undefined &&
247
+ metadata.sanitized_byte_size !== undefined &&
248
+ metadata.original_byte_size !== metadata.sanitized_byte_size) {
249
+ context.addIssue({
250
+ code: "custom",
251
+ message: "a clean scan cannot change the byte size",
252
+ path: ["sanitized_byte_size"],
253
+ });
254
+ }
255
+ });
256
+ // Same fence as the detector (BLI-3116) — the two must agree or a prefixed
257
+ // name would be detected and then left unredacted, which costs whole sessions
258
+ // (see findSecretRedactionMatches).
259
+ const SECRET_ASSIGNMENT_REDACTION_PATTERN = new RegExp(`(${SECRET_NAME_MATCH_SOURCE}${SECRET_ASSIGNMENT_SOURCE})([A-Za-z0-9_./+=-]{12,})`, "gi");
130
260
  const SECRET_REDACTION_RULES = [
131
261
  {
132
262
  ruleId: "credential_assignment",
@@ -76,7 +76,10 @@ export declare const SourceScanEventSummarySchema: z.ZodObject<{
76
76
  redacted_summary: z.ZodOptional<z.ZodString>;
77
77
  redaction: z.ZodOptional<z.ZodObject<{
78
78
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
79
- status: z.ZodLiteral<"sanitized">;
79
+ status: z.ZodEnum<{
80
+ sanitized: "sanitized";
81
+ scanned_clean: "scanned_clean";
82
+ }>;
80
83
  mode: z.ZodLiteral<"deterministic_text_replacement">;
81
84
  applied_by: z.ZodArray<z.ZodEnum<{
82
85
  local_collector: "local_collector";
@@ -190,7 +193,10 @@ export declare const SourceScanResultSchema: z.ZodObject<{
190
193
  redacted_summary: z.ZodOptional<z.ZodString>;
191
194
  redaction: z.ZodOptional<z.ZodObject<{
192
195
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
193
- status: z.ZodLiteral<"sanitized">;
196
+ status: z.ZodEnum<{
197
+ sanitized: "sanitized";
198
+ scanned_clean: "scanned_clean";
199
+ }>;
194
200
  mode: z.ZodLiteral<"deterministic_text_replacement">;
195
201
  applied_by: z.ZodArray<z.ZodEnum<{
196
202
  local_collector: "local_collector";
@@ -34,6 +34,84 @@ export declare const NO_UPLOAD_ATTEMPT_RECORDED = "no_upload_attempt_recorded";
34
34
  export declare const STORAGE_REJECTED_OBJECT_TOO_LARGE = "storage_rejected_object_too_large";
35
35
  /** Storage refused the finished object because of its declared media type. */
36
36
  export declare const STORAGE_REJECTED_MEDIA_TYPE = "storage_rejected_media_type";
37
+ /**
38
+ * The commit route never survived the object (BLI-3067).
39
+ *
40
+ * Fires when the commit call answers `>= 500` with a body that is not the
41
+ * route's JSON envelope — an HTML error page from the platform. The handler
42
+ * always answers `{ code, message, … }`, so a non-JSON 5xx means the serverless
43
+ * process was killed mid-assembly (out of memory, or a hard wall-clock timeout)
44
+ * and no catch ran, no ledger reason was written and no server log line exists.
45
+ *
46
+ * It is a distinct label because the operator-visible difference is total: a
47
+ * refusal means the server looked at these bytes and said no, this means the
48
+ * server died holding them. The collector composes the observed status onto the
49
+ * end (`commit_crashed_platform_http_502`) so a gateway timeout can still be
50
+ * told from an out-of-memory; `classifyUploadFailure` strips that suffix.
51
+ */
52
+ export declare const COMMIT_CRASHED_PLATFORM = "commit_crashed_platform";
53
+ /**
54
+ * The collector deliberately did not offer this object on this sync.
55
+ *
56
+ * Fires in the delivery-backoff partition: an object whose delivery keeps
57
+ * failing is retried on a 15-minute-to-6-hour schedule (~4 attempts a day)
58
+ * instead of on every sync, after one object failed 1,030 times in nine days
59
+ * (BLI-3066). The held object is reported as a failed outcome carrying this
60
+ * label rather than quietly omitted — a hold that read as success is exactly
61
+ * the green-status-hiding-missing-collection failure the fleet contract
62
+ * forbids.
63
+ *
64
+ * Not `withheld_by_policy`: nothing was refused and nothing needs a human. The
65
+ * hold expires on a clock, so it is transient in the strict sense — the next
66
+ * eligible sync offers the same bytes again.
67
+ */
68
+ export declare const DELIVERY_BACKOFF_HOLDING = "delivery_backoff_holding";
69
+ /**
70
+ * Raw evidence was never offered because the session's attribution state is not
71
+ * one the upload policy accepts (BLI-3272).
72
+ *
73
+ * `isRawEvidenceUploadableAttributionState` refuses a session whose attribution
74
+ * produced neither a repository identity nor an approved-root synthetic
75
+ * workspace. That refusal is a real, explainable decision — but until this label
76
+ * existed the report builders answered it by spreading `{}`, so the row reached
77
+ * `ambient_codex_sessions` with `upload_state = NULL` AND `upload_reason = NULL`.
78
+ * 1,256 sessions sat in that hole on 2026-08-29, 197 of them first seen inside a
79
+ * week from current 0.2.30 machines, every one `unattributed` /
80
+ * `cwd_outside_scanned_worktrees`. A withheld session that says nothing is the
81
+ * exact failure the session-first contract forbids, and the migration comment
82
+ * claiming "every write path now sets it" was false for all of them.
83
+ *
84
+ * Written with the deciding attribution reason appended after a colon
85
+ * (`not_uploadable_attribution_state:cwd_outside_scanned_worktrees`) so the
86
+ * upload half of the row explains itself without a join;
87
+ * `baseUploadFailureReason` strips that qualifier for classification.
88
+ *
89
+ * `deterministic` for the same reason as the bare attribution labels below: the
90
+ * scan completed and produced no target, so repeating it changes nothing.
91
+ * Widening the operator's approved roots can change it — a human decision, not a
92
+ * retry.
93
+ */
94
+ export declare const NOT_UPLOADABLE_ATTRIBUTION_STATE = "not_uploadable_attribution_state";
95
+ /**
96
+ * Compose the withheld-by-attribution reason for one session.
97
+ *
98
+ * The detail is the attribution reason that decided it. Anything outside the
99
+ * safe-label charset — colons included, so the stem always ends at the first one
100
+ * — collapses to `_`, and an empty or unusable detail degrades to the bare stem
101
+ * rather than inventing one.
102
+ */
103
+ export declare function notUploadableAttributionStateReason(attributionReason: string | null | undefined): string;
104
+ /**
105
+ * The listed reason a composed label is a variant of, or the label unchanged.
106
+ *
107
+ * The collector appends the observed status to some labels so an operator can
108
+ * tell a 502 from a 500 (`commit_crashed_platform_http_502`). Stripping it is
109
+ * deliberately NOT pattern matching on the label's words: the suffix is only
110
+ * removed when what remains is a reason somebody has already classified by
111
+ * hand, so `commit_failed_http_503` — whose stem nobody has reasoned about —
112
+ * still classifies as `unknown`, exactly as before.
113
+ */
114
+ export declare function baseUploadFailureReason(reason: string): string;
37
115
  /** Classify a persisted upload reason. Unlisted reasons stay `unknown`. */
38
116
  export declare function classifyUploadFailure(reason: string | null | undefined): UploadFailureClass;
39
117
  /**
@@ -13,6 +13,12 @@
13
13
  // future reason somebody adds, which is the same class of guessing the person
14
14
  // resolver refuses to do. An unlisted reason is `unknown` and stays visible
15
15
  // until a human decides which bucket it belongs in.
16
+ //
17
+ // The one concession is a trailing `_http_<status>`, which the collector
18
+ // appends to some labels so an operator can tell a 502 from a 500. It is
19
+ // stripped only when the remaining stem is itself a listed reason — see
20
+ // `baseUploadFailureReason` — so the table still decides everything and an
21
+ // unreasoned-about stem stays `unknown`.
16
22
  /**
17
23
  * The reason written when nothing in the pipeline explained itself.
18
24
  *
@@ -34,6 +40,85 @@ export const NO_UPLOAD_ATTEMPT_RECORDED = "no_upload_attempt_recorded";
34
40
  export const STORAGE_REJECTED_OBJECT_TOO_LARGE = "storage_rejected_object_too_large";
35
41
  /** Storage refused the finished object because of its declared media type. */
36
42
  export const STORAGE_REJECTED_MEDIA_TYPE = "storage_rejected_media_type";
43
+ /**
44
+ * The commit route never survived the object (BLI-3067).
45
+ *
46
+ * Fires when the commit call answers `>= 500` with a body that is not the
47
+ * route's JSON envelope — an HTML error page from the platform. The handler
48
+ * always answers `{ code, message, … }`, so a non-JSON 5xx means the serverless
49
+ * process was killed mid-assembly (out of memory, or a hard wall-clock timeout)
50
+ * and no catch ran, no ledger reason was written and no server log line exists.
51
+ *
52
+ * It is a distinct label because the operator-visible difference is total: a
53
+ * refusal means the server looked at these bytes and said no, this means the
54
+ * server died holding them. The collector composes the observed status onto the
55
+ * end (`commit_crashed_platform_http_502`) so a gateway timeout can still be
56
+ * told from an out-of-memory; `classifyUploadFailure` strips that suffix.
57
+ */
58
+ export const COMMIT_CRASHED_PLATFORM = "commit_crashed_platform";
59
+ /**
60
+ * The collector deliberately did not offer this object on this sync.
61
+ *
62
+ * Fires in the delivery-backoff partition: an object whose delivery keeps
63
+ * failing is retried on a 15-minute-to-6-hour schedule (~4 attempts a day)
64
+ * instead of on every sync, after one object failed 1,030 times in nine days
65
+ * (BLI-3066). The held object is reported as a failed outcome carrying this
66
+ * label rather than quietly omitted — a hold that read as success is exactly
67
+ * the green-status-hiding-missing-collection failure the fleet contract
68
+ * forbids.
69
+ *
70
+ * Not `withheld_by_policy`: nothing was refused and nothing needs a human. The
71
+ * hold expires on a clock, so it is transient in the strict sense — the next
72
+ * eligible sync offers the same bytes again.
73
+ */
74
+ export const DELIVERY_BACKOFF_HOLDING = "delivery_backoff_holding";
75
+ /**
76
+ * Raw evidence was never offered because the session's attribution state is not
77
+ * one the upload policy accepts (BLI-3272).
78
+ *
79
+ * `isRawEvidenceUploadableAttributionState` refuses a session whose attribution
80
+ * produced neither a repository identity nor an approved-root synthetic
81
+ * workspace. That refusal is a real, explainable decision — but until this label
82
+ * existed the report builders answered it by spreading `{}`, so the row reached
83
+ * `ambient_codex_sessions` with `upload_state = NULL` AND `upload_reason = NULL`.
84
+ * 1,256 sessions sat in that hole on 2026-08-29, 197 of them first seen inside a
85
+ * week from current 0.2.30 machines, every one `unattributed` /
86
+ * `cwd_outside_scanned_worktrees`. A withheld session that says nothing is the
87
+ * exact failure the session-first contract forbids, and the migration comment
88
+ * claiming "every write path now sets it" was false for all of them.
89
+ *
90
+ * Written with the deciding attribution reason appended after a colon
91
+ * (`not_uploadable_attribution_state:cwd_outside_scanned_worktrees`) so the
92
+ * upload half of the row explains itself without a join;
93
+ * `baseUploadFailureReason` strips that qualifier for classification.
94
+ *
95
+ * `deterministic` for the same reason as the bare attribution labels below: the
96
+ * scan completed and produced no target, so repeating it changes nothing.
97
+ * Widening the operator's approved roots can change it — a human decision, not a
98
+ * retry.
99
+ */
100
+ export const NOT_UPLOADABLE_ATTRIBUTION_STATE = "not_uploadable_attribution_state";
101
+ /** Longest label the ambient envelope's `SafeLabelSchema` accepts. */
102
+ const MAX_UPLOAD_REASON_LENGTH = 120;
103
+ /**
104
+ * Compose the withheld-by-attribution reason for one session.
105
+ *
106
+ * The detail is the attribution reason that decided it. Anything outside the
107
+ * safe-label charset — colons included, so the stem always ends at the first one
108
+ * — collapses to `_`, and an empty or unusable detail degrades to the bare stem
109
+ * rather than inventing one.
110
+ */
111
+ export function notUploadableAttributionStateReason(attributionReason) {
112
+ const detail = (attributionReason ?? "")
113
+ .trim()
114
+ .toLowerCase()
115
+ .replace(/[^a-z0-9_.-]+/gu, "_")
116
+ .replace(/^_+|_+$/gu, "");
117
+ if (!detail)
118
+ return NOT_UPLOADABLE_ATTRIBUTION_STATE;
119
+ const budget = MAX_UPLOAD_REASON_LENGTH - NOT_UPLOADABLE_ATTRIBUTION_STATE.length - 1;
120
+ return `${NOT_UPLOADABLE_ATTRIBUTION_STATE}:${detail.slice(0, budget)}`;
121
+ }
37
122
  const UPLOAD_FAILURE_CLASSES = {
38
123
  // Budgets and locks: the file was fine and the run simply ran out of room or
39
124
  // was beaten to it. The next pass has room.
@@ -51,6 +136,16 @@ const UPLOAD_FAILURE_CLASSES = {
51
136
  // now name themselves instead of going silent. Transient because the object
52
137
  // was never offered a chance to upload — nothing about it was refused.
53
138
  begin_batch_server_error: "transient",
139
+ // The platform killed the commit process before the route could answer
140
+ // (BLI-3067). Transient for the same reason as the batch error above: nothing
141
+ // decided anything about these bytes, so the next attempt is not a repeat of
142
+ // a refusal. It is bounded rather than infinite because a repeatedly failing
143
+ // object falls into delivery backoff after the first failure, so a genuinely
144
+ // un-committable object costs ~4 attempts a day instead of 96.
145
+ [COMMIT_CRASHED_PLATFORM]: "transient",
146
+ // Held on purpose by that same backoff. The bytes are staged and eligible;
147
+ // the clock has not come round yet.
148
+ [DELIVERY_BACKOFF_HOLDING]: "transient",
54
149
  // The file itself is the problem, and it will be the same size and the same
55
150
  // shape on the next pass. Retrying is a promise nobody can keep.
56
151
  file_too_large: "deterministic",
@@ -69,6 +164,10 @@ const UPLOAD_FAILURE_CLASSES = {
69
164
  cwd_not_a_repo: "deterministic",
70
165
  no_repo_signals: "deterministic",
71
166
  multiple_worktrees_close_scores: "deterministic",
167
+ // The upload policy declined the session's attribution state outright
168
+ // (BLI-3272). Same shape of answer as the four above — the scan finished and
169
+ // there is nowhere to put the object — so the same class.
170
+ [NOT_UPLOADABLE_ATTRIBUTION_STATE]: "deterministic",
72
171
  // Working as designed. Never retry these — a retry here is an attempt to get
73
172
  // a redaction guard to change its mind.
74
173
  secret_like_content_guard: "withheld_by_policy",
@@ -77,11 +176,46 @@ const UPLOAD_FAILURE_CLASSES = {
77
176
  // The server-side name for the same guard, returned by the commit route.
78
177
  secret_guard_rejected: "withheld_by_policy",
79
178
  };
179
+ const HTTP_STATUS_SUFFIX = /_http_\d{3}$/u;
180
+ /**
181
+ * The listed reason a composed label is a variant of, or the label unchanged.
182
+ *
183
+ * The collector appends the observed status to some labels so an operator can
184
+ * tell a 502 from a 500 (`commit_crashed_platform_http_502`). Stripping it is
185
+ * deliberately NOT pattern matching on the label's words: the suffix is only
186
+ * removed when what remains is a reason somebody has already classified by
187
+ * hand, so `commit_failed_http_503` — whose stem nobody has reasoned about —
188
+ * still classifies as `unknown`, exactly as before.
189
+ */
190
+ export function baseUploadFailureReason(reason) {
191
+ const httpStem = reason.replace(HTTP_STATUS_SUFFIX, "");
192
+ if (httpStem !== reason) {
193
+ return Object.hasOwn(UPLOAD_FAILURE_CLASSES, httpStem) ? httpStem : reason;
194
+ }
195
+ // Same contract for the `:detail` qualifier the withheld-by-attribution label
196
+ // carries (BLI-3272): the colon is only a separator when what precedes it is
197
+ // already a classified reason, so nothing an unlisted label happens to
198
+ // contain can smuggle it into a class.
199
+ const separator = reason.indexOf(":");
200
+ if (separator > 0) {
201
+ const qualifierStem = reason.slice(0, separator);
202
+ if (Object.hasOwn(UPLOAD_FAILURE_CLASSES, qualifierStem)) {
203
+ return qualifierStem;
204
+ }
205
+ }
206
+ return reason;
207
+ }
80
208
  /** Classify a persisted upload reason. Unlisted reasons stay `unknown`. */
81
209
  export function classifyUploadFailure(reason) {
82
210
  if (!reason)
83
211
  return "unknown";
84
- return UPLOAD_FAILURE_CLASSES[reason] ?? "unknown";
212
+ const label = baseUploadFailureReason(reason);
213
+ // `hasOwn` rather than a bare index: a reason called `constructor` or
214
+ // `toString` would otherwise resolve to an inherited property and answer
215
+ // something that is not an UploadFailureClass at all.
216
+ if (!Object.hasOwn(UPLOAD_FAILURE_CLASSES, label))
217
+ return "unknown";
218
+ return UPLOAD_FAILURE_CLASSES[label] ?? "unknown";
85
219
  }
86
220
  /**
87
221
  * Whether repeating the identical attempt could plausibly succeed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/telemetry-core",
3
- "version": "0.1.20",
3
+ "version": "0.1.23",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",