@obsunified/collector 2.0.1 → 2.2.0

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.
Files changed (126) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
  4. package/dist/chunk-KMEZKGSO.js.map +1 -0
  5. package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  6. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  7. package/dist/index.d.ts +630 -27
  8. package/dist/index.js +5819 -853
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  11. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  12. package/package.json +5 -5
  13. package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
  14. package/src/analyses/derive.ts +26 -25
  15. package/src/analyses/investigations.ts +8 -8
  16. package/src/analyses/tier0.ts +22 -22
  17. package/src/auth/dashboard-auth.test.ts +791 -0
  18. package/src/auth/dashboard-auth.ts +187 -38
  19. package/src/auth/login-rate-limit.test.ts +129 -0
  20. package/src/auth/login-rate-limit.ts +143 -0
  21. package/src/framework/body-limit.test.ts +165 -0
  22. package/src/framework/collector.ts +355 -115
  23. package/src/framework/env.ts +9 -0
  24. package/src/framework/limits.ts +31 -0
  25. package/src/framework/scheduled-handlers.test.ts +342 -0
  26. package/src/index.ts +59 -1
  27. package/src/lib/action-retention.test.ts +472 -0
  28. package/src/lib/action-retention.ts +420 -0
  29. package/src/lib/actions-store.ts +6 -12
  30. package/src/lib/ai-store/calls.ts +2 -2
  31. package/src/lib/ai-store/sessions.ts +3 -3
  32. package/src/lib/ai-store/spans.ts +1 -1
  33. package/src/lib/ai-store/types.ts +2 -1
  34. package/src/lib/alerts-store.test.ts +153 -0
  35. package/src/lib/alerts-store.ts +122 -21
  36. package/src/lib/analyses-runner.test.ts +75 -0
  37. package/src/lib/analyses-runner.ts +69 -3
  38. package/src/lib/analyses-store-latest.test.ts +61 -0
  39. package/src/lib/analyses-store.ts +17 -10
  40. package/src/lib/autosre-change-correlation.ts +780 -0
  41. package/src/lib/autosre-findings.ts +725 -0
  42. package/src/lib/autosre-ontology.ts +914 -0
  43. package/src/lib/autosre-remediation-gate.ts +1663 -0
  44. package/src/lib/blob-store.ts +1 -1
  45. package/src/lib/eval-cases-store.ts +63 -1
  46. package/src/lib/evidence-references.ts +6 -0
  47. package/src/lib/housekeeping.test.ts +153 -0
  48. package/src/lib/housekeeping.ts +193 -0
  49. package/src/lib/identity-index/action-lookups.ts +6 -6
  50. package/src/lib/identity-index/key-lookups.ts +2 -2
  51. package/src/lib/identity-index/user-lookup.ts +8 -5
  52. package/src/lib/identity-index.test.ts +2 -3
  53. package/src/lib/metrics-store-series.test.ts +99 -0
  54. package/src/lib/metrics-store.test.ts +23 -0
  55. package/src/lib/metrics-store.ts +27 -3
  56. package/src/lib/projects-store.test.ts +25 -0
  57. package/src/lib/projects-store.ts +4 -3
  58. package/src/lib/sql-db-dialect.test.ts +12 -3
  59. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  60. package/src/lib/sql-db-postgres.test.ts +314 -0
  61. package/src/lib/sql-db-postgres.ts +392 -54
  62. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  63. package/src/lib/sql-db.ts +40 -6
  64. package/src/lib/store/ingest.ts +94 -6
  65. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  66. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  67. package/src/lib/webhook-url.test.ts +122 -0
  68. package/src/lib/webhook-url.ts +155 -0
  69. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  70. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  71. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  72. package/src/migrations/044_actions_started_at_index.sql +10 -0
  73. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  74. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  75. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  76. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  77. package/src/migrations/049_alert_state_notified.sql +17 -0
  78. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  79. package/src/migrations/alert-state-notified.test.ts +111 -0
  80. package/src/migrations/parity.test.ts +68 -0
  81. package/src/migrations/project-scoped-keys.test.ts +432 -0
  82. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  83. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  84. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  85. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  86. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  87. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  88. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  89. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  90. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  91. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  92. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  93. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  94. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  95. package/src/migrations-postgres/README.md +58 -5
  96. package/src/otlp/decode/body.test.ts +76 -0
  97. package/src/otlp/decode/body.ts +67 -6
  98. package/src/plugins/_json.ts +21 -0
  99. package/src/plugins/action-graph-processor.ts +6 -12
  100. package/src/plugins/ai-receiver.ts +5 -12
  101. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  102. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  103. package/src/plugins/alerts-evaluator.test.ts +464 -3
  104. package/src/plugins/alerts-evaluator.ts +181 -41
  105. package/src/plugins/alerts-routes.ts +10 -6
  106. package/src/plugins/ask-routes.ts +4 -5
  107. package/src/plugins/autosre-routes.ts +1019 -0
  108. package/src/plugins/connected-routes/manifest.ts +13 -1
  109. package/src/plugins/connected-routes.test.ts +106 -0
  110. package/src/plugins/connected-routes.ts +285 -5
  111. package/src/plugins/dashboard-routes.test.ts +120 -0
  112. package/src/plugins/dashboard-routes.ts +11 -2
  113. package/src/plugins/default-span-enrichment.ts +13 -1
  114. package/src/plugins/eval-cases-routes.ts +65 -19
  115. package/src/plugins/evidence-retrieval-routes.ts +300 -4
  116. package/src/plugins/identity-receiver.ts +9 -11
  117. package/src/plugins/json-body.test.ts +91 -0
  118. package/src/plugins/onboarding-routes.test.ts +105 -0
  119. package/src/plugins/onboarding-routes.ts +26 -11
  120. package/src/plugins/projects-routes.ts +2 -1
  121. package/src/plugins/replay-isolation.test.ts +922 -0
  122. package/src/plugins/replay-receiver.ts +8 -8
  123. package/src/plugins/usage-receiver.ts +3 -6
  124. package/dist/chunk-UGWDOGGH.js.map +0 -1
  125. package/dist/chunk-XKQTVIRE.js.map +0 -1
  126. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -6,7 +6,7 @@
6
6
  * - `R2BlobStore` — Cloudflare R2 binding (default on Workers).
7
7
  * - `S3BlobStore` (sibling file) — AWS S3 / MinIO / any S3-compatible.
8
8
  *
9
- * Replay chunks live under `replays/<session_id>/<chunk_id>`; pprof
9
+ * Replay chunks live under `replays/<project_id>/<session_id>/<chunk_id>`; pprof
10
10
  * profiles live under `profiles/<service>/<trace_id>/<span_id>`. The
11
11
  * adapter never sees these paths — they're constructed by the callers
12
12
  * (`replay-receiver`, `profile-routes`).
@@ -76,6 +76,10 @@ export interface EvalRunCandidate {
76
76
  modelProvider: string | null;
77
77
  model: string | null;
78
78
  modelVersion: string | null;
79
+ environment?: string | null;
80
+ serviceVersion?: string | null;
81
+ commitSha?: string | null;
82
+ configDigest?: string | null;
79
83
  }
80
84
 
81
85
  export interface EvalRunSourceCase {
@@ -99,6 +103,12 @@ export interface EvalRun {
99
103
  evalCaseId: string | null;
100
104
  status: EvalRunStatus;
101
105
  candidate: EvalRunCandidate;
106
+ environment?: string;
107
+ serviceVersion?: string | null;
108
+ commitSha?: string | null;
109
+ configDigest?: string | null;
110
+ inputPayloadHash?: string | null;
111
+ failureFingerprint?: string | null;
102
112
  startedAt: string | null;
103
113
  endedAt: string | null;
104
114
  totalCount: number;
@@ -117,6 +127,12 @@ export interface EvalRunInput {
117
127
  evalCaseId?: string | null;
118
128
  status?: EvalRunStatus;
119
129
  candidate?: Partial<EvalRunCandidate>;
130
+ environment?: string | null;
131
+ serviceVersion?: string | null;
132
+ commitSha?: string | null;
133
+ configDigest?: string | null;
134
+ inputPayloadHash?: string | null;
135
+ failureFingerprint?: string | null;
120
136
  startedAt?: string | null;
121
137
  endedAt?: string | null;
122
138
  totalCount?: number;
@@ -144,6 +160,12 @@ interface EvalRunRow {
144
160
  candidate_model_provider: string | null;
145
161
  candidate_model: string | null;
146
162
  candidate_model_version: string | null;
163
+ environment?: string | null;
164
+ service_version?: string | null;
165
+ commit_sha?: string | null;
166
+ config_digest?: string | null;
167
+ input_payload_hash?: string | null;
168
+ failure_fingerprint?: string | null;
147
169
  status: string;
148
170
  started_at: string | null;
149
171
  ended_at: string | null;
@@ -412,7 +434,17 @@ const rowToEvalRun = (row: EvalRunRow): EvalRun => {
412
434
  modelProvider: row.candidate_model_provider ?? null,
413
435
  model: row.candidate_model ?? null,
414
436
  modelVersion: row.candidate_model_version ?? null,
437
+ environment: row.environment ?? "default",
438
+ serviceVersion: row.service_version ?? null,
439
+ commitSha: row.commit_sha ?? null,
440
+ configDigest: row.config_digest ?? null,
415
441
  },
442
+ environment: row.environment ?? "default",
443
+ serviceVersion: row.service_version ?? null,
444
+ commitSha: row.commit_sha ?? null,
445
+ configDigest: row.config_digest ?? null,
446
+ inputPayloadHash: row.input_payload_hash ?? null,
447
+ failureFingerprint: row.failure_fingerprint ?? null,
416
448
  startedAt: row.started_at ?? null,
417
449
  endedAt: row.ended_at ?? null,
418
450
  totalCount: row.total_count ?? 0,
@@ -560,6 +592,16 @@ export class EvalCasesStore {
560
592
  }
561
593
 
562
594
  const candidate = input.candidate ?? {};
595
+ const environment =
596
+ input.environment?.trim() || candidate.environment?.trim() || "default";
597
+ const serviceVersion =
598
+ input.serviceVersion?.trim() || candidate.serviceVersion?.trim() || null;
599
+ const commitSha =
600
+ input.commitSha?.trim() || candidate.commitSha?.trim() || null;
601
+ const configDigest =
602
+ input.configDigest?.trim() || candidate.configDigest?.trim() || null;
603
+ const inputPayloadHash = input.inputPayloadHash?.trim() || null;
604
+ const failureFingerprint = input.failureFingerprint?.trim() || null;
563
605
  const totalCount = nonNegativeInteger(input.totalCount);
564
606
  const passCount = nonNegativeInteger(input.passCount);
565
607
  const failCount = nonNegativeInteger(input.failCount);
@@ -580,10 +622,12 @@ export class EvalCasesStore {
580
622
  candidate_agent_id, candidate_agent_version,
581
623
  candidate_prompt_id, candidate_prompt_version,
582
624
  candidate_model_provider, candidate_model, candidate_model_version,
625
+ environment, service_version, commit_sha, config_digest,
626
+ input_payload_hash, failure_fingerprint,
583
627
  status, started_at, ended_at,
584
628
  total_count, pass_count, fail_count, average_score,
585
629
  metadata_json, created_at
586
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
630
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
587
631
  )
588
632
  .bind(
589
633
  id,
@@ -596,6 +640,12 @@ export class EvalCasesStore {
596
640
  candidate.modelProvider ?? null,
597
641
  candidate.model ?? null,
598
642
  candidate.modelVersion ?? null,
643
+ environment,
644
+ serviceVersion,
645
+ commitSha,
646
+ configDigest,
647
+ inputPayloadHash,
648
+ failureFingerprint,
599
649
  status,
600
650
  startedAt,
601
651
  endedAt,
@@ -679,6 +729,14 @@ export class EvalCasesStore {
679
729
  average_score = (
680
730
  SELECT AVG(score) FROM eval_case_results
681
731
  WHERE project_id = ? AND run_id = ? AND score IS NOT NULL
732
+ ),
733
+ input_payload_hash = COALESCE(
734
+ input_payload_hash,
735
+ (SELECT input_payload_hash FROM eval_case_results WHERE project_id = ? AND run_id = ? AND input_payload_hash IS NOT NULL LIMIT 1)
736
+ ),
737
+ failure_fingerprint = COALESCE(
738
+ failure_fingerprint,
739
+ (SELECT failure_fingerprint FROM eval_case_results WHERE project_id = ? AND run_id = ? AND failure_fingerprint IS NOT NULL LIMIT 1)
682
740
  )
683
741
  WHERE project_id = ? AND id = ?`,
684
742
  )
@@ -693,6 +751,10 @@ export class EvalCasesStore {
693
751
  id,
694
752
  projectId,
695
753
  id,
754
+ projectId,
755
+ id,
756
+ projectId,
757
+ id,
696
758
  )
697
759
  .run();
698
760
  }
@@ -146,6 +146,12 @@ export const evidenceRouteFor = (
146
146
  return traceRoute(entityId);
147
147
  case "docs":
148
148
  return entityId.startsWith("#/") ? entityId : `#/docs/${entityId}`;
149
+ case "change":
150
+ return `#/changes/${encodeURIComponent(entityId)}`;
151
+ case "finding":
152
+ return `#/findings/${encodeURIComponent(entityId)}`;
153
+ case "incident":
154
+ return `#/incidents/${encodeURIComponent(entityId)}`;
149
155
  }
150
156
  const exhaustive: never = entityKind;
151
157
  return exhaustive;
@@ -0,0 +1,153 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { purgeSecondaryRows } from "./housekeeping";
3
+ import { createMigratedSqliteDb } from "./test-utils/node-sqlite-db";
4
+
5
+ const HOUR = 3_600_000;
6
+ const NOW = Date.UTC(2026, 9, 13, 12, 0, 0);
7
+ const iso = (hoursAgo: number) => new Date(NOW - hoursAgo * HOUR).toISOString();
8
+
9
+ describe("purgeSecondaryRows", () => {
10
+ it("removes only rows no API window or ingest path can still reach", async () => {
11
+ const db = createMigratedSqliteDb();
12
+ const raw = db.raw;
13
+ raw.exec(`
14
+ INSERT INTO projects (id, name, slug, created_at) VALUES ('alpha', 'A', 'a', '${iso(1000)}');
15
+ INSERT INTO alert_rules (id, project_id, name, signal, query_json, threshold, window_mins, comparison, channels_json, enabled, created_at, updated_at)
16
+ VALUES ('r1', 'alpha', 'r', 'spans', '{}', 1, 5, '>', '[]', 1, '${iso(1000)}', '${iso(1000)}');
17
+ `);
18
+ const ev = raw.prepare(
19
+ `INSERT INTO alert_evaluations (id, rule_id, project_id, evaluated_at, value, state, notified) VALUES (?, 'r1', 'alpha', ?, 1, 'ok', 0)`,
20
+ );
21
+ ev.run("old", iso(721));
22
+ ev.run("edge", iso(719));
23
+ const ask = raw.prepare(
24
+ `INSERT INTO ask_evidence_events (project_id, analysis_id, asked_at) VALUES ('alpha', 'x', ?)`,
25
+ );
26
+ ask.run(NOW - 31 * 24 * HOUR);
27
+ ask.run(NOW - 29 * 24 * HOUR);
28
+ const series = raw.prepare(
29
+ `INSERT INTO metric_series (id, project_id, name, type, identity, created_at) VALUES (?, 'alpha', 'm', 'gauge', ?, ?)`,
30
+ );
31
+ series.run("orphan-old", "i1", iso(100)); // no points, older than window
32
+ series.run("orphan-new", "i2", iso(1)); // no points but just created
33
+ series.run("live", "i3", iso(100)); // old but still has a point
34
+ raw
35
+ .prepare(
36
+ `INSERT INTO metric_point (id, series_id, project_id, ts_ns, received_at, expires_at) VALUES ('p1', 'live', 'alpha', '1', ?, ?)`,
37
+ )
38
+ .run(iso(1), iso(-1));
39
+ const ref = raw.prepare(
40
+ `INSERT INTO evidence_retrieval_refs (ref_id, project_id, kind, anchor_kind, anchor_id, source, expires_at) VALUES (?, 'alpha', 'logs', 'trace', 't', 'test', ?)`,
41
+ );
42
+ ref.run("expired", iso(1));
43
+ ref.run("fresh", iso(-1));
44
+ ref.run("no-expiry", null);
45
+ const exp = raw.prepare(
46
+ `INSERT INTO evidence_ref_expansions (id, ref_id, project_id, kind, operation, result_status) VALUES (?, ?, 'alpha', 'logs', 'expand', 'ok')`,
47
+ );
48
+ exp.run("e1", "expired");
49
+ exp.run("e2", "fresh");
50
+
51
+ const summary = await purgeSecondaryRows(db, {
52
+ retentionHours: 72,
53
+ now: () => NOW,
54
+ });
55
+ expect(summary).toEqual({
56
+ alert_evaluations: 1,
57
+ ask_evidence_events: 1,
58
+ metric_series: 1,
59
+ evidence_refs: 1,
60
+ evidence_ref_expansions: 1,
61
+ ontology_edges: 0,
62
+ ontology_nodes: 0,
63
+ change_events: 0,
64
+ incident_investigations: 0,
65
+ remediation_proposals: 0,
66
+ investigation_findings: 0,
67
+ });
68
+ const ids = (sql: string) =>
69
+ (raw.prepare(sql).all() as Array<{ id: string }>).map((r) => r.id).sort();
70
+ expect(ids("SELECT id FROM alert_evaluations")).toEqual(["edge"]);
71
+ expect(ids("SELECT id FROM metric_series")).toEqual(["live", "orphan-new"]);
72
+ expect(ids("SELECT ref_id AS id FROM evidence_retrieval_refs")).toEqual([
73
+ "fresh",
74
+ "no-expiry",
75
+ ]);
76
+ expect(ids("SELECT id FROM evidence_ref_expansions")).toEqual(["e2"]);
77
+ db.close();
78
+ });
79
+
80
+ it("enforces RFC 0013 AutoSRE retention windows while preserving counterfactual_verified and operationally_corroborated findings", async () => {
81
+ const db = createMigratedSqliteDb();
82
+ const raw = db.raw;
83
+ raw.exec(`
84
+ INSERT INTO projects (id, name, slug, created_at) VALUES ('alpha', 'A', 'a', '${iso(3000)}');
85
+ `);
86
+ // Ontology edges/nodes (30d = 720h)
87
+ raw
88
+ .prepare(
89
+ `INSERT INTO ontology_nodes (id, project_id, environment, kind, name, first_seen_at, last_seen_at) VALUES (?, 'alpha', 'prod', 'service', ?, ?, ?)`,
90
+ )
91
+ .run("n-old", "old-svc", iso(800), iso(750));
92
+ raw
93
+ .prepare(
94
+ `INSERT INTO ontology_nodes (id, project_id, environment, kind, name, first_seen_at, last_seen_at) VALUES (?, 'alpha', 'prod', 'service', ?, ?, ?)`,
95
+ )
96
+ .run("n-fresh", "fresh-svc", iso(100), iso(10));
97
+ raw
98
+ .prepare(
99
+ `INSERT INTO ontology_edges (id, project_id, environment, source_node_id, predicate, target_node_id, relationship_certainty, first_seen_at, last_seen_at) VALUES (?, 'alpha', 'prod', 'n-old', 'calls', 'n-fresh', 'exact', ?, ?)`,
100
+ )
101
+ .run("edge-old", iso(800), iso(750));
102
+
103
+ // Change events (90d = 2160h)
104
+ raw
105
+ .prepare(
106
+ `INSERT INTO change_events (id, project_id, environment, kind, source, target_node_id, summary, occurred_at, received_at) VALUES (?, 'alpha', 'prod', 'deploy', 'ci_webhook', 'n-fresh', 'old deploy', ?, ?)`,
107
+ )
108
+ .run("chg-old", iso(2200), iso(2200));
109
+ raw
110
+ .prepare(
111
+ `INSERT INTO change_events (id, project_id, environment, kind, source, target_node_id, summary, occurred_at, received_at) VALUES (?, 'alpha', 'prod', 'deploy', 'ci_webhook', 'n-fresh', 'recent deploy', ?, ?)`,
112
+ )
113
+ .run("chg-recent", iso(100), iso(100));
114
+
115
+ // Findings (90d = 2160h, unless counterfactual_verified or operationally_corroborated)
116
+ const insFinding = raw.prepare(
117
+ `INSERT INTO investigation_findings (id, project_id, environment, fingerprint, fingerprint_input_json, title, summary, causal_proof_tier, causal_verification_status, confidence, confidence_breakdown_json, symptom_entity_kind, symptom_entity_id, fault_locus_entity_kind, fault_locus_entity_id, causal_chain_json, cited_evidence_ids_json, actor_type, actor_id, created_at, updated_at) VALUES (?, 'alpha', 'prod', 'fp_1', '{}', 't', 's', 'corroborated_candidate', ?, 0.8, '{}', 'trace', 't1', 'span', 's1', '[]', '["t1"]', 'agent', 'autosre', ?, ?)`,
118
+ );
119
+ insFinding.run("fnd-old-unverified", "unverified", iso(2200), iso(2200));
120
+ insFinding.run(
121
+ "fnd-old-verified",
122
+ "counterfactual_verified",
123
+ iso(2200),
124
+ iso(2200),
125
+ );
126
+ insFinding.run(
127
+ "fnd-old-corroborated",
128
+ "operationally_corroborated",
129
+ iso(2200),
130
+ iso(2200),
131
+ );
132
+
133
+ const summary = await purgeSecondaryRows(db, {
134
+ retentionHours: 72,
135
+ now: () => NOW,
136
+ });
137
+ expect(summary.ontology_edges).toBe(1);
138
+ expect(summary.ontology_nodes).toBe(1);
139
+ expect(summary.change_events).toBe(1);
140
+ expect(summary.investigation_findings).toBe(1);
141
+
142
+ const remainingFindings = (
143
+ raw
144
+ .prepare(`SELECT id FROM investigation_findings ORDER BY id ASC`)
145
+ .all() as Array<{ id: string }>
146
+ ).map((r) => r.id);
147
+ expect(remainingFindings).toEqual([
148
+ "fnd-old-corroborated",
149
+ "fnd-old-verified",
150
+ ]);
151
+ db.close();
152
+ });
153
+ });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Retention for the secondary tables the hourly sweep did not cover. Each
3
+ * rule is tied to an existing contract rather than a new policy:
4
+ *
5
+ * - `alert_evaluations`: `/internal/alerts/evaluations` accepts at most a
6
+ * 720-hour window, so rows older than that are unreachable through the API.
7
+ * - `ask_evidence_events`: feeds `getTopAskedAnalyses`, whose trailing window
8
+ * is 7 days; rows older than 30 days cannot influence it.
9
+ * - `metric_series`: identity rows whose points have all expired and that
10
+ * were created before the retention window. They hold no data; the next
11
+ * ingest of that identity recreates the row.
12
+ * - `evidence_retrieval_refs`: rows carry their own `expires_at` (copied from
13
+ * the signal they reference); expansions follow their ref.
14
+ */
15
+
16
+ import { evaluateRemediationWatchdogs } from "./autosre-remediation-gate";
17
+ import type { SqlDb } from "./sql-db";
18
+
19
+ export const ALERT_EVALUATIONS_MAX_AGE_HOURS = 720;
20
+ export const ASK_EVIDENCE_EVENTS_MAX_AGE_HOURS = 30 * 24;
21
+
22
+ export interface HousekeepingSummary {
23
+ alert_evaluations: number;
24
+ ask_evidence_events: number;
25
+ metric_series: number;
26
+ evidence_refs: number;
27
+ evidence_ref_expansions: number;
28
+ ontology_edges?: number;
29
+ ontology_nodes?: number;
30
+ change_events?: number;
31
+ incident_investigations?: number;
32
+ investigation_findings?: number;
33
+ remediation_proposals?: number;
34
+ }
35
+
36
+ export interface HousekeepingOptions {
37
+ /** Window that bounds orphaned metric series; same value the main sweep uses. */
38
+ retentionHours: number;
39
+ /** Clock override for tests. */
40
+ now?: () => number;
41
+ }
42
+
43
+ const changes = (r: { meta?: { changes?: number } }) => r.meta?.changes ?? 0;
44
+
45
+ export async function purgeSecondaryRows(
46
+ db: SqlDb,
47
+ options: HousekeepingOptions,
48
+ ): Promise<HousekeepingSummary> {
49
+ const nowMs = options.now?.() ?? Date.now();
50
+ const isoBefore = (hours: number) =>
51
+ new Date(nowMs - hours * 3_600_000).toISOString();
52
+ const nowIso = isoBefore(0);
53
+
54
+ const alertEvaluations = await db
55
+ .prepare(`DELETE FROM alert_evaluations WHERE evaluated_at < ?`)
56
+ .bind(isoBefore(ALERT_EVALUATIONS_MAX_AGE_HOURS))
57
+ .run();
58
+
59
+ const askEvents = await db
60
+ .prepare(`DELETE FROM ask_evidence_events WHERE asked_at < ?`)
61
+ .bind(nowMs - ASK_EVIDENCE_EVENTS_MAX_AGE_HOURS * 3_600_000)
62
+ .run();
63
+
64
+ // Points and exemplars for the window are purged by MetricsStore first;
65
+ // a series created before the window with no remaining points is inert.
66
+ const series = await db
67
+ .prepare(
68
+ `DELETE FROM metric_series
69
+ WHERE created_at < ?
70
+ AND NOT EXISTS (
71
+ SELECT 1 FROM metric_point p WHERE p.series_id = metric_series.id
72
+ )`,
73
+ )
74
+ .bind(isoBefore(options.retentionHours))
75
+ .run();
76
+
77
+ const refs = await db
78
+ .prepare(
79
+ `DELETE FROM evidence_retrieval_refs
80
+ WHERE expires_at IS NOT NULL AND expires_at < ?`,
81
+ )
82
+ .bind(nowIso)
83
+ .run();
84
+ const expansions = await db
85
+ .prepare(
86
+ `DELETE FROM evidence_ref_expansions
87
+ WHERE NOT EXISTS (
88
+ SELECT 1 FROM evidence_retrieval_refs r
89
+ WHERE r.ref_id = evidence_ref_expansions.ref_id
90
+ )`,
91
+ )
92
+ .run();
93
+
94
+ // RFC 0013 — AutoSRE ontology, change events, incident investigations,
95
+ // investigation findings, and remediation proposals retention + watchdog.
96
+ const thirtyDaysAgoIso = isoBefore(30 * 24);
97
+ const ninetyDaysAgoIso = isoBefore(90 * 24);
98
+ let ontologyEdgesPurged = 0;
99
+ let ontologyNodesPurged = 0;
100
+ let changeEventsPurged = 0;
101
+ let incidentInvestigationsPurged = 0;
102
+ let investigationFindingsPurged = 0;
103
+ let remediationProposalsPurged = 0;
104
+ try {
105
+ const edgesRes = await db
106
+ .prepare(`DELETE FROM ontology_edges WHERE last_seen_at < ?`)
107
+ .bind(thirtyDaysAgoIso)
108
+ .run();
109
+ ontologyEdgesPurged = changes(edgesRes);
110
+
111
+ const nodesRes = await db
112
+ .prepare(
113
+ `DELETE FROM ontology_nodes
114
+ WHERE last_seen_at < ?
115
+ AND NOT EXISTS (
116
+ SELECT 1 FROM ontology_edges e
117
+ WHERE e.project_id = ontology_nodes.project_id
118
+ AND e.environment = ontology_nodes.environment
119
+ AND (e.source_node_id = ontology_nodes.id OR e.target_node_id = ontology_nodes.id)
120
+ )`,
121
+ )
122
+ .bind(thirtyDaysAgoIso)
123
+ .run();
124
+ ontologyNodesPurged = changes(nodesRes);
125
+
126
+ const proposalsRes = await db
127
+ .prepare(
128
+ `DELETE FROM remediation_proposals
129
+ WHERE (expires_at IS NOT NULL AND expires_at < ?)
130
+ OR (expires_at IS NULL AND created_at < ?)`,
131
+ )
132
+ .bind(nowIso, ninetyDaysAgoIso)
133
+ .run();
134
+ remediationProposalsPurged = changes(proposalsRes);
135
+
136
+ const findingsRes = await db
137
+ .prepare(
138
+ `DELETE FROM investigation_findings
139
+ WHERE causal_verification_status NOT IN ('counterfactual_verified', 'operationally_corroborated')
140
+ AND ((expires_at IS NOT NULL AND expires_at < ?)
141
+ OR (expires_at IS NULL AND created_at < ?))`,
142
+ )
143
+ .bind(nowIso, ninetyDaysAgoIso)
144
+ .run();
145
+ investigationFindingsPurged = changes(findingsRes);
146
+
147
+ // Preserve change_events referenced by active investigation_findings
148
+ const changesRes = await db
149
+ .prepare(
150
+ `DELETE FROM change_events
151
+ WHERE ((expires_at IS NOT NULL AND expires_at < ?)
152
+ OR (expires_at IS NULL AND occurred_at < ?))
153
+ AND NOT EXISTS (
154
+ SELECT 1 FROM investigation_findings f
155
+ WHERE f.project_id = change_events.project_id
156
+ AND f.environment = change_events.environment
157
+ AND f.contributing_change_id = change_events.id
158
+ )`,
159
+ )
160
+ .bind(nowIso, ninetyDaysAgoIso)
161
+ .run();
162
+ changeEventsPurged = changes(changesRes);
163
+
164
+ const incidentsRes = await db
165
+ .prepare(
166
+ `DELETE FROM incident_investigations
167
+ WHERE status IN ('resolved', 'escalated')
168
+ AND ((expires_at IS NOT NULL AND expires_at < ?)
169
+ OR (expires_at IS NULL AND updated_at < ?))`,
170
+ )
171
+ .bind(nowIso, ninetyDaysAgoIso)
172
+ .run();
173
+ incidentInvestigationsPurged = changes(incidentsRes);
174
+
175
+ await evaluateRemediationWatchdogs(db, { nowIso });
176
+ } catch {
177
+ // Gracefully ignore when running against partial test schemas without 050
178
+ }
179
+
180
+ return {
181
+ alert_evaluations: changes(alertEvaluations),
182
+ ask_evidence_events: changes(askEvents),
183
+ metric_series: changes(series),
184
+ evidence_refs: changes(refs),
185
+ evidence_ref_expansions: changes(expansions),
186
+ ontology_edges: ontologyEdgesPurged,
187
+ ontology_nodes: ontologyNodesPurged,
188
+ change_events: changeEventsPurged,
189
+ incident_investigations: incidentInvestigationsPurged,
190
+ investigation_findings: investigationFindingsPurged,
191
+ remediation_proposals: remediationProposalsPurged,
192
+ };
193
+ }
@@ -313,10 +313,10 @@ export async function manifestByAction(
313
313
  .prepare(
314
314
  `SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
315
315
  FROM session_replay_metadata
316
- WHERE session_id IN (${sessionPlaceholders})
316
+ WHERE project_id = ? AND session_id IN (${sessionPlaceholders})
317
317
  ORDER BY last_chunk_at DESC LIMIT 1`,
318
318
  )
319
- .bind(...sessionIds)
319
+ .bind(projectId, ...sessionIds)
320
320
  .first<{
321
321
  session_id: string;
322
322
  first_chunk_at: string;
@@ -546,10 +546,10 @@ export async function manifestByAgentRun(
546
546
  .prepare(
547
547
  `SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
548
548
  FROM session_replay_metadata
549
- WHERE session_id IN (${sessionPlaceholders})
549
+ WHERE project_id = ? AND session_id IN (${sessionPlaceholders})
550
550
  ORDER BY last_chunk_at DESC LIMIT 1`,
551
551
  )
552
- .bind(...sessionIds)
552
+ .bind(projectId, ...sessionIds)
553
553
  .first<{
554
554
  session_id: string;
555
555
  first_chunk_at: string;
@@ -767,10 +767,10 @@ export async function manifestByActor(
767
767
  .prepare(
768
768
  `SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
769
769
  FROM session_replay_metadata
770
- WHERE session_id IN (${sessionPlaceholders})
770
+ WHERE project_id = ? AND session_id IN (${sessionPlaceholders})
771
771
  ORDER BY last_chunk_at DESC LIMIT 1`,
772
772
  )
773
- .bind(...sessionIds)
773
+ .bind(projectId, ...sessionIds)
774
774
  .first<{
775
775
  session_id: string;
776
776
  first_chunk_at: string;
@@ -53,9 +53,9 @@ export async function manifestBySession(
53
53
  .prepare(
54
54
  `SELECT first_chunk_at, last_chunk_at, chunk_count, events_count
55
55
  FROM session_replay_metadata
56
- WHERE session_id = ?`,
56
+ WHERE project_id = ? AND session_id = ?`,
57
57
  )
58
- .bind(sessionId)
58
+ .bind(projectId, sessionId)
59
59
  .first<{
60
60
  first_chunk_at: string;
61
61
  last_chunk_at: string;
@@ -13,10 +13,13 @@ export async function manifestByUser(
13
13
  const sessionLimit = Math.max(1, Math.min(opts.sessions ?? 5, 20));
14
14
 
15
15
  // Step 1 — resolve user_id → visitor_id. user_profiles is the only
16
- // table that owns the user_id ↔ visitor_id binding.
16
+ // table that owns the user_id ↔ visitor_id binding, keyed per project
17
+ // (the same user_id can exist in several projects — migration 045).
17
18
  const profile = await db
18
- .prepare(`SELECT visitor_id FROM user_profiles WHERE user_id = ? LIMIT 1`)
19
- .bind(userId)
19
+ .prepare(
20
+ `SELECT visitor_id FROM user_profiles WHERE project_id = ? AND user_id = ? LIMIT 1`,
21
+ )
22
+ .bind(projectId, userId)
20
23
  .first<{ visitor_id: string }>();
21
24
  if (!profile) {
22
25
  return {
@@ -105,10 +108,10 @@ export async function manifestByUser(
105
108
  .prepare(
106
109
  `SELECT session_id, first_chunk_at, last_chunk_at, chunk_count, events_count
107
110
  FROM session_replay_metadata
108
- WHERE session_id IN (${placeholders})
111
+ WHERE project_id = ? AND session_id IN (${placeholders})
109
112
  ORDER BY last_chunk_at DESC LIMIT 1`,
110
113
  )
111
- .bind(...sessionIds)
114
+ .bind(projectId, ...sessionIds)
112
115
  .first<{
113
116
  session_id: string;
114
117
  first_chunk_at: string;
@@ -81,13 +81,12 @@ describe("IdentityIndex.bySession", () => {
81
81
  eventsCount: 42,
82
82
  });
83
83
 
84
- // All five queries pin (project_id, session_id) — replay lookup uses
85
- // just session_id since replay is global.
84
+ // All five queries pin (project_id, session_id).
86
85
  const allCalls = db.calls.filter((c) => c.op !== "first" || c.binds.length);
87
86
  const sessionQueries = allCalls.filter(
88
87
  (c) => c.sql.includes("session_id") && c.binds.includes("proj-1"),
89
88
  );
90
- expect(sessionQueries.length).toBeGreaterThanOrEqual(4);
89
+ expect(sessionQueries.length).toBe(5);
91
90
  });
92
91
 
93
92
  it("returns null replay when session_replay_metadata is empty", async () => {