Skip to main content

lean_ctx_protocol/hive/
metrics_v2.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Engine metrics v2 (C0 2.0 `schemas/metrics.schema.json`, telemetry SPEC).
4//!
5//! `POST /v1/metrics/hours` [`HoursRequestV2`] → [`HoursAckV2`]: hourly
6//! counters of one engine, split by Hive vault, signed by the instance key
7//! (Ed25519 over [`ENGINE_METRICS_DOMAIN`] ‖ canonical_json(body with
8//! `key_id`, without `signature`), as `worker_register`). Bucket key (instance
9//! from the bearer token, `hour`, `vault`); a higher `rev` replaces the
10//! hour, a lower or equal one is acked `unchanged`. `GET /v1/metrics/sharing`
11//! and every ack carry the [`SharingStateV2`].
12//!
13//! Aggregates only, enforced by the types: every string on this wire is a
14//! closed-pattern newtype ([`ToolName`], [`RuleId`], [`WireName`],
15//! [`FilterName`], [`DetectorName`], [`UnregulatedName`], [`Fingerprint`],
16//! [`HourStamp`], [`VaultId`], [`EngineVersion`], [`KeyId`],
17//! [`SignatureHex`], [`TimeStamp`]) or a closed enum ([`ReadMode`],
18//! [`EngineOs`], the schema constants). None of them can hold a path, a
19//! command line, a prompt, a secret or other free text: constructing one
20//! from such a value fails (or maps to `other`), and deserialising one
21//! refuses it. Every struct refuses unknown fields.
22
23use 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.engine-metrics/v2";
31pub const ENGINE_METRICS_ACK_SCHEMA: &str = "leanctx.hive.engine-metrics-ack/v2";
32/// The policy member that gates engine metrics (`x-merge: false_wins`,
33/// `x-enforcement: server`; absent at every level = the plan default).
34pub const SHARE_ENGINE_METRICS_MEMBER: &str = "sharing.share_engine_metrics";
35/// Domain of the batch signature (instance key).
36pub const ENGINE_METRICS_DOMAIN: &str = "leanctx.hive.engine-metrics.v2\n";
37/// HKDF info of the per-workspace secret fingerprint key.
38pub const SECRET_FINGERPRINT_INFO: &str = "leanctx.hive.secret-fingerprint.v1";
39/// `POST /v1/metrics/hours`, relative to the engine API base.
40pub const HOURS_ROUTE: &str = "/metrics/hours";
41/// `GET /v1/metrics/sharing`.
42pub const SHARING_ROUTE: &str = "/metrics/sharing";
43/// `POST /v1/metrics/sharing/confirm`.
44pub 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;
55/// Largest request body Hive accepts.
56pub const MAX_HOURS_BODY_BYTES: usize = 512 * 1024;
57/// Largest count a JSON number carries exactly (2^53).
58pub const MAX_COUNT: u64 = 1 << 53;
59/// Name of the row that absorbs rows beyond a limit or names outside a rule.
60pub const OTHER: &str = "other";
61
62/// K2 (M1): the LeanCTX tool names the engine may send as `tools[].tool`;
63/// any other name (a proxied or custom MCP tool) is `other`. The engine's
64/// tool registry must be a subset (engine test).
65pub 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_edit",
97    "ctx_execute",
98    "ctx_expand",
99    "ctx_feedback",
100    "ctx_gain",
101    "ctx_git_read",
102    "ctx_glob",
103    "ctx_graph",
104    "ctx_handoff",
105    "ctx_heatmap",
106    "ctx_impact",
107    "ctx_index",
108    "ctx_intent",
109    "ctx_knowledge",
110    "ctx_load_tools",
111    "ctx_memory",
112    "ctx_metrics",
113    "ctx_multi_read",
114    "ctx_multi_repo",
115    "ctx_outline",
116    "ctx_overview",
117    "ctx_pack",
118    "ctx_package",
119    "ctx_patch",
120    "ctx_prefetch",
121    "ctx_preload",
122    "ctx_proof",
123    "ctx_provider",
124    "ctx_radar",
125    "ctx_read",
126    "ctx_review",
127    "ctx_search",
128    "ctx_semantic_search",
129    "ctx_session",
130    "ctx_shape",
131    "ctx_share",
132    "ctx_shell",
133    "ctx_smart_read",
134    "ctx_symbol",
135    "ctx_tasks",
136    "ctx_tools",
137    "ctx_transcript_compact",
138    "ctx_tree",
139    "ctx_url_read",
140    "ctx_verify",
141    "ctx_work_graph",
142    "ctx_wrapped",
143    "ocla_savings",
144    "shell",
145    OTHER,
146];
147
148/// Policy member paths and gateway rule ids (`policy_blocks[].rule`).
149pub const KNOWN_RULES: &[&str] = &[
150    "context.allowed_sources",
151    "context.allowed_tools",
152    "context.auto_extraction",
153    "context.denied_tools",
154    "context.extraction_requires_source_anchor",
155    "context.injection_filter",
156    "context.max_context_tokens",
157    "context.max_cost_cents_per_person_per_day",
158    "context.max_cost_cents_per_project_per_month",
159    "context.max_requests_per_minute_per_person",
160    "context.max_source_age_days",
161    "context.max_writes_per_min",
162    "context.ranking_profile",
163    "context.read_mode",
164    "security.allowed_models",
165    "security.block_secrets",
166    "security.blocked_labels",
167    "security.classification",
168    "security.confidential_out_of_sharing",
169    "security.excluded_paths",
170    "security.external_model_excluded_classes",
171    "security.pii",
172    "sharing.allowed_vaults",
173    "sharing.auto_merge_results",
174    "sharing.consent_required_for",
175    "sharing.cross_team_sharing",
176    "sharing.personal_workspace",
177    "sharing.share_engine_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
190/// Re-fetch filters: a read mode or an output class (`refetch[].filter`).
191pub 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
207/// Secret detectors (`secret_hits[].detector`).
208pub 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
227/// Known programs (`unregulated[]` of kind `program`); never a command line.
228pub 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
278/// K2 (H2): MCP hosts by their client id; any other `clientInfo.name` is `other`.
279pub 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
318/// K13: agent host families (`unregulated[]` of kind `host`), coarser than
319/// [`KNOWN_HOSTS`]: one family per vendor integration; anything else is `other`.
320pub 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/// The host family of an MCP client id (a [`KNOWN_HOSTS`] entry or what the
343/// client announced); never the announced text itself.
344#[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
365/// Providers of `spend[]`.
366pub const KNOWN_PROVIDERS: &[&str] = &[
367    "anthropic",
368    "openai",
369    "google",
370    "microsoft",
371    "deepseek",
372    "meta",
373    "unknown",
374    OTHER,
375];
376
377/// K2 (H3): models of `spend[]`: the engine's embedded price table keys
378/// (and the contract fixture names); any other model id (fine-tunes, local
379/// or org-named models) is `other`. The engine's price table must be a
380/// subset (engine test).
381pub 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!("engine metrics: invalid {what}"))
409}
410
411// ── closed-pattern strings ───────────────────────────────────────────────────
412
413macro_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            /// `value` when it matches the rule, else `None`.
421            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
452/// K2 H3: a closed table, not a shape. A custom or proxied MCP tool name
453/// that looks like an identifier (`acme_secret_project`) is refused, so the
454/// engine maps it to `other` ([`ToolName::or_other`]) before it is counted.
455fn 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
471/// An MCP server name as the contract allows it: `^[a-z0-9][a-z0-9_-]{0,47}$`
472/// and never a run of 24+ letters or digits (token-like).
473pub 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
492/// v1 `name` rule (`[A-Za-z0-9._:/-]{1,64}`, never a path) and, since the
493/// K2 review, a closed table: a known host, provider or model, or `other`.
494/// A relative path, a host name, a fine-tune id or a key never passes.
495fn 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
509/// A known program, a host family or an MCP server name (the kind decides
510/// which one applies, [`SliceV2::validate`]).
511fn 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
519/// `YYYY-MM-DDTHH:MM:SSZ`.
520fn 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    /// A registered LeanCTX tool name ([`KNOWN_TOOLS`], `other` included).
563    ToolName, is_tool, "tool name"
564);
565closed_string!(
566    /// A policy member path or gateway rule id (`security.excluded_paths`).
567    RuleId, is_rule, "rule id"
568);
569closed_string!(
570    /// A model, provider or host name (v1 `name` rule, never a path).
571    WireName, is_wire_name, "name"
572);
573closed_string!(
574    /// A compression filter: a read mode or a pattern name (tool rule).
575    FilterName, is_filter, "filter name"
576);
577closed_string!(
578    /// A secret detector name (tool rule).
579    DetectorName, is_detector, "detector name"
580);
581closed_string!(
582    /// A known program, host or MCP server name (never a command line).
583    UnregulatedName, is_unregulated_name, "unregulated name"
584);
585closed_string!(
586    /// First 16 hex of HMAC-SHA256(workspace fingerprint key, secret).
587    Fingerprint, is_hex16, "fingerprint"
588);
589closed_string!(
590    /// A UTC hour start, `YYYY-MM-DDTHH:00:00Z`.
591    HourStamp, is_hour, "hour"
592);
593closed_string!(
594    /// A UTC time, second precision.
595    TimeStamp, is_time, "time"
596);
597closed_string!(
598    /// A Hive vault id (32 lowercase hex characters).
599    VaultId, is_hex32, "vault id"
600);
601closed_string!(
602    /// `CARGO_PKG_VERSION` as the contract allows it.
603    EngineVersion, is_engine_version, "engine version"
604);
605closed_string!(
606    /// `hex(blake3(instance public key))[..16]`.
607    KeyId, is_hex16, "key id"
608);
609closed_string!(
610    /// An Ed25519 signature, lowercase hex.
611    SignatureHex, is_hex128, "signature"
612);
613
614impl ToolName {
615    /// `value` when it is a registered LeanCTX tool ([`KNOWN_TOOLS`]), else
616    /// `other`: a command line, a path, a proxied or custom MCP tool name
617    /// never becomes a tool name.
618    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    /// The hour that holds `time` (UTC).
667    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
686/// A schema id that serialises as exactly `ID` and accepts nothing else.
687macro_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// ── closed enums ─────────────────────────────────────────────────────────────
714
715/// `ctx_read` read modes (closed set; unknown modes count as `other`).
716#[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    /// The read mode a recorded mode name counts as: `lines:10-20` is
734    /// `lines`, anything outside the set is `other`.
735    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/// Policy level that set the rule.
762#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
763#[serde(rename_all = "snake_case")]
764pub enum PolicyLevel {
765    Org,
766    Workspace,
767    Vault,
768    Engine,
769}
770
771impl PolicyLevel {
772    /// How people read the level (Company › Team › Vault › Engine).
773    pub fn label(self) -> &'static str {
774        match self {
775            Self::Org => "Company",
776            Self::Workspace => "Team",
777            Self::Vault => "Vault",
778            Self::Engine => "Engine",
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            "engine" => Some(Self::Engine),
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/// `unregulated[].kind`.
806#[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/// `sharing_state.state`.
815#[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/// `sharing_state.plan_default`.
824#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
825#[serde(rename_all = "snake_case")]
826pub enum PlanDefault {
827    On,
828    Off,
829}
830
831// ── wire structs ─────────────────────────────────────────────────────────────
832
833/// The batch as signed: `hours_request` without `key_id` and `signature`.
834/// `hive metrics preview --json` prints this.
835#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
836#[serde(deny_unknown_fields)]
837pub struct HoursBodyV2 {
838    pub schema: HoursSchema,
839    pub engine: EngineV2,
840    pub buckets: Vec<BucketV2>,
841}
842
843/// `POST /v1/metrics/hours` body.
844#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
845#[serde(deny_unknown_fields)]
846pub struct HoursRequestV2 {
847    pub schema: HoursSchema,
848    pub engine: 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 engine_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/// Saved tokens by trust label (K7) and LeanCTX's own overhead (K8).
903#[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/// Saved tokens by ledger kind (the ROI view).
938#[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/// K12: refusals in force and refusals a monitor-mode rule would make.
961#[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/// K10: outputs truncated and fetched back in full.
970#[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/// K13: activity no rule covers.
979#[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/// K21: a redacted secret as its workspace fingerprint, never its value.
988#[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/// X4 Hive help in one hour (moved from the retired `POST /v1/metrics`).
996#[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/// Ledger usage while a session held a Hive task (by number, never a title).
1006#[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/// The policy part of a sharing state.
1071#[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/// `sharing_state`: `GET /v1/metrics/sharing`, the confirm answer, and the
1079/// `sharing` of every ack.
1080#[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
1090// ── checks ───────────────────────────────────────────────────────────────────
1091
1092fn 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    /// Every counter of the slice, for the 2^53 bound and activity checks.
1103    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    /// Whether anything happened (the gauge is a level, not activity; the
1171    /// overhead alone is not activity either).
1172    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    /// The bytes the signature covers: the request with `key_id` and
1276    /// without `signature` (the `worker_register` scheme).
1277    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!("engine 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            engine: self.engine,
1288            buckets: self.buckets,
1289            key_id,
1290            signature,
1291        }
1292    }
1293}
1294
1295impl HoursRequestV2 {
1296    /// The signed part.
1297    pub fn body(&self) -> HoursBodyV2 {
1298        HoursBodyV2 {
1299            schema: self.schema,
1300            engine: self.engine.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_engine_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.engine-metrics/v3""#);
1397        assert!(schema.is_err());
1398    }
1399}