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.instance-metrics/v2";
31pub const ENGINE_METRICS_ACK_SCHEMA: &str = "leanctx.hive.instance-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_instance_metrics";
35/// Domain of the batch signature (instance key).
36pub const ENGINE_METRICS_DOMAIN: &str = "leanctx.hive.instance-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_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
149/// Policy member paths and gateway rule ids (`policy_blocks[].rule`).
150pub 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
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!("instance 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,
564    is_tool,
565    "tool name"
566);
567closed_string!(
568    /// A policy member path or gateway rule id (`security.excluded_paths`).
569    RuleId,
570    is_rule,
571    "rule id"
572);
573closed_string!(
574    /// A model, provider or host name (v1 `name` rule, never a path).
575    WireName,
576    is_wire_name,
577    "name"
578);
579closed_string!(
580    /// A compression filter: a read mode or a pattern name (tool rule).
581    FilterName,
582    is_filter,
583    "filter name"
584);
585closed_string!(
586    /// A secret detector name (tool rule).
587    DetectorName,
588    is_detector,
589    "detector name"
590);
591closed_string!(
592    /// A known program, host or MCP server name (never a command line).
593    UnregulatedName,
594    is_unregulated_name,
595    "unregulated name"
596);
597closed_string!(
598    /// First 16 hex of HMAC-SHA256(workspace fingerprint key, secret).
599    Fingerprint,
600    is_hex16,
601    "fingerprint"
602);
603closed_string!(
604    /// A UTC hour start, `YYYY-MM-DDTHH:00:00Z`.
605    HourStamp,
606    is_hour,
607    "hour"
608);
609closed_string!(
610    /// A UTC time, second precision.
611    TimeStamp,
612    is_time,
613    "time"
614);
615closed_string!(
616    /// A Hive vault id (32 lowercase hex characters).
617    VaultId,
618    is_hex32,
619    "vault id"
620);
621closed_string!(
622    /// `CARGO_PKG_VERSION` as the contract allows it.
623    EngineVersion,
624    is_engine_version,
625    "engine version"
626);
627closed_string!(
628    /// `hex(blake3(instance public key))[..16]`.
629    KeyId,
630    is_hex16,
631    "key id"
632);
633closed_string!(
634    /// An Ed25519 signature, lowercase hex.
635    SignatureHex,
636    is_hex128,
637    "signature"
638);
639
640impl ToolName {
641    /// `value` when it is a registered LeanCTX tool ([`KNOWN_TOOLS`]), else
642    /// `other`: a command line, a path, a proxied or custom MCP tool name
643    /// never becomes a tool name.
644    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    /// The hour that holds `time` (UTC).
693    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
712/// A schema id that serialises as exactly `ID` and accepts nothing else.
713macro_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// ── closed enums ─────────────────────────────────────────────────────────────
740
741/// `ctx_read` read modes (closed set; unknown modes count as `other`).
742#[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    /// The read mode a recorded mode name counts as: `lines:10-20` is
760    /// `lines`, anything outside the set is `other`.
761    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/// Policy level that set the rule.
788#[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    /// How people read the level (Company › Workspace › Vault › Instance).
799    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/// `unregulated[].kind`.
832#[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/// `sharing_state.state`.
841#[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/// `sharing_state.plan_default`.
850#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
851#[serde(rename_all = "snake_case")]
852pub enum PlanDefault {
853    On,
854    Off,
855}
856
857// ── wire structs ─────────────────────────────────────────────────────────────
858
859/// The batch as signed: `hours_request` without `key_id` and `signature`.
860/// `hive metrics preview --json` prints this.
861#[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/// `POST /v1/metrics/hours` body.
870#[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/// Saved tokens by trust label (K7) and LeanCTX's own overhead (K8).
929#[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/// Saved tokens by ledger kind (the ROI view).
964#[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/// K12: refusals in force and refusals a monitor-mode rule would make.
987#[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/// K10: outputs truncated and fetched back in full.
996#[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/// K13: activity no rule covers.
1005#[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/// K21: a redacted secret as its workspace fingerprint, never its value.
1014#[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/// X4 Hive help in one hour.
1022#[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/// Ledger usage while a session held a Hive task (by number, never a title).
1032#[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/// The policy part of a sharing state.
1097#[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/// `sharing_state`: `GET /v1/metrics/sharing`, the confirm answer, and the
1105/// `sharing` of every ack.
1106#[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
1116// ── checks ───────────────────────────────────────────────────────────────────
1117
1118fn 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    /// Every counter of the slice, for the 2^53 bound and activity checks.
1129    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    /// Whether anything happened (the gauge is a level, not activity; the
1197    /// overhead alone is not activity either).
1198    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    /// The bytes the signature covers: the request with `key_id` and
1302    /// without `signature` (the `worker_register` scheme).
1303    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    /// The signed part.
1323    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}