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, is_tool, "tool name"
564);
565closed_string!(
566 RuleId, is_rule, "rule id"
568);
569closed_string!(
570 WireName, is_wire_name, "name"
572);
573closed_string!(
574 FilterName, is_filter, "filter name"
576);
577closed_string!(
578 DetectorName, is_detector, "detector name"
580);
581closed_string!(
582 UnregulatedName, is_unregulated_name, "unregulated name"
584);
585closed_string!(
586 Fingerprint, is_hex16, "fingerprint"
588);
589closed_string!(
590 HourStamp, is_hour, "hour"
592);
593closed_string!(
594 TimeStamp, is_time, "time"
596);
597closed_string!(
598 VaultId, is_hex32, "vault id"
600);
601closed_string!(
602 EngineVersion, is_engine_version, "engine version"
604);
605closed_string!(
606 KeyId, is_hex16, "key id"
608);
609closed_string!(
610 SignatureHex, is_hex128, "signature"
612);
613
614impl ToolName {
615 pub fn or_other(value: &str) -> Self {
619 if KNOWN_TOOLS.contains(&value) {
620 Self(value.to_string())
621 } else {
622 Self::other()
623 }
624 }
625
626 pub fn other() -> Self {
627 Self(OTHER.into())
628 }
629}
630
631impl FilterName {
632 pub fn or_other(value: &str) -> Self {
633 Self::new(value).unwrap_or_else(|| Self(OTHER.into()))
634 }
635}
636
637impl UnregulatedName {
638 pub fn other() -> Self {
639 Self(OTHER.into())
640 }
641}
642
643impl WireName {
644 pub fn other() -> Self {
645 Self(OTHER.into())
646 }
647}
648
649impl DetectorName {
650 pub fn or_other(value: &str) -> Self {
651 Self::new(value).unwrap_or_else(|| Self(OTHER.into()))
652 }
653}
654
655impl RuleId {
656 pub fn or_other(value: &str) -> Self {
657 Self::new(value).unwrap_or_else(Self::other)
658 }
659
660 pub fn other() -> Self {
661 Self(OTHER.into())
662 }
663}
664
665impl HourStamp {
666 pub fn of(time: chrono::DateTime<chrono::Utc>) -> Self {
668 Self(time.format("%Y-%m-%dT%H:00:00Z").to_string())
669 }
670
671 pub fn start(&self) -> chrono::DateTime<chrono::Utc> {
672 chrono::DateTime::parse_from_rfc3339(&self.0)
673 .map(|t| t.with_timezone(&chrono::Utc))
674 .unwrap_or_default()
675 }
676}
677
678impl TimeStamp {
679 pub fn at(&self) -> chrono::DateTime<chrono::Utc> {
680 chrono::DateTime::parse_from_rfc3339(&self.0)
681 .map(|t| t.with_timezone(&chrono::Utc))
682 .unwrap_or_default()
683 }
684}
685
686macro_rules! schema_const {
688 ($name:ident, $id:expr) => {
689 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
690 pub struct $name;
691
692 impl Serialize for $name {
693 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
694 serializer.serialize_str($id)
695 }
696 }
697
698 impl<'de> Deserialize<'de> for $name {
699 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
700 let raw = String::deserialize(deserializer)?;
701 (raw == $id)
702 .then_some(Self)
703 .ok_or_else(|| serde::de::Error::custom("wrong schema"))
704 }
705 }
706 };
707}
708pub(crate) use schema_const;
709
710schema_const!(HoursSchema, ENGINE_METRICS_SCHEMA);
711schema_const!(HoursAckSchema, ENGINE_METRICS_ACK_SCHEMA);
712
713#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
717#[serde(rename_all = "snake_case")]
718pub enum ReadMode {
719 Auto,
720 Full,
721 Map,
722 Signatures,
723 Diff,
724 Task,
725 Reference,
726 Aggressive,
727 Entropy,
728 Lines,
729 Other,
730}
731
732impl ReadMode {
733 pub fn of(mode: &str) -> Self {
736 let head = mode.split(':').next().unwrap_or(mode).trim();
737 match head {
738 "auto" => Self::Auto,
739 "full" => Self::Full,
740 "map" => Self::Map,
741 "signatures" => Self::Signatures,
742 "diff" => Self::Diff,
743 "task" => Self::Task,
744 "reference" => Self::Reference,
745 "aggressive" => Self::Aggressive,
746 "entropy" => Self::Entropy,
747 "lines" => Self::Lines,
748 _ => Self::Other,
749 }
750 }
751}
752
753#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
754#[serde(rename_all = "snake_case")]
755pub enum EngineOs {
756 Macos,
757 Linux,
758 Windows,
759}
760
761#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
763#[serde(rename_all = "snake_case")]
764pub enum PolicyLevel {
765 Org,
766 Workspace,
767 Vault,
768 Instance,
769}
770
771impl PolicyLevel {
772 pub fn label(self) -> &'static str {
774 match self {
775 Self::Org => "Company",
776 Self::Workspace => "Workspace",
777 Self::Vault => "Vault",
778 Self::Instance => "Instance",
779 }
780 }
781
782 pub fn from_wire(value: &str) -> Option<Self> {
783 match value {
784 "org" => Some(Self::Org),
785 "workspace" => Some(Self::Workspace),
786 "vault" => Some(Self::Vault),
787 "instance" => Some(Self::Instance),
788 _ => None,
789 }
790 }
791}
792
793#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
794#[serde(rename_all = "snake_case")]
795pub enum RefusalReason {
796 TooOld,
797 Future,
798 BeforeSharing,
799 PolicyDenied,
800 VaultNotAllowed,
801 VaultUnknown,
802 UnattributedNotPersonal,
803}
804
805#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
807#[serde(rename_all = "snake_case")]
808pub enum UnregulatedKind {
809 Program,
810 Host,
811 McpServer,
812}
813
814#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
816#[serde(rename_all = "snake_case")]
817pub enum SharingValue {
818 On,
819 Off,
820 NeedsConfirmation,
821}
822
823#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
825#[serde(rename_all = "snake_case")]
826pub enum PlanDefault {
827 On,
828 Off,
829}
830
831#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
836#[serde(deny_unknown_fields)]
837pub struct HoursBodyV2 {
838 pub schema: HoursSchema,
839 pub instance: EngineV2,
840 pub buckets: Vec<BucketV2>,
841}
842
843#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
845#[serde(deny_unknown_fields)]
846pub struct HoursRequestV2 {
847 pub schema: HoursSchema,
848 pub instance: EngineV2,
849 pub buckets: Vec<BucketV2>,
850 pub key_id: KeyId,
851 pub signature: SignatureHex,
852}
853
854#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
855#[serde(deny_unknown_fields)]
856pub struct EngineV2 {
857 pub instance_version: EngineVersion,
858 pub os: EngineOs,
859}
860
861#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
862#[serde(deny_unknown_fields)]
863pub struct BucketV2 {
864 pub hour: HourStamp,
865 pub rev: u32,
866 pub slices: Vec<SliceV2>,
867}
868
869#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
870#[serde(deny_unknown_fields)]
871pub struct SliceV2 {
872 pub vault: Option<VaultId>,
873 pub tokens: TokensV2,
874 pub savings: SavingsV2,
875 pub calls: CallsV2,
876 pub read_modes: BTreeMap<ReadMode, u64>,
877 pub tools: Vec<ToolRowV2>,
878 pub compression: CompressionV2,
879 pub ledger: LedgerV2,
880 pub guards: GuardsV2,
881 pub policy_blocks: Vec<BlockRowV2>,
882 pub refetch: Vec<RefetchRowV2>,
883 pub unregulated: Vec<UnregulatedRowV2>,
884 pub secret_hits: Vec<SecretRowV2>,
885 pub sessions: SessionsV2,
886 pub hosts: Vec<HostRowV2>,
887 pub spend: Vec<SpendRowV2>,
888 #[serde(default, skip_serializing_if = "Option::is_none")]
889 pub hive: Option<HiveHourV2>,
890 #[serde(default, skip_serializing_if = "Option::is_none")]
891 pub gauges: Option<GaugesV2>,
892}
893
894#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
895#[serde(deny_unknown_fields)]
896pub struct TokensV2 {
897 pub input: u64,
898 pub output: u64,
899 pub saved: u64,
900}
901
902#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
904#[serde(deny_unknown_fields)]
905pub struct SavingsV2 {
906 pub measured: u64,
907 pub estimated: u64,
908 pub estimated_low: u64,
909 pub estimated_high: u64,
910 pub evidenced: u64,
911 pub overhead: u64,
912}
913
914#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
915#[serde(deny_unknown_fields)]
916pub struct CallsV2 {
917 pub tool_calls: u64,
918 pub cache_reads: u64,
919 pub cache_hits: u64,
920}
921
922#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
923#[serde(deny_unknown_fields)]
924pub struct ToolRowV2 {
925 pub tool: ToolName,
926 pub calls: u64,
927 pub saved: u64,
928}
929
930#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
931#[serde(deny_unknown_fields)]
932pub struct CompressionV2 {
933 pub original: u64,
934 pub compressed: u64,
935}
936
937#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
939#[serde(deny_unknown_fields)]
940pub struct LedgerV2 {
941 pub reduction: u64,
942 pub cache_discount: u64,
943 pub correction: u64,
944 pub edit: u64,
945 pub other: u64,
946}
947
948#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
949#[serde(deny_unknown_fields)]
950pub struct GuardsV2 {
951 pub secrets_redacted: u64,
952 pub pii_redacted: u64,
953 pub injection_flags: u64,
954 pub content_withheld: u64,
955 pub paths_blocked: u64,
956 pub shell_blocked: u64,
957 pub partial_inspections: u64,
958}
959
960#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
962#[serde(deny_unknown_fields)]
963pub struct BlockRowV2 {
964 pub rule: RuleId,
965 pub blocked: u64,
966 pub would_block: u64,
967}
968
969#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
971#[serde(deny_unknown_fields)]
972pub struct RefetchRowV2 {
973 pub filter: FilterName,
974 pub truncated: u64,
975 pub refetched: u64,
976}
977
978#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
980#[serde(deny_unknown_fields)]
981pub struct UnregulatedRowV2 {
982 pub kind: UnregulatedKind,
983 pub name: UnregulatedName,
984 pub calls: u64,
985}
986
987#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
989#[serde(deny_unknown_fields)]
990pub struct SecretRowV2 {
991 pub detector: DetectorName,
992 pub count: u64,
993}
994
995#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
997#[serde(deny_unknown_fields)]
998pub struct HiveHourV2 {
999 pub knowledge_hits_referenced: u64,
1000 pub first_context_sessions: u64,
1001 pub first_context_ms_p50: Option<u64>,
1002 pub task_usage: Vec<TaskUsageV2>,
1003}
1004
1005#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1007#[serde(deny_unknown_fields)]
1008pub struct TaskUsageV2 {
1009 pub number: u64,
1010 pub input_tokens: u64,
1011 pub output_tokens: u64,
1012 pub list_usd_micros: u64,
1013}
1014
1015pub const MAX_TASK_USAGE: usize = 64;
1016pub const MAX_FIRST_CONTEXT_MS: u64 = 3_600_000;
1017
1018#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1019#[serde(deny_unknown_fields)]
1020pub struct SessionsV2 {
1021 pub started: u64,
1022}
1023
1024#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1025#[serde(deny_unknown_fields)]
1026pub struct HostRowV2 {
1027 pub host: WireName,
1028 pub sessions: u64,
1029 pub tool_calls: u64,
1030}
1031
1032#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1033#[serde(deny_unknown_fields)]
1034pub struct SpendRowV2 {
1035 pub model: WireName,
1036 pub provider: WireName,
1037 pub requests: u64,
1038 pub input_tokens: u64,
1039 pub output_tokens: u64,
1040 pub cached_tokens: u64,
1041 pub usd_micros: u64,
1042}
1043
1044#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1045#[serde(deny_unknown_fields)]
1046pub struct GaugesV2 {
1047 #[serde(default, skip_serializing_if = "Option::is_none")]
1048 pub facts_current: Option<u64>,
1049}
1050
1051#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1052#[serde(deny_unknown_fields)]
1053pub struct HoursAckV2 {
1054 pub schema: HoursAckSchema,
1055 pub accepted: u32,
1056 pub unchanged: u32,
1057 pub refused: Vec<RefusalV2>,
1058 pub sharing: SharingStateV2,
1059}
1060
1061#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1062#[serde(deny_unknown_fields)]
1063pub struct RefusalV2 {
1064 pub hour: HourStamp,
1065 pub vault: Option<VaultId>,
1066 pub reason: RefusalReason,
1067 pub level: Option<PolicyLevel>,
1068}
1069
1070#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1072#[serde(deny_unknown_fields)]
1073pub struct SharingPolicyValueV2 {
1074 pub value: bool,
1075 pub level: PolicyLevel,
1076}
1077
1078#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1081#[serde(deny_unknown_fields)]
1082pub struct SharingStateV2 {
1083 pub state: SharingValue,
1084 pub plan_default: PlanDefault,
1085 pub policy: Option<SharingPolicyValueV2>,
1086 pub confirmed_at: Option<TimeStamp>,
1087 pub since: Option<TimeStamp>,
1088}
1089
1090fn counts_ok(values: impl IntoIterator<Item = u64>) -> bool {
1093 values.into_iter().all(|n| n <= MAX_COUNT)
1094}
1095
1096fn unique<'a>(names: impl IntoIterator<Item = &'a str>) -> bool {
1097 let mut seen = BTreeSet::new();
1098 names.into_iter().all(|n| seen.insert(n))
1099}
1100
1101impl SliceV2 {
1102 pub fn counters(&self) -> Vec<u64> {
1104 let t = &self.tokens;
1105 let s = &self.savings;
1106 let c = &self.calls;
1107 let l = &self.ledger;
1108 let g = &self.guards;
1109 let mut all = vec![
1110 t.input,
1111 t.output,
1112 t.saved,
1113 s.measured,
1114 s.estimated,
1115 s.estimated_low,
1116 s.estimated_high,
1117 s.evidenced,
1118 s.overhead,
1119 c.tool_calls,
1120 c.cache_reads,
1121 c.cache_hits,
1122 self.compression.original,
1123 self.compression.compressed,
1124 l.reduction,
1125 l.cache_discount,
1126 l.correction,
1127 l.edit,
1128 l.other,
1129 g.secrets_redacted,
1130 g.pii_redacted,
1131 g.injection_flags,
1132 g.content_withheld,
1133 g.paths_blocked,
1134 g.shell_blocked,
1135 g.partial_inspections,
1136 self.sessions.started,
1137 ];
1138 all.extend(self.read_modes.values().copied());
1139 all.extend(self.tools.iter().flat_map(|r| [r.calls, r.saved]));
1140 all.extend(
1141 self.policy_blocks
1142 .iter()
1143 .flat_map(|r| [r.blocked, r.would_block]),
1144 );
1145 all.extend(self.refetch.iter().flat_map(|r| [r.truncated, r.refetched]));
1146 all.extend(self.unregulated.iter().map(|r| r.calls));
1147 if let Some(a) = &self.hive {
1148 all.extend([a.knowledge_hits_referenced, a.first_context_sessions]);
1149 all.extend(a.first_context_ms_p50);
1150 all.extend(
1151 a.task_usage
1152 .iter()
1153 .flat_map(|t| [t.number, t.input_tokens, t.output_tokens, t.list_usd_micros]),
1154 );
1155 }
1156 all.extend(self.secret_hits.iter().map(|r| r.count));
1157 all.extend(self.hosts.iter().flat_map(|r| [r.sessions, r.tool_calls]));
1158 all.extend(self.spend.iter().flat_map(|r| {
1159 [
1160 r.requests,
1161 r.input_tokens,
1162 r.output_tokens,
1163 r.cached_tokens,
1164 r.usd_micros,
1165 ]
1166 }));
1167 all
1168 }
1169
1170 pub fn has_activity(&self) -> bool {
1173 let mut counters = self.counters();
1174 counters.remove(8);
1175 counters.into_iter().any(|n| n > 0)
1176 }
1177
1178 pub fn validate(&self) -> Result<(), ValidationError> {
1179 if self.tools.len() > MAX_TOOLS
1180 || self.policy_blocks.len() > MAX_POLICY_BLOCKS
1181 || self.refetch.len() > MAX_REFETCH
1182 || self.unregulated.len() > MAX_UNREGULATED
1183 || self.secret_hits.len() > MAX_SECRET_HITS
1184 || self.hosts.len() > MAX_HOSTS
1185 || self.spend.len() > MAX_SPEND
1186 {
1187 return Err(invalid("slice: too many rows"));
1188 }
1189 if !unique(self.tools.iter().map(|r| r.tool.as_str()))
1190 || !unique(self.policy_blocks.iter().map(|r| r.rule.as_str()))
1191 || !unique(self.refetch.iter().map(|r| r.filter.as_str()))
1192 || !unique(self.hosts.iter().map(|r| r.host.as_str()))
1193 {
1194 return Err(invalid("slice: duplicate row name"));
1195 }
1196 let mut pairs = BTreeSet::new();
1197 if !self
1198 .spend
1199 .iter()
1200 .all(|r| pairs.insert((r.model.as_str(), r.provider.as_str())))
1201 {
1202 return Err(invalid("slice: duplicate spend row"));
1203 }
1204 let mut unregulated = BTreeSet::new();
1205 if !self
1206 .unregulated
1207 .iter()
1208 .all(|r| unregulated.insert((r.kind, r.name.as_str())))
1209 {
1210 return Err(invalid("slice: duplicate unregulated row"));
1211 }
1212 if !unique(self.secret_hits.iter().map(|r| r.detector.as_str())) {
1213 return Err(invalid("slice: duplicate secret row"));
1214 }
1215 for row in &self.unregulated {
1216 let name = row.name.as_str();
1217 let fits = match row.kind {
1218 UnregulatedKind::Program => KNOWN_PROGRAMS.contains(&name),
1219 UnregulatedKind::Host => HOST_FAMILIES.contains(&name),
1220 UnregulatedKind::McpServer => is_mcp_server_name(name),
1221 };
1222 if !fits {
1223 return Err(invalid("slice: unregulated name of another kind"));
1224 }
1225 }
1226 if let Some(a) = &self.hive {
1227 let sorted = a.task_usage.windows(2).all(|w| w[0].number < w[1].number);
1228 if a.task_usage.len() > MAX_TASK_USAGE
1229 || !sorted
1230 || a.task_usage.iter().any(|t| t.number == 0)
1231 || a.first_context_ms_p50
1232 .is_some_and(|ms| ms > MAX_FIRST_CONTEXT_MS)
1233 {
1234 return Err(invalid("slice: hive"));
1235 }
1236 }
1237 let gauge = self.gauges.and_then(|g| g.facts_current).unwrap_or(0);
1238 if !counts_ok(self.counters()) || gauge > MAX_COUNT {
1239 return Err(invalid("slice: count beyond 2^53"));
1240 }
1241 Ok(())
1242 }
1243}
1244
1245impl BucketV2 {
1246 pub fn validate(&self) -> Result<(), ValidationError> {
1247 if self.rev == 0 {
1248 return Err(invalid("bucket rev"));
1249 }
1250 if self.slices.is_empty() || self.slices.len() > MAX_SLICES {
1251 return Err(invalid("bucket: 1-33 slices"));
1252 }
1253 let mut vaults = BTreeSet::new();
1254 for slice in &self.slices {
1255 if !vaults.insert(slice.vault.as_ref().map(VaultId::as_str)) {
1256 return Err(invalid("bucket: duplicate vault"));
1257 }
1258 slice.validate()?;
1259 }
1260 Ok(())
1261 }
1262}
1263
1264impl HoursBodyV2 {
1265 pub fn validate(&self) -> Result<(), ValidationError> {
1266 if self.buckets.is_empty() || self.buckets.len() > MAX_BUCKETS {
1267 return Err(invalid("request: 1-168 buckets"));
1268 }
1269 if !unique(self.buckets.iter().map(|b| b.hour.as_str())) {
1270 return Err(invalid("request: duplicate hour"));
1271 }
1272 self.buckets.iter().try_for_each(BucketV2::validate)
1273 }
1274
1275 pub fn signing_input(&self, key_id: &KeyId) -> Result<Vec<u8>, ValidationError> {
1278 let mut value = serde_json::to_value(self)
1279 .map_err(|e| ValidationError::new(format!("instance metrics: encode: {e}")))?;
1280 value["key_id"] = serde_json::Value::String(key_id.as_str().to_string());
1281 super::signing_input(ENGINE_METRICS_DOMAIN, &value)
1282 }
1283
1284 pub fn signed(self, key_id: KeyId, signature: SignatureHex) -> HoursRequestV2 {
1285 HoursRequestV2 {
1286 schema: self.schema,
1287 instance: self.instance,
1288 buckets: self.buckets,
1289 key_id,
1290 signature,
1291 }
1292 }
1293}
1294
1295impl HoursRequestV2 {
1296 pub fn body(&self) -> HoursBodyV2 {
1298 HoursBodyV2 {
1299 schema: self.schema,
1300 instance: self.instance.clone(),
1301 buckets: self.buckets.clone(),
1302 }
1303 }
1304
1305 pub fn validate(&self) -> Result<(), ValidationError> {
1306 self.body().validate()
1307 }
1308}
1309
1310#[cfg(test)]
1311mod tests {
1312 use super::*;
1313
1314 #[test]
1315 fn names_follow_their_closed_rules() {
1316 assert!(ToolName::new("ctx_read").is_some());
1317 for bad in ["rm -rf /", "/etc/passwd", "Ctx_Read", "ctx-read", "", "a b"] {
1318 assert!(ToolName::new(bad).is_none(), "{bad}");
1319 assert_eq!(ToolName::or_other(bad).as_str(), OTHER);
1320 }
1321 for unknown in ["acme_secret_project", "a", "ctx_reader"] {
1322 assert!(ToolName::new(unknown).is_none(), "{unknown}");
1323 assert_eq!(ToolName::or_other(unknown).as_str(), OTHER);
1324 }
1325 assert!(ToolName::new(OTHER).is_some());
1326 assert!(RuleId::new("sharing.share_instance_metrics").is_some());
1327 assert!(RuleId::new("gateway.tool_denied").is_some());
1328 assert!(RuleId::new("gateway.destination").is_some());
1329 assert!(RuleId::new("gateway.egress").is_some());
1330 assert_eq!(host_family("claude-code"), "claude");
1331 assert_eq!(host_family("Cursor"), "cursor");
1332 assert_eq!(host_family("oh-my-pi"), "pi");
1333 assert_eq!(host_family("acme-internal-agent"), OTHER);
1334 assert!(HOST_FAMILIES.iter().all(|f| is_unregulated_name(f)));
1335 for bad in ["blocked because of /x", "a.b.c", "Security.pii", ".x", "x."] {
1336 assert!(RuleId::new(bad).is_none(), "{bad}");
1337 }
1338 assert!(WireName::new("claude-sonnet-4-5").is_some());
1339 assert!(WireName::new("anthropic").is_some());
1340 assert!(WireName::new("claude-code").is_some());
1341 for bad in [
1342 "/Users/me",
1343 "a//b",
1344 "../x",
1345 "with space",
1346 "",
1347 "anthropic/claude",
1348 "ada-macbook.local",
1349 ] {
1350 assert!(WireName::new(bad).is_none(), "{bad}");
1351 }
1352 assert_eq!(ToolName::or_other("ctx_read").as_str(), "ctx_read");
1353 assert_eq!(ToolName::or_other("acme_internal_db").as_str(), OTHER);
1354 assert!(UnregulatedName::new("terraform").is_some());
1355 assert!(
1356 UnregulatedName::new("github").is_some(),
1357 "an MCP server name"
1358 );
1359 assert!(!is_mcp_server_name(&"a".repeat(30)), "token-like");
1360 assert_eq!(RuleId::or_other("custom.rule").as_str(), OTHER);
1361 assert_eq!(FilterName::or_other("cargo_test").as_str(), OTHER);
1362 assert_eq!(DetectorName::or_other("Generic").as_str(), OTHER);
1363 for bad in [
1364 "terraform apply -auto-approve",
1365 "/usr/bin/terraform",
1366 "a..b",
1367 "Git",
1368 ] {
1369 assert!(UnregulatedName::new(bad).is_none(), "{bad}");
1370 }
1371 assert!(Fingerprint::new("9c1e4b7a02d3f658").is_some());
1372 assert!(Fingerprint::new("[REDACTED:AWS key]").is_none());
1373 assert!(HourStamp::new("2026-10-26T17:00:00Z").is_some());
1374 for bad in [
1375 "2026-10-26T17:05:00Z",
1376 "2026-10-26T17:00:00+01:00",
1377 "2026-13-26T17:00:00Z",
1378 ] {
1379 assert!(HourStamp::new(bad).is_none(), "{bad}");
1380 }
1381 assert!(TimeStamp::new("2026-10-26T15:12:40Z").is_some());
1382 assert_eq!(ReadMode::of("lines:10-20"), ReadMode::Lines);
1383 assert_eq!(ReadMode::of("signatures"), ReadMode::Signatures);
1384 assert_eq!(ReadMode::of("something new"), ReadMode::Other);
1385 }
1386
1387 #[test]
1388 fn free_text_cannot_be_deserialised_into_any_field() {
1389 let tool = serde_json::from_str::<ToolRowV2>(
1390 r#"{"tool":"cat ~/.ssh/id_rsa","calls":1,"saved":0}"#,
1391 );
1392 assert!(tool.is_err());
1393 let extra =
1394 serde_json::from_str::<TokensV2>(r#"{"input":1,"output":1,"saved":0,"prompt":"x"}"#);
1395 assert!(extra.is_err());
1396 let schema = serde_json::from_str::<HoursSchema>(r#""leanctx.hive.instance-metrics/v3""#);
1397 assert!(schema.is_err());
1398 }
1399}