@bli-cockpit/telemetry-core 0.1.15 → 0.1.17

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.
@@ -26,10 +26,24 @@ export declare function normalizeActorLabel(rawLabel: string): string;
26
26
  export declare function unresolvedActorId(rawLabel: string): string;
27
27
  export interface DecisionEventIdInput {
28
28
  captureSource: string;
29
- /** Hash of the file this was read out of; null for a live hook firing. */
30
- sourceArtifactHashSha256: string | null;
31
- /** Line within that file; null when the artifact is not line-oriented. */
32
- sourceLineNumber: number | null;
29
+ /**
30
+ * Hash of exactly the bytes that make up this one decision; null for a live
31
+ * hook firing, which has no artifact at all.
32
+ *
33
+ * "This one decision" and not "the file it arrived in", which is the whole
34
+ * point. A verdict JSON holds one decision, so its record is the file and the
35
+ * two hashes coincide. `merge-log.jsonl` holds thousands and gains another
36
+ * every time somebody merges, so its record is the single line — hashing the
37
+ * file there would change every id in it on the next append, and a re-import
38
+ * would write a second copy of the ledger instead of updating the rows it
39
+ * already has.
40
+ *
41
+ * Which snapshot of which file the record came out of is still worth knowing;
42
+ * it is kept as `source_artifact_hash_sha256`, beside `source_line_number`, as
43
+ * provenance. Neither is an identity input. Conflating the two jobs in one
44
+ * field is what made a growing ledger un-re-importable.
45
+ */
46
+ sourceRecordHashSha256: string | null;
33
47
  kind: string;
34
48
  /**
35
49
  * Only for a live firing, which has no artifact to hash and no line to point
@@ -43,10 +57,19 @@ export interface DecisionEventIdInput {
43
57
  /**
44
58
  * The id of one decision event.
45
59
  *
46
- * Two consequences worth stating. Re-running the backfill is idempotent, because
47
- * the same line of the same file always yields the same id. And an *edited*
48
- * artifact produces a new id rather than mutating an old one, which keeps the
49
- * store append-only in the same way jarvis_corrections is history is added to,
50
- * never rewritten.
60
+ * Three consequences worth stating. Re-running the backfill is idempotent,
61
+ * because the same record always yields the same id no matter how much the file
62
+ * around it has grown or how far down it has been pushed. An *edited* record
63
+ * produces a new id rather than mutating an old one, which keeps the store
64
+ * append-only in the same way jarvis_corrections is — history is added to, never
65
+ * rewritten. And two byte-identical records collapse onto one row, which is the
66
+ * intended reading: in a ledger, identical bytes mean a hook wrote the same
67
+ * decision twice. If that is ever false the ledger format is underspecified, and
68
+ * the fix belongs there rather than in a tiebreaker here.
69
+ *
70
+ * The line number is deliberately absent. It is the one part of a record's
71
+ * position that a rotation or a compaction can change without the decision
72
+ * itself changing at all, and an id that moves when a file is renumbered is
73
+ * worse than a duplicate: it silently lands on some other decision's row.
51
74
  */
52
75
  export declare function decisionEventId(input: DecisionEventIdInput): string;
@@ -52,19 +52,25 @@ export function unresolvedActorId(rawLabel) {
52
52
  /**
53
53
  * The id of one decision event.
54
54
  *
55
- * Two consequences worth stating. Re-running the backfill is idempotent, because
56
- * the same line of the same file always yields the same id. And an *edited*
57
- * artifact produces a new id rather than mutating an old one, which keeps the
58
- * store append-only in the same way jarvis_corrections is history is added to,
59
- * never rewritten.
55
+ * Three consequences worth stating. Re-running the backfill is idempotent,
56
+ * because the same record always yields the same id no matter how much the file
57
+ * around it has grown or how far down it has been pushed. An *edited* record
58
+ * produces a new id rather than mutating an old one, which keeps the store
59
+ * append-only in the same way jarvis_corrections is — history is added to, never
60
+ * rewritten. And two byte-identical records collapse onto one row, which is the
61
+ * intended reading: in a ledger, identical bytes mean a hook wrote the same
62
+ * decision twice. If that is ever false the ledger format is underspecified, and
63
+ * the fix belongs there rather than in a tiebreaker here.
64
+ *
65
+ * The line number is deliberately absent. It is the one part of a record's
66
+ * position that a rotation or a compaction can change without the decision
67
+ * itself changing at all, and an id that moves when a file is renumbered is
68
+ * worse than a duplicate: it silently lands on some other decision's row.
60
69
  */
61
70
  export function decisionEventId(input) {
62
71
  const parts = [
63
72
  input.captureSource,
64
- input.sourceArtifactHashSha256 ?? "",
65
- input.sourceLineNumber === null || input.sourceLineNumber === undefined
66
- ? ""
67
- : String(input.sourceLineNumber),
73
+ input.sourceRecordHashSha256 ?? "",
68
74
  input.kind,
69
75
  input.occurredAt ?? "",
70
76
  input.subjectDiscriminator ?? "",
@@ -411,6 +411,7 @@ export declare const DecisionEventSchema: z.ZodObject<{
411
411
  source_line_number: z.ZodNullable<z.ZodNumber>;
412
412
  rule_id: z.ZodNullable<z.ZodString>;
413
413
  rulebook_version: z.ZodNullable<z.ZodString>;
414
+ host: z.ZodNullable<z.ZodString>;
414
415
  evidence_pointers: z.ZodArray<z.ZodObject<{
415
416
  kind: z.ZodEnum<{
416
417
  claude_state_path: "claude_state_path";
@@ -593,6 +594,7 @@ export declare const DecisionEventEnvelopeSchema: z.ZodObject<{
593
594
  source_line_number: z.ZodNullable<z.ZodNumber>;
594
595
  rule_id: z.ZodNullable<z.ZodString>;
595
596
  rulebook_version: z.ZodNullable<z.ZodString>;
597
+ host: z.ZodNullable<z.ZodString>;
596
598
  evidence_pointers: z.ZodArray<z.ZodObject<{
597
599
  kind: z.ZodEnum<{
598
600
  claude_state_path: "claude_state_path";
@@ -330,7 +330,8 @@ export const DecisionEventSchema = z
330
330
  .object({
331
331
  /**
332
332
  * Deterministic, so re-running the backfill is idempotent and an edited
333
- * artifact produces a new id rather than mutating an old one. See
333
+ * record produces a new id rather than mutating an old one. Derived from the
334
+ * record's own bytes, never from the file around them — see
334
335
  * `decisionEventId`.
335
336
  */
336
337
  decision_event_id: NonEmptyStringSchema.max(64),
@@ -340,7 +341,13 @@ export const DecisionEventSchema = z
340
341
  subject: DecisionSubjectSchema,
341
342
  outcome: DecisionOutcomeSchema,
342
343
  capture_source: DecisionCaptureSourceSchema,
343
- /** The artifact this was read out of, so an edit is detectable. */
344
+ /**
345
+ * Which snapshot of which file this was read out of, and where in it. Both
346
+ * are provenance, not identity: a re-import of a ledger that has grown since
347
+ * refreshes them on the existing row. Identity comes from the record's own
348
+ * bytes instead, which is what lets that re-import update rather than
349
+ * duplicate — see `decisionEventId`.
350
+ */
344
351
  source_artifact_hash_sha256: Sha256Schema.nullable(),
345
352
  source_line_number: z.number().int().nonnegative().nullable(),
346
353
  /**
@@ -350,6 +357,20 @@ export const DecisionEventSchema = z
350
357
  */
351
358
  rule_id: NonEmptyStringSchema.max(40).nullable(),
352
359
  rulebook_version: VersionStringSchema.nullable(),
360
+ /**
361
+ * Which machine the decision was made on, straight from the ledger line.
362
+ *
363
+ * A column rather than an `attributes` key, and deliberately beside
364
+ * `rulebook_version`: both are provenance about the *decision* rather than
365
+ * about the actor, and "did the laptop behave differently from the desktop"
366
+ * is a group-by question. A jsonb key would answer it by digging.
367
+ *
368
+ * Nullable because both ledgers only started stamping `host` on 2026-08-04
369
+ * (BLI-2435). Every line before that legitimately has none, so absence is
370
+ * history rather than an error, and a record without one must ingest
371
+ * cleanly.
372
+ */
373
+ host: NonEmptyStringSchema.max(253).nullable(),
353
374
  evidence_pointers: z.array(DecisionEvidencePointerSchema).max(20),
354
375
  /**
355
376
  * Small, flat, non-narrative extras. Enforced as scalar-or-scalar-array by
@@ -448,6 +448,7 @@ export declare const CodexSessionAttributionSchema: z.ZodObject<{
448
448
  upload_failed: "upload_failed";
449
449
  not_uploaded: "not_uploaded";
450
450
  }>>;
451
+ upload_reason: z.ZodOptional<z.ZodString>;
451
452
  }, z.core.$strict>;
452
453
  export type CodexSessionAttribution = z.infer<typeof CodexSessionAttributionSchema>;
453
454
  export declare const CodexSessionAttributionReportRequestSchema: z.ZodObject<{
@@ -516,6 +517,7 @@ export declare const CodexSessionAttributionReportRequestSchema: z.ZodObject<{
516
517
  upload_failed: "upload_failed";
517
518
  not_uploaded: "not_uploaded";
518
519
  }>>;
520
+ upload_reason: z.ZodOptional<z.ZodString>;
519
521
  }, z.core.$strict>>;
520
522
  }, z.core.$strict>;
521
523
  export type CodexSessionAttributionReportRequest = z.infer<typeof CodexSessionAttributionReportRequestSchema>;
@@ -217,6 +217,23 @@ export const CodexSessionAttributionSchema = z
217
217
  .optional(),
218
218
  raw_evidence_pointer_id: NonEmptyStringSchema.optional(),
219
219
  upload_state: CodexSessionUploadStateSchema.optional(),
220
+ /**
221
+ * Why this session has no durable pointer.
222
+ *
223
+ * `attribution_reason` is required, so a session that fails attribution
224
+ * always says why. Until BLI-2107 the upload half had no such field, and
225
+ * `not_uploaded` was written as the branch of last resort — the state
226
+ * meaning "we did not upload it and we are not saying why". 1,118 of the
227
+ * 2,400 sessions missing a durable pointer on 2026-07-28 sat in exactly
228
+ * that hole, 87 of them fully `attributed` with no ambiguity at all.
229
+ *
230
+ * That is unfixable and un-retryable by construction: you cannot tell a
231
+ * dropped network from a file we will never accept, so a retry either
232
+ * repeats a doomed attempt forever or gives up on a recoverable one.
233
+ * Optional only because already-published CLI versions do not send it;
234
+ * every write path in this repo sets it.
235
+ */
236
+ upload_reason: SafeLabelSchema.optional(),
220
237
  })
221
238
  .strict();
222
239
  export const CodexSessionAttributionReportRequestSchema = z
package/dist/index.d.ts CHANGED
@@ -13,4 +13,5 @@ export * from "./secret-guards.js";
13
13
  export * from "./source-adapter.js";
14
14
  export * from "./ticket-id.js";
15
15
  export * from "./trace-sink.js";
16
+ export * from "./upload-failure-class.js";
16
17
  export * from "./work-context.js";
package/dist/index.js CHANGED
@@ -13,4 +13,5 @@ export * from "./secret-guards.js";
13
13
  export * from "./source-adapter.js";
14
14
  export * from "./ticket-id.js";
15
15
  export * from "./trace-sink.js";
16
+ export * from "./upload-failure-class.js";
16
17
  export * from "./work-context.js";
@@ -0,0 +1,63 @@
1
+ /**
2
+ * What a caller should do about it.
3
+ *
4
+ * - `transient` — something outside the run interfered. The identical retry is
5
+ * the right retry.
6
+ * - `deterministic` — the run completed and the answer was no. Repeating it
7
+ * changes nothing; this needs a different method or a human.
8
+ * - `withheld_by_policy` — collection worked and we chose not to upload. Not a
9
+ * failure, and emphatically not something to "fix" by retrying: that would be
10
+ * defeating a redaction guard.
11
+ * - `unknown` — an unlisted reason. Not silently lumped in with the others,
12
+ * because the fleet contract's whole point is that a green status must never
13
+ * hide missing collection.
14
+ */
15
+ export type UploadFailureClass = "transient" | "deterministic" | "withheld_by_policy" | "unknown";
16
+ /**
17
+ * The reason written when nothing in the pipeline explained itself.
18
+ *
19
+ * This is the honest name for the hole BLI-2107 found. It is expected to be
20
+ * non-zero on the first release and to fall toward zero as each path that can
21
+ * reach it learns to say why — a session carrying this label means the collector
22
+ * declined to upload and no code on the way there recorded a cause.
23
+ */
24
+ export declare const NO_UPLOAD_ATTEMPT_RECORDED = "no_upload_attempt_recorded";
25
+ /**
26
+ * Storage refused the finished object because of its size.
27
+ *
28
+ * Named here rather than at the commit route so the label the server writes and
29
+ * the label the collector classifies are the same string by construction. They
30
+ * drifted apart once already: the collector could say `file_too_large` about a
31
+ * file it declined to send, and had no word at all for storage declining to
32
+ * keep one it had already received.
33
+ */
34
+ export declare const STORAGE_REJECTED_OBJECT_TOO_LARGE = "storage_rejected_object_too_large";
35
+ /** Storage refused the finished object because of its declared media type. */
36
+ export declare const STORAGE_REJECTED_MEDIA_TYPE = "storage_rejected_media_type";
37
+ /** Classify a persisted upload reason. Unlisted reasons stay `unknown`. */
38
+ export declare function classifyUploadFailure(reason: string | null | undefined): UploadFailureClass;
39
+ /**
40
+ * Whether repeating the identical attempt could plausibly succeed.
41
+ *
42
+ * `unknown` answers false. An unclassified reason is one nobody has reasoned
43
+ * about yet, and retrying on that basis is the loop this ticket exists to end;
44
+ * it should surface for a decision instead.
45
+ */
46
+ export declare function isRetryableUploadFailure(reason: string | null | undefined): boolean;
47
+ /**
48
+ * Whether repeating the attempt is known to be pointless.
49
+ *
50
+ * The deliberate mirror image of `isRetryableUploadFailure`, and not its
51
+ * negation: `unknown` answers false here and false there. The two questions
52
+ * are asked in different places and an unlisted reason has to answer them
53
+ * differently.
54
+ *
55
+ * `isRetryableUploadFailure` decides whether to *claim* a retry will help, so
56
+ * silence means no. This one decides whether to *stop* retrying, so silence
57
+ * means keep going — the labels the collector composes on the fly
58
+ * (`commit_failed_http_503`, `chunk_7_failed_http_500`) are unlisted by
59
+ * construction, and treating them as permanent would abandon a sync over a
60
+ * storage blip. Only a reason somebody has classified as deterministic or
61
+ * withheld ends the loop.
62
+ */
63
+ export declare function isPermanentUploadFailure(reason: string | null | undefined): boolean;
@@ -0,0 +1,109 @@
1
+ // BLI-2107: what to do about a session that has no durable pointer.
2
+ //
3
+ // A retry that repeats the same call is only correct when something outside the
4
+ // run interfered — the network dropped, a budget was exhausted, a lock was
5
+ // held. If the run completed and the answer was still no, repeating it produces
6
+ // the same no, forever, and the only signal that anything is wrong is a number
7
+ // that never improves. That is how 1,118 sessions reached 2026-07-28 with no
8
+ // pointer and no explanation.
9
+ //
10
+ // So the reason is not just recorded, it is classified, and the class decides
11
+ // the response. Deliberately a flat lookup rather than pattern matching on the
12
+ // label: a rule like "contains `failed` means retry" quietly reclassifies every
13
+ // future reason somebody adds, which is the same class of guessing the person
14
+ // resolver refuses to do. An unlisted reason is `unknown` and stays visible
15
+ // until a human decides which bucket it belongs in.
16
+ /**
17
+ * The reason written when nothing in the pipeline explained itself.
18
+ *
19
+ * This is the honest name for the hole BLI-2107 found. It is expected to be
20
+ * non-zero on the first release and to fall toward zero as each path that can
21
+ * reach it learns to say why — a session carrying this label means the collector
22
+ * declined to upload and no code on the way there recorded a cause.
23
+ */
24
+ export const NO_UPLOAD_ATTEMPT_RECORDED = "no_upload_attempt_recorded";
25
+ /**
26
+ * Storage refused the finished object because of its size.
27
+ *
28
+ * Named here rather than at the commit route so the label the server writes and
29
+ * the label the collector classifies are the same string by construction. They
30
+ * drifted apart once already: the collector could say `file_too_large` about a
31
+ * file it declined to send, and had no word at all for storage declining to
32
+ * keep one it had already received.
33
+ */
34
+ export const STORAGE_REJECTED_OBJECT_TOO_LARGE = "storage_rejected_object_too_large";
35
+ /** Storage refused the finished object because of its declared media type. */
36
+ export const STORAGE_REJECTED_MEDIA_TYPE = "storage_rejected_media_type";
37
+ const UPLOAD_FAILURE_CLASSES = {
38
+ // Budgets and locks: the file was fine and the run simply ran out of room or
39
+ // was beaten to it. The next pass has room.
40
+ deferred_object_budget: "transient",
41
+ deferred_byte_budget: "transient",
42
+ sync_already_running: "transient",
43
+ // Delivery. The bytes never reached the other end, or the run died on the way.
44
+ upload_failed: "transient",
45
+ collection_failed: "transient",
46
+ file_read_failed: "transient",
47
+ git_diff_failed: "transient",
48
+ // The file itself is the problem, and it will be the same size and the same
49
+ // shape on the next pass. Retrying is a promise nobody can keep.
50
+ file_too_large: "deterministic",
51
+ // Storage looked at the finished object and said no. The bytes are already
52
+ // final and content-addressed, so the next attempt presents the identical
53
+ // object to the identical rule (BLI-2528). Retrying these is what let 76
54
+ // objects retry for 57 days without one of them ever landing.
55
+ [STORAGE_REJECTED_OBJECT_TOO_LARGE]: "deterministic",
56
+ [STORAGE_REJECTED_MEDIA_TYPE]: "deterministic",
57
+ // The content-addressed key already holds different bytes. One of the two is
58
+ // wrong and repeating the write cannot decide which; a human has to look.
59
+ object_conflict: "deterministic",
60
+ // Attribution never produced a target, so there is nowhere to put the object.
61
+ // Widening discovery can change this; running the same scan again cannot.
62
+ cwd_outside_scanned_worktrees: "deterministic",
63
+ cwd_not_a_repo: "deterministic",
64
+ no_repo_signals: "deterministic",
65
+ multiple_worktrees_close_scores: "deterministic",
66
+ // Working as designed. Never retry these — a retry here is an attempt to get
67
+ // a redaction guard to change its mind.
68
+ secret_like_content_guard: "withheld_by_policy",
69
+ secret_like_file_name: "withheld_by_policy",
70
+ secret_redaction_failed: "withheld_by_policy",
71
+ // The server-side name for the same guard, returned by the commit route.
72
+ secret_guard_rejected: "withheld_by_policy",
73
+ };
74
+ /** Classify a persisted upload reason. Unlisted reasons stay `unknown`. */
75
+ export function classifyUploadFailure(reason) {
76
+ if (!reason)
77
+ return "unknown";
78
+ return UPLOAD_FAILURE_CLASSES[reason] ?? "unknown";
79
+ }
80
+ /**
81
+ * Whether repeating the identical attempt could plausibly succeed.
82
+ *
83
+ * `unknown` answers false. An unclassified reason is one nobody has reasoned
84
+ * about yet, and retrying on that basis is the loop this ticket exists to end;
85
+ * it should surface for a decision instead.
86
+ */
87
+ export function isRetryableUploadFailure(reason) {
88
+ return classifyUploadFailure(reason) === "transient";
89
+ }
90
+ /**
91
+ * Whether repeating the attempt is known to be pointless.
92
+ *
93
+ * The deliberate mirror image of `isRetryableUploadFailure`, and not its
94
+ * negation: `unknown` answers false here and false there. The two questions
95
+ * are asked in different places and an unlisted reason has to answer them
96
+ * differently.
97
+ *
98
+ * `isRetryableUploadFailure` decides whether to *claim* a retry will help, so
99
+ * silence means no. This one decides whether to *stop* retrying, so silence
100
+ * means keep going — the labels the collector composes on the fly
101
+ * (`commit_failed_http_503`, `chunk_7_failed_http_500`) are unlisted by
102
+ * construction, and treating them as permanent would abandon a sync over a
103
+ * storage blip. Only a reason somebody has classified as deterministic or
104
+ * withheld ends the loop.
105
+ */
106
+ export function isPermanentUploadFailure(reason) {
107
+ const failureClass = classifyUploadFailure(reason);
108
+ return failureClass === "deterministic" || failureClass === "withheld_by_policy";
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/telemetry-core",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",