@bli-cockpit/telemetry-core 0.1.22 → 0.1.24

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";
@@ -51,6 +54,7 @@ export declare const RawEvidenceLegacyUploadResponseSchema: z.ZodObject<{
51
54
  sanitized_content_hash_sha256: z.ZodOptional<z.ZodString>;
52
55
  original_byte_size: z.ZodOptional<z.ZodNumber>;
53
56
  sanitized_byte_size: z.ZodOptional<z.ZodNumber>;
57
+ applied_at: z.ZodOptional<z.ZodString>;
54
58
  }, z.core.$strict>>;
55
59
  }, z.core.$strict>>;
56
60
  }, z.core.$strict>;
@@ -90,7 +94,10 @@ export declare const RawEvidenceUploadBeginObjectSchema: z.ZodObject<{
90
94
  redacted_summary: z.ZodOptional<z.ZodString>;
91
95
  redaction: z.ZodOptional<z.ZodObject<{
92
96
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
93
- status: z.ZodLiteral<"sanitized">;
97
+ status: z.ZodEnum<{
98
+ sanitized: "sanitized";
99
+ scanned_clean: "scanned_clean";
100
+ }>;
94
101
  mode: z.ZodLiteral<"deterministic_text_replacement">;
95
102
  applied_by: z.ZodArray<z.ZodEnum<{
96
103
  local_collector: "local_collector";
@@ -113,6 +120,7 @@ export declare const RawEvidenceUploadBeginObjectSchema: z.ZodObject<{
113
120
  sanitized_content_hash_sha256: z.ZodOptional<z.ZodString>;
114
121
  original_byte_size: z.ZodOptional<z.ZodNumber>;
115
122
  sanitized_byte_size: z.ZodOptional<z.ZodNumber>;
123
+ applied_at: z.ZodOptional<z.ZodString>;
116
124
  }, z.core.$strict>>;
117
125
  }, z.core.$strict>;
118
126
  chunk_size_bytes: z.ZodNumber;
@@ -186,7 +194,10 @@ export declare const RawEvidenceUploadBeginRequestSchema: z.ZodObject<{
186
194
  redacted_summary: z.ZodOptional<z.ZodString>;
187
195
  redaction: z.ZodOptional<z.ZodObject<{
188
196
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
189
- status: z.ZodLiteral<"sanitized">;
197
+ status: z.ZodEnum<{
198
+ sanitized: "sanitized";
199
+ scanned_clean: "scanned_clean";
200
+ }>;
190
201
  mode: z.ZodLiteral<"deterministic_text_replacement">;
191
202
  applied_by: z.ZodArray<z.ZodEnum<{
192
203
  local_collector: "local_collector";
@@ -209,6 +220,7 @@ export declare const RawEvidenceUploadBeginRequestSchema: z.ZodObject<{
209
220
  sanitized_content_hash_sha256: z.ZodOptional<z.ZodString>;
210
221
  original_byte_size: z.ZodOptional<z.ZodNumber>;
211
222
  sanitized_byte_size: z.ZodOptional<z.ZodNumber>;
223
+ applied_at: z.ZodOptional<z.ZodString>;
212
224
  }, z.core.$strict>>;
213
225
  }, z.core.$strict>;
214
226
  chunk_size_bytes: z.ZodNumber;
@@ -351,7 +363,10 @@ export declare const RawEvidenceUploadCommitResponseSchema: z.ZodObject<{
351
363
  chunk_count: z.ZodNumber;
352
364
  redaction: z.ZodOptional<z.ZodObject<{
353
365
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
354
- status: z.ZodLiteral<"sanitized">;
366
+ status: z.ZodEnum<{
367
+ sanitized: "sanitized";
368
+ scanned_clean: "scanned_clean";
369
+ }>;
355
370
  mode: z.ZodLiteral<"deterministic_text_replacement">;
356
371
  applied_by: z.ZodArray<z.ZodEnum<{
357
372
  local_collector: "local_collector";
@@ -374,6 +389,7 @@ export declare const RawEvidenceUploadCommitResponseSchema: z.ZodObject<{
374
389
  sanitized_content_hash_sha256: z.ZodOptional<z.ZodString>;
375
390
  original_byte_size: z.ZodOptional<z.ZodNumber>;
376
391
  sanitized_byte_size: z.ZodOptional<z.ZodNumber>;
392
+ applied_at: z.ZodOptional<z.ZodString>;
377
393
  }, z.core.$strict>>;
378
394
  }, z.core.$strict>;
379
395
  export type RawEvidenceUploadCommitResponse = z.infer<typeof RawEvidenceUploadCommitResponseSchema>;
@@ -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";
@@ -313,6 +316,7 @@ export declare const TelemetryIngestEventDtoSchema: z.ZodObject<{
313
316
  sanitized_content_hash_sha256: z.ZodOptional<z.ZodString>;
314
317
  original_byte_size: z.ZodOptional<z.ZodNumber>;
315
318
  sanitized_byte_size: z.ZodOptional<z.ZodNumber>;
319
+ applied_at: z.ZodOptional<z.ZodString>;
316
320
  }, z.core.$strict>>;
317
321
  }, z.core.$strict>>>;
318
322
  otel: z.ZodOptional<z.ZodObject<{
@@ -763,7 +767,10 @@ export declare const TelemetryIngestEnvelopeSchema: z.ZodObject<{
763
767
  redacted_summary: z.ZodOptional<z.ZodString>;
764
768
  redaction: z.ZodOptional<z.ZodObject<{
765
769
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
766
- status: z.ZodLiteral<"sanitized">;
770
+ status: z.ZodEnum<{
771
+ sanitized: "sanitized";
772
+ scanned_clean: "scanned_clean";
773
+ }>;
767
774
  mode: z.ZodLiteral<"deterministic_text_replacement">;
768
775
  applied_by: z.ZodArray<z.ZodEnum<{
769
776
  local_collector: "local_collector";
@@ -786,6 +793,7 @@ export declare const TelemetryIngestEnvelopeSchema: z.ZodObject<{
786
793
  sanitized_content_hash_sha256: z.ZodOptional<z.ZodString>;
787
794
  original_byte_size: z.ZodOptional<z.ZodNumber>;
788
795
  sanitized_byte_size: z.ZodOptional<z.ZodNumber>;
796
+ applied_at: z.ZodOptional<z.ZodString>;
789
797
  }, z.core.$strict>>;
790
798
  }, z.core.$strict>>>;
791
799
  otel: z.ZodOptional<z.ZodObject<{
@@ -913,7 +921,10 @@ export declare const TelemetryIngestEnvelopeSchema: z.ZodObject<{
913
921
  redacted_summary: z.ZodOptional<z.ZodString>;
914
922
  redaction: z.ZodOptional<z.ZodObject<{
915
923
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
916
- status: z.ZodLiteral<"sanitized">;
924
+ status: z.ZodEnum<{
925
+ sanitized: "sanitized";
926
+ scanned_clean: "scanned_clean";
927
+ }>;
917
928
  mode: z.ZodLiteral<"deterministic_text_replacement">;
918
929
  applied_by: z.ZodArray<z.ZodEnum<{
919
930
  local_collector: "local_collector";
@@ -936,6 +947,7 @@ export declare const TelemetryIngestEnvelopeSchema: z.ZodObject<{
936
947
  sanitized_content_hash_sha256: z.ZodOptional<z.ZodString>;
937
948
  original_byte_size: z.ZodOptional<z.ZodNumber>;
938
949
  sanitized_byte_size: z.ZodOptional<z.ZodNumber>;
950
+ applied_at: z.ZodOptional<z.ZodString>;
939
951
  }, z.core.$strict>>;
940
952
  }, z.core.$strict>>>;
941
953
  }, z.core.$strict>>>;
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";
@@ -91,6 +94,7 @@ export declare const RawEvidencePointerSchema: z.ZodObject<{
91
94
  sanitized_content_hash_sha256: z.ZodOptional<z.ZodString>;
92
95
  original_byte_size: z.ZodOptional<z.ZodNumber>;
93
96
  sanitized_byte_size: z.ZodOptional<z.ZodNumber>;
97
+ applied_at: z.ZodOptional<z.ZodString>;
94
98
  }, z.core.$strict>>;
95
99
  }, z.core.$strict>;
96
100
  export type RawEvidencePointer = z.infer<typeof RawEvidencePointerSchema>;
@@ -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";
@@ -68,8 +86,35 @@ export declare const RawEvidenceRedactionMetadataSchema: z.ZodObject<{
68
86
  sanitized_content_hash_sha256: z.ZodOptional<z.ZodString>;
69
87
  original_byte_size: z.ZodOptional<z.ZodNumber>;
70
88
  sanitized_byte_size: z.ZodOptional<z.ZodNumber>;
89
+ applied_at: z.ZodOptional<z.ZodString>;
71
90
  }, z.core.$strict>;
72
91
  export type RawEvidenceRedactionMetadata = z.infer<typeof RawEvidenceRedactionMetadataSchema>;
92
+ /**
93
+ * The receipt for a file the scan cleared (BLI-3277, moved here by BLI-3280).
94
+ *
95
+ * Same schema, same `mode` — the deterministic ruleset is what ran — with the
96
+ * verdict `scanned_clean` and zero of everything else. The content fields are
97
+ * the point: an evidence ref is only readable downstream when its redaction
98
+ * record hashes the bytes that are actually in the bucket, and for a clean file
99
+ * those are the original bytes, so both halves carry the same digest and size.
100
+ *
101
+ * It lives in telemetry-core rather than in the collector because two callers
102
+ * now build this exact record and they must never drift: the collector writes
103
+ * it at upload time, and the BLI-3280 operator backfill writes it onto refs
104
+ * that were stored before the receipt existed. Both must satisfy the same
105
+ * `sanitized_content_hash_sha256 === content_hash_sha256` /
106
+ * `sanitized_byte_size === byte_size` equality the digest reader checks, and a
107
+ * second copy of this function is a second chance to get that wrong.
108
+ *
109
+ * `appliedBy` is who ran the scan, and it is not decoration: a backfill passes
110
+ * `legacy_upload` so a reader can tell "the collector scanned this before it
111
+ * uploaded" apart from "an operator scanned the stored bytes afterwards".
112
+ */
113
+ export declare function scannedCleanRedactionMetadata(bytes: Uint8Array, options?: {
114
+ appliedBy?: RawEvidenceRedactionSource;
115
+ /** ISO-8601 with offset. Omitted entirely when absent (BLI-3290). */
116
+ appliedAt?: string;
117
+ }): RawEvidenceRedactionMetadata;
73
118
  export interface SecretRedactionResult {
74
119
  redacted: boolean;
75
120
  text: string;
@@ -1,4 +1,6 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { z } from "zod";
3
+ import { IsoDateTimeSchema } from "./common.js";
2
4
  /**
3
5
  * Secret-like content + file-name guards shared by the local collector
4
6
  * (attribution, collection) and the dashboard commit-time guard. Keeping a
@@ -153,22 +155,156 @@ export const RawEvidenceRedactionRangeSchema = z
153
155
  });
154
156
  }
155
157
  });
158
+ /**
159
+ * The verdict of a secret scan — NOT "did the bytes change" (BLI-3277).
160
+ *
161
+ * `sanitized` means the scan matched and the uploaded bytes differ from what was
162
+ * read. `scanned_clean` means the same scan ran over the same bytes and matched
163
+ * nothing, so the uploaded bytes are the original ones. The second value exists
164
+ * because every reader downstream asks "was this scanned?", and for 70% of
165
+ * transcripts — the ones with no secret in them — the honest answer used to be
166
+ * an absent record, which reads identically to "nobody ever looked".
167
+ */
168
+ export const RawEvidenceRedactionStatusSchema = z.enum([
169
+ "sanitized",
170
+ "scanned_clean",
171
+ ]);
156
172
  export const RawEvidenceRedactionMetadataSchema = z
157
173
  .object({
158
174
  schema_version: z.literal("raw-evidence-redaction.v1"),
159
- status: z.literal("sanitized"),
175
+ status: RawEvidenceRedactionStatusSchema,
160
176
  mode: z.literal("deterministic_text_replacement"),
161
177
  applied_by: z.array(RawEvidenceRedactionSourceSchema).min(1),
162
- rule_counts: z.array(RawEvidenceRedactionRuleCountSchema).min(1),
163
- secret_like_match_count: z.number().int().positive(),
178
+ rule_counts: z.array(RawEvidenceRedactionRuleCountSchema),
179
+ secret_like_match_count: z.number().int().nonnegative(),
164
180
  redacted_fields: z.array(z.string().trim().min(1).max(160)).default([]),
165
181
  redacted_ranges: z.array(RawEvidenceRedactionRangeSchema).max(200).default([]),
166
182
  original_content_hash_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
167
183
  sanitized_content_hash_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
168
184
  original_byte_size: z.number().int().nonnegative().optional(),
169
185
  sanitized_byte_size: z.number().int().nonnegative().optional(),
186
+ /**
187
+ * When the scan ran, ISO-8601 with an offset (BLI-3290).
188
+ *
189
+ * Optional, and it stays optional: every receipt written before this field
190
+ * existed is still valid, and the collector does not stamp it — at upload
191
+ * time the ref's own `received_at` already answers "when". It exists for
192
+ * receipts written over bytes that were stored long ago, where the ref's
193
+ * timestamps describe the ORIGINAL upload and nothing in the record would
194
+ * otherwise say when an operator re-scanned or re-masked the object.
195
+ */
196
+ applied_at: IsoDateTimeSchema.optional(),
170
197
  })
171
- .strict();
198
+ .strict()
199
+ // The per-status invariants the old `z.literal("sanitized")` + `.min(1)` +
200
+ // `.positive()` shape enforced structurally. They still hold for a sanitized
201
+ // record; a clean one has to prove the opposite — no rule fired, no range was
202
+ // replaced, no field was masked, and the bytes came out the way they went in.
203
+ .superRefine((metadata, context) => {
204
+ if (metadata.status === "sanitized") {
205
+ if (metadata.rule_counts.length === 0) {
206
+ context.addIssue({
207
+ code: "custom",
208
+ message: "a sanitized redaction must name at least one rule",
209
+ path: ["rule_counts"],
210
+ });
211
+ }
212
+ if (metadata.secret_like_match_count < 1) {
213
+ context.addIssue({
214
+ code: "custom",
215
+ message: "a sanitized redaction must count at least one match",
216
+ path: ["secret_like_match_count"],
217
+ });
218
+ }
219
+ return;
220
+ }
221
+ if (metadata.rule_counts.length > 0) {
222
+ context.addIssue({
223
+ code: "custom",
224
+ message: "a clean scan cannot name a rule that fired",
225
+ path: ["rule_counts"],
226
+ });
227
+ }
228
+ if (metadata.secret_like_match_count !== 0) {
229
+ context.addIssue({
230
+ code: "custom",
231
+ message: "a clean scan cannot count a match",
232
+ path: ["secret_like_match_count"],
233
+ });
234
+ }
235
+ if (metadata.redacted_ranges.length > 0) {
236
+ context.addIssue({
237
+ code: "custom",
238
+ message: "a clean scan cannot redact a range",
239
+ path: ["redacted_ranges"],
240
+ });
241
+ }
242
+ if (metadata.redacted_fields.length > 0) {
243
+ context.addIssue({
244
+ code: "custom",
245
+ message: "a clean scan cannot redact a field",
246
+ path: ["redacted_fields"],
247
+ });
248
+ }
249
+ if (metadata.original_content_hash_sha256 !== undefined &&
250
+ metadata.sanitized_content_hash_sha256 !== undefined &&
251
+ metadata.original_content_hash_sha256 !==
252
+ metadata.sanitized_content_hash_sha256) {
253
+ context.addIssue({
254
+ code: "custom",
255
+ message: "a clean scan cannot change the content hash",
256
+ path: ["sanitized_content_hash_sha256"],
257
+ });
258
+ }
259
+ if (metadata.original_byte_size !== undefined &&
260
+ metadata.sanitized_byte_size !== undefined &&
261
+ metadata.original_byte_size !== metadata.sanitized_byte_size) {
262
+ context.addIssue({
263
+ code: "custom",
264
+ message: "a clean scan cannot change the byte size",
265
+ path: ["sanitized_byte_size"],
266
+ });
267
+ }
268
+ });
269
+ /**
270
+ * The receipt for a file the scan cleared (BLI-3277, moved here by BLI-3280).
271
+ *
272
+ * Same schema, same `mode` — the deterministic ruleset is what ran — with the
273
+ * verdict `scanned_clean` and zero of everything else. The content fields are
274
+ * the point: an evidence ref is only readable downstream when its redaction
275
+ * record hashes the bytes that are actually in the bucket, and for a clean file
276
+ * those are the original bytes, so both halves carry the same digest and size.
277
+ *
278
+ * It lives in telemetry-core rather than in the collector because two callers
279
+ * now build this exact record and they must never drift: the collector writes
280
+ * it at upload time, and the BLI-3280 operator backfill writes it onto refs
281
+ * that were stored before the receipt existed. Both must satisfy the same
282
+ * `sanitized_content_hash_sha256 === content_hash_sha256` /
283
+ * `sanitized_byte_size === byte_size` equality the digest reader checks, and a
284
+ * second copy of this function is a second chance to get that wrong.
285
+ *
286
+ * `appliedBy` is who ran the scan, and it is not decoration: a backfill passes
287
+ * `legacy_upload` so a reader can tell "the collector scanned this before it
288
+ * uploaded" apart from "an operator scanned the stored bytes afterwards".
289
+ */
290
+ export function scannedCleanRedactionMetadata(bytes, options = {}) {
291
+ const digest = createHash("sha256").update(bytes).digest("hex");
292
+ return {
293
+ schema_version: "raw-evidence-redaction.v1",
294
+ status: "scanned_clean",
295
+ mode: "deterministic_text_replacement",
296
+ applied_by: [options.appliedBy ?? "local_collector"],
297
+ rule_counts: [],
298
+ secret_like_match_count: 0,
299
+ redacted_fields: [],
300
+ redacted_ranges: [],
301
+ original_content_hash_sha256: digest,
302
+ sanitized_content_hash_sha256: digest,
303
+ original_byte_size: bytes.byteLength,
304
+ sanitized_byte_size: bytes.byteLength,
305
+ ...(options.appliedAt === undefined ? {} : { applied_at: options.appliedAt }),
306
+ };
307
+ }
172
308
  // Same fence as the detector (BLI-3116) — the two must agree or a prefixed
173
309
  // name would be detected and then left unredacted, which costs whole sessions
174
310
  // (see findSecretRedactionMatches).
@@ -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";
@@ -99,6 +102,7 @@ export declare const SourceScanEventSummarySchema: z.ZodObject<{
99
102
  sanitized_content_hash_sha256: z.ZodOptional<z.ZodString>;
100
103
  original_byte_size: z.ZodOptional<z.ZodNumber>;
101
104
  sanitized_byte_size: z.ZodOptional<z.ZodNumber>;
105
+ applied_at: z.ZodOptional<z.ZodString>;
102
106
  }, z.core.$strict>>;
103
107
  }, z.core.$strict>>>;
104
108
  }, z.core.$strict>;
@@ -190,7 +194,10 @@ export declare const SourceScanResultSchema: z.ZodObject<{
190
194
  redacted_summary: z.ZodOptional<z.ZodString>;
191
195
  redaction: z.ZodOptional<z.ZodObject<{
192
196
  schema_version: z.ZodLiteral<"raw-evidence-redaction.v1">;
193
- status: z.ZodLiteral<"sanitized">;
197
+ status: z.ZodEnum<{
198
+ sanitized: "sanitized";
199
+ scanned_clean: "scanned_clean";
200
+ }>;
194
201
  mode: z.ZodLiteral<"deterministic_text_replacement">;
195
202
  applied_by: z.ZodArray<z.ZodEnum<{
196
203
  local_collector: "local_collector";
@@ -213,6 +220,7 @@ export declare const SourceScanResultSchema: z.ZodObject<{
213
220
  sanitized_content_hash_sha256: z.ZodOptional<z.ZodString>;
214
221
  original_byte_size: z.ZodOptional<z.ZodNumber>;
215
222
  sanitized_byte_size: z.ZodOptional<z.ZodNumber>;
223
+ applied_at: z.ZodOptional<z.ZodString>;
216
224
  }, z.core.$strict>>;
217
225
  }, z.core.$strict>>>;
218
226
  }, z.core.$strict>>>;
@@ -66,6 +66,41 @@ export declare const COMMIT_CRASHED_PLATFORM = "commit_crashed_platform";
66
66
  * eligible sync offers the same bytes again.
67
67
  */
68
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;
69
104
  /**
70
105
  * The listed reason a composed label is a variant of, or the label unchanged.
71
106
  *
@@ -72,6 +72,53 @@ export const COMMIT_CRASHED_PLATFORM = "commit_crashed_platform";
72
72
  * eligible sync offers the same bytes again.
73
73
  */
74
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
+ }
75
122
  const UPLOAD_FAILURE_CLASSES = {
76
123
  // Budgets and locks: the file was fine and the run simply ran out of room or
77
124
  // was beaten to it. The next pass has room.
@@ -117,6 +164,10 @@ const UPLOAD_FAILURE_CLASSES = {
117
164
  cwd_not_a_repo: "deterministic",
118
165
  no_repo_signals: "deterministic",
119
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",
120
171
  // Working as designed. Never retry these — a retry here is an attempt to get
121
172
  // a redaction guard to change its mind.
122
173
  secret_like_content_guard: "withheld_by_policy",
@@ -137,10 +188,22 @@ const HTTP_STATUS_SUFFIX = /_http_\d{3}$/u;
137
188
  * still classifies as `unknown`, exactly as before.
138
189
  */
139
190
  export function baseUploadFailureReason(reason) {
140
- const stem = reason.replace(HTTP_STATUS_SUFFIX, "");
141
- if (stem === reason)
142
- return reason;
143
- return Object.hasOwn(UPLOAD_FAILURE_CLASSES, stem) ? stem : 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;
144
207
  }
145
208
  /** Classify a persisted upload reason. Unlisted reasons stay `unknown`. */
146
209
  export function classifyUploadFailure(reason) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/telemetry-core",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",