1use std::collections::{BTreeMap, BTreeSet};
24use std::fmt;
25
26use serde::{Deserialize, Deserializer, Serialize, Serializer};
27
28use crate::ValidationError;
29
30pub const ENGINE_METRICS_SCHEMA: &str = "leanctx.hive.instance-metrics/v2";
31pub const ENGINE_METRICS_ACK_SCHEMA: &str = "leanctx.hive.instance-metrics-ack/v2";
32pub const SHARE_ENGINE_METRICS_MEMBER: &str = "sharing.share_instance_metrics";
35pub const ENGINE_METRICS_DOMAIN: &str = "leanctx.hive.instance-metrics.v2\n";
37pub const SECRET_FINGERPRINT_INFO: &str = "leanctx.hive.secret-fingerprint.v1";
39pub const HOURS_ROUTE: &str = "/metrics/hours";
41pub const SHARING_ROUTE: &str = "/metrics/sharing";
43pub const SHARING_CONFIRM_ROUTE: &str = "/metrics/sharing/confirm";
45
46pub const MAX_BUCKETS: usize = 168;
47pub const MAX_SLICES: usize = 33;
48pub const MAX_TOOLS: usize = 48;
49pub const MAX_POLICY_BLOCKS: usize = 32;
50pub const MAX_REFETCH: usize = 32;
51pub const MAX_UNREGULATED: usize = 32;
52pub const MAX_SECRET_HITS: usize = 32;
53pub const MAX_HOSTS: usize = 16;
54pub const MAX_SPEND: usize = 32;
55pub const MAX_HOURS_BODY_BYTES: usize = 512 * 1024;
57pub const MAX_COUNT: u64 = 1 << 53;
59pub const OTHER: &str = "other";
61
62pub const KNOWN_TOOLS: &[&str] = &[
66 "cli_deps",
67 "cli_diff",
68 "cli_find",
69 "cli_full",
70 "cli_glob",
71 "cli_grep",
72 "cli_ls",
73 "cli_read",
74 "cli_read_dedup",
75 "cli_shell",
76 "cli_signatures",
77 "ctx_agent",
78 "ctx_analyze",
79 "ctx_architecture",
80 "ctx_artifacts",
81 "ctx_benchmark",
82 "ctx_call",
83 "ctx_callgraph",
84 "ctx_checkpoint",
85 "ctx_compose",
86 "ctx_compress",
87 "ctx_compress_memory",
88 "ctx_compute",
89 "ctx_context",
90 "ctx_control",
91 "ctx_cost",
92 "ctx_dedup",
93 "ctx_delta",
94 "ctx_discover",
95 "ctx_discover_tools",
96 "ctx_doc",
97 "ctx_edit",
98 "ctx_execute",
99 "ctx_expand",
100 "ctx_feedback",
101 "ctx_gain",
102 "ctx_git_read",
103 "ctx_glob",
104 "ctx_graph",
105 "ctx_handoff",
106 "ctx_heatmap",
107 "ctx_impact",
108 "ctx_index",
109 "ctx_intent",
110 "ctx_knowledge",
111 "ctx_load_tools",
112 "ctx_memory",
113 "ctx_metrics",
114 "ctx_multi_read",
115 "ctx_multi_repo",
116 "ctx_outline",
117 "ctx_overview",
118 "ctx_pack",
119 "ctx_package",
120 "ctx_patch",
121 "ctx_prefetch",
122 "ctx_preload",
123 "ctx_proof",
124 "ctx_provider",
125 "ctx_radar",
126 "ctx_read",
127 "ctx_review",
128 "ctx_search",
129 "ctx_semantic_search",
130 "ctx_session",
131 "ctx_shape",
132 "ctx_share",
133 "ctx_shell",
134 "ctx_smart_read",
135 "ctx_symbol",
136 "ctx_tasks",
137 "ctx_tools",
138 "ctx_transcript_compact",
139 "ctx_tree",
140 "ctx_url_read",
141 "ctx_verify",
142 "ctx_work_graph",
143 "ctx_wrapped",
144 "ocla_savings",
145 "shell",
146 OTHER,
147];
148
149pub const KNOWN_RULES: &[&str] = &[
151 "context.allowed_sources",
152 "context.allowed_tools",
153 "context.auto_extraction",
154 "context.denied_tools",
155 "context.extraction_requires_source_anchor",
156 "context.injection_filter",
157 "context.max_context_tokens",
158 "context.max_cost_cents_per_person_per_day",
159 "context.max_cost_cents_per_project_per_month",
160 "context.max_requests_per_minute_per_person",
161 "context.max_source_age_days",
162 "context.max_writes_per_min",
163 "context.ranking_profile",
164 "context.read_mode",
165 "security.allowed_models",
166 "security.block_secrets",
167 "security.blocked_labels",
168 "security.classification",
169 "security.confidential_out_of_sharing",
170 "security.excluded_paths",
171 "security.external_model_excluded_classes",
172 "security.pii",
173 "sharing.allowed_vaults",
174 "sharing.consent_required_for",
175 "sharing.cross_team_sharing",
176 "sharing.personal_workspace",
177 "sharing.share_instance_metrics",
178 "sharing.task_claim_min_role",
179 "gateway.budget_exceeded",
180 "gateway.cross_project",
181 "gateway.path_jail",
182 "gateway.rate_limited",
183 "gateway.security_violation",
184 "gateway.tool_denied",
185 "gateway.destination",
186 "gateway.egress",
187 OTHER,
188];
189
190pub const KNOWN_FILTERS: &[&str] = &[
192 "auto",
193 "full",
194 "map",
195 "signatures",
196 "diff",
197 "task",
198 "reference",
199 "aggressive",
200 "entropy",
201 "lines",
202 "shell_output",
203 "search_output",
204 OTHER,
205];
206
207pub const KNOWN_DETECTORS: &[&str] = &[
209 "aws_access_key",
210 "aws_secret_key",
211 "gcp_key",
212 "azure_key",
213 "github_token",
214 "gitlab_token",
215 "slack_token",
216 "stripe_key",
217 "openai_key",
218 "anthropic_key",
219 "private_key",
220 "jwt",
221 "password_assignment",
222 "connection_string",
223 "generic_api_key",
224 OTHER,
225];
226
227pub const KNOWN_PROGRAMS: &[&str] = &[
229 "git",
230 "cargo",
231 "rustc",
232 "npm",
233 "npx",
234 "pnpm",
235 "yarn",
236 "node",
237 "deno",
238 "bun",
239 "python",
240 "python3",
241 "pip",
242 "uv",
243 "go",
244 "make",
245 "cmake",
246 "docker",
247 "kubectl",
248 "helm",
249 "terraform",
250 "aws",
251 "gcloud",
252 "az",
253 "gh",
254 "curl",
255 "wget",
256 "ssh",
257 "scp",
258 "rsync",
259 "psql",
260 "mysql",
261 "redis-cli",
262 "java",
263 "mvn",
264 "gradle",
265 "dotnet",
266 "ruby",
267 "bundle",
268 "php",
269 "composer",
270 "swift",
271 "xcodebuild",
272 "bash",
273 "sh",
274 "zsh",
275 OTHER,
276];
277
278pub const KNOWN_HOSTS: &[&str] = &[
280 "amp",
281 "antigravity",
282 "augment",
283 "chatgpt",
284 "claude-code",
285 "claude-desktop",
286 "cline",
287 "codebuddy",
288 "codewhale",
289 "codex",
290 "continue",
291 "copilot-cli",
292 "crush",
293 "cursor",
294 "emacs",
295 "factory",
296 "gemini-cli",
297 "goose",
298 "jetbrains",
299 "kilo-code",
300 "kiro",
301 "lm-studio",
302 "neovim",
303 "oh-my-pi",
304 "opencode",
305 "pi",
306 "qwen-code",
307 "roo-code",
308 "trae",
309 "visual-studio",
310 "vscode-copilot",
311 "warp",
312 "windsurf",
313 "zed",
314 "unknown",
315 OTHER,
316];
317
318pub const HOST_FAMILIES: &[&str] = &[
321 "amp",
322 "claude",
323 "cline",
324 "codex",
325 "continue",
326 "copilot",
327 "cursor",
328 "gemini",
329 "goose",
330 "jetbrains",
331 "kilo",
332 "kiro",
333 "opencode",
334 "pi",
335 "qwen",
336 "roo",
337 "windsurf",
338 "zed",
339 OTHER,
340];
341
342#[must_use]
345pub fn host_family(client: &str) -> &'static str {
346 let client = client.to_ascii_lowercase();
347 let family = match client.as_str() {
348 "claude" | "claude-code" | "claude-desktop" | "claude-ai" => "claude",
349 "codex" | "codex-cli" | "codex-mcp-client" => "codex",
350 "copilot" | "copilot-cli" | "vscode-copilot" | "vscode" => "copilot",
351 "gemini" | "gemini-cli" => "gemini",
352 "pi" | "oh-my-pi" | "omp" => "pi",
353 "roo" | "roo-code" => "roo",
354 "kilo" | "kilo-code" => "kilo",
355 "qwen" | "qwen-code" => "qwen",
356 other => other,
357 };
358 HOST_FAMILIES
359 .iter()
360 .copied()
361 .find(|f| *f == family)
362 .unwrap_or(OTHER)
363}
364
365pub const KNOWN_PROVIDERS: &[&str] = &[
367 "anthropic",
368 "openai",
369 "google",
370 "microsoft",
371 "deepseek",
372 "meta",
373 "unknown",
374 OTHER,
375];
376
377pub const KNOWN_MODELS: &[&str] = &[
382 "claude-fable-5",
383 "claude-opus-4.5",
384 "claude-sonnet-4.5",
385 "claude-haiku-4.5",
386 "claude-sonnet-4-5",
387 "claude-3.5-sonnet",
388 "claude-3-opus",
389 "claude-3-haiku",
390 "gpt-5",
391 "gpt-5.4",
392 "gpt-5.4-mini",
393 "gpt-5.4-nano",
394 "gemini-2.5-pro",
395 "gemini-2.5-flash",
396 "gemini-2.5-flash-lite",
397 "phi-4",
398 "phi-4-mini",
399 "deepseek-v3.2",
400 "deepseek-v3",
401 "llama-3.3-70b",
402 "llama-4-maverick",
403 "fallback-blended",
404 OTHER,
405];
406
407fn invalid(what: &str) -> ValidationError {
408 ValidationError::new(format!("instance metrics: invalid {what}"))
409}
410
411macro_rules! closed_string {
414 ($(#[$doc:meta])* $name:ident, $check:path, $what:literal) => {
415 $(#[$doc])*
416 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
417 pub struct $name(String);
418
419 impl $name {
420 pub fn new(value: &str) -> Option<Self> {
422 $check(value).then(|| Self(value.to_string()))
423 }
424
425 pub fn as_str(&self) -> &str {
426 &self.0
427 }
428 }
429
430 impl fmt::Display for $name {
431 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
432 f.write_str(&self.0)
433 }
434 }
435
436 impl Serialize for $name {
437 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
438 serializer.serialize_str(&self.0)
439 }
440 }
441
442 impl<'de> Deserialize<'de> for $name {
443 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
444 let raw = String::deserialize(deserializer)?;
445 Self::new(&raw).ok_or_else(|| serde::de::Error::custom(concat!("invalid ", $what)))
446 }
447 }
448 };
449}
450pub(crate) use closed_string;
451
452fn is_tool(value: &str) -> bool {
456 KNOWN_TOOLS.contains(&value)
457}
458
459fn is_rule(value: &str) -> bool {
460 KNOWN_RULES.contains(&value)
461}
462
463fn is_filter(value: &str) -> bool {
464 KNOWN_FILTERS.contains(&value)
465}
466
467fn is_detector(value: &str) -> bool {
468 KNOWN_DETECTORS.contains(&value)
469}
470
471pub fn is_mcp_server_name(value: &str) -> bool {
474 let mut bytes = value.bytes();
475 let shape = bytes
476 .next()
477 .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
478 && value.len() <= 48
479 && bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'));
480 let mut run = 0usize;
481 let token_like = value.bytes().any(|b| {
482 run = if b.is_ascii_alphanumeric() {
483 run + 1
484 } else {
485 0
486 };
487 run >= 24
488 });
489 shape && !token_like
490}
491
492fn is_wire_name(value: &str) -> bool {
496 (1..=64).contains(&value.len())
497 && value
498 .bytes()
499 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'/' | b'-'))
500 && !value.starts_with('/')
501 && !value.contains("..")
502 && !value.contains("//")
503 && (value == OTHER
504 || KNOWN_HOSTS.contains(&value)
505 || KNOWN_PROVIDERS.contains(&value)
506 || KNOWN_MODELS.contains(&value))
507}
508
509fn is_unregulated_name(value: &str) -> bool {
512 KNOWN_PROGRAMS.contains(&value) || HOST_FAMILIES.contains(&value) || is_mcp_server_name(value)
513}
514
515fn is_hour(value: &str) -> bool {
516 is_time(value) && value.ends_with(":00:00Z")
517}
518
519fn is_time(value: &str) -> bool {
521 let b = value.as_bytes();
522 b.len() == 20
523 && b[4] == b'-'
524 && b[7] == b'-'
525 && b[10] == b'T'
526 && b[13] == b':'
527 && b[16] == b':'
528 && b[19] == b'Z'
529 && [0, 1, 2, 3, 5, 6, 8, 9, 11, 12, 14, 15, 17, 18]
530 .iter()
531 .all(|&i| b[i].is_ascii_digit())
532 && chrono::DateTime::parse_from_rfc3339(value).is_ok()
533}
534
535fn is_hex(value: &str, len: usize) -> bool {
536 value.len() == len
537 && value
538 .bytes()
539 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
540}
541
542fn is_hex16(value: &str) -> bool {
543 is_hex(value, 16)
544}
545
546fn is_hex32(value: &str) -> bool {
547 is_hex(value, 32)
548}
549
550fn is_hex128(value: &str) -> bool {
551 is_hex(value, 128)
552}
553
554fn is_engine_version(value: &str) -> bool {
555 (1..=32).contains(&value.len())
556 && value
557 .bytes()
558 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'+' | b'-'))
559}
560
561closed_string!(
562 ToolName,
564 is_tool,
565 "tool name"
566);
567closed_string!(
568 RuleId,
570 is_rule,
571 "rule id"
572);
573closed_string!(
574 WireName,
576 is_wire_name,
577 "name"
578);
579closed_string!(
580 FilterName,
582 is_filter,
583 "filter name"
584);
585closed_string!(
586 DetectorName,
588 is_detector,
589 "detector name"
590);
591closed_string!(
592 UnregulatedName,
594 is_unregulated_name,
595 "unregulated name"
596);
597closed_string!(
598 Fingerprint,
600 is_hex16,
601 "fingerprint"
602);
603closed_string!(
604 HourStamp,
606 is_hour,
607 "hour"
608);
609closed_string!(
610 TimeStamp,
612 is_time,
613 "time"
614);
615closed_string!(
616 VaultId,
618 is_hex32,
619 "vault id"
620);
621closed_string!(
622 EngineVersion,
624 is_engine_version,
625 "engine version"
626);
627closed_string!(
628 KeyId,
630 is_hex16,
631 "key id"
632);
633closed_string!(
634 SignatureHex,
636 is_hex128,
637 "signature"
638);
639
640impl ToolName {
641 pub fn or_other(value: &str) -> Self {
645 if KNOWN_TOOLS.contains(&value) {
646 Self(value.to_string())
647 } else {
648 Self::other()
649 }
650 }
651
652 pub fn other() -> Self {
653 Self(OTHER.into())
654 }
655}
656
657impl FilterName {
658 pub fn or_other(value: &str) -> Self {
659 Self::new(value).unwrap_or_else(|| Self(OTHER.into()))
660 }
661}
662
663impl UnregulatedName {
664 pub fn other() -> Self {
665 Self(OTHER.into())
666 }
667}
668
669impl WireName {
670 pub fn other() -> Self {
671 Self(OTHER.into())
672 }
673}
674
675impl DetectorName {
676 pub fn or_other(value: &str) -> Self {
677 Self::new(value).unwrap_or_else(|| Self(OTHER.into()))
678 }
679}
680
681impl RuleId {
682 pub fn or_other(value: &str) -> Self {
683 Self::new(value).unwrap_or_else(Self::other)
684 }
685
686 pub fn other() -> Self {
687 Self(OTHER.into())
688 }
689}
690
691impl HourStamp {
692 pub fn of(time: chrono::DateTime<chrono::Utc>) -> Self {
694 Self(time.format("%Y-%m-%dT%H:00:00Z").to_string())
695 }
696
697 pub fn start(&self) -> chrono::DateTime<chrono::Utc> {
698 chrono::DateTime::parse_from_rfc3339(&self.0)
699 .map(|t| t.with_timezone(&chrono::Utc))
700 .unwrap_or_default()
701 }
702}
703
704impl TimeStamp {
705 pub fn at(&self) -> chrono::DateTime<chrono::Utc> {
706 chrono::DateTime::parse_from_rfc3339(&self.0)
707 .map(|t| t.with_timezone(&chrono::Utc))
708 .unwrap_or_default()
709 }
710}
711
712macro_rules! schema_const {
714 ($name:ident, $id:expr) => {
715 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
716 pub struct $name;
717
718 impl Serialize for $name {
719 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
720 serializer.serialize_str($id)
721 }
722 }
723
724 impl<'de> Deserialize<'de> for $name {
725 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
726 let raw = String::deserialize(deserializer)?;
727 (raw == $id)
728 .then_some(Self)
729 .ok_or_else(|| serde::de::Error::custom("wrong schema"))
730 }
731 }
732 };
733}
734pub(crate) use schema_const;
735
736schema_const!(HoursSchema, ENGINE_METRICS_SCHEMA);
737schema_const!(HoursAckSchema, ENGINE_METRICS_ACK_SCHEMA);
738
739#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
743#[serde(rename_all = "snake_case")]
744pub enum ReadMode {
745 Auto,
746 Full,
747 Map,
748 Signatures,
749 Diff,
750 Task,
751 Reference,
752 Aggressive,
753 Entropy,
754 Lines,
755 Other,
756}
757
758impl ReadMode {
759 pub fn of(mode: &str) -> Self {
762 let head = mode.split(':').next().unwrap_or(mode).trim();
763 match head {
764 "auto" => Self::Auto,
765 "full" => Self::Full,
766 "map" => Self::Map,
767 "signatures" => Self::Signatures,
768 "diff" => Self::Diff,
769 "task" => Self::Task,
770 "reference" => Self::Reference,
771 "aggressive" => Self::Aggressive,
772 "entropy" => Self::Entropy,
773 "lines" => Self::Lines,
774 _ => Self::Other,
775 }
776 }
777}
778
779#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
780#[serde(rename_all = "snake_case")]
781pub enum EngineOs {
782 Macos,
783 Linux,
784 Windows,
785}
786
787#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
789#[serde(rename_all = "snake_case")]
790pub enum PolicyLevel {
791 Org,
792 Workspace,
793 Vault,
794 Instance,
795}
796
797impl PolicyLevel {
798 pub fn label(self) -> &'static str {
800 match self {
801 Self::Org => "Company",
802 Self::Workspace => "Workspace",
803 Self::Vault => "Vault",
804 Self::Instance => "Instance",
805 }
806 }
807
808 pub fn from_wire(value: &str) -> Option<Self> {
809 match value {
810 "org" => Some(Self::Org),
811 "workspace" => Some(Self::Workspace),
812 "vault" => Some(Self::Vault),
813 "instance" => Some(Self::Instance),
814 _ => None,
815 }
816 }
817}
818
819#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
820#[serde(rename_all = "snake_case")]
821pub enum RefusalReason {
822 TooOld,
823 Future,
824 BeforeSharing,
825 PolicyDenied,
826 VaultNotAllowed,
827 VaultUnknown,
828 UnattributedNotPersonal,
829}
830
831#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
833#[serde(rename_all = "snake_case")]
834pub enum UnregulatedKind {
835 Program,
836 Host,
837 McpServer,
838}
839
840#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
842#[serde(rename_all = "snake_case")]
843pub enum SharingValue {
844 On,
845 Off,
846 NeedsConfirmation,
847}
848
849#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
851#[serde(rename_all = "snake_case")]
852pub enum PlanDefault {
853 On,
854 Off,
855}
856
857#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
862#[serde(deny_unknown_fields)]
863pub struct HoursBodyV2 {
864 pub schema: HoursSchema,
865 pub instance: EngineV2,
866 pub buckets: Vec<BucketV2>,
867}
868
869#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
871#[serde(deny_unknown_fields)]
872pub struct HoursRequestV2 {
873 pub schema: HoursSchema,
874 pub instance: EngineV2,
875 pub buckets: Vec<BucketV2>,
876 pub key_id: KeyId,
877 pub signature: SignatureHex,
878}
879
880#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
881#[serde(deny_unknown_fields)]
882pub struct EngineV2 {
883 pub instance_version: EngineVersion,
884 pub os: EngineOs,
885}
886
887#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
888#[serde(deny_unknown_fields)]
889pub struct BucketV2 {
890 pub hour: HourStamp,
891 pub rev: u32,
892 pub slices: Vec<SliceV2>,
893}
894
895#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
896#[serde(deny_unknown_fields)]
897pub struct SliceV2 {
898 pub vault: Option<VaultId>,
899 pub tokens: TokensV2,
900 pub savings: SavingsV2,
901 pub calls: CallsV2,
902 pub read_modes: BTreeMap<ReadMode, u64>,
903 pub tools: Vec<ToolRowV2>,
904 pub compression: CompressionV2,
905 pub ledger: LedgerV2,
906 pub guards: GuardsV2,
907 pub policy_blocks: Vec<BlockRowV2>,
908 pub refetch: Vec<RefetchRowV2>,
909 pub unregulated: Vec<UnregulatedRowV2>,
910 pub secret_hits: Vec<SecretRowV2>,
911 pub sessions: SessionsV2,
912 pub hosts: Vec<HostRowV2>,
913 pub spend: Vec<SpendRowV2>,
914 #[serde(default, skip_serializing_if = "Option::is_none")]
915 pub hive: Option<HiveHourV2>,
916 #[serde(default, skip_serializing_if = "Option::is_none")]
917 pub gauges: Option<GaugesV2>,
918}
919
920#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
921#[serde(deny_unknown_fields)]
922pub struct TokensV2 {
923 pub input: u64,
924 pub output: u64,
925 pub saved: u64,
926}
927
928#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
930#[serde(deny_unknown_fields)]
931pub struct SavingsV2 {
932 pub measured: u64,
933 pub estimated: u64,
934 pub estimated_low: u64,
935 pub estimated_high: u64,
936 pub evidenced: u64,
937 pub overhead: u64,
938}
939
940#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
941#[serde(deny_unknown_fields)]
942pub struct CallsV2 {
943 pub tool_calls: u64,
944 pub cache_reads: u64,
945 pub cache_hits: u64,
946}
947
948#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
949#[serde(deny_unknown_fields)]
950pub struct ToolRowV2 {
951 pub tool: ToolName,
952 pub calls: u64,
953 pub saved: u64,
954}
955
956#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
957#[serde(deny_unknown_fields)]
958pub struct CompressionV2 {
959 pub original: u64,
960 pub compressed: u64,
961}
962
963#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
965#[serde(deny_unknown_fields)]
966pub struct LedgerV2 {
967 pub reduction: u64,
968 pub cache_discount: u64,
969 pub correction: u64,
970 pub edit: u64,
971 pub other: u64,
972}
973
974#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
975#[serde(deny_unknown_fields)]
976pub struct GuardsV2 {
977 pub secrets_redacted: u64,
978 pub pii_redacted: u64,
979 pub injection_flags: u64,
980 pub content_withheld: u64,
981 pub paths_blocked: u64,
982 pub shell_blocked: u64,
983 pub partial_inspections: u64,
984}
985
986#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
988#[serde(deny_unknown_fields)]
989pub struct BlockRowV2 {
990 pub rule: RuleId,
991 pub blocked: u64,
992 pub would_block: u64,
993}
994
995#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
997#[serde(deny_unknown_fields)]
998pub struct RefetchRowV2 {
999 pub filter: FilterName,
1000 pub truncated: u64,
1001 pub refetched: u64,
1002}
1003
1004#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1006#[serde(deny_unknown_fields)]
1007pub struct UnregulatedRowV2 {
1008 pub kind: UnregulatedKind,
1009 pub name: UnregulatedName,
1010 pub calls: u64,
1011}
1012
1013#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1015#[serde(deny_unknown_fields)]
1016pub struct SecretRowV2 {
1017 pub detector: DetectorName,
1018 pub count: u64,
1019}
1020
1021#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1023#[serde(deny_unknown_fields)]
1024pub struct HiveHourV2 {
1025 pub knowledge_hits_referenced: u64,
1026 pub first_context_sessions: u64,
1027 pub first_context_ms_p50: Option<u64>,
1028 pub task_usage: Vec<TaskUsageV2>,
1029}
1030
1031#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1033#[serde(deny_unknown_fields)]
1034pub struct TaskUsageV2 {
1035 pub number: u64,
1036 pub input_tokens: u64,
1037 pub output_tokens: u64,
1038 pub list_usd_micros: u64,
1039}
1040
1041pub const MAX_TASK_USAGE: usize = 64;
1042pub const MAX_FIRST_CONTEXT_MS: u64 = 3_600_000;
1043
1044#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1045#[serde(deny_unknown_fields)]
1046pub struct SessionsV2 {
1047 pub started: u64,
1048}
1049
1050#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1051#[serde(deny_unknown_fields)]
1052pub struct HostRowV2 {
1053 pub host: WireName,
1054 pub sessions: u64,
1055 pub tool_calls: u64,
1056}
1057
1058#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1059#[serde(deny_unknown_fields)]
1060pub struct SpendRowV2 {
1061 pub model: WireName,
1062 pub provider: WireName,
1063 pub requests: u64,
1064 pub input_tokens: u64,
1065 pub output_tokens: u64,
1066 pub cached_tokens: u64,
1067 pub usd_micros: u64,
1068}
1069
1070#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1071#[serde(deny_unknown_fields)]
1072pub struct GaugesV2 {
1073 #[serde(default, skip_serializing_if = "Option::is_none")]
1074 pub facts_current: Option<u64>,
1075}
1076
1077#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1078#[serde(deny_unknown_fields)]
1079pub struct HoursAckV2 {
1080 pub schema: HoursAckSchema,
1081 pub accepted: u32,
1082 pub unchanged: u32,
1083 pub refused: Vec<RefusalV2>,
1084 pub sharing: SharingStateV2,
1085}
1086
1087#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1088#[serde(deny_unknown_fields)]
1089pub struct RefusalV2 {
1090 pub hour: HourStamp,
1091 pub vault: Option<VaultId>,
1092 pub reason: RefusalReason,
1093 pub level: Option<PolicyLevel>,
1094}
1095
1096#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1098#[serde(deny_unknown_fields)]
1099pub struct SharingPolicyValueV2 {
1100 pub value: bool,
1101 pub level: PolicyLevel,
1102}
1103
1104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1107#[serde(deny_unknown_fields)]
1108pub struct SharingStateV2 {
1109 pub state: SharingValue,
1110 pub plan_default: PlanDefault,
1111 pub policy: Option<SharingPolicyValueV2>,
1112 pub confirmed_at: Option<TimeStamp>,
1113 pub since: Option<TimeStamp>,
1114}
1115
1116fn counts_ok(values: impl IntoIterator<Item = u64>) -> bool {
1119 values.into_iter().all(|n| n <= MAX_COUNT)
1120}
1121
1122fn unique<'a>(names: impl IntoIterator<Item = &'a str>) -> bool {
1123 let mut seen = BTreeSet::new();
1124 names.into_iter().all(|n| seen.insert(n))
1125}
1126
1127impl SliceV2 {
1128 pub fn counters(&self) -> Vec<u64> {
1130 let t = &self.tokens;
1131 let s = &self.savings;
1132 let c = &self.calls;
1133 let l = &self.ledger;
1134 let g = &self.guards;
1135 let mut all = vec![
1136 t.input,
1137 t.output,
1138 t.saved,
1139 s.measured,
1140 s.estimated,
1141 s.estimated_low,
1142 s.estimated_high,
1143 s.evidenced,
1144 s.overhead,
1145 c.tool_calls,
1146 c.cache_reads,
1147 c.cache_hits,
1148 self.compression.original,
1149 self.compression.compressed,
1150 l.reduction,
1151 l.cache_discount,
1152 l.correction,
1153 l.edit,
1154 l.other,
1155 g.secrets_redacted,
1156 g.pii_redacted,
1157 g.injection_flags,
1158 g.content_withheld,
1159 g.paths_blocked,
1160 g.shell_blocked,
1161 g.partial_inspections,
1162 self.sessions.started,
1163 ];
1164 all.extend(self.read_modes.values().copied());
1165 all.extend(self.tools.iter().flat_map(|r| [r.calls, r.saved]));
1166 all.extend(
1167 self.policy_blocks
1168 .iter()
1169 .flat_map(|r| [r.blocked, r.would_block]),
1170 );
1171 all.extend(self.refetch.iter().flat_map(|r| [r.truncated, r.refetched]));
1172 all.extend(self.unregulated.iter().map(|r| r.calls));
1173 if let Some(a) = &self.hive {
1174 all.extend([a.knowledge_hits_referenced, a.first_context_sessions]);
1175 all.extend(a.first_context_ms_p50);
1176 all.extend(
1177 a.task_usage
1178 .iter()
1179 .flat_map(|t| [t.number, t.input_tokens, t.output_tokens, t.list_usd_micros]),
1180 );
1181 }
1182 all.extend(self.secret_hits.iter().map(|r| r.count));
1183 all.extend(self.hosts.iter().flat_map(|r| [r.sessions, r.tool_calls]));
1184 all.extend(self.spend.iter().flat_map(|r| {
1185 [
1186 r.requests,
1187 r.input_tokens,
1188 r.output_tokens,
1189 r.cached_tokens,
1190 r.usd_micros,
1191 ]
1192 }));
1193 all
1194 }
1195
1196 pub fn has_activity(&self) -> bool {
1199 let mut counters = self.counters();
1200 counters.remove(8);
1201 counters.into_iter().any(|n| n > 0)
1202 }
1203
1204 pub fn validate(&self) -> Result<(), ValidationError> {
1205 if self.tools.len() > MAX_TOOLS
1206 || self.policy_blocks.len() > MAX_POLICY_BLOCKS
1207 || self.refetch.len() > MAX_REFETCH
1208 || self.unregulated.len() > MAX_UNREGULATED
1209 || self.secret_hits.len() > MAX_SECRET_HITS
1210 || self.hosts.len() > MAX_HOSTS
1211 || self.spend.len() > MAX_SPEND
1212 {
1213 return Err(invalid("slice: too many rows"));
1214 }
1215 if !unique(self.tools.iter().map(|r| r.tool.as_str()))
1216 || !unique(self.policy_blocks.iter().map(|r| r.rule.as_str()))
1217 || !unique(self.refetch.iter().map(|r| r.filter.as_str()))
1218 || !unique(self.hosts.iter().map(|r| r.host.as_str()))
1219 {
1220 return Err(invalid("slice: duplicate row name"));
1221 }
1222 let mut pairs = BTreeSet::new();
1223 if !self
1224 .spend
1225 .iter()
1226 .all(|r| pairs.insert((r.model.as_str(), r.provider.as_str())))
1227 {
1228 return Err(invalid("slice: duplicate spend row"));
1229 }
1230 let mut unregulated = BTreeSet::new();
1231 if !self
1232 .unregulated
1233 .iter()
1234 .all(|r| unregulated.insert((r.kind, r.name.as_str())))
1235 {
1236 return Err(invalid("slice: duplicate unregulated row"));
1237 }
1238 if !unique(self.secret_hits.iter().map(|r| r.detector.as_str())) {
1239 return Err(invalid("slice: duplicate secret row"));
1240 }
1241 for row in &self.unregulated {
1242 let name = row.name.as_str();
1243 let fits = match row.kind {
1244 UnregulatedKind::Program => KNOWN_PROGRAMS.contains(&name),
1245 UnregulatedKind::Host => HOST_FAMILIES.contains(&name),
1246 UnregulatedKind::McpServer => is_mcp_server_name(name),
1247 };
1248 if !fits {
1249 return Err(invalid("slice: unregulated name of another kind"));
1250 }
1251 }
1252 if let Some(a) = &self.hive {
1253 let sorted = a.task_usage.windows(2).all(|w| w[0].number < w[1].number);
1254 if a.task_usage.len() > MAX_TASK_USAGE
1255 || !sorted
1256 || a.task_usage.iter().any(|t| t.number == 0)
1257 || a.first_context_ms_p50
1258 .is_some_and(|ms| ms > MAX_FIRST_CONTEXT_MS)
1259 {
1260 return Err(invalid("slice: hive"));
1261 }
1262 }
1263 let gauge = self.gauges.and_then(|g| g.facts_current).unwrap_or(0);
1264 if !counts_ok(self.counters()) || gauge > MAX_COUNT {
1265 return Err(invalid("slice: count beyond 2^53"));
1266 }
1267 Ok(())
1268 }
1269}
1270
1271impl BucketV2 {
1272 pub fn validate(&self) -> Result<(), ValidationError> {
1273 if self.rev == 0 {
1274 return Err(invalid("bucket rev"));
1275 }
1276 if self.slices.is_empty() || self.slices.len() > MAX_SLICES {
1277 return Err(invalid("bucket: 1-33 slices"));
1278 }
1279 let mut vaults = BTreeSet::new();
1280 for slice in &self.slices {
1281 if !vaults.insert(slice.vault.as_ref().map(VaultId::as_str)) {
1282 return Err(invalid("bucket: duplicate vault"));
1283 }
1284 slice.validate()?;
1285 }
1286 Ok(())
1287 }
1288}
1289
1290impl HoursBodyV2 {
1291 pub fn validate(&self) -> Result<(), ValidationError> {
1292 if self.buckets.is_empty() || self.buckets.len() > MAX_BUCKETS {
1293 return Err(invalid("request: 1-168 buckets"));
1294 }
1295 if !unique(self.buckets.iter().map(|b| b.hour.as_str())) {
1296 return Err(invalid("request: duplicate hour"));
1297 }
1298 self.buckets.iter().try_for_each(BucketV2::validate)
1299 }
1300
1301 pub fn signing_input(&self, key_id: &KeyId) -> Result<Vec<u8>, ValidationError> {
1304 let mut value = serde_json::to_value(self)
1305 .map_err(|e| ValidationError::new(format!("instance metrics: encode: {e}")))?;
1306 value["key_id"] = serde_json::Value::String(key_id.as_str().to_string());
1307 super::signing_input(ENGINE_METRICS_DOMAIN, &value)
1308 }
1309
1310 pub fn signed(self, key_id: KeyId, signature: SignatureHex) -> HoursRequestV2 {
1311 HoursRequestV2 {
1312 schema: self.schema,
1313 instance: self.instance,
1314 buckets: self.buckets,
1315 key_id,
1316 signature,
1317 }
1318 }
1319}
1320
1321impl HoursRequestV2 {
1322 pub fn body(&self) -> HoursBodyV2 {
1324 HoursBodyV2 {
1325 schema: self.schema,
1326 instance: self.instance.clone(),
1327 buckets: self.buckets.clone(),
1328 }
1329 }
1330
1331 pub fn validate(&self) -> Result<(), ValidationError> {
1332 self.body().validate()
1333 }
1334}
1335
1336#[cfg(test)]
1337mod tests {
1338 use super::*;
1339
1340 #[test]
1341 fn names_follow_their_closed_rules() {
1342 assert!(ToolName::new("ctx_read").is_some());
1343 for bad in ["rm -rf /", "/etc/passwd", "Ctx_Read", "ctx-read", "", "a b"] {
1344 assert!(ToolName::new(bad).is_none(), "{bad}");
1345 assert_eq!(ToolName::or_other(bad).as_str(), OTHER);
1346 }
1347 for unknown in ["acme_secret_project", "a", "ctx_reader"] {
1348 assert!(ToolName::new(unknown).is_none(), "{unknown}");
1349 assert_eq!(ToolName::or_other(unknown).as_str(), OTHER);
1350 }
1351 assert!(ToolName::new(OTHER).is_some());
1352 assert!(RuleId::new("sharing.share_instance_metrics").is_some());
1353 assert!(RuleId::new("gateway.tool_denied").is_some());
1354 assert!(RuleId::new("gateway.destination").is_some());
1355 assert!(RuleId::new("gateway.egress").is_some());
1356 assert_eq!(host_family("claude-code"), "claude");
1357 assert_eq!(host_family("Cursor"), "cursor");
1358 assert_eq!(host_family("oh-my-pi"), "pi");
1359 assert_eq!(host_family("acme-internal-agent"), OTHER);
1360 assert!(HOST_FAMILIES.iter().all(|f| is_unregulated_name(f)));
1361 for bad in ["blocked because of /x", "a.b.c", "Security.pii", ".x", "x."] {
1362 assert!(RuleId::new(bad).is_none(), "{bad}");
1363 }
1364 assert!(WireName::new("claude-sonnet-4-5").is_some());
1365 assert!(WireName::new("anthropic").is_some());
1366 assert!(WireName::new("claude-code").is_some());
1367 for bad in [
1368 "/Users/me",
1369 "a//b",
1370 "../x",
1371 "with space",
1372 "",
1373 "anthropic/claude",
1374 "ada-macbook.local",
1375 ] {
1376 assert!(WireName::new(bad).is_none(), "{bad}");
1377 }
1378 assert_eq!(ToolName::or_other("ctx_read").as_str(), "ctx_read");
1379 assert_eq!(ToolName::or_other("acme_internal_db").as_str(), OTHER);
1380 assert!(UnregulatedName::new("terraform").is_some());
1381 assert!(
1382 UnregulatedName::new("github").is_some(),
1383 "an MCP server name"
1384 );
1385 assert!(!is_mcp_server_name(&"a".repeat(30)), "token-like");
1386 assert_eq!(RuleId::or_other("custom.rule").as_str(), OTHER);
1387 assert_eq!(FilterName::or_other("cargo_test").as_str(), OTHER);
1388 assert_eq!(DetectorName::or_other("Generic").as_str(), OTHER);
1389 for bad in [
1390 "terraform apply -auto-approve",
1391 "/usr/bin/terraform",
1392 "a..b",
1393 "Git",
1394 ] {
1395 assert!(UnregulatedName::new(bad).is_none(), "{bad}");
1396 }
1397 assert!(Fingerprint::new("9c1e4b7a02d3f658").is_some());
1398 assert!(Fingerprint::new("[REDACTED:AWS key]").is_none());
1399 assert!(HourStamp::new("2026-10-26T17:00:00Z").is_some());
1400 for bad in [
1401 "2026-10-26T17:05:00Z",
1402 "2026-10-26T17:00:00+01:00",
1403 "2026-13-26T17:00:00Z",
1404 ] {
1405 assert!(HourStamp::new(bad).is_none(), "{bad}");
1406 }
1407 assert!(TimeStamp::new("2026-10-26T15:12:40Z").is_some());
1408 assert_eq!(ReadMode::of("lines:10-20"), ReadMode::Lines);
1409 assert_eq!(ReadMode::of("signatures"), ReadMode::Signatures);
1410 assert_eq!(ReadMode::of("something new"), ReadMode::Other);
1411 }
1412
1413 #[test]
1414 fn free_text_cannot_be_deserialised_into_any_field() {
1415 let tool = serde_json::from_str::<ToolRowV2>(
1416 r#"{"tool":"cat ~/.ssh/id_rsa","calls":1,"saved":0}"#,
1417 );
1418 assert!(tool.is_err());
1419 let extra =
1420 serde_json::from_str::<TokensV2>(r#"{"input":1,"output":1,"saved":0,"prompt":"x"}"#);
1421 assert!(extra.is_err());
1422 let schema = serde_json::from_str::<HoursSchema>(r#""leanctx.hive.instance-metrics/v3""#);
1423 assert!(schema.is_err());
1424 }
1425}