1use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod};
17use tokio_postgres::NoTls;
18use tokio_postgres::config::SslMode;
19
20use crate::core::config::BaselineConfig;
21use crate::core::gain::model_pricing::ModelPricing;
22use crate::core::ocla::OclaRegistry;
23use crate::proxy::usage::RealUsage;
24
25pub const WRITER_QUEUE: usize = 4096;
28
29pub const POOL_MAX_SIZE_ENV: &str = "LEAN_CTX_PG_POOL_MAX_SIZE";
31
32const POOL_MAX_SIZE_DEFAULT: usize = 8;
37
38fn pool_max_size() -> usize {
42 std::env::var(POOL_MAX_SIZE_ENV)
43 .ok()
44 .and_then(|v| v.trim().parse::<usize>().ok())
45 .map_or(POOL_MAX_SIZE_DEFAULT, |n| n.clamp(2, 64))
46}
47
48pub fn pool_from_database_url(database_url: &str) -> anyhow::Result<Pool> {
58 let pg_cfg: tokio_postgres::Config = database_url.parse()?;
59 let mgr_cfg = ManagerConfig {
60 recycling_method: RecyclingMethod::Fast,
61 };
62 let mgr = if wants_tls(&pg_cfg) {
63 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
66 let roots = rustls::RootCertStore {
67 roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
68 };
69 let tls_cfg = rustls::ClientConfig::builder()
70 .with_root_certificates(roots)
71 .with_no_client_auth();
72 Manager::from_config(
73 pg_cfg,
74 tokio_postgres_rustls::MakeRustlsConnect::new(tls_cfg),
75 mgr_cfg,
76 )
77 } else {
78 Manager::from_config(pg_cfg, NoTls, mgr_cfg)
79 };
80 Ok(Pool::builder(mgr).max_size(pool_max_size()).build()?)
81}
82
83fn wants_tls(cfg: &tokio_postgres::Config) -> bool {
86 matches!(cfg.get_ssl_mode(), SslMode::Require)
87}
88
89const USAGE_EVENTS_DDL: &str = r"
93CREATE TABLE IF NOT EXISTS usage_events (
94 id BIGSERIAL PRIMARY KEY,
95 ts TIMESTAMPTZ NOT NULL DEFAULT now(),
96 person TEXT NOT NULL,
97 team TEXT,
98 project TEXT NOT NULL,
99 tool TEXT,
100 provider TEXT NOT NULL,
101 model TEXT NOT NULL,
102 routed_from TEXT,
103 input_tokens BIGINT NOT NULL,
104 output_tokens BIGINT NOT NULL,
105 cache_read_tokens BIGINT NOT NULL DEFAULT 0,
106 cache_write_tokens BIGINT NOT NULL DEFAULT 0,
107 reasoning_tokens BIGINT NOT NULL DEFAULT 0,
108 cost_usd DOUBLE PRECISION NOT NULL,
109 saved_tokens BIGINT NOT NULL DEFAULT 0,
110 saved_usd DOUBLE PRECISION NOT NULL DEFAULT 0,
111 -- Avoided-cost baseline for the success fee (enterprise#18, Doc 04 §6):
112 uncompressed_input_tokens BIGINT NOT NULL DEFAULT 0,
113 reference_model TEXT,
114 reference_cost_usd DOUBLE PRECISION NOT NULL DEFAULT 0,
115 is_local BOOLEAN NOT NULL DEFAULT false,
116 -- Cost provenance (#1179): provider | shadow | list | live | heuristic.
117 cost_source TEXT NOT NULL DEFAULT 'list'
118);
119ALTER TABLE usage_events ADD COLUMN IF NOT EXISTS cost_source TEXT NOT NULL DEFAULT 'list';
120CREATE INDEX IF NOT EXISTS idx_usage_events_person_ts ON usage_events (person, ts);
121CREATE INDEX IF NOT EXISTS idx_usage_events_project_ts ON usage_events (project, ts);
122CREATE INDEX IF NOT EXISTS idx_usage_events_model_ts ON usage_events (model, ts);
123";
124
125pub async fn init_schema(pool: &Pool) -> anyhow::Result<()> {
127 let client = pool.get().await?;
128 client.batch_execute(USAGE_EVENTS_DDL).await?;
129 Ok(())
130}
131
132#[derive(Debug, Clone, PartialEq)]
134pub struct UsageEvent {
135 pub person: String,
136 pub team: Option<String>,
137 pub project: String,
138 pub provider: String,
139 pub model: String,
140 pub routed_from: Option<String>,
141 pub input_tokens: i64,
142 pub output_tokens: i64,
143 pub cache_read_tokens: i64,
144 pub cache_write_tokens: i64,
145 pub reasoning_tokens: i64,
146 pub cost_usd: f64,
147 pub saved_tokens: i64,
148 pub saved_usd: f64,
149 pub uncompressed_input_tokens: i64,
150 pub reference_model: Option<String>,
151 pub reference_cost_usd: f64,
152 pub is_local: bool,
153 pub cost_source: &'static str,
157}
158
159fn cost_source_of(kind: crate::core::gain::model_pricing::PricingMatchKind) -> &'static str {
162 use crate::core::gain::model_pricing::PricingMatchKind as K;
163 match kind {
164 K::Exact => "list",
165 K::Live => "live",
166 K::Alias | K::Heuristic | K::Fallback => "heuristic",
167 }
168}
169
170const ANONYMOUS_PERSON: &str = "anonymous";
175const DEFAULT_PROJECT: &str = "default";
176
177impl UsageEvent {
178 #[must_use]
193 pub fn from_usage(
194 usage: &RealUsage,
195 pricing: &ModelPricing,
196 baseline: &BaselineConfig,
197 ) -> Self {
198 let wire = usage.wire.as_deref();
199 let quote = pricing.quote(Some(&usage.model));
200 let is_local = wire.is_some_and(|w| w.is_local);
201 #[allow(clippy::cast_precision_loss)]
202 let (cost_usd, cost_source) = if is_local {
203 let billable = usage.input_tokens
204 + usage.output_tokens
205 + usage.cache_read_tokens
206 + usage.cache_write_tokens;
207 (
208 baseline.effective_local_shadow_rate() / 1_000_000.0 * billable as f64,
209 "shadow",
210 )
211 } else if let Some(measured) = usage.provider_cost_usd {
212 (measured, "provider")
213 } else {
214 let estimated = quote.cost.estimate_usd(
215 usage.input_tokens,
216 usage.output_tokens,
217 usage.cache_write_tokens,
218 usage.cache_read_tokens,
219 );
220 (estimated, cost_source_of(quote.match_kind))
221 };
222 let saved_tokens = wire.map_or(0, |w| w.saved_tokens);
223 #[allow(clippy::cast_precision_loss)]
225 let saved_usd = quote.cost.input_per_m / 1_000_000.0 * saved_tokens as f64;
226
227 let uncompressed_input_tokens = wire.map_or(0, |w| w.uncompressed_input_tokens);
228 let reference_model = baseline
229 .reference_model
230 .as_deref()
231 .map(str::trim)
232 .filter(|m| !m.is_empty())
233 .map(str::to_string);
234 #[allow(clippy::cast_precision_loss)]
235 let reference_cost_usd = reference_model.as_deref().map_or(0.0, |reference| {
236 pricing.quote(Some(reference)).cost.input_per_m / 1_000_000.0
237 * uncompressed_input_tokens as f64
238 });
239
240 Self {
241 person: wire
242 .and_then(|w| w.person.clone())
243 .unwrap_or_else(|| ANONYMOUS_PERSON.to_string()),
244 team: wire.and_then(|w| w.team.clone()),
245 project: wire
246 .and_then(|w| w.project.clone())
247 .unwrap_or_else(|| DEFAULT_PROJECT.to_string()),
248 provider: wire.map_or_else(String::new, |w| w.provider.clone()),
249 model: usage.model.clone(),
250 routed_from: wire.and_then(|w| w.routed_from.clone()),
251 input_tokens: to_i64(usage.input_tokens),
252 output_tokens: to_i64(usage.output_tokens),
253 cache_read_tokens: to_i64(usage.cache_read_tokens),
254 cache_write_tokens: to_i64(usage.cache_write_tokens),
255 reasoning_tokens: to_i64(usage.reasoning_tokens),
256 cost_usd,
257 saved_tokens: to_i64(saved_tokens),
258 saved_usd,
259 uncompressed_input_tokens: to_i64(uncompressed_input_tokens),
260 reference_model,
261 reference_cost_usd,
262 is_local,
263 cost_source,
264 }
265 }
266}
267
268fn to_i64(v: u64) -> i64 {
269 i64::try_from(v).unwrap_or(i64::MAX)
270}
271
272pub async fn insert_event(
274 client: &deadpool_postgres::Client,
275 e: &UsageEvent,
276) -> anyhow::Result<()> {
277 client
278 .execute(
279 "INSERT INTO usage_events \
280 (person, team, project, provider, model, routed_from, \
281 input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, \
282 reasoning_tokens, cost_usd, saved_tokens, saved_usd, \
283 uncompressed_input_tokens, reference_model, reference_cost_usd, is_local, \
284 cost_source) \
285 VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)",
286 &[
287 &e.person,
288 &e.team,
289 &e.project,
290 &e.provider,
291 &e.model,
292 &e.routed_from,
293 &e.input_tokens,
294 &e.output_tokens,
295 &e.cache_read_tokens,
296 &e.cache_write_tokens,
297 &e.reasoning_tokens,
298 &e.cost_usd,
299 &e.saved_tokens,
300 &e.saved_usd,
301 &e.uncompressed_input_tokens,
302 &e.reference_model,
303 &e.reference_cost_usd,
304 &e.is_local,
305 &e.cost_source,
306 ],
307 )
308 .await?;
309 Ok(())
310}
311
312pub async fn budget_window_sums(
316 pool: &Pool,
317) -> anyhow::Result<(
318 std::collections::HashMap<String, f64>,
319 std::collections::HashMap<String, f64>,
320)> {
321 let client = pool.get().await?;
322 let mut person_day = std::collections::HashMap::new();
323 for row in client
324 .query(
325 "SELECT person, SUM(cost_usd) FROM usage_events \
326 WHERE ts >= date_trunc('day', now() AT TIME ZONE 'utc') AT TIME ZONE 'utc' \
327 GROUP BY person",
328 &[],
329 )
330 .await?
331 {
332 person_day.insert(row.get::<_, String>(0), row.get::<_, f64>(1));
333 }
334 let mut project_month = std::collections::HashMap::new();
335 for row in client
336 .query(
337 "SELECT project, SUM(cost_usd) FROM usage_events \
338 WHERE ts >= date_trunc('month', now() AT TIME ZONE 'utc') AT TIME ZONE 'utc' \
339 GROUP BY project",
340 &[],
341 )
342 .await?
343 {
344 project_month.insert(row.get::<_, String>(0), row.get::<_, f64>(1));
345 }
346 Ok((person_day, project_month))
347}
348
349pub async fn purge_events_older_than(pool: &Pool, days: u32) -> anyhow::Result<u64> {
353 let client = pool.get().await?;
354 let purged = client
355 .execute(
356 "DELETE FROM usage_events WHERE ts < now() - make_interval(days => $1)",
357 &[&i32::try_from(days).unwrap_or(i32::MAX)],
358 )
359 .await?;
360 Ok(purged)
361}
362
363pub async fn person_events(
366 pool: &Pool,
367 person_keys: &[String],
368) -> anyhow::Result<Vec<serde_json::Value>> {
369 let client = pool.get().await?;
370 let rows = client
371 .query(
372 "SELECT to_jsonb(usage_events) FROM usage_events \
373 WHERE person = ANY($1) ORDER BY ts",
374 &[&person_keys],
375 )
376 .await?;
377 Ok(rows
378 .into_iter()
379 .map(|r| r.get::<_, serde_json::Value>(0))
380 .collect())
381}
382
383pub async fn delete_person_events(pool: &Pool, person_keys: &[String]) -> anyhow::Result<u64> {
385 let client = pool.get().await?;
386 let deleted = client
387 .execute(
388 "DELETE FROM usage_events WHERE person = ANY($1)",
389 &[&person_keys],
390 )
391 .await?;
392 Ok(deleted)
393}
394
395pub async fn evidence_rows(
399 pool: &Pool,
400 from: chrono::DateTime<chrono::Utc>,
401 to: chrono::DateTime<chrono::Utc>,
402) -> anyhow::Result<Vec<serde_json::Value>> {
403 let client = pool.get().await?;
404 let rows = client
405 .query(
406 "SELECT jsonb_build_object(
407 'date', to_char(date_trunc('day', ts AT TIME ZONE 'utc'), 'YYYY-MM-DD'),
408 'person', person,
409 'project', project,
410 'model', model,
411 'provider', provider,
412 'requests', count(*),
413 'input_tokens', sum(input_tokens)::BIGINT,
414 'output_tokens', sum(output_tokens)::BIGINT,
415 'cache_read_tokens', sum(cache_read_tokens)::BIGINT,
416 'cost_usd', round(sum(cost_usd)::numeric, 6),
417 'saved_usd', round(sum(saved_usd)::numeric, 6),
418 'reference_cost_usd', round(sum(reference_cost_usd)::numeric, 6),
419 'local_requests', count(*) FILTER (WHERE is_local),
420 'measured_requests', count(*) FILTER (WHERE cost_source = 'provider'),
421 'estimated_requests', count(*) FILTER (WHERE cost_source = 'heuristic')
422 )
423 FROM usage_events WHERE ts >= $1 AND ts <= $2
424 GROUP BY
425 date_trunc('day', ts AT TIME ZONE 'utc'), person, project, model, provider
426 ORDER BY
427 date_trunc('day', ts AT TIME ZONE 'utc'), person, project, model, provider",
428 &[&from, &to],
429 )
430 .await?;
431 Ok(rows
432 .into_iter()
433 .map(|r| r.get::<_, serde_json::Value>(0))
434 .collect())
435}
436
437pub fn spawn_writer(pool: Pool) -> bool {
443 let (tx, mut rx) = tokio::sync::mpsc::channel::<RealUsage>(WRITER_QUEUE);
444 if !crate::proxy::usage_sink::install(tx) {
445 return false;
446 }
447 tokio::spawn(async move {
448 let pricing = ModelPricing::load();
452 let baseline = crate::core::config::Config::load().proxy.baseline.clone();
453 while let Some(usage) = rx.recv().await {
454 let event = UsageEvent::from_usage(&usage, &pricing, &baseline);
455 match pool.get().await {
456 Ok(client) => {
457 match insert_event(&client, &event).await {
458 Ok(()) => {
459 if let Ok(Some(ocla_record)) = usage.to_ocla_usage_record()
461 && let Err(e) =
462 OclaRegistry::global().usage_sink.record_usage(ocla_record)
463 {
464 tracing::debug!("OCLA usage projection: {e}");
465 }
466 }
467 Err(e) => {
468 tracing::warn!("usage_events insert failed (fail-open): {e:#}");
469 }
470 }
471 }
472 Err(e) => {
473 tracing::warn!("usage_events pool unavailable (fail-open): {e:#}");
474 }
475 }
476 }
477 });
478 true
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::proxy::usage::WireContext;
485
486 fn usage_with_wire(wire: Option<Box<WireContext>>) -> RealUsage {
487 RealUsage {
488 model: "claude-sonnet-4-5".into(),
489 input_tokens: 1000,
490 output_tokens: 500,
491 cache_read_tokens: 200,
492 cache_write_tokens: 100,
493 reasoning_tokens: 50,
494 provider_cost_usd: None,
495 cohort: None,
496 wire,
497 }
498 }
499
500 #[test]
501 fn event_carries_identity_and_baseline_fields() {
502 let usage = usage_with_wire(Some(Box::new(WireContext {
503 provider: "Anthropic".into(),
504 person: Some("yves".into()),
505 team: Some("platform".into()),
506 project: Some("ai-gateway".into()),
507 saved_tokens: 4000,
508 uncompressed_input_tokens: 5000,
509 is_local: false,
510 routed_from: Some("claude-opus-4-5".into()),
511 counterfactual: None,
512 lineage: None,
513 })));
514 let event = UsageEvent::from_usage(
515 &usage,
516 &ModelPricing::load(),
517 &BaselineConfig {
518 reference_model: Some("claude-opus-4.5".into()),
519 local_shadow_rate_per_mtok: None,
520 },
521 );
522
523 assert_eq!(event.person, "yves");
524 assert_eq!(event.team.as_deref(), Some("platform"));
525 assert_eq!(event.project, "ai-gateway");
526 assert_eq!(event.provider, "Anthropic");
527 assert_eq!(event.model, "claude-sonnet-4-5");
528 assert_eq!(event.routed_from.as_deref(), Some("claude-opus-4-5"));
529 assert_eq!(event.input_tokens, 1000);
530 assert_eq!(event.saved_tokens, 4000);
531 assert_eq!(event.uncompressed_input_tokens, 5000);
532 assert!(!event.is_local);
533 assert!(event.cost_usd > 0.0, "known model must be priced");
534 assert_eq!(
535 event.cost_source, "list",
536 "exact table match books list price"
537 );
538 assert!(
539 event.saved_usd > 0.0,
540 "saved tokens on a priced model must yield saved USD"
541 );
542 assert_eq!(event.reference_model.as_deref(), Some("claude-opus-4.5"));
545 assert!((event.reference_cost_usd - 0.025).abs() < 1e-9);
546 }
547
548 #[test]
549 fn provider_reported_cost_beats_the_price_table() {
550 let mut usage = usage_with_wire(Some(Box::new(WireContext {
554 provider: "openrouter".into(),
555 person: Some("nicolas".into()),
556 team: None,
557 project: Some("bot".into()),
558 saved_tokens: 0,
559 uncompressed_input_tokens: 1000,
560 is_local: false,
561 routed_from: None,
562 counterfactual: None,
563 lineage: None,
564 })));
565 usage.provider_cost_usd = Some(0.0123);
566 let event =
567 UsageEvent::from_usage(&usage, &ModelPricing::load(), &BaselineConfig::default());
568 assert!((event.cost_usd - 0.0123).abs() < 1e-12);
569 assert_eq!(event.cost_source, "provider");
570
571 usage.provider_cost_usd = Some(0.0);
573 let event =
574 UsageEvent::from_usage(&usage, &ModelPricing::load(), &BaselineConfig::default());
575 assert_eq!(event.cost_usd, 0.0);
576 assert_eq!(event.cost_source, "provider");
577 }
578
579 #[test]
580 fn unknown_model_is_marked_heuristic_and_local_shadow_beats_measured() {
581 let mut usage = usage_with_wire(None);
584 usage.model = "vendor/brand-new-model-20990101".into();
585 let event =
586 UsageEvent::from_usage(&usage, &ModelPricing::load(), &BaselineConfig::default());
587 assert_eq!(event.cost_source, "heuristic");
588
589 let mut usage = usage_with_wire(Some(Box::new(WireContext {
592 provider: "ollama".into(),
593 person: None,
594 team: None,
595 project: None,
596 saved_tokens: 0,
597 uncompressed_input_tokens: 0,
598 is_local: true,
599 routed_from: None,
600 counterfactual: None,
601 lineage: None,
602 })));
603 usage.provider_cost_usd = Some(9.99);
604 let event =
605 UsageEvent::from_usage(&usage, &ModelPricing::load(), &BaselineConfig::default());
606 assert_eq!(event.cost_source, "shadow");
607 assert!(
608 event.cost_usd < 1.0,
609 "shadow rate, not the stray measured figure"
610 );
611 }
612
613 #[test]
614 fn event_without_wire_context_uses_honest_fallbacks() {
615 let event = UsageEvent::from_usage(
616 &usage_with_wire(None),
617 &ModelPricing::load(),
618 &BaselineConfig::default(),
619 );
620 assert_eq!(event.person, ANONYMOUS_PERSON);
621 assert_eq!(event.project, DEFAULT_PROJECT);
622 assert_eq!(event.team, None);
623 assert_eq!(event.saved_tokens, 0);
624 assert_eq!(event.saved_usd, 0.0);
625 assert_eq!(event.uncompressed_input_tokens, 0);
626 assert!(!event.is_local);
627 assert_eq!(event.reference_model, None);
629 assert_eq!(event.reference_cost_usd, 0.0);
630 }
631
632 #[test]
633 fn local_usage_books_shadow_rate_never_zero() {
634 let usage = usage_with_wire(Some(Box::new(WireContext {
637 provider: "ollama".into(),
638 person: Some("yves".into()),
639 team: None,
640 project: None,
641 saved_tokens: 0,
642 uncompressed_input_tokens: 2000,
643 is_local: true,
644 routed_from: None,
645 counterfactual: None,
646 lineage: None,
647 })));
648 let event =
649 UsageEvent::from_usage(&usage, &ModelPricing::load(), &BaselineConfig::default());
650 assert!(event.is_local);
651 assert!((event.cost_usd - 0.25 / 1_000_000.0 * 1800.0).abs() < 1e-12);
654 assert!(event.cost_usd > 0.0, "local cost must never be zero");
655
656 let cfg = BaselineConfig {
658 reference_model: None,
659 local_shadow_rate_per_mtok: Some(1.0),
660 };
661 let event = UsageEvent::from_usage(&usage, &ModelPricing::load(), &cfg);
662 assert!((event.cost_usd - 1.0 / 1_000_000.0 * 1800.0).abs() < 1e-12);
663 let zero = BaselineConfig {
664 reference_model: None,
665 local_shadow_rate_per_mtok: Some(0.0),
666 };
667 assert!(zero.effective_local_shadow_rate() > 0.0);
668 }
669
670 #[test]
671 fn sslmode_selects_tls_and_pool_builds_for_both() {
672 let tls: tokio_postgres::Config = "postgres://u:p@db.example.com:5432/app?sslmode=require"
674 .parse()
675 .unwrap();
676 assert!(wants_tls(&tls));
677 let plain: tokio_postgres::Config = "postgres://u:p@localhost:5432/app".parse().unwrap();
678 assert!(!wants_tls(&plain));
679 let disabled: tokio_postgres::Config = "postgres://u:p@localhost:5432/app?sslmode=disable"
680 .parse()
681 .unwrap();
682 assert!(!wants_tls(&disabled));
683
684 assert!(
687 pool_from_database_url("postgres://u:p@db.example.com:5432/app?sslmode=require")
688 .is_ok()
689 );
690 assert!(pool_from_database_url("postgres://u:p@localhost:5432/app").is_ok());
691 }
692
693 #[test]
694 fn pool_size_env_is_clamped_and_falls_back() {
695 let _guard = crate::core::data_dir::test_env_lock();
697 crate::test_env::remove_var(POOL_MAX_SIZE_ENV);
698 assert_eq!(pool_max_size(), 8, "unset -> default");
699 crate::test_env::set_var(POOL_MAX_SIZE_ENV, "24");
700 assert_eq!(pool_max_size(), 24, "explicit value wins");
701 crate::test_env::set_var(POOL_MAX_SIZE_ENV, "0");
702 assert_eq!(pool_max_size(), 2, "clamped low");
703 crate::test_env::set_var(POOL_MAX_SIZE_ENV, "9999");
704 assert_eq!(pool_max_size(), 64, "clamped high");
705 crate::test_env::set_var(POOL_MAX_SIZE_ENV, "not-a-number");
706 assert_eq!(pool_max_size(), 8, "garbage -> default, never panic");
707 crate::test_env::remove_var(POOL_MAX_SIZE_ENV);
708 }
709
710 #[test]
711 fn schema_ddl_is_idempotent_by_construction() {
712 for stmt in ["CREATE TABLE", "CREATE INDEX"] {
716 for (i, _) in USAGE_EVENTS_DDL.match_indices(stmt) {
717 let tail = &USAGE_EVENTS_DDL[i..(i + stmt.len() + 14).min(USAGE_EVENTS_DDL.len())];
718 assert!(
719 tail.contains("IF NOT EXISTS"),
720 "non-idempotent DDL statement: {tail}"
721 );
722 }
723 }
724 for (i, _) in USAGE_EVENTS_DDL.match_indices("ADD COLUMN") {
725 let tail = &USAGE_EVENTS_DDL[i..(i + 25).min(USAGE_EVENTS_DDL.len())];
726 assert!(
727 tail.contains("IF NOT EXISTS"),
728 "non-idempotent ALTER statement: {tail}"
729 );
730 }
731 for col in [
733 "uncompressed_input_tokens",
734 "reference_model",
735 "reference_cost_usd",
736 "is_local",
737 "cost_source",
738 ] {
739 assert!(
740 USAGE_EVENTS_DDL.contains(col),
741 "baseline column {col} missing from schema"
742 );
743 }
744 }
745}