grafos_core/
policy_vocab.rs

1//! Cross-phase shared vocabulary for phases 218–222.
2//!
3//! This module is the home for typed surfaces that are referenced by
4//! multiple phases: preemption reasons (218), audit event kinds and
5//! evidence labels (219), and workload identity (220). It exists so
6//! that the five upstream phases compose a single layered story
7//! rather than each forking its own enum.
8//!
9//! ## What lives here
10//!
11//! - `PreemptionReason` — Phase 218's typed enum for "why was this
12//!   work preempted." Used by scheduler admission, audit emit, runtime
13//!   events, and the DRA driver's typed failure path.
14//! - `EvidenceLabel` — Phase 219's evidence-label vocabulary, reused
15//!   without translation by Phase 220 assurance posture, Phase 221
16//!   HCL status, and Phase 222 DRA claim provenance. The same docs
17//!   lint enforces all four.
18//! - `AuditEventKind` — Phase 219's typed audit event names. The set
19//!   here is intentionally minimal at slice 1; new variants land
20//!   alongside the code paths that emit them.
21//! - `WorkloadIdentity` — Phase 220's canonical workload-identity
22//!   tuple. Single shape across scheduler, audit, attestation,
23//!   Kubernetes/DRA. No phase invents a parallel record.
24//!
25//! ## What does NOT live here
26//!
27//! - Anything that `fabricbios-core` would import. These types are
28//!   policy vocabulary; they MUST NOT cross the TCB boundary into
29//!   `fabricbios-core`. A CI guard at `scripts/check-fabricbios-core-tcb.sh`
30//!   asserts this structurally by reading the manifest.
31//! - Implementation logic. This file is types and small helpers
32//!   (Display, FromStr) only. Emit code, validation, persistence,
33//!   docs lint — all elsewhere.
34//! - Phase-internal records. Per-phase concrete records (admission
35//!   record, audit-chain entry, HCL row) live in their owning crate
36//!   and embed these vocabulary types as fields.
37
38use core::fmt;
39
40// ---------------------------------------------------------------------------
41// Phase 218 — PreemptionReason
42// ---------------------------------------------------------------------------
43
44/// Typed reasons for which an admission was rejected or an existing
45/// lease was preempted. Phase 218 enumerates these up front so the
46/// admission, audit, and DRA layers all agree on the vocabulary.
47///
48/// The reason MUST be present on every preemption emit. There is no
49/// `Other(String)` variant on purpose: a generic kill path is exactly
50/// what the typed-lifecycle rule rejects.
51///
52/// New reasons require a design-doc update plus a typed regression
53/// test that asserts the reason fires in the case it names. Adding
54/// a reason is a vocabulary change, not a runtime config knob.
55#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
56#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
57#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
58pub enum PreemptionReason {
59    /// Higher-priority admitted work is allowed to reclaim resources
60    /// from lower-priority preemptible work.
61    #[cfg_attr(feature = "serde", serde(alias = "PriorityPreemption"))]
62    PriorityPreemption,
63    /// Tenant or project usage must be moved back inside its configured
64    /// fair-share/quota envelope. Distinct from `BurstCreditExhausted`:
65    /// a `QuotaRebalance` typically admitted a different tenant.
66    #[cfg_attr(feature = "serde", serde(alias = "QuotaRebalance"))]
67    QuotaRebalance,
68    /// The tenant's token-bucket burst credit reached zero while the
69    /// request required burst capacity beyond hard quota. No other
70    /// tenant was admitted; the workload was rejected for exceeding
71    /// its own configured burst envelope.
72    #[cfg_attr(feature = "serde", serde(alias = "BurstCreditExhausted"))]
73    BurstCreditExhausted,
74    /// Tenant budget or spend policy is exhausted and the work is
75    /// configured as preemptible on budget exhaustion.
76    #[cfg_attr(feature = "serde", serde(alias = "BudgetExhausted"))]
77    BudgetExhausted,
78    /// Phase 216 economics policy requires eviction because a hard
79    /// cost cap can no longer be satisfied.
80    #[cfg_attr(feature = "serde", serde(alias = "CostCapEviction"))]
81    CostCapEviction,
82    /// An operator initiated node/cell drain.
83    #[cfg_attr(feature = "serde", serde(alias = "OperatorDrain"))]
84    OperatorDrain,
85    /// An operator initiated GPU MIG profile recompose. Shared with
86    /// Phase 221 dynamic MIG profile recompose so audit logs
87    /// distinguish operator-driven GPU repartitioning from ordinary
88    /// priority preemption.
89    #[cfg_attr(feature = "serde", serde(alias = "OperatorMigProfileChange"))]
90    OperatorMigProfileChange,
91    /// A scheduled maintenance policy requires the resource to be
92    /// cleared.
93    #[cfg_attr(feature = "serde", serde(alias = "MaintenanceWindow"))]
94    MaintenanceWindow,
95    /// The system detected a previously admitted lease no longer
96    /// satisfies a mandatory policy and must fail closed or recover.
97    /// Emit MUST carry a typed `policy_id` naming the violated policy
98    /// — emit without policy_id is a typed error, not a string default.
99    #[cfg_attr(feature = "serde", serde(alias = "PolicyViolationRecovery"))]
100    PolicyViolationRecovery,
101}
102
103impl PreemptionReason {
104    /// Stable wire / log identifier. Used by audit emit, DRA failure
105    /// types, and Phase 219 docs lint. MUST be stable across minor
106    /// versions: external collectors group on this string.
107    pub fn as_str(self) -> &'static str {
108        match self {
109            Self::PriorityPreemption => "priority_preemption",
110            Self::QuotaRebalance => "quota_rebalance",
111            Self::BurstCreditExhausted => "burst_credit_exhausted",
112            Self::BudgetExhausted => "budget_exhausted",
113            Self::CostCapEviction => "cost_cap_eviction",
114            Self::OperatorDrain => "operator_drain",
115            Self::OperatorMigProfileChange => "operator_mig_profile_change",
116            Self::MaintenanceWindow => "maintenance_window",
117            Self::PolicyViolationRecovery => "policy_violation_recovery",
118        }
119    }
120
121    /// Tooltip-grade operator-readable phrase for this preemption
122    /// reason. Mirrors the [`RejectionReason::human_summary`] shape
123    /// so dashboard / CLI surfaces can render typed preemption
124    /// reasons without re-deriving the prose.
125    ///
126    /// `as_str()` remains the wire-format-grade snake_case label
127    /// SIEM rules and audit collectors group on; `human_summary()`
128    /// is the rendering helper. They are NOT interchangeable.
129    ///
130    /// The string is intentionally short (one line, no terminal
131    /// punctuation) so a renderer can compose it inline. The
132    /// runbook at `docs/runbooks/preemption-explanations.md` is
133    /// the long-form operator-action prose; this helper is the
134    /// tooltip-grade sibling.
135    ///
136    /// Slice 73 lands the helper without dashboard wiring — there
137    /// is no preemption panel yet. When that surface lands it
138    /// can read this method directly.
139    pub fn human_summary(self) -> &'static str {
140        match self {
141            Self::PriorityPreemption => "reclaimed for higher-priority work that outranked it",
142            Self::QuotaRebalance => {
143                "reclaimed because tenant fair-share moved a different tenant in"
144            }
145            Self::BurstCreditExhausted => {
146                "reclaimed because the tenant's burst-bucket envelope was exceeded"
147            }
148            Self::BudgetExhausted => "reclaimed because the tenant's spend budget was exhausted",
149            Self::CostCapEviction => {
150                "reclaimed because the global cost cap could no longer be satisfied"
151            }
152            Self::OperatorDrain => "reclaimed because an operator drained the host node",
153            Self::OperatorMigProfileChange => {
154                "reclaimed because an operator recomposed the GPU MIG profile"
155            }
156            Self::MaintenanceWindow => "reclaimed by a scheduled maintenance window",
157            Self::PolicyViolationRecovery => {
158                "reclaimed because the workload no longer satisfied a mandatory policy"
159            }
160        }
161    }
162}
163
164impl fmt::Display for PreemptionReason {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        f.write_str(self.as_str())
167    }
168}
169
170// ---------------------------------------------------------------------------
171// Phase 218.2 — Priority (slice 85)
172// ---------------------------------------------------------------------------
173
174/// Phase 218.2 — Scheduling priority class for a tenant's workloads.
175///
176/// Three classes that compose with the rest of the typed policy
177/// vocabulary. Ordering is `Guaranteed > Standard > Scavenger`;
178/// discriminants are arranged so the derived `Ord` yields this
179/// ordering directly. Slice 85 relocated this enum from
180/// `grafos-scheduler::tenant` to `grafos-core::policy_vocab`
181/// alongside the other cross-crate typed surfaces (`PreemptionReason`,
182/// `RejectionReason`, `FairShareWindow`, `EvidenceLabel`,
183/// `AuditEventKind`, `WorkloadIdentity`) so every consumer agrees on
184/// the same shape and the same docs-lint discipline applies.
185///
186/// `as_str()` is the wire-format-grade snake_case label (SIEM rules,
187/// dashboard panel JSONPath selectors, audit-chain markers all alert
188/// off this exact string). `human_summary()` is the operator-readable
189/// rendering string used by CLI status pages and dashboard tooltips.
190/// They are NOT interchangeable surfaces.
191///
192/// Adding a variant is wire-compatibility-grade — coordinate with
193/// whoever owns the SIEM rules + a coordinated docs/tests/rules
194/// update. Renaming a label is forbidden once shipped.
195///
196/// Historical: was renamed from `BestEffort` to `Standard` in Phase
197/// 48.5 hygiene to avoid confusion with `CpuIsolationClass::BestEffort`
198/// (an orthogonal per-lease CPU isolation policy).
199#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
200#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
201#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
202pub enum Priority {
203    /// Lowest priority — preempted first.
204    #[cfg_attr(feature = "serde", serde(alias = "Scavenger"))]
205    Scavenger = 0,
206    /// Uses unreserved capacity, preemptible by guaranteed work.
207    #[cfg_attr(feature = "serde", serde(alias = "Standard"))]
208    Standard = 1,
209    /// Reserved capacity, never preempted by ordinary scheduling.
210    #[cfg_attr(feature = "serde", serde(alias = "Guaranteed"))]
211    Guaranteed = 2,
212}
213
214impl Priority {
215    /// Stable wire / log identifier (snake_case). Wire-format-grade —
216    /// SIEM rules, dashboard panel selectors, and audit-chain markers
217    /// all alert off this exact string.
218    pub fn as_str(self) -> &'static str {
219        match self {
220            Self::Scavenger => "scavenger",
221            Self::Standard => "standard",
222            Self::Guaranteed => "guaranteed",
223        }
224    }
225
226    /// Operator-readable one-line summary. Distinct surface from
227    /// `as_str()`: this is what a CLI status page or dashboard
228    /// tooltip renders so an operator can read the priority class
229    /// without consulting documentation. Not localized, not
230    /// terminated with punctuation, intentionally short so a
231    /// renderer can compose it inline.
232    pub fn human_summary(self) -> &'static str {
233        match self {
234            Self::Scavenger => "uses leftover capacity; preempted first when contention rises",
235            Self::Standard => "uses unreserved capacity; preemptible by guaranteed work",
236            Self::Guaranteed => "reserved capacity; never preempted by ordinary scheduling",
237        }
238    }
239}
240
241impl fmt::Display for Priority {
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        f.write_str(self.as_str())
244    }
245}
246
247impl PartialOrd for Priority {
248    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
249        Some(self.cmp(other))
250    }
251}
252
253impl Ord for Priority {
254    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
255        (*self as u8).cmp(&(*other as u8))
256    }
257}
258
259/// Phase 218.2 slice 87 — typed parse error returned by
260/// `Priority::from_str` for an unrecognized label. The carried
261/// `String` is the offending input so the surface (clap value
262/// parser, dashboard label table, scheduler request validator)
263/// can echo it back to the operator without losing the original
264/// spelling. The error message lists the canonical set so a
265/// typo at the CLI fails closed at parse time with operator
266/// guidance instead of silently propagating a free-form
267/// `priority` string downstream.
268///
269/// SIEM rules and audit emit alert off `Priority::as_str()`
270/// values; a `--priority bogus` invocation that quietly fell
271/// through to the wire would shift those rules into the typed
272/// vocabulary without operator intent. Fail-closed at the CLI
273/// arg-parsing layer is the discipline boundary.
274#[derive(Clone, Debug, PartialEq, Eq)]
275pub struct ParsePriorityError(pub String);
276
277impl fmt::Display for ParsePriorityError {
278    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279        write!(
280            f,
281            "invalid priority {:?} — valid values: scavenger | standard | guaranteed",
282            self.0
283        )
284    }
285}
286
287impl std::error::Error for ParsePriorityError {}
288
289impl core::str::FromStr for Priority {
290    type Err = ParsePriorityError;
291
292    /// Parse a snake_case priority label.
293    ///
294    /// Accepted labels:
295    /// - `scavenger` → [`Priority::Scavenger`]
296    /// - `standard` → [`Priority::Standard`]
297    /// - `guaranteed` → [`Priority::Guaranteed`]
298    /// - `best_effort` → [`Priority::Standard`] (deprecation alias)
299    ///
300    /// ## Deprecation alias rationale
301    ///
302    /// The Phase 48.5 hygiene rename (`BestEffort -> Standard`)
303    /// avoided a name collision with the orthogonal
304    /// `CpuIsolationClass::BestEffort` per-lease policy. The
305    /// CLI surfaces (`grafos deploy run --priority …`,
306    /// `cloud/doctor.rs` probe, `run.rs` lease body) and the
307    /// dashboard JS label table never received the rename;
308    /// they still emit and accept `best_effort`. Slice 87 lands
309    /// the CLI/dashboard typed migration but the migration
310    /// window is real — operators have CLI invocations,
311    /// shell scripts, and dashboard cookies in flight that pass
312    /// `best_effort`.
313    ///
314    /// We pick fail-closed-with-a-named-alias rather than
315    /// silently accept any free-form string: unknown labels
316    /// (`bogus`, `besteffort`, `Best Effort`) return a typed
317    /// error naming the canonical set; only the one named
318    /// historical spelling is accepted.
319    ///
320    /// Retiring the alias (typed error on `best_effort`
321    /// instead of `Standard`) is tracked as a separate sub-bullet
322    /// in TODO.md Phase 218.2; the migration must complete first.
323    fn from_str(s: &str) -> Result<Self, Self::Err> {
324        match s {
325            "scavenger" => Ok(Self::Scavenger),
326            "standard" => Ok(Self::Standard),
327            "guaranteed" => Ok(Self::Guaranteed),
328            // Deprecation alias — see method docs.
329            "best_effort" => Ok(Self::Standard),
330            _ => Err(ParsePriorityError(s.to_string())),
331        }
332    }
333}
334
335// ---------------------------------------------------------------------------
336// Phase 219 — EvidenceLabel
337// ---------------------------------------------------------------------------
338
339/// Evidence labels classify the strength of a public claim. Phase 219
340/// defines these; Phase 220 (assurance posture), Phase 221 (HCL status),
341/// and Phase 222 (DRA claim provenance) reuse them unchanged. The same
342/// docs lint enforces all four.
343///
344/// A claim may not carry a stronger label than its backing artifact.
345/// The lint executes in CI: any public docs page using a label
346/// stronger than `DesignTarget` must link to a named artifact, report,
347/// test, or drill record. Phase 221 HCL labels reuse this enum
348/// directly; an HCL row at `LabSupported` is the same kind of evidence
349/// as a public docs claim labeled `LabEvidence`.
350#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
351#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
352#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
353pub enum EvidenceLabel {
354    /// Intended architecture; not yet implemented.
355    #[cfg_attr(feature = "serde", serde(alias = "DesignTarget"))]
356    DesignTarget,
357    /// Local deterministic tests (unit/integration).
358    #[cfg_attr(feature = "serde", serde(alias = "UnitIntegrationEvidence"))]
359    UnitIntegrationEvidence,
360    /// Validated in our local lab environment with a recorded artifact.
361    #[cfg_attr(feature = "serde", serde(alias = "LabEvidence"))]
362    LabEvidence,
363    /// Validated in a real cloud or provider cell drill with a captured
364    /// artifact.
365    #[cfg_attr(feature = "serde", serde(alias = "StagedProviderEvidence"))]
366    StagedProviderEvidence,
367    /// Measured in a partner environment, anonymized as agreed.
368    #[cfg_attr(feature = "serde", serde(alias = "DesignPartnerEvidence"))]
369    DesignPartnerEvidence,
370    /// Measured in production with a customer support process.
371    #[cfg_attr(feature = "serde", serde(alias = "ProductionEvidence"))]
372    ProductionEvidence,
373}
374
375impl EvidenceLabel {
376    pub fn as_str(self) -> &'static str {
377        match self {
378            Self::DesignTarget => "design target",
379            Self::UnitIntegrationEvidence => "unit/integration evidence",
380            Self::LabEvidence => "lab evidence",
381            Self::StagedProviderEvidence => "staged provider evidence",
382            Self::DesignPartnerEvidence => "design-partner evidence",
383            Self::ProductionEvidence => "production evidence",
384        }
385    }
386
387    /// True when the label requires a backing artifact under the
388    /// Phase 219 docs-lint rule. `DesignTarget` does not (it's
389    /// roadmap); everything stronger does.
390    pub fn requires_artifact(self) -> bool {
391        !matches!(self, Self::DesignTarget)
392    }
393}
394
395impl fmt::Display for EvidenceLabel {
396    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
397        f.write_str(self.as_str())
398    }
399}
400
401// ---------------------------------------------------------------------------
402// Phase 219.2 — RejectionReason
403// ---------------------------------------------------------------------------
404
405/// Typed vocabulary for admission / placement / preemption-victim-selection
406/// rejection reasons surfaced through operator-facing explanation APIs
407/// (CLI, dashboard JSON, audit-chain marker labels).
408///
409/// Design constraints (from Phase 219 cross-phase invariants and the
410/// Phase 219.2 standardize-explanation-fields task):
411///
412///   - Each variant maps to a stable snake_case label via
413///     [`RejectionReason::as_str`]. The label appears verbatim in the
414///     audit-chain `instance_id` markers (see slice 26's
415///     `admission_denied_reason_label`) AND in the JSON of operator-
416///     facing endpoints. SIEM rules and dashboard panels alert off
417///     these labels.
418///   - Adding a variant is wire-compatibility-grade — a SIEM rule
419///     keying off the label must keep matching, so a rename is a
420///     coordinated change requiring docs and rules updates.
421///   - Removing a variant is forbidden once the label has shipped.
422///   - Free-form `reason: String` rejection paths are NOT replaced
423///     wholesale; the typed reason lands alongside the human-readable
424///     string so operators see both. The wire contract is: the typed
425///     reason is the load-bearing API field; the prose is for human
426///     consumption.
427///
428/// Coverage matches the rejection paths surfaced by
429/// `grafos_scheduler::AdmissionDenied` (slice 26) and
430/// `grafos_scheduler::QuotaDenied`. Adding new failure modes requires
431/// a new variant rather than a new free-form string.
432#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
433#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
434#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
435pub enum RejectionReason {
436    /// Free pool has insufficient bytes across all eligible nodes.
437    /// Maps to `AdmissionDenied::InsufficientCapacity`.
438    #[cfg_attr(feature = "serde", serde(alias = "InsufficientCapacity"))]
439    InsufficientCapacity,
440    /// Placement constraint matches no node (the constraint excluded
441    /// every fenced/healthy/non-fenced candidate). Maps to
442    /// `AdmissionDenied::NoEligibleNodes`.
443    #[cfg_attr(feature = "serde", serde(alias = "NoEligibleNodes"))]
444    NoEligibleNodes,
445    /// Hard quota cap reached for the tenant. Maps to
446    /// `QuotaDenied::HardLimitExceeded`.
447    #[cfg_attr(feature = "serde", serde(alias = "QuotaHardLimitExceeded"))]
448    QuotaHardLimitExceeded,
449    /// Burst quota cap reached for the tenant (the soft limit allows
450    /// bursting up to the burst cap; this fires when the burst cap is
451    /// also reached). Maps to `QuotaDenied::BurstExceeded`.
452    #[cfg_attr(feature = "serde", serde(alias = "QuotaBurstExceeded"))]
453    QuotaBurstExceeded,
454    /// Per-tenant lease count limit reached. Maps to
455    /// `QuotaDenied::LeaseCountExceeded`.
456    #[cfg_attr(feature = "serde", serde(alias = "QuotaLeaseCountExceeded"))]
457    QuotaLeaseCountExceeded,
458    /// Per-tenant per-node capacity limit reached. Maps to
459    /// `QuotaDenied::PerNodeLimitExceeded`.
460    #[cfg_attr(feature = "serde", serde(alias = "QuotaPerNodeLimitExceeded"))]
461    QuotaPerNodeLimitExceeded,
462    /// Tenant id not registered in the tenant store. Maps to
463    /// `QuotaDenied::TenantNotFound` and to the early-exit branch of
464    /// `AdmissionGate::check_admission` when the gate's TenantStore
465    /// lookup fails.
466    #[cfg_attr(feature = "serde", serde(alias = "TenantNotFound"))]
467    TenantNotFound,
468    /// Selected node is fenced for the requested resource type. Maps
469    /// to `AdmissionDenied::NodeFenced`.
470    #[cfg_attr(feature = "serde", serde(alias = "NodeFenced"))]
471    NodeFenced,
472    /// Power / cost budget cap exceeded. Maps to
473    /// `AdmissionDenied::BudgetDenied`.
474    #[cfg_attr(feature = "serde", serde(alias = "BudgetExhausted"))]
475    BudgetExhausted,
476    /// Reservation found but does not have enough remaining capacity.
477    /// Maps to `AdmissionDenied::ReservationExhausted`.
478    #[cfg_attr(feature = "serde", serde(alias = "ReservationExhausted"))]
479    ReservationExhausted,
480    /// Phase 219.2 slice 36 — placement layer rejected this
481    /// candidate by policy (top-level placement authorization,
482    /// active-degradation set exclusion, or explicit `Pinned`
483    /// strategy mismatch). The prose `reasons` field on the
484    /// `NodePlacementCandidateExplanation` carries the specific
485    /// sub-reason for human consumption; the typed enum is the
486    /// SIEM-rule / dashboard-panel hook that fires on any
487    /// policy-driven exclusion.
488    #[cfg_attr(feature = "serde", serde(alias = "PlacementPolicyExcluded"))]
489    PlacementPolicyExcluded,
490    /// Phase 219.2 slice 36 — the scheduler does not have a
491    /// control-plane address for the node, so a candidate that
492    /// would otherwise have been considered cannot be reached.
493    /// Operational state, not a policy decision; SIEM should
494    /// alert on this distinct from policy exclusions.
495    #[cfg_attr(feature = "serde", serde(alias = "NodeAddressUnknown"))]
496    NodeAddressUnknown,
497    /// Target node was drained by the operator via
498    /// `/api/v1/admin/manage-node`. The node sits in a
499    /// non-admitting drain/maintenance mode (`Draining`,
500    /// `Drained`, `Maintenance`, `Returning`, or `FenceLost`) and
501    /// `filter_schedulable_nodes` excludes it from new workload
502    /// admission. Distinct from `NodeFenced` (a real fence-epoch
503    /// transition under fence-epoch authority) — `NodeDrained` is
504    /// operator-initiated maintenance, and admission resumes only
505    /// after the node returns to `Accepting` mode. Distinct from
506    /// `NoEligibleNodes` (no node fits the placement
507    /// constraint) — `NodeDrained` is a cause-erasure-free
508    /// label so operators reading the audit chain or running
509    /// `grafos admissions --reason node_drained` see the
510    /// drain-skipped path as its own line item.
511    #[cfg_attr(feature = "serde", serde(alias = "NodeDrained"))]
512    NodeDrained,
513    /// Phase 218.5 slice 118 — bundle admission request had zero
514    /// requirements. Mirrors `BundleAdmissionFailure::EmptyBundle`
515    /// on the flat admissions query surface.
516    #[cfg_attr(feature = "serde", serde(alias = "EmptyBundle"))]
517    EmptyBundle,
518    /// Phase 218.5 slice 118 — at least one requirement in a
519    /// bundle could not be satisfied with available capacity.
520    #[cfg_attr(feature = "serde", serde(alias = "InsufficientBundleCapacity"))]
521    InsufficientBundleCapacity,
522    /// Phase 218.5 slice 118 — bundle-level placement constraints
523    /// excluded every viable fan-out or colocated plan.
524    #[cfg_attr(feature = "serde", serde(alias = "BundleConstraintViolation"))]
525    BundleConstraintViolation,
526    /// Phase 218.5 slice 118 — requested hardware generation had
527    /// no matching inventory.
528    #[cfg_attr(feature = "serde", serde(alias = "HardwareGenerationUnavailable"))]
529    HardwareGenerationUnavailable,
530    /// Phase 218.5 slice 118 — closed-vocabulary catch-all for
531    /// bundle admission failures not yet split into a more specific
532    /// rejection reason.
533    #[cfg_attr(feature = "serde", serde(alias = "OtherBundleFailure"))]
534    OtherBundleFailure,
535}
536
537impl RejectionReason {
538    /// Stable snake_case label. Wire-format-grade — SIEM rules,
539    /// dashboard panel JSONPath selectors, and audit-chain
540    /// `instance_id` markers all alert off this exact string. Pinned
541    /// per-variant in the `rejection_reason_labels_are_stable` test.
542    pub fn as_str(self) -> &'static str {
543        match self {
544            Self::InsufficientCapacity => "insufficient_capacity",
545            Self::NoEligibleNodes => "no_eligible_nodes",
546            Self::QuotaHardLimitExceeded => "quota_hard_limit_exceeded",
547            Self::QuotaBurstExceeded => "quota_burst_exceeded",
548            Self::QuotaLeaseCountExceeded => "quota_lease_count_exceeded",
549            Self::QuotaPerNodeLimitExceeded => "quota_per_node_limit_exceeded",
550            Self::TenantNotFound => "tenant_not_found",
551            Self::NodeFenced => "node_fenced",
552            Self::BudgetExhausted => "budget_exhausted",
553            Self::ReservationExhausted => "reservation_exhausted",
554            Self::PlacementPolicyExcluded => "placement_policy_excluded",
555            Self::NodeAddressUnknown => "node_address_unknown",
556            Self::NodeDrained => "node_drained",
557            Self::EmptyBundle => "empty_bundle",
558            Self::InsufficientBundleCapacity => "insufficient_bundle_capacity",
559            Self::BundleConstraintViolation => "bundle_constraint_violation",
560            Self::HardwareGenerationUnavailable => "hardware_generation_unavailable",
561            Self::OtherBundleFailure => "other_bundle_failure",
562        }
563    }
564
565    /// Phase 219.2 slice 37 — operator-friendly prose for one
566    /// rejection. CLI status pages, dashboard panels, and the
567    /// `grafos admissions`/`grafos placements` rendering paths
568    /// (forthcoming) consume this so each surface presents the
569    /// same prose for the same typed reason — no per-surface
570    /// drift.
571    ///
572    /// The string is intentionally short (one line, no
573    /// punctuation at the end) so a renderer can compose it
574    /// inline: `"{} (at {})"`, `"reason={}, requested={}"`, etc.
575    /// Operator-readable but not user-localized; localization
576    /// is a future concern handled by the consuming surface.
577    ///
578    /// `as_str()` remains the wire-format-grade label SIEM
579    /// rules alert off; `human_summary()` is the rendering
580    /// helper. They are NOT interchangeable.
581    pub fn human_summary(self) -> &'static str {
582        match self {
583            Self::InsufficientCapacity => "insufficient free capacity across all eligible nodes",
584            Self::NoEligibleNodes => "no nodes match the placement constraint",
585            Self::QuotaHardLimitExceeded => "tenant quota hard limit reached",
586            Self::QuotaBurstExceeded => "tenant burst quota cap reached",
587            Self::QuotaLeaseCountExceeded => "tenant lease count limit reached",
588            Self::QuotaPerNodeLimitExceeded => "tenant per-node capacity limit reached",
589            Self::TenantNotFound => "tenant id not registered with the scheduler",
590            Self::NodeFenced => "selected node is fenced for the requested resource type",
591            Self::BudgetExhausted => "power or cost budget cap reached",
592            Self::ReservationExhausted => "reservation has insufficient remaining capacity",
593            Self::PlacementPolicyExcluded => "placement policy excluded this candidate",
594            Self::NodeAddressUnknown => {
595                "scheduler does not have a control-plane address for this node"
596            }
597            Self::NodeDrained => {
598                "target node is drained for maintenance; admission resumes after the node returns to accepting mode"
599            }
600            Self::EmptyBundle => "bundle had zero requirements; nothing to admit",
601            Self::InsufficientBundleCapacity => {
602                "insufficient capacity for at least one bundle requirement"
603            }
604            Self::BundleConstraintViolation => {
605                "bundle placement constraint excludes every viable candidate plan"
606            }
607            Self::HardwareGenerationUnavailable => {
608                "requested bundle hardware generation has no matching inventory"
609            }
610            Self::OtherBundleFailure => "other bundle admission failure",
611        }
612    }
613}
614
615impl fmt::Display for RejectionReason {
616    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
617        f.write_str(self.as_str())
618    }
619}
620
621// ---------------------------------------------------------------------------
622// Phase 220.2 — Egress enforcement vocabulary
623// ---------------------------------------------------------------------------
624
625/// Typed classification of the enforcement boundary that
626/// applies to a tasklet/program target's outbound network
627/// access.
628///
629/// Phase 220.2 design rule: the scheduler MUST classify each
630/// target with one of these labels so it does not advertise a
631/// mode as fabric-enforced when the runtime cannot observe the
632/// outbound path. See the egress matrix in
633/// [`docs/design/220-security-trust-egress-assurance.md`](../../../docs/design/220-security-trust-egress-assurance.md).
634///
635/// SIEM rules and dashboard panels alert off the snake_case
636/// label from `as_str()`. Adding a variant is wire-compatibility-
637/// grade — coordinate with whoever owns the SIEM rules.
638#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
639#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
640#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
641pub enum EgressEnforcement {
642    /// fabricBIOS / its runtime fully owns the path. Deny-all
643    /// is enforceable in the runtime and audit events for
644    /// denied attempts can be emitted. Today: wasm tasklets
645    /// under the wasmi executor, which has no ambient network
646    /// surface.
647    #[cfg_attr(feature = "serde", serde(alias = "FabricEnforced"))]
648    FabricEnforced,
649    /// A host integration point (kernel module, eBPF program,
650    /// systemd network policy, DPU NIC) enforces egress on
651    /// behalf of the fabric. The fabric supplies policy; the
652    /// host enforces. Audit events flow through the
653    /// integration's own logging path.
654    #[cfg_attr(feature = "serde", serde(alias = "HostRuntimeIntegration"))]
655    HostRuntimeIntegration,
656    /// The operator's external network policy enforces egress
657    /// — the fabric does not own this path. Today: native
658    /// host processes, container-adjacent workloads,
659    /// Kubernetes/DRA workloads. Operators run iptables /
660    /// eBPF / CNI / NetworkPolicy outside the fabric.
661    #[cfg_attr(feature = "serde", serde(alias = "OperatorControlled"))]
662    OperatorControlled,
663    /// Egress enforcement is not currently available for this
664    /// target on any deployment. Distinct from
665    /// `OperatorControlled` (which has a clear external
666    /// enforcement story) — this label means "we have nothing
667    /// today, period". A scheduler must refuse to admit a
668    /// workload that requires fabric-enforced egress against
669    /// an `Unsupported` target.
670    #[cfg_attr(feature = "serde", serde(alias = "Unsupported"))]
671    Unsupported,
672}
673
674impl EgressEnforcement {
675    /// Stable snake_case label. Wire-format-grade — SIEM
676    /// rules, dashboard panel JSONPath selectors, and operator
677    /// CLI tools alert off this exact string.
678    pub fn as_str(self) -> &'static str {
679        match self {
680            Self::FabricEnforced => "fabric_enforced",
681            Self::HostRuntimeIntegration => "host_runtime_integration",
682            Self::OperatorControlled => "operator_controlled",
683            Self::Unsupported => "unsupported",
684        }
685    }
686}
687
688impl fmt::Display for EgressEnforcement {
689    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
690        f.write_str(self.as_str())
691    }
692}
693
694/// Typed target classes for tasklet / program runtime
695/// environments. Each variant maps to exactly one
696/// `EgressEnforcement` class today via
697/// [`EgressTarget::enforcement`]; that mapping is the
698/// canonical policy table the scheduler consults.
699///
700/// The set is intentionally narrow — adding a target requires
701/// committing its enforcement class in the same change. A
702/// scheduler that synthesizes a target name without going
703/// through this enum can drift away from the matrix.
704#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
705#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
706#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
707pub enum EgressTarget {
708    /// wasm tasklet running in a wasmi executor. No ambient
709    /// network surface; deny-all is enforced by absence.
710    #[cfg_attr(feature = "serde", serde(alias = "WasmTasklet"))]
711    WasmTasklet,
712    /// Native host process — `grafos-jobs`, `grafos-posix`,
713    /// or any code running outside a wasm sandbox. The host
714    /// kernel owns the network stack.
715    #[cfg_attr(feature = "serde", serde(alias = "NativeProgram"))]
716    NativeProgram,
717    /// Container-adjacent workload (e.g. a sidecar process
718    /// running in the same network namespace as a tenant
719    /// container). Container runtime owns the network.
720    #[cfg_attr(feature = "serde", serde(alias = "ContainerAdjacent"))]
721    ContainerAdjacent,
722    /// Kubernetes / DRA workload. The Kubernetes
723    /// NetworkPolicy / Cilium / Calico stack owns egress.
724    /// The DRA driver records workload identity for audit
725    /// but does not enforce egress.
726    #[cfg_attr(feature = "serde", serde(alias = "KubernetesDra"))]
727    KubernetesDra,
728    /// Bare-metal target. Where the firmware owns the NIC,
729    /// fabric enforcement is possible; where the host kernel
730    /// owns the NIC, it falls back to operator control. The
731    /// HCL entry per node names which path applies.
732    #[cfg_attr(feature = "serde", serde(alias = "BareMetal"))]
733    BareMetal,
734}
735
736impl EgressTarget {
737    /// Stable snake_case label.
738    pub fn as_str(self) -> &'static str {
739        match self {
740            Self::WasmTasklet => "wasm_tasklet",
741            Self::NativeProgram => "native_program",
742            Self::ContainerAdjacent => "container_adjacent",
743            Self::KubernetesDra => "kubernetes_dra",
744            Self::BareMetal => "bare_metal",
745        }
746    }
747
748    /// Canonical enforcement class for this target per the
749    /// Phase 220.2 design-doc matrix. The scheduler MUST use
750    /// this function rather than constructing
751    /// `EgressEnforcement` values directly so the matrix
752    /// stays the single source of truth.
753    ///
754    /// Bare-metal returns `HostRuntimeIntegration` as the
755    /// canonical class — when the firmware owns the NIC the
756    /// fabric supplies policy and the firmware enforces;
757    /// where the host kernel owns the NIC, the per-HCL entry
758    /// can override to `OperatorControlled` at a higher
759    /// layer. The HCL override is Phase 221 work; this enum
760    /// commits the design-doc default.
761    pub fn enforcement(self) -> EgressEnforcement {
762        match self {
763            Self::WasmTasklet => EgressEnforcement::FabricEnforced,
764            Self::NativeProgram => EgressEnforcement::OperatorControlled,
765            Self::ContainerAdjacent => EgressEnforcement::OperatorControlled,
766            Self::KubernetesDra => EgressEnforcement::OperatorControlled,
767            Self::BareMetal => EgressEnforcement::HostRuntimeIntegration,
768        }
769    }
770}
771
772impl fmt::Display for EgressTarget {
773    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
774        f.write_str(self.as_str())
775    }
776}
777
778// ---------------------------------------------------------------------------
779// Phase 216 — Economics observation vocabulary
780// ---------------------------------------------------------------------------
781
782/// Provenance class for an economics observation (cost,
783/// energy, carbon, egress weight, latency). The scheduler
784/// uses the source to decide what confidence to apply and
785/// whether the observation is fresh enough for a required
786/// admission decision.
787///
788/// Spec context:
789/// [`docs/design/216-cost-carbon-aware-placement.md`](../../../docs/design/216-cost-carbon-aware-placement.md)
790/// "Authority and Freshness" section.
791///
792/// Adding a variant is wire-compatibility-grade —
793/// scheduler-side admission rules may filter on source
794/// strings, and committed economics-generation records
795/// store the source.
796#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
797#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
798#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
799pub enum EconomicsSource {
800    /// Operator-supplied configuration (rate cards, regional
801    /// carbon defaults). Authoritative for admission
802    /// estimates within the operator's deployment.
803    #[cfg_attr(feature = "serde", serde(alias = "OperatorStaticConfig"))]
804    OperatorStaticConfig,
805    /// Cloud provider's price sheet (AWS pricing API, GCP
806    /// SKU catalog, Azure rate card). Authoritative when
807    /// the operator authorizes its use.
808    #[cfg_attr(feature = "serde", serde(alias = "ProviderPriceSheet"))]
809    ProviderPriceSheet,
810    /// Provider's actual usage / billing export
811    /// (CloudTrail/billing CSV equivalents). Higher
812    /// confidence than the price sheet because it reflects
813    /// realized cost, not list price.
814    #[cfg_attr(feature = "serde", serde(alias = "ProviderUsageExport"))]
815    ProviderUsageExport,
816    /// Power telemetry measured at the node or accelerator.
817    /// Highest-confidence source for energy values when
818    /// available.
819    #[cfg_attr(feature = "serde", serde(alias = "MeasuredPowerTelemetry"))]
820    MeasuredPowerTelemetry,
821    /// Third-party carbon-intensity feed authorized by the
822    /// operator (e.g. WattTime, ElectricityMaps).
823    #[cfg_attr(feature = "serde", serde(alias = "ThirdPartyCarbonFeed"))]
824    ThirdPartyCarbonFeed,
825    /// Estimate computed from other sources (e.g. carbon =
826    /// energy × regional intensity). Lowest-confidence
827    /// source by default.
828    #[cfg_attr(feature = "serde", serde(alias = "DerivedEstimate"))]
829    DerivedEstimate,
830}
831
832impl EconomicsSource {
833    /// Stable snake_case label.
834    pub fn as_str(self) -> &'static str {
835        match self {
836            Self::OperatorStaticConfig => "operator_static_config",
837            Self::ProviderPriceSheet => "provider_price_sheet",
838            Self::ProviderUsageExport => "provider_usage_export",
839            Self::MeasuredPowerTelemetry => "measured_power_telemetry",
840            Self::ThirdPartyCarbonFeed => "third_party_carbon_feed",
841            Self::DerivedEstimate => "derived_estimate",
842        }
843    }
844}
845
846impl fmt::Display for EconomicsSource {
847    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
848        f.write_str(self.as_str())
849    }
850}
851
852/// Confidence band for an economics observation. The
853/// scheduler's `EconomicConstraints::min_confidence` field
854/// uses this to filter observations that fall below a
855/// tenant-required floor.
856///
857/// Bands are ordinal — `Low < Medium < High`. The
858/// `PartialOrd` / `Ord` derives make filter queries like
859/// `obs.confidence >= constraints.min_confidence` work
860/// directly.
861#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
862#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
863#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
864pub enum ObservationConfidence {
865    /// Low confidence — derived estimate, stale data, or
866    /// minimal corroboration. Acceptable for non-required
867    /// preferences (PreferLowerCost, PreferLowerCarbon) but
868    /// rejected for required admission caps.
869    #[cfg_attr(feature = "serde", serde(alias = "Low"))]
870    Low,
871    /// Medium confidence — operator-authorized provider
872    /// price sheet, third-party feed within freshness
873    /// window. The default floor for tenant-policy required
874    /// economics decisions unless explicitly overridden.
875    #[cfg_attr(feature = "serde", serde(alias = "Medium"))]
876    Medium,
877    /// High confidence — measured telemetry or provider
878    /// usage export. Required for `production evidence`
879    /// utilization claims and for tenants explicitly
880    /// requesting `min_confidence = High`.
881    #[cfg_attr(feature = "serde", serde(alias = "High"))]
882    High,
883}
884
885impl ObservationConfidence {
886    /// Stable snake_case label.
887    pub fn as_str(self) -> &'static str {
888        match self {
889            Self::Low => "low",
890            Self::Medium => "medium",
891            Self::High => "high",
892        }
893    }
894}
895
896impl fmt::Display for ObservationConfidence {
897    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
898        f.write_str(self.as_str())
899    }
900}
901
902/// Monotonic generation counter for committed economics
903/// views. Phase 216 design rule: this is a FRESHNESS
904/// counter, not an authority epoch. It identifies WHICH
905/// version of the rate cards / carbon feeds / egress
906/// weights was used for a placement decision so admission
907/// records and post-run accounting can be reproduced.
908///
909/// It does NOT gate writes, fence resources, or substitute
910/// for the Phase 215 fence-epoch check. Phase 215's
911/// epoch/generation vocabulary names fence epoch as the
912/// only counter that gates write authority.
913///
914/// Wraparound is not a real concern: a u64 incremented
915/// once per economics commit lasts longer than the universe.
916#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
917#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
918pub struct EconomicsGeneration(pub u64);
919
920impl EconomicsGeneration {
921    /// The unanchored sentinel — a scheduler that has not
922    /// yet committed any economics view starts here.
923    pub const UNANCHORED: Self = Self(0);
924
925    /// Next generation. `next()` is the canonical
926    /// monotonic-step API; the scheduler MUST NOT add a
927    /// constant or jump generations to make a view "look
928    /// fresher."
929    pub fn next(self) -> Self {
930        Self(self.0.saturating_add(1))
931    }
932}
933
934impl fmt::Display for EconomicsGeneration {
935    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
936        write!(f, "gen={}", self.0)
937    }
938}
939
940/// Phase 216 slice 59 — typed envelope for any economics
941/// observation. Carries the value plus the provenance,
942/// confidence, freshness window, and generation the scheduler
943/// uses for required-decision admission rules.
944///
945/// Generic over the value type `T` so the eventual concrete
946/// observations (domain rate cards, carbon intensity, energy
947/// vectors, egress weights) can fit without new envelope
948/// types. The envelope is what the scheduler stores; the
949/// inner value is what's measured.
950///
951/// `observed_at` and `valid_until` are unix seconds (u64),
952/// matching the audit-chain `timestamp_unix` convention so
953/// observations and audit records are correlatable on time.
954///
955/// Phase 216 design rule: a scheduler that requires a
956/// specific economics input for an admission decision MUST
957/// reject when the observation is missing, stale (now >=
958/// `valid_until`), or below the tenant's
959/// `min_confidence` floor. The scheduler MUST NOT silently
960/// substitute a default.
961#[derive(Clone, Debug, PartialEq, Eq)]
962#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
963pub struct EconomicsObservation<T> {
964    pub value: T,
965    pub source: EconomicsSource,
966    pub confidence: ObservationConfidence,
967    /// Unix seconds at which the underlying measurement was
968    /// taken (or — for static config — committed).
969    pub observed_at: u64,
970    /// Unix seconds at which this observation expires. The
971    /// scheduler MUST treat `now >= valid_until` as stale and
972    /// fail closed for required decisions.
973    pub valid_until: u64,
974    pub generation: EconomicsGeneration,
975}
976
977impl<T> EconomicsObservation<T> {
978    /// True when `now` falls within the observation's
979    /// freshness window: `observed_at <= now < valid_until`.
980    /// `observed_at == now` is fresh; `now == valid_until`
981    /// is stale (boundary is exclusive on the upper end).
982    pub fn is_fresh(&self, now: u64) -> bool {
983        self.observed_at <= now && now < self.valid_until
984    }
985
986    /// True when the observation's confidence band is at or
987    /// above `min`. Uses the slice-53 ordinal contract on
988    /// `ObservationConfidence`.
989    pub fn meets_floor(&self, min: ObservationConfidence) -> bool {
990        self.confidence >= min
991    }
992
993    /// True when the observation is acceptable for a required
994    /// admission decision: fresh AND meets the confidence
995    /// floor. Convenience for the common scheduler-side check.
996    pub fn admissible(&self, now: u64, min: ObservationConfidence) -> bool {
997        self.is_fresh(now) && self.meets_floor(min)
998    }
999}
1000
1001/// Phase 218 — typed fair-share window descriptor for tenants,
1002/// projects, and priority classes. Carries the window length,
1003/// the dimensionless priority-class weight, and the lower /
1004/// upper resource-second bounds the scheduler will eventually
1005/// enforce over a rolling window.
1006///
1007/// This landing is vocabulary-only — a docs target. There is
1008/// no scheduler integration today; the production wiring
1009/// (capacity-ledger window accounting, admission-side weight
1010/// resolution, eviction selection on share overshoot) lands in
1011/// a follow-on slice. Pinning the typed shape first lets the
1012/// scheduler, audit, and dashboard layers compose against a
1013/// single record rather than each forking their own envelope.
1014///
1015/// `priority_class_weight` is dimensionless. Operator policy
1016/// decides what `100` means relative to `50`: it could be
1017/// proportional fair share, lexicographic priority, or any
1018/// other interpretation the scheduler chooses at compute time.
1019/// The value is just the typed input; the policy layer does
1020/// the conversion to a share fraction.
1021///
1022/// `min_share_secs` and `max_share_secs` are resource-seconds
1023/// over the rolling `window_secs`. `min_share_secs` is the
1024/// guaranteed floor (the tenant cannot fall below this when
1025/// it has work pending); `max_share_secs` is the ceiling per
1026/// window (the tenant cannot consume above this even if the
1027/// fabric is idle).
1028///
1029/// `generation` is the slice-53 [`EconomicsGeneration`]
1030/// committed-config freshness counter — it identifies WHICH
1031/// version of the fair-share table was used when an admission
1032/// decision was made so admission records and post-run
1033/// accounting can be reproduced. Per the same Phase 218 design
1034/// rule that gates [`EconomicsGeneration`], this is NOT an
1035/// authority epoch: the scheduler MUST NOT gate writes on the
1036/// share number or the generation. Write authority is gated
1037/// by the Phase 215 fence epoch and nothing else.
1038#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1039#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1040pub struct FairShareWindow {
1041    /// Tenant identifier the window applies to.
1042    pub tenant_id: u128,
1043    /// Dimensionless priority-class weight. The scheduler
1044    /// converts this to a share fraction at compute time;
1045    /// operator policy decides what the relative magnitudes
1046    /// mean. MUST be > 0; a zero weight is rejected by
1047    /// [`Self::is_valid`].
1048    pub priority_class_weight: u32,
1049    /// Rolling window length in seconds. MUST be > 0; a zero
1050    /// window is rejected by [`Self::is_valid`].
1051    pub window_secs: u64,
1052    /// Lower bound: minimum guaranteed share over the window
1053    /// in resource-seconds.
1054    pub min_share_secs: u64,
1055    /// Upper bound: ceiling per window in resource-seconds.
1056    /// MUST be >= `min_share_secs`.
1057    pub max_share_secs: u64,
1058    /// Committed-config freshness counter — which version of
1059    /// the fair-share table this descriptor came from. Not an
1060    /// authority epoch; see the type-level doc comment.
1061    pub generation: EconomicsGeneration,
1062}
1063
1064impl FairShareWindow {
1065    /// Returns true iff the descriptor is internally
1066    /// consistent: `min_share_secs <= max_share_secs`,
1067    /// `priority_class_weight > 0`, and `window_secs > 0`.
1068    /// Returns false for any violation. Callers handle the
1069    /// typed reject — this never panics, matching the
1070    /// fail-closed-without-panic discipline used elsewhere in
1071    /// this module.
1072    pub fn is_valid(&self) -> bool {
1073        self.min_share_secs <= self.max_share_secs
1074            && self.priority_class_weight > 0
1075            && self.window_secs > 0
1076    }
1077}
1078
1079/// Scope that participates in fair-share accounting.
1080///
1081/// Phase 218.1 needs the scheduler to reason about shares at three
1082/// levels without conflating them: a tenant, a project within a
1083/// tenant, or a priority class. The scope is policy vocabulary only;
1084/// it does not grant authority and it must not cross into
1085/// `fabricbios-core`.
1086#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1087#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1088#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
1089pub enum FairShareScope {
1090    /// Share for all work owned by a tenant.
1091    #[cfg_attr(feature = "serde", serde(alias = "Tenant"))]
1092    Tenant { tenant_id: u128 },
1093    /// Share for a project or account namespace within a tenant.
1094    #[cfg_attr(feature = "serde", serde(alias = "Project"))]
1095    Project { project_id: u128 },
1096    /// Share for all work in a priority class.
1097    #[cfg_attr(feature = "serde", serde(alias = "PriorityClass"))]
1098    PriorityClass { priority: Priority },
1099}
1100
1101impl FairShareScope {
1102    /// Stable scope-kind label. The identifiers inside the scope are
1103    /// fields, not part of this grouping label.
1104    pub fn kind(self) -> &'static str {
1105        match self {
1106            Self::Tenant { .. } => "tenant",
1107            Self::Project { .. } => "project",
1108            Self::PriorityClass { .. } => "priority_class",
1109        }
1110    }
1111}
1112
1113impl fmt::Display for FairShareScope {
1114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1115        f.write_str(self.kind())
1116    }
1117}
1118
1119/// Weighted fair-share entry for one scope.
1120///
1121/// `weight` is dimensionless and must be positive. `min_share_secs`
1122/// and `max_share_secs` are resource-seconds over the containing
1123/// [`WeightedFairSharePolicy::window_secs`] rolling window.
1124#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1125#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1126pub struct FairShareWeight {
1127    pub scope: FairShareScope,
1128    pub weight: u32,
1129    pub min_share_secs: u64,
1130    pub max_share_secs: u64,
1131}
1132
1133impl FairShareWeight {
1134    /// Returns true iff the entry is internally consistent.
1135    pub fn is_valid(&self) -> bool {
1136        self.weight > 0 && self.min_share_secs <= self.max_share_secs
1137    }
1138}
1139
1140/// Committed fair-share table for a scheduler policy generation.
1141///
1142/// This is the typed input a scheduler can use to compute fair-share
1143/// ordering across tenants, projects, and priority classes. It is not
1144/// an admission decision by itself; admission code still evaluates
1145/// concrete usage, placement, quota, and preemption state.
1146#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1147#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1148pub struct WeightedFairSharePolicy {
1149    /// Rolling accounting window in seconds.
1150    pub window_secs: u64,
1151    /// Committed-config freshness counter. Not an authority epoch.
1152    pub generation: EconomicsGeneration,
1153    /// One entry per fair-share scope. Duplicate scopes are invalid:
1154    /// callers must update a scope's single entry rather than relying
1155    /// on last-writer-wins behavior.
1156    pub entries: Vec<FairShareWeight>,
1157}
1158
1159impl WeightedFairSharePolicy {
1160    /// Validate the table without panicking. Returns false when the
1161    /// policy has no entries, a zero window, an invalid entry, or a
1162    /// duplicate scope.
1163    pub fn is_valid(&self) -> bool {
1164        if self.window_secs == 0 || self.entries.is_empty() {
1165            return false;
1166        }
1167        for (idx, entry) in self.entries.iter().enumerate() {
1168            if !entry.is_valid() {
1169                return false;
1170            }
1171            if self.entries[..idx]
1172                .iter()
1173                .any(|prior| prior.scope == entry.scope)
1174            {
1175                return false;
1176            }
1177        }
1178        true
1179    }
1180
1181    /// Sum of all positive weights. Invalid entries are deliberately
1182    /// ignored here; callers should check [`Self::is_valid`] before
1183    /// using the table for admission or preemption policy.
1184    pub fn total_weight(&self) -> u64 {
1185        self.entries
1186            .iter()
1187            .filter(|entry| entry.weight > 0)
1188            .map(|entry| u64::from(entry.weight))
1189            .sum()
1190    }
1191}
1192
1193// ---------------------------------------------------------------------------
1194// Phase 218.3 — RevokeState (slice 86)
1195// ---------------------------------------------------------------------------
1196
1197/// Phase 218.3 — Typed revoke lifecycle states.
1198///
1199/// A workload that holds a lease can observe the revoke transitions
1200/// described in `docs/design/218-tenant-policy-and-lifecycle.md`
1201/// § "Revoke State Machine". The 9 states form a deterministic
1202/// state machine: a lease enters via `Active`, optionally transits
1203/// through warning → grace → either cooperative checkpoint or
1204/// forced teardown, and lands in one of three terminal states
1205/// (`Torndown`, `Fenced`, `FailedClosed`) plus the TTL-driven
1206/// `Expired` (which itself is non-terminal — the spec routes
1207/// `expired -> failed-closed`).
1208///
1209/// A typed primitive in `policy_vocab` (not split across leasekit /
1210/// runtime / scheduler) so SIEM rules, audit emit, dashboard
1211/// renderers, and the operator-facing revoke runbook all use the
1212/// same vocabulary. Same discipline as Phase 218.2 `Priority` /
1213/// 219.2 `RejectionReason` / 219 `AuditEventKind`.
1214///
1215/// `as_str()` is the wire-format-grade snake_case label (SIEM rules,
1216/// dashboard panel JSONPath selectors, audit-chain markers all alert
1217/// off this exact string). `human_summary()` is the operator-readable
1218/// rendering string used by CLI status pages and dashboard tooltips.
1219/// They are NOT interchangeable surfaces.
1220///
1221/// `legal_transition_to(next)` pins the spec-allowed transition set.
1222/// Slice 86 does NOT wire this primitive into the existing revoke
1223/// pathway — that integration spans `grafos-leasekit`,
1224/// `grafos-runtime`, `grafos-scheduler-service`, and `fabricbiosd`
1225/// and is captured as TODO carry-overs. Slice 86 lands the typed
1226/// primitive + discipline methods + legal-transition pin only,
1227/// matching the slice-62 / slice-73 / slice-85 "land primitive,
1228/// defer integration" pattern.
1229#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1230#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1231#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
1232pub enum RevokeState {
1233    /// Pre-revoke normal state. Lease is active, no revoke pending.
1234    #[cfg_attr(feature = "serde", serde(alias = "Active"))]
1235    Active,
1236    /// Revoke initiated. Workload has been notified; grace timer started.
1237    #[cfg_attr(feature = "serde", serde(alias = "RevokeWarning"))]
1238    RevokeWarning,
1239    /// Grace period running. Workload has time to checkpoint
1240    /// cooperatively before forced teardown.
1241    #[cfg_attr(feature = "serde", serde(alias = "GraceRunning"))]
1242    GraceRunning,
1243    /// Workload reported checkpoint complete during grace. Cooperative
1244    /// teardown can proceed.
1245    #[cfg_attr(feature = "serde", serde(alias = "CheckpointReported"))]
1246    CheckpointReported,
1247    /// Forced teardown initiated. Reachable directly from `Active` for
1248    /// hard revokes (no grace) per the spec's
1249    /// `active -> forced-teardown -> torn-down` arrow.
1250    #[cfg_attr(feature = "serde", serde(alias = "ForcedTeardown"))]
1251    ForcedTeardown,
1252    /// Teardown completed successfully (cooperative or forced path).
1253    /// Terminal.
1254    #[cfg_attr(feature = "serde", serde(alias = "Torndown"))]
1255    Torndown,
1256    /// Lease TTL aged out without operator/tenant-initiated revoke.
1257    /// Distinct from `RevokeWarning`. Per the spec's
1258    /// `active -> expired -> failed-closed` arrow this is a
1259    /// transitional state, not a terminal one.
1260    #[cfg_attr(feature = "serde", serde(alias = "Expired"))]
1261    Expired,
1262    /// Teardown failed; resource is fenced. No further leases until
1263    /// operator forensic clearing. Terminal.
1264    #[cfg_attr(feature = "serde", serde(alias = "Fenced"))]
1265    Fenced,
1266    /// Explicit fail-closed terminal. Reachable from `Expired` per
1267    /// the spec, and from any non-terminal state when the state
1268    /// machine encounters an invariant violation (a transition the
1269    /// legal set rejects). Terminal.
1270    #[cfg_attr(feature = "serde", serde(alias = "FailedClosed"))]
1271    FailedClosed,
1272}
1273
1274impl RevokeState {
1275    /// Stable wire / log identifier (snake_case). Wire-format-grade —
1276    /// SIEM rules, dashboard panel selectors, and audit-chain markers
1277    /// all alert off this exact string.
1278    pub fn as_str(self) -> &'static str {
1279        match self {
1280            Self::Active => "active",
1281            Self::RevokeWarning => "revoke_warning",
1282            Self::GraceRunning => "grace_running",
1283            Self::CheckpointReported => "checkpoint_reported",
1284            Self::ForcedTeardown => "forced_teardown",
1285            Self::Torndown => "torndown",
1286            Self::Expired => "expired",
1287            Self::Fenced => "fenced",
1288            Self::FailedClosed => "failed_closed",
1289        }
1290    }
1291
1292    /// Operator-readable one-line summary. Distinct surface from
1293    /// `as_str()`: this is what a CLI status page or dashboard
1294    /// tooltip renders so an operator can read the lifecycle
1295    /// position without consulting documentation. Not localized,
1296    /// not terminated with punctuation, intentionally short so a
1297    /// renderer can compose it inline.
1298    pub fn human_summary(self) -> &'static str {
1299        match self {
1300            Self::Active => "lease is active; no revoke pending",
1301            Self::RevokeWarning => "revoke initiated; workload has been notified",
1302            Self::GraceRunning => "grace period running; workload may checkpoint",
1303            Self::CheckpointReported => "workload reported checkpoint; cooperative teardown",
1304            Self::ForcedTeardown => "forced teardown in progress; no grace or grace exceeded",
1305            Self::Torndown => "teardown complete; lease released",
1306            Self::Expired => "lease TTL aged out without revoke",
1307            Self::Fenced => "teardown failed; resource fenced for forensic clearing",
1308            Self::FailedClosed => "fail-closed terminal; lease invariant violated",
1309        }
1310    }
1311
1312    /// Returns `true` for the four terminal states. `Torndown`,
1313    /// `Fenced`, and `FailedClosed` are explicit terminals. `Expired`
1314    /// is included here because once a lease has aged out it cannot
1315    /// re-enter `Active` — the spec routes it through
1316    /// `failed-closed`, but observers treating `Expired` as a
1317    /// terminal-for-display purpose stay correct. The typed legal
1318    /// transition set still allows `Expired -> FailedClosed`.
1319    pub fn is_terminal(self) -> bool {
1320        matches!(
1321            self,
1322            Self::Torndown | Self::Fenced | Self::FailedClosed | Self::Expired
1323        )
1324    }
1325
1326    /// Returns `true` if a direct transition from `self` to `next`
1327    /// is allowed by the spec. The legal set is derived from
1328    /// `docs/design/218-tenant-policy-and-lifecycle.md` § "Revoke
1329    /// State Machine":
1330    ///
1331    /// ```text
1332    /// active -> revoke-warning -> grace-running
1333    ///   -> checkpoint-reported -> torn-down
1334    ///   -> forced-teardown -> torn-down
1335    /// active -> forced-teardown -> torn-down
1336    /// active -> expired -> failed-closed
1337    /// active -> fenced
1338    /// ```
1339    ///
1340    /// In addition to the spec arrows, this implementation allows:
1341    ///
1342    /// - `CheckpointReported -> Fenced` and
1343    ///   `ForcedTeardown -> Fenced`: a teardown attempt that fails
1344    ///   fences the resource, matching the existing `LeaseFenced`
1345    ///   audit-event kind ("Lease teardown failed; the resource is
1346    ///   fenced").
1347    /// - `_ -> FailedClosed` from any non-terminal state: the spec's
1348    ///   `expired -> failed-closed` arrow is one path; the
1349    ///   FailedClosed sink is also the explicit landing for any
1350    ///   invariant violation, so that an unexpected transition
1351    ///   request can be rejected fail-closed without inventing a
1352    ///   string default.
1353    ///
1354    /// Any transition not in this set is either an invariant
1355    /// violation (use `FailedClosed` as the landing state) or a
1356    /// multi-step path (model intermediate transitions explicitly).
1357    pub fn legal_transition_to(self, next: Self) -> bool {
1358        use RevokeState::*;
1359        matches!(
1360            (self, next),
1361            // Spec arrows from `active`.
1362            (Active, RevokeWarning)
1363                | (Active, ForcedTeardown)
1364                | (Active, Expired)
1365                | (Active, Fenced)
1366                // Spec: revoke-warning -> grace-running.
1367                | (RevokeWarning, GraceRunning)
1368                // Spec: grace-running -> checkpoint-reported.
1369                | (GraceRunning, CheckpointReported)
1370                // Spec: checkpoint-reported -> torn-down.
1371                | (CheckpointReported, Torndown)
1372                // Spec: forced-teardown -> torn-down.
1373                | (ForcedTeardown, Torndown)
1374                // Spec: expired -> failed-closed.
1375                | (Expired, FailedClosed)
1376                // Teardown-failure routes to Fenced (matches
1377                // LeaseFenced audit-event kind semantics).
1378                | (CheckpointReported, Fenced)
1379                | (ForcedTeardown, Fenced)
1380                // Invariant-violation sink: any non-terminal state
1381                // can be forced to FailedClosed.
1382                | (Active, FailedClosed)
1383                | (RevokeWarning, FailedClosed)
1384                | (GraceRunning, FailedClosed)
1385                | (CheckpointReported, FailedClosed)
1386                | (ForcedTeardown, FailedClosed)
1387        )
1388    }
1389}
1390
1391impl fmt::Display for RevokeState {
1392    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1393        f.write_str(self.as_str())
1394    }
1395}
1396
1397/// Policy-bounded grace window for cooperative lease revoke.
1398///
1399/// `RevokeGracePolicy` is deliberately small: it answers whether a
1400/// revoke is hard (`grace_secs == 0`) or cooperative, what the maximum
1401/// permitted grace is, and whether checkpoint hooks may report
1402/// committed progress during that window. It does not choose victims
1403/// and it does not extend lease TTLs; scheduler/runtime integration
1404/// must enforce the policy before emitting `RevokeWarning` /
1405/// `GraceRunning`.
1406#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1407#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1408#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
1409pub struct RevokeGracePolicy {
1410    /// Requested cooperative grace window in seconds. `0` means hard
1411    /// revoke: no warning/grace path and no checkpoint hooks.
1412    pub grace_secs: u64,
1413    /// Maximum grace this policy allows. `grace_secs` must be less
1414    /// than or equal to this value.
1415    pub max_grace_secs: u64,
1416    /// Whether checkpoint hooks are allowed to report committed
1417    /// progress during the grace window.
1418    pub checkpoint_hooks_allowed: bool,
1419}
1420
1421impl RevokeGracePolicy {
1422    pub const fn hard_revoke() -> Self {
1423        Self {
1424            grace_secs: 0,
1425            max_grace_secs: 0,
1426            checkpoint_hooks_allowed: false,
1427        }
1428    }
1429
1430    pub fn bounded(
1431        grace_secs: u64,
1432        max_grace_secs: u64,
1433        checkpoint_hooks_allowed: bool,
1434    ) -> Result<Self, RevokeGracePolicyError> {
1435        let policy = Self {
1436            grace_secs,
1437            max_grace_secs,
1438            checkpoint_hooks_allowed,
1439        };
1440        policy.validate()?;
1441        Ok(policy)
1442    }
1443
1444    pub fn validate(self) -> Result<(), RevokeGracePolicyError> {
1445        if self.grace_secs > self.max_grace_secs {
1446            return Err(RevokeGracePolicyError::GraceExceedsMaximum);
1447        }
1448        if self.grace_secs == 0 && self.checkpoint_hooks_allowed {
1449            return Err(RevokeGracePolicyError::CheckpointHooksRequireGrace);
1450        }
1451        Ok(())
1452    }
1453
1454    pub fn is_hard_revoke(self) -> bool {
1455        self.grace_secs == 0
1456    }
1457
1458    pub fn checkpoint_hooks_allowed(self) -> bool {
1459        self.checkpoint_hooks_allowed && !self.is_hard_revoke()
1460    }
1461
1462    pub fn grace_deadline_unix_secs(
1463        self,
1464        start_unix_secs: u64,
1465    ) -> Result<Option<u64>, RevokeGracePolicyError> {
1466        self.validate()?;
1467        if self.is_hard_revoke() {
1468            return Ok(None);
1469        }
1470        start_unix_secs
1471            .checked_add(self.grace_secs)
1472            .map(Some)
1473            .ok_or(RevokeGracePolicyError::DeadlineOverflow)
1474    }
1475}
1476
1477impl Default for RevokeGracePolicy {
1478    fn default() -> Self {
1479        Self::hard_revoke()
1480    }
1481}
1482
1483#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1484#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1485#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
1486pub enum RevokeGracePolicyError {
1487    GraceExceedsMaximum,
1488    CheckpointHooksRequireGrace,
1489    DeadlineOverflow,
1490}
1491
1492impl RevokeGracePolicyError {
1493    pub fn as_str(self) -> &'static str {
1494        match self {
1495            Self::GraceExceedsMaximum => "grace_exceeds_maximum",
1496            Self::CheckpointHooksRequireGrace => "checkpoint_hooks_require_grace",
1497            Self::DeadlineOverflow => "deadline_overflow",
1498        }
1499    }
1500
1501    pub fn human_summary(self) -> &'static str {
1502        match self {
1503            Self::GraceExceedsMaximum => "requested grace exceeds policy maximum",
1504            Self::CheckpointHooksRequireGrace => "checkpoint hooks require a nonzero grace window",
1505            Self::DeadlineOverflow => "grace deadline overflowed unix timestamp range",
1506        }
1507    }
1508}
1509
1510impl fmt::Display for RevokeGracePolicyError {
1511    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1512        f.write_str(self.as_str())
1513    }
1514}
1515
1516// ---------------------------------------------------------------------------
1517// Phase 218.2 — Preemptibility (slice 88)
1518// ---------------------------------------------------------------------------
1519
1520/// Phase 218.2 — Whether a workload accepts preemption.
1521///
1522/// Orthogonal to `Priority`: a `Priority::Standard` workload that
1523/// declares `Preemptibility::Protected` overrides the priority-based
1524/// preemption rules and cannot be preempted by ordinary scheduling.
1525/// Operators use this to mark real-time / latency-sensitive work
1526/// where the preemption cost outweighs the rebalance benefit.
1527///
1528/// `Preemptibility` is the WORKLOAD-SCOPE policy. The lease-scope
1529/// override is `NonPreemptibleReason` below — a workload may be
1530/// `Preemptible` overall but a specific lease (e.g. one currently
1531/// flushing a checkpoint) carries a typed non-preemptible marker.
1532#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1533#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1534#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
1535pub enum Preemptibility {
1536    /// Default. Workload accepts preemption per priority rules.
1537    #[cfg_attr(feature = "serde", serde(alias = "Preemptible"))]
1538    Preemptible,
1539    /// Workload is explicitly non-preemptible. Cannot be preempted by
1540    /// ordinary scheduling regardless of priority pressure. Operators
1541    /// use this for real-time / latency-sensitive work where preemption
1542    /// cost outweighs rebalance benefit.
1543    #[cfg_attr(feature = "serde", serde(alias = "Protected"))]
1544    Protected,
1545}
1546
1547impl Preemptibility {
1548    pub fn as_str(self) -> &'static str {
1549        match self {
1550            Self::Preemptible => "preemptible",
1551            Self::Protected => "protected",
1552        }
1553    }
1554    pub fn human_summary(self) -> &'static str {
1555        match self {
1556            Self::Preemptible => "accepts preemption per priority rules (default)",
1557            Self::Protected => "non-preemptible by ordinary scheduling; operator-declared",
1558        }
1559    }
1560    pub fn allows_preemption(self) -> bool {
1561        matches!(self, Self::Preemptible)
1562    }
1563}
1564
1565impl fmt::Display for Preemptibility {
1566    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1567        f.write_str(self.as_str())
1568    }
1569}
1570
1571/// Phase 218.2 — Lease-scope marker explaining WHY a specific lease
1572/// is non-preemptible right now, distinct from the workload-scope
1573/// `Preemptibility::Protected` declaration.
1574///
1575/// A workload that is overall `Preemptibility::Preemptible` may have
1576/// a transient lease that carries a `NonPreemptibleReason` — e.g. a
1577/// lease that's currently flushing a checkpoint. Once the reason
1578/// clears (checkpoint complete), the lease becomes preemptible again
1579/// per the workload's standing policy.
1580///
1581/// The typed reason set is closed and stable. Adding a variant is
1582/// wire-compatibility-grade — SIEM rules and the audit chain group
1583/// on these strings.
1584#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1585#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1586#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
1587pub enum NonPreemptibleReason {
1588    /// Lease is mid-checkpoint flush. Preempting now would lose the
1589    /// checkpoint. Cleared when the checkpoint reports complete.
1590    #[cfg_attr(feature = "serde", serde(alias = "CheckpointInProgress"))]
1591    CheckpointInProgress,
1592    /// Lease has an active data-plane binding (RDMA QP, NVMe-oF
1593    /// connection) that cannot be torn down without coordination.
1594    /// Cleared when the data plane releases.
1595    #[cfg_attr(feature = "serde", serde(alias = "DataPlaneActive"))]
1596    DataPlaneActive,
1597    /// Operator manually pinned this lease as non-preemptible via the
1598    /// admin surface. Cleared by an explicit operator unpin.
1599    #[cfg_attr(feature = "serde", serde(alias = "OperatorPin"))]
1600    OperatorPin,
1601    /// Lease has an attestation contract (TEE binding, sealed key)
1602    /// that requires the specific holder. Preemption would invalidate
1603    /// the attestation chain. Cleared when the attestation is
1604    /// renewed against a different holder.
1605    #[cfg_attr(feature = "serde", serde(alias = "AttestationLocked"))]
1606    AttestationLocked,
1607}
1608
1609impl NonPreemptibleReason {
1610    pub fn as_str(self) -> &'static str {
1611        match self {
1612            Self::CheckpointInProgress => "checkpoint_in_progress",
1613            Self::DataPlaneActive => "data_plane_active",
1614            Self::OperatorPin => "operator_pin",
1615            Self::AttestationLocked => "attestation_locked",
1616        }
1617    }
1618
1619    pub fn human_summary(self) -> &'static str {
1620        match self {
1621            Self::CheckpointInProgress => {
1622                "lease is mid-checkpoint; preempting would lose the checkpoint"
1623            }
1624            Self::DataPlaneActive => "data-plane binding active; teardown requires coordination",
1625            Self::OperatorPin => "operator manually pinned this lease as non-preemptible",
1626            Self::AttestationLocked => "attestation chain requires this specific lease holder",
1627        }
1628    }
1629}
1630
1631impl fmt::Display for NonPreemptibleReason {
1632    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1633        f.write_str(self.as_str())
1634    }
1635}
1636
1637// ---------------------------------------------------------------------------
1638// Phase 218.5 — ResourceBundle (slice 106)
1639// ---------------------------------------------------------------------------
1640
1641/// Phase 218.5 — Hardware-generation tag for typed resource requirements.
1642///
1643/// Heterogeneous workloads (e.g. A100+H100 distributed training, MI300
1644/// inference) often must pin a requirement to a specific generation
1645/// because driver / interconnect / numeric characteristics differ
1646/// materially. `ResourceRequirement::hardware_generation` carries this
1647/// constraint; `None` means "any generation satisfying the kind".
1648///
1649/// The variant set is closed and stable. Adding a generation is
1650/// wire-compatibility-grade: operators query `--generation` on the
1651/// CLI surface (deferred to a follow-up slice) and SIEM rules group
1652/// on the `as_str()` label. The slice-90 single-source-of-truth pin
1653/// catches any drift between `as_str()` and the serde wire shape.
1654///
1655/// `Other` is the explicit landing for non-NVIDIA / non-AMD / non-Intel
1656/// accelerators (or unknown generations) so the enum does not need a
1657/// free-form `String` escape hatch — fail-closed if the operator's
1658/// CLI string does not resolve to a known variant.
1659#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
1660#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1661#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
1662pub enum HardwareGeneration {
1663    /// NVIDIA Ampere (A100, A30, A40).
1664    NvidiaAmpere,
1665    /// NVIDIA Hopper (H100, H200).
1666    NvidiaHopper,
1667    /// NVIDIA Blackwell (B200, GB200).
1668    NvidiaBlackwell,
1669    /// AMD CDNA3 (MI300, MI300X, MI300A).
1670    AmdMi300,
1671    /// AMD CDNA4 (MI325X+).
1672    AmdMi325,
1673    /// Intel Gaudi 2/3.
1674    IntelGaudi,
1675    /// Other / generic. Use when no specific generation is required
1676    /// or the accelerator family is not yet enumerated.
1677    Other,
1678}
1679
1680impl HardwareGeneration {
1681    /// Stable wire / log identifier (snake_case). Wire-format-grade —
1682    /// SIEM rules and dashboard panel selectors alert off this exact
1683    /// string.
1684    pub fn as_str(self) -> &'static str {
1685        match self {
1686            Self::NvidiaAmpere => "nvidia_ampere",
1687            Self::NvidiaHopper => "nvidia_hopper",
1688            Self::NvidiaBlackwell => "nvidia_blackwell",
1689            Self::AmdMi300 => "amd_mi300",
1690            Self::AmdMi325 => "amd_mi325",
1691            Self::IntelGaudi => "intel_gaudi",
1692            Self::Other => "other",
1693        }
1694    }
1695
1696    /// Operator-readable one-line summary. Distinct surface from
1697    /// `as_str()`: this is what a CLI status page or dashboard
1698    /// tooltip renders. Carries the load-bearing generation noun
1699    /// (Ampere / Hopper / Blackwell / MI300 / MI325 / Gaudi) so a
1700    /// reader can tell which accelerator family the variant names.
1701    pub fn human_summary(self) -> &'static str {
1702        match self {
1703            Self::NvidiaAmpere => "NVIDIA Ampere (A100, A30, A40)",
1704            Self::NvidiaHopper => "NVIDIA Hopper (H100, H200)",
1705            Self::NvidiaBlackwell => "NVIDIA Blackwell (B200, GB200)",
1706            Self::AmdMi300 => "AMD CDNA3 (MI300, MI300X, MI300A)",
1707            Self::AmdMi325 => "AMD CDNA4 (MI325X and newer)",
1708            Self::IntelGaudi => "Intel Gaudi 2 / Gaudi 3",
1709            Self::Other => "other / generic accelerator family",
1710        }
1711    }
1712}
1713
1714impl fmt::Display for HardwareGeneration {
1715    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1716        f.write_str(self.as_str())
1717    }
1718}
1719
1720/// Typed parse error for hardware-generation labels.
1721#[derive(Clone, Debug, PartialEq, Eq)]
1722pub struct ParseHardwareGenerationError(pub String);
1723
1724impl fmt::Display for ParseHardwareGenerationError {
1725    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1726        write!(
1727            f,
1728            "invalid hardware generation {:?} — valid values: nvidia_ampere | nvidia_hopper | nvidia_blackwell | amd_mi300 | amd_mi325 | intel_gaudi | other",
1729            self.0
1730        )
1731    }
1732}
1733
1734impl std::error::Error for ParseHardwareGenerationError {}
1735
1736impl core::str::FromStr for HardwareGeneration {
1737    type Err = ParseHardwareGenerationError;
1738
1739    fn from_str(s: &str) -> Result<Self, Self::Err> {
1740        match s {
1741            "nvidia_ampere" => Ok(Self::NvidiaAmpere),
1742            "nvidia_hopper" => Ok(Self::NvidiaHopper),
1743            "nvidia_blackwell" => Ok(Self::NvidiaBlackwell),
1744            "amd_mi300" => Ok(Self::AmdMi300),
1745            "amd_mi325" => Ok(Self::AmdMi325),
1746            "intel_gaudi" => Ok(Self::IntelGaudi),
1747            "other" => Ok(Self::Other),
1748            _ => Err(ParseHardwareGenerationError(s.to_string())),
1749        }
1750    }
1751}
1752
1753// ---------------------------------------------------------------------------
1754// Phase 218.1 — Tenant quota schema (slice 116)
1755// ---------------------------------------------------------------------------
1756
1757/// Per-GPU-generation quota entry.
1758///
1759/// The scheduler consumes this as policy. `fabricbios-core` only reports
1760/// hardware facts; it does not evaluate tenant quotas.
1761#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1762#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1763pub struct GpuGenerationQuota {
1764    /// Accelerator generation the count applies to.
1765    pub generation: HardwareGeneration,
1766    /// Maximum active GPU leases of this generation.
1767    pub count: u32,
1768}
1769
1770/// Phase 218.1 — typed tenant quota schema.
1771///
1772/// This is the cross-crate shape used to describe a tenant's hard quota
1773/// envelope. `None` means "no hard quota for this dimension". Zero means
1774/// an explicit zero-capacity quota and must be enforced as such.
1775///
1776/// The type intentionally lives in `grafos-core`, not `fabricbios-core`:
1777/// quota is scheduler policy. fabricBIOS enforces the lease/capability
1778/// result; it does not decide a tenant's quota envelope.
1779#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
1780#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1781pub struct QuotaSchema {
1782    /// Maximum memory bytes.
1783    pub mem_bytes: Option<u64>,
1784    /// Maximum CPU cores.
1785    pub cpu_cores: Option<u64>,
1786    /// Maximum total GPU leases regardless of hardware generation.
1787    pub gpu_count: Option<u32>,
1788    /// Maximum accelerator-local memory bytes. User-facing surfaces may call
1789    /// this GPU VRAM. It maps to `ResourceKind::GpuMem`, not `Gpu`, because
1790    /// GPU execution leases and GPU memory leases are distinct capacity
1791    /// dimensions.
1792    pub gpu_vram_bytes: Option<u64>,
1793    /// Optional per-generation GPU counts. When present, these refine the
1794    /// total `gpu_count` envelope for generation-aware placement.
1795    pub gpu_count_by_generation: Vec<GpuGenerationQuota>,
1796    /// Maximum block-storage bytes.
1797    pub block_bytes: Option<u64>,
1798    /// Maximum network bandwidth in bits per second.
1799    pub net_bps: Option<u64>,
1800    /// Maximum concurrent tasklet leases.
1801    pub tasklet_concurrency: Option<u32>,
1802    /// Maximum lease-create operations per minute.
1803    pub lease_create_per_minute: Option<u32>,
1804    /// Optional burst-credit policy shared by quota dimensions that allow
1805    /// bounded temporary overage.
1806    pub burst_credits: Option<BurstCreditPolicy>,
1807    /// Optional fair-share window tied to this quota record.
1808    pub fair_share: Option<FairShareWindow>,
1809}
1810
1811impl QuotaSchema {
1812    /// Returns true when every hard quota dimension is absent.
1813    pub fn is_unlimited(&self) -> bool {
1814        self.mem_bytes.is_none()
1815            && self.cpu_cores.is_none()
1816            && self.gpu_count.is_none()
1817            && self.gpu_vram_bytes.is_none()
1818            && self.gpu_count_by_generation.is_empty()
1819            && self.block_bytes.is_none()
1820            && self.net_bps.is_none()
1821            && self.tasklet_concurrency.is_none()
1822            && self.lease_create_per_minute.is_none()
1823    }
1824
1825    /// Returns the hard quota limit for the scheduler's coarse resource
1826    /// kind, when that kind maps directly to the schema.
1827    pub fn limit_for_resource_kind(&self, kind: crate::ResourceKind) -> Option<u64> {
1828        match kind {
1829            crate::ResourceKind::Mem => self.mem_bytes,
1830            crate::ResourceKind::Cpu => self.cpu_cores,
1831            crate::ResourceKind::Gpu => self.total_gpu_count().map(u64::from),
1832            crate::ResourceKind::GpuMem => self.gpu_vram_bytes,
1833            crate::ResourceKind::Block => self.block_bytes,
1834            crate::ResourceKind::Net => self.net_bps,
1835            crate::ResourceKind::Tasklet => self.tasklet_concurrency.map(u64::from),
1836        }
1837    }
1838
1839    /// Total GPU-count quota across all generations, if configured.
1840    pub fn total_gpu_count(&self) -> Option<u32> {
1841        self.gpu_count.or_else(|| {
1842            if self.gpu_count_by_generation.is_empty() {
1843                None
1844            } else {
1845                Some(
1846                    self.gpu_count_by_generation
1847                        .iter()
1848                        .fold(0u32, |acc, q| acc.saturating_add(q.count)),
1849                )
1850            }
1851        })
1852    }
1853
1854    /// Per-generation GPU quota, when configured.
1855    pub fn gpu_limit_for_generation(&self, generation: HardwareGeneration) -> Option<u32> {
1856        self.gpu_count_by_generation
1857            .iter()
1858            .find(|q| q.generation == generation)
1859            .map(|q| q.count)
1860    }
1861}
1862
1863/// Deterministic burst-credit policy for quota overage.
1864#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1865#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1866pub struct BurstCreditPolicy {
1867    /// Maximum burst capacity above the steady quota.
1868    pub capacity: u64,
1869    /// Refill rate in capacity units per minute.
1870    pub refill_per_minute: u64,
1871    /// Accounting window in seconds.
1872    pub window_secs: u64,
1873}
1874
1875impl BurstCreditPolicy {
1876    /// Returns true iff the policy can make progress and has a bounded
1877    /// accounting window.
1878    pub fn is_valid(&self) -> bool {
1879        self.capacity > 0 && self.refill_per_minute > 0 && self.window_secs > 0
1880    }
1881}
1882
1883/// Snapshot of tenant usage in the same dimensions as [`QuotaSchema`].
1884#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
1885#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1886pub struct QuotaUsage {
1887    pub mem_bytes: u64,
1888    pub cpu_cores: u64,
1889    pub gpu_count: u32,
1890    pub gpu_vram_bytes: u64,
1891    pub gpu_count_by_generation: Vec<GpuGenerationQuota>,
1892    pub block_bytes: u64,
1893    pub net_bps: u64,
1894    pub tasklet_concurrency: u32,
1895    pub lease_creates_in_window: u32,
1896}
1897
1898impl QuotaUsage {
1899    /// Returns usage for the scheduler's coarse resource kind.
1900    pub fn usage_for_resource_kind(&self, kind: crate::ResourceKind) -> u64 {
1901        match kind {
1902            crate::ResourceKind::Mem => self.mem_bytes,
1903            crate::ResourceKind::Cpu => self.cpu_cores,
1904            crate::ResourceKind::Gpu => u64::from(self.gpu_count),
1905            crate::ResourceKind::GpuMem => self.gpu_vram_bytes,
1906            crate::ResourceKind::Block => self.block_bytes,
1907            crate::ResourceKind::Net => self.net_bps,
1908            crate::ResourceKind::Tasklet => u64::from(self.tasklet_concurrency),
1909        }
1910    }
1911
1912    /// Per-generation GPU usage, when tracked.
1913    pub fn gpu_usage_for_generation(&self, generation: HardwareGeneration) -> u32 {
1914        self.gpu_count_by_generation
1915            .iter()
1916            .find(|q| q.generation == generation)
1917            .map_or(0, |q| q.count)
1918    }
1919}
1920
1921/// Typed quota violation vocabulary.
1922///
1923/// This maps quota decisions onto stable labels without using free-form
1924/// strings. Admission paths may still include human prose, but this enum is
1925/// the load-bearing operator/API surface.
1926#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
1927#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
1928#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
1929pub enum QuotaViolation {
1930    MemBytesExceeded {
1931        limit: u64,
1932        used: u64,
1933        requested: u64,
1934    },
1935    CpuCoresExceeded {
1936        limit: u64,
1937        used: u64,
1938        requested: u64,
1939    },
1940    GpuCountExceeded {
1941        generation: Option<HardwareGeneration>,
1942        limit: u32,
1943        used: u32,
1944        requested: u32,
1945    },
1946    GpuVramBytesExceeded {
1947        limit: u64,
1948        used: u64,
1949        requested: u64,
1950    },
1951    BlockBytesExceeded {
1952        limit: u64,
1953        used: u64,
1954        requested: u64,
1955    },
1956    NetBpsExceeded {
1957        limit: u64,
1958        used: u64,
1959        requested: u64,
1960    },
1961    TaskletConcurrencyExceeded {
1962        limit: u32,
1963        used: u32,
1964        requested: u32,
1965    },
1966    LeaseCreateRateExceeded {
1967        limit_per_minute: u32,
1968        used_in_window: u32,
1969        requested: u32,
1970    },
1971    ActiveLeaseCountExceeded {
1972        limit: u32,
1973        active: u32,
1974        requested: u32,
1975    },
1976    PerNodeCapacityExceeded {
1977        limit: u64,
1978        used: u64,
1979        requested: u64,
1980    },
1981    BurstCreditExhausted {
1982        capacity: u64,
1983        remaining: u64,
1984        requested: u64,
1985    },
1986    FairShareExceeded {
1987        window_secs: u64,
1988        max_share_secs: u64,
1989        used_share_secs: u64,
1990        requested_share_secs: u64,
1991    },
1992    TenantQuotaMissing,
1993}
1994
1995impl QuotaViolation {
1996    /// Stable snake_case label for logs, dashboards, and API consumers.
1997    pub fn as_str(self) -> &'static str {
1998        match self {
1999            Self::MemBytesExceeded { .. } => "mem_bytes_exceeded",
2000            Self::CpuCoresExceeded { .. } => "cpu_cores_exceeded",
2001            Self::GpuCountExceeded { .. } => "gpu_count_exceeded",
2002            Self::GpuVramBytesExceeded { .. } => "gpu_vram_bytes_exceeded",
2003            Self::BlockBytesExceeded { .. } => "block_bytes_exceeded",
2004            Self::NetBpsExceeded { .. } => "net_bps_exceeded",
2005            Self::TaskletConcurrencyExceeded { .. } => "tasklet_concurrency_exceeded",
2006            Self::LeaseCreateRateExceeded { .. } => "lease_create_rate_exceeded",
2007            Self::ActiveLeaseCountExceeded { .. } => "active_lease_count_exceeded",
2008            Self::PerNodeCapacityExceeded { .. } => "per_node_capacity_exceeded",
2009            Self::BurstCreditExhausted { .. } => "burst_credit_exhausted",
2010            Self::FairShareExceeded { .. } => "fair_share_exceeded",
2011            Self::TenantQuotaMissing => "tenant_quota_missing",
2012        }
2013    }
2014
2015    /// Tooltip-grade operator-readable summary.
2016    pub fn human_summary(self) -> &'static str {
2017        match self {
2018            Self::MemBytesExceeded { .. } => "tenant memory quota exceeded",
2019            Self::CpuCoresExceeded { .. } => "tenant CPU quota exceeded",
2020            Self::GpuCountExceeded { .. } => "tenant GPU-count quota exceeded",
2021            Self::GpuVramBytesExceeded { .. } => "tenant GPU VRAM quota exceeded",
2022            Self::BlockBytesExceeded { .. } => "tenant block-storage quota exceeded",
2023            Self::NetBpsExceeded { .. } => "tenant network-bandwidth quota exceeded",
2024            Self::TaskletConcurrencyExceeded { .. } => "tenant tasklet-concurrency quota exceeded",
2025            Self::LeaseCreateRateExceeded { .. } => "tenant lease-create rate quota exceeded",
2026            Self::ActiveLeaseCountExceeded { .. } => "tenant active-lease count quota exceeded",
2027            Self::PerNodeCapacityExceeded { .. } => "tenant per-node capacity quota exceeded",
2028            Self::BurstCreditExhausted { .. } => "tenant burst-credit envelope exhausted",
2029            Self::FairShareExceeded { .. } => "tenant fair-share window exceeded",
2030            Self::TenantQuotaMissing => "tenant has no quota record",
2031        }
2032    }
2033
2034    /// Existing admission rejection bucket used until the admission surface
2035    /// grows per-violation typed fields.
2036    pub fn rejection_reason(self) -> RejectionReason {
2037        match self {
2038            Self::BurstCreditExhausted { .. } => RejectionReason::QuotaBurstExceeded,
2039            Self::TenantQuotaMissing => RejectionReason::TenantNotFound,
2040            _ => RejectionReason::QuotaHardLimitExceeded,
2041        }
2042    }
2043}
2044
2045impl fmt::Display for QuotaViolation {
2046    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2047        f.write_str(self.as_str())
2048    }
2049}
2050
2051/// Phase 218.5 — Atomicity policy for a `ResourceBundleSpec`.
2052///
2053/// Slice 106 lands the typed vocabulary; the AdmissionGate wiring
2054/// that actually enforces atomicity is deferred to a follow-up slice
2055/// (see TODO.md Phase 218.5 carry-overs).
2056#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2057#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2058#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
2059pub enum BundleAtomicity {
2060    /// All-or-nothing: either every requirement is granted, or NONE.
2061    /// Partial allocation is impossible. The default for slice 106 —
2062    /// most bundle workloads (mixed-GPU training, CPU+mem+GPU
2063    /// inference) are meaningless under a partial grant.
2064    AllOrNothing,
2065    /// Best-effort: grant as many requirements as fit; the workload
2066    /// is told which were granted. Rare — included so the
2067    /// vocabulary surface is closed, not open to interpretation.
2068    BestEffort,
2069}
2070
2071impl BundleAtomicity {
2072    /// Stable wire / log identifier (snake_case).
2073    pub fn as_str(self) -> &'static str {
2074        match self {
2075            Self::AllOrNothing => "all_or_nothing",
2076            Self::BestEffort => "best_effort",
2077        }
2078    }
2079
2080    /// Operator-readable one-line summary.
2081    pub fn human_summary(self) -> &'static str {
2082        match self {
2083            Self::AllOrNothing => "all-or-nothing: every requirement granted or none",
2084            Self::BestEffort => "best-effort: grant as many requirements as fit",
2085        }
2086    }
2087}
2088
2089impl fmt::Display for BundleAtomicity {
2090    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2091        f.write_str(self.as_str())
2092    }
2093}
2094
2095impl Default for BundleAtomicity {
2096    fn default() -> Self {
2097        Self::AllOrNothing
2098    }
2099}
2100
2101/// Phase 218.5 — A single resource requirement within a bundle.
2102///
2103/// A heterogeneous workload declares a list of `ResourceRequirement`
2104/// items wrapped in `ResourceBundleSpec`; the scheduler's
2105/// atomic-admission path grants ALL of them or rejects with a typed
2106/// `BundleAdmissionFailure`. This struct is the requirement shape;
2107/// `ResourceBundleSpec` is the bundle (list + atomicity).
2108#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2109#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2110pub struct ResourceRequirement {
2111    /// Resource type required (Mem / Cpu / Gpu / Block / Net / Tasklet).
2112    pub kind: crate::ResourceKind,
2113    /// Capacity required, in the kind's natural units: bytes for `Mem`
2114    /// and `Block`, cores for `Cpu`, VRAM-bytes for `Gpu`, bytes/sec
2115    /// for `Net`, slots for `Tasklet`.
2116    pub capacity: u64,
2117    /// Optional hardware-generation constraint. `None` = any generation
2118    /// satisfying the kind; `Some(g)` = require this exact generation.
2119    /// Mostly applies to `Gpu` — operators express "A100 OR H100" by
2120    /// constructing two `ResourceBundleSpec` candidates and choosing
2121    /// at admission time, not by widening this field.
2122    pub hardware_generation: Option<HardwareGeneration>,
2123}
2124
2125/// Phase 218.5 — A bundle of resource requirements admitted atomically.
2126///
2127/// The scheduler's atomic-admission path (deferred to a follow-up
2128/// slice) walks `requirements` and either grants them all (writing
2129/// the leases) or rejects with a typed `BundleAdmissionFailure`. An
2130/// empty `requirements` list is a typed error
2131/// (`BundleAdmissionFailure::EmptyBundle`), not a no-op grant.
2132#[derive(Clone, Debug, PartialEq, Eq)]
2133#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2134pub struct ResourceBundleSpec {
2135    /// The requirements. Order is not significant — the scheduler MAY
2136    /// satisfy them in any order. An empty `Vec` is rejected at
2137    /// admission as `BundleAdmissionFailure::EmptyBundle`.
2138    pub requirements: Vec<ResourceRequirement>,
2139    /// Atomicity policy. Defaults to `AllOrNothing`.
2140    #[cfg_attr(feature = "serde", serde(default))]
2141    pub atomicity: BundleAtomicity,
2142}
2143
2144impl Default for ResourceBundleSpec {
2145    fn default() -> Self {
2146        Self {
2147            requirements: Vec::new(),
2148            atomicity: BundleAtomicity::AllOrNothing,
2149        }
2150    }
2151}
2152
2153/// Phase 218.5 — Typed admission-failure reasons specific to bundle
2154/// admission.
2155///
2156/// Slice 106 lands the vocabulary; the AdmissionGate wiring that
2157/// emits these reasons is deferred to a follow-up slice. Operators
2158/// querying `--reason` on the (forthcoming) `grafos admissions`
2159/// bundle filter see these labels — the cross-layer pin pattern
2160/// (slice 87 / slice 93) will catch drift once the CLI surface lands.
2161///
2162/// `BundleAdmissionFailure` is its own axis, orthogonal to the
2163/// per-resource `RejectionReason` vocabulary. A bundle admission can
2164/// fail at the bundle level (e.g. `EmptyBundle`) without any single
2165/// requirement carrying a per-resource rejection. Whether the two
2166/// vocabularies fold or stay separate is a follow-up slice decision.
2167#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2168#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2169#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
2170pub enum BundleAdmissionFailure {
2171    /// The bundle had zero requirements. Operators occasionally send
2172    /// this from misconfigured templates; surface it as a typed
2173    /// reason rather than a string default.
2174    EmptyBundle,
2175    /// At least one requirement could not be satisfied with the
2176    /// available capacity, and the bundle's atomicity is
2177    /// `AllOrNothing`. The scheduler did NOT grant any requirement.
2178    InsufficientBundleCapacity,
2179    /// At least one requirement violates a placement constraint and
2180    /// no viable per-candidate placement exists for it, AND the
2181    /// atomicity is `AllOrNothing`. Distinct from the per-resource
2182    /// `RejectionReason::PlacementPolicyExcluded` which fires per
2183    /// candidate.
2184    BundleConstraintViolation,
2185    /// A specific `HardwareGeneration` requirement could not be met
2186    /// (e.g. the workload asked for an A100+H100 bundle and the
2187    /// cluster has no H100 inventory).
2188    HardwareGenerationUnavailable,
2189    /// Reserved placeholder for future bundle-failure reasons. The
2190    /// typed-vocabulary discipline rejects free-form `reason: String`,
2191    /// so the closed set has an explicit catch-all rather than an
2192    /// open `String` escape. New variants land via
2193    /// wire-format-grade slices.
2194    OtherBundleFailure,
2195}
2196
2197impl BundleAdmissionFailure {
2198    /// Stable wire / log identifier (snake_case). Wire-format-grade —
2199    /// SIEM rules group on this exact string.
2200    pub fn as_str(self) -> &'static str {
2201        match self {
2202            Self::EmptyBundle => "empty_bundle",
2203            Self::InsufficientBundleCapacity => "insufficient_bundle_capacity",
2204            Self::BundleConstraintViolation => "bundle_constraint_violation",
2205            Self::HardwareGenerationUnavailable => "hardware_generation_unavailable",
2206            Self::OtherBundleFailure => "other_bundle_failure",
2207        }
2208    }
2209
2210    /// Operator-readable one-line summary. Distinct surface from
2211    /// `as_str()`. Carries load-bearing nouns so a reader can tell
2212    /// why a bundle admission failed at a glance.
2213    pub fn human_summary(self) -> &'static str {
2214        match self {
2215            Self::EmptyBundle => "bundle had zero requirements; nothing to admit",
2216            Self::InsufficientBundleCapacity => {
2217                "insufficient capacity for at least one requirement; all-or-nothing rejected"
2218            }
2219            Self::BundleConstraintViolation => {
2220                "placement constraint excludes every candidate for at least one requirement"
2221            }
2222            Self::HardwareGenerationUnavailable => {
2223                "requested hardware generation has no matching inventory in the cluster"
2224            }
2225            Self::OtherBundleFailure => "other bundle admission failure; reserved catch-all",
2226        }
2227    }
2228
2229    /// Fold bundle admission failures onto the flat admissions
2230    /// `RejectionReason` vocabulary used by `/api/v1/admissions`,
2231    /// `grafos admissions --reason`, and dashboard aggregation.
2232    pub fn rejection_reason(self) -> RejectionReason {
2233        match self {
2234            Self::EmptyBundle => RejectionReason::EmptyBundle,
2235            Self::InsufficientBundleCapacity => RejectionReason::InsufficientBundleCapacity,
2236            Self::BundleConstraintViolation => RejectionReason::BundleConstraintViolation,
2237            Self::HardwareGenerationUnavailable => RejectionReason::HardwareGenerationUnavailable,
2238            Self::OtherBundleFailure => RejectionReason::OtherBundleFailure,
2239        }
2240    }
2241}
2242
2243impl fmt::Display for BundleAdmissionFailure {
2244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2245        f.write_str(self.as_str())
2246    }
2247}
2248
2249/// Phase 218.5 / slice 108 — bundle-level admission decision summary.
2250///
2251/// The slice-107 `check_bundle_admission` validates; slice-108
2252/// `reserve_bundle` validates AND reserves under a critical section.
2253/// Each `reserve_bundle` call emits one `BundleAdmissionDecided`
2254/// audit record (in addition to the per-requirement `AdmissionDecided`
2255/// emits the inner `check_admission` calls already fire). Operators
2256/// querying `--kind admission_decided` see this bundle-level summary
2257/// alongside the per-requirement detail.
2258///
2259/// The four variants are closed — adding a new outcome shape is
2260/// wire-format-grade and lands via a follow-up slice.
2261#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2262#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2263#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
2264pub enum BundleDecision {
2265    /// Bundle was fully approved (AllOrNothing: all requirements
2266    /// granted; BestEffort: every requirement granted). The
2267    /// reservation is held under a critical section pending the
2268    /// caller's `commit()` or `rollback()`.
2269    Approved,
2270    /// AllOrNothing bundle rejected because at least one requirement
2271    /// failed admission. No capacity was reserved (the slice 108
2272    /// reservation path holds the lock through validation AND
2273    /// reservation, so a partial-fail rolls back cleanly).
2274    DeniedAllOrNothing,
2275    /// Bundle had zero requirements. Operators occasionally send this
2276    /// from misconfigured templates; the typed decision surfaces it
2277    /// distinctly from a capacity-shaped denial.
2278    DeniedEmpty,
2279    /// BestEffort bundle approved with some requirements granted and
2280    /// others denied. The caller's `BundleReservation` token holds
2281    /// the granted subset; the per-requirement results expose which
2282    /// failed.
2283    PartialBestEffort,
2284}
2285
2286impl BundleDecision {
2287    /// Stable wire / log identifier (snake_case). Wire-format-grade —
2288    /// SIEM rules group on this exact string.
2289    pub fn as_str(self) -> &'static str {
2290        match self {
2291            Self::Approved => "approved",
2292            Self::DeniedAllOrNothing => "denied_all_or_nothing",
2293            Self::DeniedEmpty => "denied_empty",
2294            Self::PartialBestEffort => "partial_best_effort",
2295        }
2296    }
2297
2298    /// Operator-readable one-line summary. Distinct surface from
2299    /// `as_str()`.
2300    pub fn human_summary(self) -> &'static str {
2301        match self {
2302            Self::Approved => "bundle fully approved; reservation held pending commit",
2303            Self::DeniedAllOrNothing => {
2304                "bundle rejected; at least one requirement failed and atomicity is all-or-nothing"
2305            }
2306            Self::DeniedEmpty => "bundle rejected; zero requirements",
2307            Self::PartialBestEffort => {
2308                "best-effort bundle approved with mixed per-requirement results"
2309            }
2310        }
2311    }
2312}
2313
2314impl fmt::Display for BundleDecision {
2315    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2316        f.write_str(self.as_str())
2317    }
2318}
2319
2320// ---------------------------------------------------------------------------
2321// Phase 219 — AuditEventKind
2322// ---------------------------------------------------------------------------
2323
2324/// Typed kinds for events that compose the Phase 219 audit chain. The
2325/// set here is the slice-1 minimum; subsequent slices add variants
2326/// alongside the code paths that emit them.
2327///
2328/// Important: this enum names what the daemon / scheduler / runtime
2329/// EMITS. The chain assembly (prev_event_hash, current_event_hash,
2330/// signature) lives in `fabricbiosd` per the Phase 217 audit_anchor
2331/// pin and the cross-phase invariant ("core emits typed records when
2332/// state transitions, it does not own the chain"). The collector then
2333/// stores the chain entries.
2334///
2335/// New variants MUST be added here, not synthesized from string fields
2336/// at emit time. Free-form `reason: String` event kinds are rejected.
2337#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
2338#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2339#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
2340pub enum AuditEventKind {
2341    /// Capability token issued. Includes the issuer (Phase 220 custody-
2342    /// matrix entry) and the WorkloadIdentity audience.
2343    #[cfg_attr(feature = "serde", serde(alias = "CapabilityIssued"))]
2344    CapabilityIssued,
2345    /// Capability token revoked.
2346    #[cfg_attr(feature = "serde", serde(alias = "CapabilityRevoked"))]
2347    CapabilityRevoked,
2348    /// Lease allocated.
2349    #[cfg_attr(feature = "serde", serde(alias = "LeaseAllocated"))]
2350    LeaseAllocated,
2351    /// Lease renewed.
2352    #[cfg_attr(feature = "serde", serde(alias = "LeaseRenewed"))]
2353    LeaseRenewed,
2354    /// Lease released cooperatively by the holder.
2355    #[cfg_attr(feature = "serde", serde(alias = "LeaseReleased"))]
2356    LeaseReleased,
2357    /// Lease expired (TTL). The teardown path is observed by
2358    /// `fabricbiosd`.
2359    #[cfg_attr(feature = "serde", serde(alias = "LeaseExpired"))]
2360    LeaseExpired,
2361    /// Lease torn down successfully (cooperative or revoke-driven).
2362    #[cfg_attr(feature = "serde", serde(alias = "LeaseTorndown"))]
2363    LeaseTorndown,
2364    /// Lease teardown failed; the resource is fenced.
2365    #[cfg_attr(feature = "serde", serde(alias = "LeaseFenced"))]
2366    LeaseFenced,
2367    /// Scheduler admission decision (accept or reject).
2368    #[cfg_attr(feature = "serde", serde(alias = "AdmissionDecided"))]
2369    AdmissionDecided,
2370    /// Workload preempted. Includes `PreemptionReason`.
2371    #[cfg_attr(feature = "serde", serde(alias = "Preempted"))]
2372    Preempted,
2373    /// Operator-driven node/cell drain initiated.
2374    #[cfg_attr(feature = "serde", serde(alias = "DrainInitiated"))]
2375    DrainInitiated,
2376    /// Daemon emitted its first audit event after `audit_anchor`
2377    /// was unanchored — the chain anchor marker. Phase 219 collectors
2378    /// use this to distinguish fresh-boot from missed-events.
2379    #[cfg_attr(feature = "serde", serde(alias = "ChainAnchored"))]
2380    ChainAnchored,
2381    /// Phase 220 / slice 13 — a soft / weaker-mode toggle was
2382    /// enabled at process startup. The discipline preamble says:
2383    /// "Bearer / soft-mode surfaces are typed config flags with
2384    /// audit, not env vars." Examples include the spec §8.5
2385    /// trusted-fabric exception profile, Phase 220's worker-broader-
2386    /// signing opt-in, Phase 222's TokenReview kubelet-metadata
2387    /// fallback. Each emit names the specific mode (via
2388    /// `WorkloadIdentity::instance_id`) so an upstream collector can
2389    /// alert on enables of any soft-mode toggle without parsing
2390    /// free-form text.
2391    #[cfg_attr(feature = "serde", serde(alias = "SoftModeEnabled"))]
2392    SoftModeEnabled,
2393    /// Phase 218–222 / slice 77 — tenant CRUD: a new tenant was
2394    /// registered with the scheduler. Stage 2a of the audit-surface
2395    /// migration: producers now dual-write this typed kind alongside
2396    /// the older enterprise_audit `kind=tenant, outcome="created"`
2397    /// row. Operators querying the unified `/api/v1/admin/audit`
2398    /// endpoint with `kind=tenant_created` see typed rows for every
2399    /// `POST /api/v1/tenants` admission.
2400    #[cfg_attr(feature = "serde", serde(alias = "TenantCreated"))]
2401    TenantCreated,
2402    /// Phase 218–222 / slice 77 — tenant CRUD: an existing tenant was
2403    /// removed from the scheduler. Stage 2a producer convergence
2404    /// emits this alongside the older enterprise_audit `kind=tenant,
2405    /// outcome="deleted"` / `outcome="deleted_force"` row. The typed
2406    /// record's `instance_id` carries the `tenant:deleted:<name>`
2407    /// marker so collectors can grep delete events without joining
2408    /// against the enterprise log.
2409    #[cfg_attr(feature = "serde", serde(alias = "TenantDeleted"))]
2410    TenantDeleted,
2411    /// Phase 218–222 / slice 77 — tenant CRUD: a tenant's quota was
2412    /// updated. Stage 2a producer convergence emits this alongside
2413    /// the older enterprise_audit `kind=tenant, outcome="quota_updated"`
2414    /// row. The typed record carries the NEW values (mem_bytes,
2415    /// cpu_cores) — an operator reading the chain sees the resulting
2416    /// quota state.
2417    #[cfg_attr(feature = "serde", serde(alias = "TenantQuotaUpdated"))]
2418    TenantQuotaUpdated,
2419    /// Phase 218–222 / slice 79 — provider conformance run recorded.
2420    /// Stage 2b of the audit-surface migration: producers now dual-
2421    /// write this typed kind alongside the older enterprise_audit
2422    /// `kind=admin, outcome="provider_conformance_recorded"` row.
2423    /// Fires on `POST /api/v1/providers/{provider}/conformance` after
2424    /// the durable cloud-state record has been persisted.
2425    #[cfg_attr(feature = "serde", serde(alias = "ProviderConformanceRecorded"))]
2426    ProviderConformanceRecorded,
2427    /// Phase 218–222 / slice 79 — provider bootstrap token issued.
2428    /// Stage 2b producer convergence emits this alongside the older
2429    /// `kind=admin, outcome="provider_bootstrap_token_issued"` row.
2430    /// Fires on `POST /api/v1/cells/bootstrap/tokens` after the new
2431    /// `BootstrapTokenRecord` is durable. The typed marker carries
2432    /// the bootstrap_id; a SIEM rule alerting on long-lived bootstrap
2433    /// tokens can grep `provider:bootstrap_token_issued:*` without
2434    /// joining against the older enterprise_audit details.
2435    #[cfg_attr(feature = "serde", serde(alias = "ProviderBootstrapTokenIssued"))]
2436    ProviderBootstrapTokenIssued,
2437    /// Phase 218–222 / slice 79 — provider bootstrap exchanged for
2438    /// cell identity. Stage 2b producer convergence emits this
2439    /// alongside the older `kind=admin,
2440    /// outcome="provider_bootstrap_exchanged"` row. Fires on
2441    /// `POST /api/v1/cells/bootstrap/exchange` after the durable
2442    /// consume of the bootstrap token has succeeded.
2443    #[cfg_attr(feature = "serde", serde(alias = "ProviderBootstrapExchanged"))]
2444    ProviderBootstrapExchanged,
2445    /// Phase 218–222 / slice 79 — cell identity certificate issued.
2446    /// Stage 2b producer convergence emits this alongside the older
2447    /// `kind=admin, outcome="provider_cell_identity_issued"` row.
2448    /// Fires on the same handler as `ProviderBootstrapExchanged`
2449    /// (immediately after); the two emits are separate so a SIEM rule
2450    /// can alert specifically on cert issuance without parsing the
2451    /// bootstrap-exchange row.
2452    #[cfg_attr(feature = "serde", serde(alias = "ProviderCellIdentityIssued"))]
2453    ProviderCellIdentityIssued,
2454    /// Phase 218–222 / slice 79 — cell identity rotated. Stage 2b
2455    /// producer convergence emits this alongside the older
2456    /// `kind=admin, outcome="provider_cell_identity_rotated"` row.
2457    /// Fires on `POST /api/v1/cells/identity/rotate` after the
2458    /// new cert has been issued and the durable record updated. The
2459    /// marker carries the new registration_generation so a chain
2460    /// reader sees rotation cadence at a glance.
2461    #[cfg_attr(feature = "serde", serde(alias = "ProviderCellIdentityRotated"))]
2462    ProviderCellIdentityRotated,
2463    /// Phase 218–222 / slice 79 — cell identity revoked. Stage 2b
2464    /// producer convergence emits this alongside the older
2465    /// `kind=admin, outcome="provider_cell_identity_revoked"` row.
2466    /// Fires on `POST /api/v1/providers/{provider}/cells/{cell_id}/revoke`
2467    /// after the durable cloud-state record marks the cell revoked.
2468    #[cfg_attr(feature = "serde", serde(alias = "ProviderCellIdentityRevoked"))]
2469    ProviderCellIdentityRevoked,
2470    /// Phase 218–222 / slice 80 — admin/auth bearer token issued.
2471    /// Stage 2c of the audit-surface migration: producers now dual-
2472    /// write this typed kind alongside the older
2473    /// `enterprise_audit::AuditKind::Token + outcome="created"` row.
2474    /// Fires on `POST /api/v1/admin/tokens` (cell-side and orchestrator
2475    /// surfaces). Distinct from `CapabilityIssued` — bearer tokens
2476    /// are HTTP Authorization-shaped admin/tenant API keys for
2477    /// authenticating control-plane requests, NOT lease-bound
2478    /// capability tokens (which are spec § 4 with audience binding
2479    /// and resource scope). The `bearer_token_*` snake_case prefix
2480    /// grep-disambiguates the two vocabularies so SIEM rules don't
2481    /// conflate them.
2482    #[cfg_attr(feature = "serde", serde(alias = "BearerTokenIssued"))]
2483    BearerTokenIssued,
2484    /// Phase 218–222 / slice 80 — admin/auth bearer token revoked.
2485    /// Stage 2c producer convergence emits this alongside the older
2486    /// `enterprise_audit::AuditKind::Token + outcome="revoked"` row.
2487    /// Fires on `DELETE /api/v1/admin/tokens/<id>` (cell-side and
2488    /// orchestrator surfaces). See `BearerTokenIssued` for the
2489    /// vocabulary distinction from `CapabilityRevoked`.
2490    #[cfg_attr(feature = "serde", serde(alias = "BearerTokenRevoked"))]
2491    BearerTokenRevoked,
2492    /// Phase 218–222 / slice 81 — cell-side scheduler active/standby
2493    /// promotion succeeded. Stage 2 mop-up of the audit-surface
2494    /// migration: this slice closes the cell-side admin emit slice 79
2495    /// missed (slice 79 covered orchestrator-side admin only).
2496    /// Producers now dual-write this typed kind alongside the older
2497    /// `enterprise_audit::AuditKind::Admin + outcome="promoted"` row
2498    /// emitted by `emit_admin_promote_succeeded`. Fires on a
2499    /// successful `POST /api/v1/admin/promote` after
2500    /// `promote_inner` returns `PromoteOutcome::Ok`. The marker
2501    /// embeds the new epoch (`scheduler:promoted:epoch=<n>`) so SIEM
2502    /// rules grouping by epoch transition find the row directly
2503    /// without joining against the enterprise row's details JSON.
2504    #[cfg_attr(feature = "serde", serde(alias = "SchedulerPromoted"))]
2505    SchedulerPromoted,
2506    /// Phase 218–222 / slice 81 — billing rate-card installation.
2507    /// Stage 2 mop-up: the only Billing-category producer
2508    /// (`enterprise_audit::AuditKind::Billing`). Lifecycle-axis,
2509    /// low-volume — clean dual-write. Producers emit this alongside
2510    /// the older `enterprise_audit::AuditKind::Billing +
2511    /// outcome="installed"` row produced by
2512    /// `emit_billing_rate_card_installed`. Fires on a successful
2513    /// `POST /api/v1/billing/rate-card` after the new card has been
2514    /// persisted. The marker carries the operator-supplied card
2515    /// name (`billing:rate_card_installed:<card_name>`) so a SIEM
2516    /// rule alerting on rate-card swaps can grep the typed marker
2517    /// space directly.
2518    #[cfg_attr(feature = "serde", serde(alias = "BillingRateCardInstalled"))]
2519    BillingRateCardInstalled,
2520    /// Slice 261 (EdgeRecord audit-chain integration arc, opened by
2521    /// slice 259's design doc). A rewrite has applied an edge mutation.
2522    /// The accompanying `AuditEventData::EdgeRewritten` payload (slice
2523    /// 260) carries the slice 226 canonical EdgeRecord encoding so
2524    /// audit readers can decode the typed graph-object schema for any
2525    /// edge that's been touched. Fired by the rewrite engine in slice
2526    /// 262 (producer wiring) for each affected edge on a committed
2527    /// rewrite; rollback / precondition-failed paths deliberately
2528    /// don't emit — `EdgeRewritten` implies durable edge state. SIEM
2529    /// rules can filter `--kind edge_rewritten` for the
2530    /// per-rewrite-edge ledger.
2531    #[cfg_attr(feature = "serde", serde(alias = "EdgeRewritten"))]
2532    EdgeRewritten,
2533}
2534
2535impl AuditEventKind {
2536    pub fn as_str(self) -> &'static str {
2537        match self {
2538            Self::CapabilityIssued => "capability_issued",
2539            Self::CapabilityRevoked => "capability_revoked",
2540            Self::LeaseAllocated => "lease_allocated",
2541            Self::LeaseRenewed => "lease_renewed",
2542            Self::LeaseReleased => "lease_released",
2543            Self::LeaseExpired => "lease_expired",
2544            Self::LeaseTorndown => "lease_torndown",
2545            Self::LeaseFenced => "lease_fenced",
2546            Self::AdmissionDecided => "admission_decided",
2547            Self::Preempted => "preempted",
2548            Self::DrainInitiated => "drain_initiated",
2549            Self::ChainAnchored => "chain_anchored",
2550            Self::SoftModeEnabled => "soft_mode_enabled",
2551            Self::TenantCreated => "tenant_created",
2552            Self::TenantDeleted => "tenant_deleted",
2553            Self::TenantQuotaUpdated => "tenant_quota_updated",
2554            Self::ProviderConformanceRecorded => "provider_conformance_recorded",
2555            Self::ProviderBootstrapTokenIssued => "provider_bootstrap_token_issued",
2556            Self::ProviderBootstrapExchanged => "provider_bootstrap_exchanged",
2557            Self::ProviderCellIdentityIssued => "provider_cell_identity_issued",
2558            Self::ProviderCellIdentityRotated => "provider_cell_identity_rotated",
2559            Self::ProviderCellIdentityRevoked => "provider_cell_identity_revoked",
2560            Self::BearerTokenIssued => "bearer_token_issued",
2561            Self::BearerTokenRevoked => "bearer_token_revoked",
2562            Self::SchedulerPromoted => "scheduler_promoted",
2563            Self::BillingRateCardInstalled => "billing_rate_card_installed",
2564            Self::EdgeRewritten => "edge_rewritten",
2565        }
2566    }
2567}
2568
2569impl fmt::Display for AuditEventKind {
2570    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2571        f.write_str(self.as_str())
2572    }
2573}
2574
2575// ---------------------------------------------------------------------------
2576// Phase 220 — WorkloadIdentity
2577// ---------------------------------------------------------------------------
2578
2579/// Phase 220 canonical workload identity. The shared shape used by
2580/// scheduler admission/audit, attestation, and Kubernetes/DRA
2581/// integration. No phase invents a parallel identity record.
2582///
2583/// Field semantics per `docs/design/217-222-cross-phase-invariants.md`
2584/// "Workload Identity Mapping":
2585///
2586/// | Canonical field | scheduler/grafOS         | Kubernetes DRA           |
2587/// | --------------- | ------------------------ | ------------------------ |
2588/// | tenant          | tenant id / org id       | namespace or namespace→tenant map |
2589/// | namespace_scope | project / service / run scope | Kubernetes namespace |
2590/// | service         | grafOS service / program identity | ServiceAccount name+UID |
2591/// | instance_id     | run id / tasklet id / lease holder | Pod UID + claim name |
2592/// | attestation     | TEE/runtime attestation record | optional pod attestation |
2593/// | policy_gen      | scheduler policy generation | DeviceClass/namespace gen |
2594///
2595/// The struct fields use `Option<String>` for the slots that don't
2596/// always apply (attestation, namespace_scope), so a caller emitting
2597/// an audit event for a non-tenant operation (e.g. operator drain)
2598/// can build the record cleanly. `tenant` is the only required field;
2599/// if there is no tenant context, use a sentinel like `"_system"`.
2600///
2601/// This type is deliberately simple — no signing, no validation. It
2602/// is the shape; correctness of any specific instance is the caller's
2603/// responsibility (and is asserted by the layers that compose it,
2604/// e.g. the DRA driver's TokenReview path validates the namespace and
2605/// service before constructing a `WorkloadIdentity`).
2606#[derive(Clone, Debug, PartialEq, Eq, Hash)]
2607#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
2608pub struct WorkloadIdentity {
2609    /// Tenant / org identifier. Required.
2610    pub tenant: String,
2611    /// Workload namespace, project, or run scope. Optional for
2612    /// operations that have no scope (e.g. operator-driven drain).
2613    pub namespace_scope: Option<String>,
2614    /// Service identity — grafOS program identity or Kubernetes
2615    /// ServiceAccount.
2616    pub service: Option<String>,
2617    /// Workload instance — run id, tasklet id, lease holder, or Pod UID.
2618    pub instance_id: Option<String>,
2619    /// Attestation evidence record when present (TEE quote, vTPM, etc.).
2620    /// Opaque to this type; consumers parse per their attestation root.
2621    pub attestation: Option<Vec<u8>>,
2622    /// Policy generation that admitted / authorized this identity.
2623    /// `None` means "no policy gen recorded"; concrete callers should
2624    /// fill this in for any decision worth auditing.
2625    pub policy_gen: Option<u64>,
2626}
2627
2628impl WorkloadIdentity {
2629    /// System identity used for emits with no tenant context (e.g.
2630    /// daemon-restart `chain_anchored` events). Distinct from any
2631    /// real tenant by the `_system` prefix.
2632    pub fn system() -> Self {
2633        Self {
2634            tenant: "_system".to_string(),
2635            namespace_scope: None,
2636            service: None,
2637            instance_id: None,
2638            attestation: None,
2639            policy_gen: None,
2640        }
2641    }
2642
2643    /// Minimal tenant-scoped identity. Convenience for tests; real
2644    /// emit paths should populate the optional fields.
2645    pub fn tenant_only(tenant: impl Into<String>) -> Self {
2646        Self {
2647            tenant: tenant.into(),
2648            namespace_scope: None,
2649            service: None,
2650            instance_id: None,
2651            attestation: None,
2652            policy_gen: None,
2653        }
2654    }
2655}
2656
2657// ---------------------------------------------------------------------------
2658// Tests
2659// ---------------------------------------------------------------------------
2660
2661#[cfg(test)]
2662mod tests {
2663    use super::*;
2664
2665    #[test]
2666    fn preemption_reason_strings_are_stable() {
2667        // External collectors group on these strings — changing one
2668        // is a vocabulary breaking change. The expected values here
2669        // match docs/design/218-tenant-policy-and-lifecycle.md.
2670        assert_eq!(
2671            PreemptionReason::PriorityPreemption.as_str(),
2672            "priority_preemption"
2673        );
2674        assert_eq!(PreemptionReason::QuotaRebalance.as_str(), "quota_rebalance");
2675        assert_eq!(
2676            PreemptionReason::BurstCreditExhausted.as_str(),
2677            "burst_credit_exhausted"
2678        );
2679        assert_eq!(
2680            PreemptionReason::BudgetExhausted.as_str(),
2681            "budget_exhausted"
2682        );
2683        assert_eq!(
2684            PreemptionReason::CostCapEviction.as_str(),
2685            "cost_cap_eviction"
2686        );
2687        assert_eq!(PreemptionReason::OperatorDrain.as_str(), "operator_drain");
2688        assert_eq!(
2689            PreemptionReason::OperatorMigProfileChange.as_str(),
2690            "operator_mig_profile_change"
2691        );
2692        assert_eq!(
2693            PreemptionReason::MaintenanceWindow.as_str(),
2694            "maintenance_window"
2695        );
2696        assert_eq!(
2697            PreemptionReason::PolicyViolationRecovery.as_str(),
2698            "policy_violation_recovery"
2699        );
2700    }
2701
2702    /// Phases 218-222 slice 73 — `PreemptionReason::human_summary()`
2703    /// returns a stable operator-readable string per variant.
2704    /// Pinning each by value means a future CLI / dashboard
2705    /// surface can rely on the prose without re-deriving it; it
2706    /// also catches an accidental rename that would shift what
2707    /// operators see.
2708    #[test]
2709    fn preemption_reason_human_summary_is_stable() {
2710        assert_eq!(
2711            PreemptionReason::PriorityPreemption.human_summary(),
2712            "reclaimed for higher-priority work that outranked it"
2713        );
2714        assert_eq!(
2715            PreemptionReason::QuotaRebalance.human_summary(),
2716            "reclaimed because tenant fair-share moved a different tenant in"
2717        );
2718        assert_eq!(
2719            PreemptionReason::BurstCreditExhausted.human_summary(),
2720            "reclaimed because the tenant's burst-bucket envelope was exceeded"
2721        );
2722        assert_eq!(
2723            PreemptionReason::BudgetExhausted.human_summary(),
2724            "reclaimed because the tenant's spend budget was exhausted"
2725        );
2726        assert_eq!(
2727            PreemptionReason::CostCapEviction.human_summary(),
2728            "reclaimed because the global cost cap could no longer be satisfied"
2729        );
2730        assert_eq!(
2731            PreemptionReason::OperatorDrain.human_summary(),
2732            "reclaimed because an operator drained the host node"
2733        );
2734        assert_eq!(
2735            PreemptionReason::OperatorMigProfileChange.human_summary(),
2736            "reclaimed because an operator recomposed the GPU MIG profile"
2737        );
2738        assert_eq!(
2739            PreemptionReason::MaintenanceWindow.human_summary(),
2740            "reclaimed by a scheduled maintenance window"
2741        );
2742        assert_eq!(
2743            PreemptionReason::PolicyViolationRecovery.human_summary(),
2744            "reclaimed because the workload no longer satisfied a mandatory policy"
2745        );
2746    }
2747
2748    /// Every variant returns a non-empty summary. A blank
2749    /// summary would render as a hole in tooltip UI without
2750    /// surfacing the missing prose anywhere structured.
2751    #[test]
2752    fn preemption_reason_human_summary_is_non_empty() {
2753        for reason in [
2754            PreemptionReason::PriorityPreemption,
2755            PreemptionReason::QuotaRebalance,
2756            PreemptionReason::BurstCreditExhausted,
2757            PreemptionReason::BudgetExhausted,
2758            PreemptionReason::CostCapEviction,
2759            PreemptionReason::OperatorDrain,
2760            PreemptionReason::OperatorMigProfileChange,
2761            PreemptionReason::MaintenanceWindow,
2762            PreemptionReason::PolicyViolationRecovery,
2763        ] {
2764            assert!(
2765                !reason.human_summary().is_empty(),
2766                "{reason:?}: human_summary must be non-empty"
2767            );
2768        }
2769    }
2770
2771    /// Load-bearing phrase pins. The summary text isn't fully
2772    /// stable in wording (we may tighten the prose), but the
2773    /// noun naming the cause is what makes the tooltip useful —
2774    /// "operator" for drain, "burst" for burst-credit, "policy"
2775    /// for policy-violation, etc. A future copy-edit that drops
2776    /// the load-bearing noun changes the meaning, not just the
2777    /// phrasing, and should fail the test.
2778    #[test]
2779    fn preemption_reason_human_summary_load_bearing_phrases() {
2780        assert!(
2781            PreemptionReason::OperatorDrain
2782                .human_summary()
2783                .contains("operator"),
2784            "OperatorDrain.human_summary must mention \"operator\""
2785        );
2786        assert!(
2787            PreemptionReason::OperatorMigProfileChange
2788                .human_summary()
2789                .contains("operator"),
2790            "OperatorMigProfileChange.human_summary must mention \"operator\""
2791        );
2792        assert!(
2793            PreemptionReason::OperatorMigProfileChange
2794                .human_summary()
2795                .contains("MIG"),
2796            "OperatorMigProfileChange.human_summary must mention \"MIG\""
2797        );
2798        assert!(
2799            PreemptionReason::BurstCreditExhausted
2800                .human_summary()
2801                .contains("burst"),
2802            "BurstCreditExhausted.human_summary must mention \"burst\""
2803        );
2804        assert!(
2805            PreemptionReason::BudgetExhausted
2806                .human_summary()
2807                .contains("budget"),
2808            "BudgetExhausted.human_summary must mention \"budget\""
2809        );
2810        assert!(
2811            PreemptionReason::CostCapEviction
2812                .human_summary()
2813                .contains("cost cap"),
2814            "CostCapEviction.human_summary must mention \"cost cap\""
2815        );
2816        assert!(
2817            PreemptionReason::MaintenanceWindow
2818                .human_summary()
2819                .contains("maintenance"),
2820            "MaintenanceWindow.human_summary must mention \"maintenance\""
2821        );
2822        assert!(
2823            PreemptionReason::PolicyViolationRecovery
2824                .human_summary()
2825                .contains("policy"),
2826            "PolicyViolationRecovery.human_summary must mention \"policy\""
2827        );
2828        assert!(
2829            PreemptionReason::PriorityPreemption
2830                .human_summary()
2831                .contains("higher-priority"),
2832            "PriorityPreemption.human_summary must mention \"higher-priority\""
2833        );
2834        assert!(
2835            PreemptionReason::QuotaRebalance
2836                .human_summary()
2837                .contains("fair-share"),
2838            "QuotaRebalance.human_summary must mention \"fair-share\""
2839        );
2840    }
2841
2842    /// The 9-variant closed set must yield 9 distinct summaries.
2843    /// Two variants sharing one summary would lose meaning at
2844    /// the tooltip boundary; the audit chain still distinguishes
2845    /// them via `as_str()`, but operator-facing surfaces would
2846    /// not. This pins the closed set and the distinctness.
2847    #[test]
2848    fn preemption_reason_human_summary_count_and_distinct() {
2849        let summaries = [
2850            PreemptionReason::PriorityPreemption.human_summary(),
2851            PreemptionReason::QuotaRebalance.human_summary(),
2852            PreemptionReason::BurstCreditExhausted.human_summary(),
2853            PreemptionReason::BudgetExhausted.human_summary(),
2854            PreemptionReason::CostCapEviction.human_summary(),
2855            PreemptionReason::OperatorDrain.human_summary(),
2856            PreemptionReason::OperatorMigProfileChange.human_summary(),
2857            PreemptionReason::MaintenanceWindow.human_summary(),
2858            PreemptionReason::PolicyViolationRecovery.human_summary(),
2859        ];
2860        assert_eq!(summaries.len(), 9, "closed-set size pinned at 9 variants");
2861        for (i, a) in summaries.iter().enumerate() {
2862            for (j, b) in summaries.iter().enumerate() {
2863                if i != j {
2864                    assert_ne!(
2865                        a, b,
2866                        "human_summary collision between variant {i} and {j}: {a:?}"
2867                    );
2868                }
2869            }
2870        }
2871    }
2872
2873    /// `as_str()` and `human_summary()` are intentionally
2874    /// distinct surfaces. `as_str()` is the wire-format-grade
2875    /// snake_case label SIEM rules alert off; `human_summary()`
2876    /// is the operator-facing rendering helper. A consumer that
2877    /// confuses the two would surface snake_case in tooltips or
2878    /// wire prose into a SIEM rule. Pin the distinction so a
2879    /// future refactor doesn't unify them.
2880    #[test]
2881    fn preemption_reason_human_summary_differs_from_as_str() {
2882        for reason in [
2883            PreemptionReason::PriorityPreemption,
2884            PreemptionReason::QuotaRebalance,
2885            PreemptionReason::BurstCreditExhausted,
2886            PreemptionReason::BudgetExhausted,
2887            PreemptionReason::CostCapEviction,
2888            PreemptionReason::OperatorDrain,
2889            PreemptionReason::OperatorMigProfileChange,
2890            PreemptionReason::MaintenanceWindow,
2891            PreemptionReason::PolicyViolationRecovery,
2892        ] {
2893            assert_ne!(
2894                reason.as_str(),
2895                reason.human_summary(),
2896                "{reason:?}: as_str and human_summary must be different surfaces"
2897            );
2898        }
2899    }
2900
2901    #[test]
2902    fn evidence_label_ordering_matches_strength() {
2903        // The Ord derivation reflects evidence strength: stronger
2904        // labels compare greater. This is the order callers should
2905        // use when checking "is this label at least as strong as X."
2906        assert!(EvidenceLabel::DesignTarget < EvidenceLabel::LabEvidence);
2907        assert!(EvidenceLabel::LabEvidence < EvidenceLabel::StagedProviderEvidence);
2908        assert!(EvidenceLabel::StagedProviderEvidence < EvidenceLabel::DesignPartnerEvidence);
2909        assert!(EvidenceLabel::DesignPartnerEvidence < EvidenceLabel::ProductionEvidence);
2910    }
2911
2912    #[test]
2913    fn evidence_label_requires_artifact_for_anything_above_design_target() {
2914        assert!(!EvidenceLabel::DesignTarget.requires_artifact());
2915        assert!(EvidenceLabel::UnitIntegrationEvidence.requires_artifact());
2916        assert!(EvidenceLabel::LabEvidence.requires_artifact());
2917        assert!(EvidenceLabel::StagedProviderEvidence.requires_artifact());
2918        assert!(EvidenceLabel::DesignPartnerEvidence.requires_artifact());
2919        assert!(EvidenceLabel::ProductionEvidence.requires_artifact());
2920    }
2921
2922    #[test]
2923    fn audit_event_kind_strings_are_stable() {
2924        assert_eq!(
2925            AuditEventKind::CapabilityIssued.as_str(),
2926            "capability_issued"
2927        );
2928        assert_eq!(AuditEventKind::LeaseAllocated.as_str(), "lease_allocated");
2929        assert_eq!(AuditEventKind::LeaseFenced.as_str(), "lease_fenced");
2930        assert_eq!(AuditEventKind::Preempted.as_str(), "preempted");
2931        assert_eq!(AuditEventKind::ChainAnchored.as_str(), "chain_anchored");
2932        assert_eq!(
2933            AuditEventKind::SoftModeEnabled.as_str(),
2934            "soft_mode_enabled"
2935        );
2936        // Phase 218–222 / slice 77 — tenant CRUD typed kinds.
2937        // External SIEM rules / dashboard panels alert off these
2938        // exact strings; renaming a variant here is a wire-format
2939        // breaking change.
2940        assert_eq!(AuditEventKind::TenantCreated.as_str(), "tenant_created");
2941        assert_eq!(AuditEventKind::TenantDeleted.as_str(), "tenant_deleted");
2942        assert_eq!(
2943            AuditEventKind::TenantQuotaUpdated.as_str(),
2944            "tenant_quota_updated"
2945        );
2946        // Phase 218–222 / slice 79 — admin (provider/cell-identity)
2947        // typed kinds. Same external-API discipline as the slice-77
2948        // tenant kinds: dashboards and SIEM rules alert off these
2949        // exact strings.
2950        assert_eq!(
2951            AuditEventKind::ProviderConformanceRecorded.as_str(),
2952            "provider_conformance_recorded"
2953        );
2954        assert_eq!(
2955            AuditEventKind::ProviderBootstrapTokenIssued.as_str(),
2956            "provider_bootstrap_token_issued"
2957        );
2958        assert_eq!(
2959            AuditEventKind::ProviderBootstrapExchanged.as_str(),
2960            "provider_bootstrap_exchanged"
2961        );
2962        assert_eq!(
2963            AuditEventKind::ProviderCellIdentityIssued.as_str(),
2964            "provider_cell_identity_issued"
2965        );
2966        assert_eq!(
2967            AuditEventKind::ProviderCellIdentityRotated.as_str(),
2968            "provider_cell_identity_rotated"
2969        );
2970        assert_eq!(
2971            AuditEventKind::ProviderCellIdentityRevoked.as_str(),
2972            "provider_cell_identity_revoked"
2973        );
2974        // Phase 218–222 / slice 80 — admin/auth bearer-token typed
2975        // kinds. The "bearer_token_*" prefix is grep-disambiguated
2976        // from "capability_*" (lease-bound capability tokens, spec
2977        // § 4) so SIEM rules don't conflate the two vocabularies.
2978        // External wire-format-grade — rename = breaking.
2979        assert_eq!(
2980            AuditEventKind::BearerTokenIssued.as_str(),
2981            "bearer_token_issued"
2982        );
2983        assert_eq!(
2984            AuditEventKind::BearerTokenRevoked.as_str(),
2985            "bearer_token_revoked"
2986        );
2987        // Phase 218–222 / slice 81 — Stage 2 mop-up convergence
2988        // typed kinds (cell-side scheduler promote + billing rate-
2989        // card installation). Same external-API discipline: SIEM
2990        // rules / dashboard panels alert off these exact strings.
2991        assert_eq!(
2992            AuditEventKind::SchedulerPromoted.as_str(),
2993            "scheduler_promoted"
2994        );
2995        assert_eq!(
2996            AuditEventKind::BillingRateCardInstalled.as_str(),
2997            "billing_rate_card_installed"
2998        );
2999        // Slice 261 (EdgeRecord audit-chain integration arc) — kind
3000        // marker for committed rewrite-engine edge mutations. The
3001        // accompanying slice 260 `AuditEventData::EdgeRewritten`
3002        // payload carries the slice 226 canonical EdgeRecord
3003        // encoding.
3004        assert_eq!(AuditEventKind::EdgeRewritten.as_str(), "edge_rewritten");
3005    }
3006
3007    /// Phase 218–222 / slice 80 — pin that bearer-token kinds and
3008    /// capability kinds remain distinct strings. They name
3009    /// different vocabularies (HTTP Authorization bearer tokens vs
3010    /// lease-bound capability tokens, spec § 4); a future merge of
3011    /// the two would silently fold SIEM-grouping rules together
3012    /// and is a wire-format-grade regression.
3013    #[test]
3014    fn bearer_token_kinds_distinct_from_capability_kinds() {
3015        assert_ne!(
3016            AuditEventKind::BearerTokenIssued.as_str(),
3017            AuditEventKind::CapabilityIssued.as_str()
3018        );
3019        assert_ne!(
3020            AuditEventKind::BearerTokenRevoked.as_str(),
3021            AuditEventKind::CapabilityRevoked.as_str()
3022        );
3023    }
3024
3025    #[test]
3026    fn workload_identity_system_uses_underscore_prefix() {
3027        // The `_system` prefix is the convention for distinguishing
3028        // operator/system identities from real tenant identifiers.
3029        // Any future tenant-name validation MUST reject identifiers
3030        // starting with `_` so this prefix stays unambiguous.
3031        let id = WorkloadIdentity::system();
3032        assert!(id.tenant.starts_with('_'));
3033        assert_eq!(id.tenant, "_system");
3034        assert!(id.namespace_scope.is_none());
3035        assert!(id.service.is_none());
3036        assert!(id.instance_id.is_none());
3037        assert!(id.attestation.is_none());
3038        assert!(id.policy_gen.is_none());
3039    }
3040
3041    #[test]
3042    fn workload_identity_tenant_only_carries_tenant() {
3043        let id = WorkloadIdentity::tenant_only("acme");
3044        assert_eq!(id.tenant, "acme");
3045        assert!(id.namespace_scope.is_none());
3046    }
3047
3048    /// Phase 219.2 slice 33 — RejectionReason labels are wire-
3049    /// format-grade. SIEM rules and dashboard panels alert off
3050    /// these exact strings; pin every variant by value so a
3051    /// future variant rename can't silently shift the wire
3052    /// format.
3053    #[test]
3054    fn rejection_reason_labels_are_stable() {
3055        assert_eq!(
3056            RejectionReason::InsufficientCapacity.as_str(),
3057            "insufficient_capacity"
3058        );
3059        assert_eq!(
3060            RejectionReason::NoEligibleNodes.as_str(),
3061            "no_eligible_nodes"
3062        );
3063        assert_eq!(
3064            RejectionReason::QuotaHardLimitExceeded.as_str(),
3065            "quota_hard_limit_exceeded"
3066        );
3067        assert_eq!(
3068            RejectionReason::QuotaBurstExceeded.as_str(),
3069            "quota_burst_exceeded"
3070        );
3071        assert_eq!(
3072            RejectionReason::QuotaLeaseCountExceeded.as_str(),
3073            "quota_lease_count_exceeded"
3074        );
3075        assert_eq!(
3076            RejectionReason::QuotaPerNodeLimitExceeded.as_str(),
3077            "quota_per_node_limit_exceeded"
3078        );
3079        assert_eq!(RejectionReason::TenantNotFound.as_str(), "tenant_not_found");
3080        assert_eq!(RejectionReason::NodeFenced.as_str(), "node_fenced");
3081        assert_eq!(
3082            RejectionReason::BudgetExhausted.as_str(),
3083            "budget_exhausted"
3084        );
3085        assert_eq!(
3086            RejectionReason::ReservationExhausted.as_str(),
3087            "reservation_exhausted"
3088        );
3089        // Phase 219.2 slice 36 additions.
3090        assert_eq!(
3091            RejectionReason::PlacementPolicyExcluded.as_str(),
3092            "placement_policy_excluded"
3093        );
3094        assert_eq!(
3095            RejectionReason::NodeAddressUnknown.as_str(),
3096            "node_address_unknown"
3097        );
3098        // Phase 218 slice 68 addition.
3099        assert_eq!(RejectionReason::NodeDrained.as_str(), "node_drained");
3100        // Phase 218.5 slice 118 bundle-admission fold additions.
3101        assert_eq!(RejectionReason::EmptyBundle.as_str(), "empty_bundle");
3102        assert_eq!(
3103            RejectionReason::InsufficientBundleCapacity.as_str(),
3104            "insufficient_bundle_capacity"
3105        );
3106        assert_eq!(
3107            RejectionReason::BundleConstraintViolation.as_str(),
3108            "bundle_constraint_violation"
3109        );
3110        assert_eq!(
3111            RejectionReason::HardwareGenerationUnavailable.as_str(),
3112            "hardware_generation_unavailable"
3113        );
3114        assert_eq!(
3115            RejectionReason::OtherBundleFailure.as_str(),
3116            "other_bundle_failure"
3117        );
3118    }
3119
3120    /// Phase 220.2 slice 46 — EgressEnforcement labels are
3121    /// wire-format-grade. Pin every variant by value.
3122    #[test]
3123    fn egress_enforcement_labels_are_stable() {
3124        assert_eq!(
3125            EgressEnforcement::FabricEnforced.as_str(),
3126            "fabric_enforced"
3127        );
3128        assert_eq!(
3129            EgressEnforcement::HostRuntimeIntegration.as_str(),
3130            "host_runtime_integration"
3131        );
3132        assert_eq!(
3133            EgressEnforcement::OperatorControlled.as_str(),
3134            "operator_controlled"
3135        );
3136        assert_eq!(EgressEnforcement::Unsupported.as_str(), "unsupported");
3137    }
3138
3139    /// EgressTarget labels are stable.
3140    #[test]
3141    fn egress_target_labels_are_stable() {
3142        assert_eq!(EgressTarget::WasmTasklet.as_str(), "wasm_tasklet");
3143        assert_eq!(EgressTarget::NativeProgram.as_str(), "native_program");
3144        assert_eq!(
3145            EgressTarget::ContainerAdjacent.as_str(),
3146            "container_adjacent"
3147        );
3148        assert_eq!(EgressTarget::KubernetesDra.as_str(), "kubernetes_dra");
3149        assert_eq!(EgressTarget::BareMetal.as_str(), "bare_metal");
3150    }
3151
3152    /// The target → enforcement mapping is the canonical
3153    /// matrix the scheduler consults. Pinning every entry by
3154    /// value means a future change to the matrix fails this
3155    /// test until the docs and any consumers are updated in
3156    /// the same change.
3157    #[test]
3158    fn egress_target_to_enforcement_matrix() {
3159        assert_eq!(
3160            EgressTarget::WasmTasklet.enforcement(),
3161            EgressEnforcement::FabricEnforced,
3162            "wasm tasklet must be fabric-enforced (deny-all under wasmi)"
3163        );
3164        assert_eq!(
3165            EgressTarget::NativeProgram.enforcement(),
3166            EgressEnforcement::OperatorControlled,
3167            "native program egress is operator-controlled (kernel owns NIC)"
3168        );
3169        assert_eq!(
3170            EgressTarget::ContainerAdjacent.enforcement(),
3171            EgressEnforcement::OperatorControlled,
3172            "container-adjacent egress is operator-controlled (container runtime)"
3173        );
3174        assert_eq!(
3175            EgressTarget::KubernetesDra.enforcement(),
3176            EgressEnforcement::OperatorControlled,
3177            "Kubernetes DRA egress is operator-controlled (NetworkPolicy / CNI)"
3178        );
3179        assert_eq!(
3180            EgressTarget::BareMetal.enforcement(),
3181            EgressEnforcement::HostRuntimeIntegration,
3182            "bare-metal default is host-runtime-integration; HCL may override"
3183        );
3184    }
3185
3186    /// Display impl matches as_str on both enums.
3187    #[test]
3188    fn egress_display_matches_as_str() {
3189        assert_eq!(
3190            format!("{}", EgressEnforcement::FabricEnforced),
3191            "fabric_enforced"
3192        );
3193        assert_eq!(format!("{}", EgressTarget::WasmTasklet), "wasm_tasklet");
3194    }
3195
3196    /// Phase 216 slice 53 — EconomicsSource labels are
3197    /// wire-format-grade. Pin every variant by value.
3198    #[test]
3199    fn economics_source_labels_are_stable() {
3200        assert_eq!(
3201            EconomicsSource::OperatorStaticConfig.as_str(),
3202            "operator_static_config"
3203        );
3204        assert_eq!(
3205            EconomicsSource::ProviderPriceSheet.as_str(),
3206            "provider_price_sheet"
3207        );
3208        assert_eq!(
3209            EconomicsSource::ProviderUsageExport.as_str(),
3210            "provider_usage_export"
3211        );
3212        assert_eq!(
3213            EconomicsSource::MeasuredPowerTelemetry.as_str(),
3214            "measured_power_telemetry"
3215        );
3216        assert_eq!(
3217            EconomicsSource::ThirdPartyCarbonFeed.as_str(),
3218            "third_party_carbon_feed"
3219        );
3220        assert_eq!(
3221            EconomicsSource::DerivedEstimate.as_str(),
3222            "derived_estimate"
3223        );
3224    }
3225
3226    /// ObservationConfidence labels are stable.
3227    #[test]
3228    fn observation_confidence_labels_are_stable() {
3229        assert_eq!(ObservationConfidence::Low.as_str(), "low");
3230        assert_eq!(ObservationConfidence::Medium.as_str(), "medium");
3231        assert_eq!(ObservationConfidence::High.as_str(), "high");
3232    }
3233
3234    /// ObservationConfidence is ordinal — Low < Medium <
3235    /// High. Filter queries `obs.confidence >= floor` rely
3236    /// on this; pin the contract.
3237    #[test]
3238    fn observation_confidence_is_ordinal() {
3239        assert!(ObservationConfidence::Low < ObservationConfidence::Medium);
3240        assert!(ObservationConfidence::Medium < ObservationConfidence::High);
3241        assert!(ObservationConfidence::Low < ObservationConfidence::High);
3242        // Filter idiom.
3243        let floor = ObservationConfidence::Medium;
3244        let high = ObservationConfidence::High;
3245        let low = ObservationConfidence::Low;
3246        assert!(high >= floor);
3247        assert!(!(low >= floor));
3248    }
3249
3250    /// EconomicsGeneration is monotonic; UNANCHORED is the
3251    /// starting sentinel; next() advances by one and saturates.
3252    #[test]
3253    fn economics_generation_is_monotonic() {
3254        assert_eq!(EconomicsGeneration::UNANCHORED.0, 0);
3255        let g1 = EconomicsGeneration::UNANCHORED.next();
3256        assert_eq!(g1.0, 1);
3257        let g2 = g1.next();
3258        assert_eq!(g2.0, 2);
3259        assert!(g2 > g1);
3260        assert!(g1 > EconomicsGeneration::UNANCHORED);
3261        // Saturates at u64::MAX.
3262        let max = EconomicsGeneration(u64::MAX);
3263        assert_eq!(max.next().0, u64::MAX);
3264    }
3265
3266    /// Display impls round-trip through as_str() for the
3267    /// enums; EconomicsGeneration uses the gen=N format.
3268    #[test]
3269    fn economics_display_matches_as_str() {
3270        assert_eq!(
3271            format!("{}", EconomicsSource::OperatorStaticConfig),
3272            "operator_static_config"
3273        );
3274        assert_eq!(format!("{}", ObservationConfidence::High), "high");
3275        assert_eq!(format!("{}", EconomicsGeneration(42)), "gen=42");
3276    }
3277
3278    /// Phase 216 slice 59 — EconomicsObservation freshness
3279    /// window: `observed_at <= now < valid_until`.
3280    /// Boundary check: `now == observed_at` is fresh,
3281    /// `now == valid_until` is stale.
3282    #[test]
3283    fn economics_observation_freshness_boundaries() {
3284        let obs = EconomicsObservation {
3285            value: 42u32,
3286            source: EconomicsSource::OperatorStaticConfig,
3287            confidence: ObservationConfidence::Medium,
3288            observed_at: 100,
3289            valid_until: 200,
3290            generation: EconomicsGeneration(1),
3291        };
3292        // Below window: stale.
3293        assert!(!obs.is_fresh(99));
3294        // Lower edge inclusive: fresh.
3295        assert!(obs.is_fresh(100));
3296        // Mid window: fresh.
3297        assert!(obs.is_fresh(150));
3298        // Upper edge exclusive: stale.
3299        assert!(!obs.is_fresh(200));
3300        // Above window: stale.
3301        assert!(!obs.is_fresh(201));
3302    }
3303
3304    /// Confidence floor uses the slice-53 ordinal contract.
3305    /// A High observation meets a Medium floor; a Low does
3306    /// not.
3307    #[test]
3308    fn economics_observation_confidence_floor() {
3309        let high = EconomicsObservation {
3310            value: 1u32,
3311            source: EconomicsSource::MeasuredPowerTelemetry,
3312            confidence: ObservationConfidence::High,
3313            observed_at: 0,
3314            valid_until: u64::MAX,
3315            generation: EconomicsGeneration(1),
3316        };
3317        let low = EconomicsObservation {
3318            confidence: ObservationConfidence::Low,
3319            ..high.clone()
3320        };
3321        let medium = EconomicsObservation {
3322            confidence: ObservationConfidence::Medium,
3323            ..high.clone()
3324        };
3325
3326        assert!(high.meets_floor(ObservationConfidence::Low));
3327        assert!(high.meets_floor(ObservationConfidence::Medium));
3328        assert!(high.meets_floor(ObservationConfidence::High));
3329
3330        assert!(medium.meets_floor(ObservationConfidence::Low));
3331        assert!(medium.meets_floor(ObservationConfidence::Medium));
3332        assert!(!medium.meets_floor(ObservationConfidence::High));
3333
3334        assert!(low.meets_floor(ObservationConfidence::Low));
3335        assert!(!low.meets_floor(ObservationConfidence::Medium));
3336        assert!(!low.meets_floor(ObservationConfidence::High));
3337    }
3338
3339    /// `admissible` combines freshness AND confidence floor.
3340    /// A scheduler doing a required-decision admission can
3341    /// rely on this single call rather than reimplementing
3342    /// the rule in every consumer.
3343    #[test]
3344    fn economics_observation_admissible_combines_freshness_and_confidence() {
3345        let obs = EconomicsObservation {
3346            value: 1u32,
3347            source: EconomicsSource::ProviderUsageExport,
3348            confidence: ObservationConfidence::Medium,
3349            observed_at: 100,
3350            valid_until: 200,
3351            generation: EconomicsGeneration(7),
3352        };
3353        // Fresh + meets floor → admissible.
3354        assert!(obs.admissible(150, ObservationConfidence::Medium));
3355        assert!(obs.admissible(150, ObservationConfidence::Low));
3356        // Fresh but below floor → NOT admissible.
3357        assert!(!obs.admissible(150, ObservationConfidence::High));
3358        // Stale (now >= valid_until) but meets floor → NOT admissible.
3359        assert!(!obs.admissible(200, ObservationConfidence::Medium));
3360        assert!(!obs.admissible(201, ObservationConfidence::Medium));
3361        // Stale AND below floor → NOT admissible.
3362        assert!(!obs.admissible(200, ObservationConfidence::High));
3363    }
3364
3365    /// EconomicsObservation is generic over its value type.
3366    /// Pin that contract by constructing one over a struct
3367    /// type — a future cost-model integration can wrap any
3368    /// typed payload without new envelope code.
3369    #[test]
3370    fn economics_observation_is_generic_over_value_type() {
3371        #[derive(Clone, Debug, PartialEq, Eq)]
3372        struct CarbonGrams {
3373            grams_co2e_per_kwh: u64,
3374        }
3375        let obs = EconomicsObservation {
3376            value: CarbonGrams {
3377                grams_co2e_per_kwh: 250,
3378            },
3379            source: EconomicsSource::ThirdPartyCarbonFeed,
3380            confidence: ObservationConfidence::Medium,
3381            observed_at: 1_700_000_000,
3382            valid_until: 1_700_003_600,
3383            generation: EconomicsGeneration(1),
3384        };
3385        assert_eq!(obs.value.grams_co2e_per_kwh, 250);
3386        assert!(obs.admissible(1_700_001_000, ObservationConfidence::Medium));
3387    }
3388
3389    /// Display impl is the same as `as_str()`.
3390    #[test]
3391    fn rejection_reason_display_matches_as_str() {
3392        assert_eq!(
3393            format!("{}", RejectionReason::InsufficientCapacity),
3394            "insufficient_capacity"
3395        );
3396        // Phase 218 slice 68 — Display pin for the new variant.
3397        assert_eq!(format!("{}", RejectionReason::NodeDrained), "node_drained");
3398    }
3399
3400    /// Phase 219.2 slice 37 — `human_summary()` returns a
3401    /// stable operator-readable string per variant. Pinning
3402    /// each by value means a CLI / dashboard rendering test
3403    /// can rely on the prose without re-deriving it; it also
3404    /// catches an accidental rename that would shift what
3405    /// operators see.
3406    #[test]
3407    fn rejection_reason_human_summary_is_stable() {
3408        assert_eq!(
3409            RejectionReason::InsufficientCapacity.human_summary(),
3410            "insufficient free capacity across all eligible nodes"
3411        );
3412        assert_eq!(
3413            RejectionReason::NoEligibleNodes.human_summary(),
3414            "no nodes match the placement constraint"
3415        );
3416        assert_eq!(
3417            RejectionReason::QuotaHardLimitExceeded.human_summary(),
3418            "tenant quota hard limit reached"
3419        );
3420        assert_eq!(
3421            RejectionReason::QuotaBurstExceeded.human_summary(),
3422            "tenant burst quota cap reached"
3423        );
3424        assert_eq!(
3425            RejectionReason::QuotaLeaseCountExceeded.human_summary(),
3426            "tenant lease count limit reached"
3427        );
3428        assert_eq!(
3429            RejectionReason::QuotaPerNodeLimitExceeded.human_summary(),
3430            "tenant per-node capacity limit reached"
3431        );
3432        assert_eq!(
3433            RejectionReason::TenantNotFound.human_summary(),
3434            "tenant id not registered with the scheduler"
3435        );
3436        assert_eq!(
3437            RejectionReason::NodeFenced.human_summary(),
3438            "selected node is fenced for the requested resource type"
3439        );
3440        assert_eq!(
3441            RejectionReason::BudgetExhausted.human_summary(),
3442            "power or cost budget cap reached"
3443        );
3444        assert_eq!(
3445            RejectionReason::ReservationExhausted.human_summary(),
3446            "reservation has insufficient remaining capacity"
3447        );
3448        assert_eq!(
3449            RejectionReason::PlacementPolicyExcluded.human_summary(),
3450            "placement policy excluded this candidate"
3451        );
3452        assert_eq!(
3453            RejectionReason::NodeAddressUnknown.human_summary(),
3454            "scheduler does not have a control-plane address for this node"
3455        );
3456        assert_eq!(
3457            RejectionReason::NodeDrained.human_summary(),
3458            "target node is drained for maintenance; admission resumes after the node returns to accepting mode"
3459        );
3460        assert_eq!(
3461            RejectionReason::EmptyBundle.human_summary(),
3462            "bundle had zero requirements; nothing to admit"
3463        );
3464        assert_eq!(
3465            RejectionReason::InsufficientBundleCapacity.human_summary(),
3466            "insufficient capacity for at least one bundle requirement"
3467        );
3468        assert_eq!(
3469            RejectionReason::BundleConstraintViolation.human_summary(),
3470            "bundle placement constraint excludes every viable candidate plan"
3471        );
3472        assert_eq!(
3473            RejectionReason::HardwareGenerationUnavailable.human_summary(),
3474            "requested bundle hardware generation has no matching inventory"
3475        );
3476        assert_eq!(
3477            RejectionReason::OtherBundleFailure.human_summary(),
3478            "other bundle admission failure"
3479        );
3480    }
3481
3482    /// Phase 218 slice 62 — `FairShareWindow` accepts a
3483    /// well-formed descriptor: min<=max, weight>0, window>0.
3484    #[test]
3485    fn fair_share_window_valid_accepts_well_formed() {
3486        let w = FairShareWindow {
3487            tenant_id: 0x0000_0000_0000_0000_0000_0000_0000_00ac,
3488            priority_class_weight: 100,
3489            window_secs: 600,
3490            min_share_secs: 60,
3491            max_share_secs: 300,
3492            generation: EconomicsGeneration::UNANCHORED.next(),
3493        };
3494        assert!(w.is_valid());
3495    }
3496
3497    /// `min_share_secs > max_share_secs` is rejected. The
3498    /// scheduler treats a typed reject as fail-closed; the
3499    /// validator never panics.
3500    #[test]
3501    fn fair_share_window_invalid_rejects_min_above_max() {
3502        let w = FairShareWindow {
3503            tenant_id: 1,
3504            priority_class_weight: 50,
3505            window_secs: 600,
3506            min_share_secs: 400,
3507            max_share_secs: 300,
3508            generation: EconomicsGeneration::UNANCHORED,
3509        };
3510        assert!(!w.is_valid());
3511    }
3512
3513    /// `priority_class_weight == 0` is rejected. A zero
3514    /// weight has no meaningful share-fraction conversion.
3515    #[test]
3516    fn fair_share_window_invalid_rejects_zero_weight() {
3517        let w = FairShareWindow {
3518            tenant_id: 1,
3519            priority_class_weight: 0,
3520            window_secs: 600,
3521            min_share_secs: 60,
3522            max_share_secs: 300,
3523            generation: EconomicsGeneration::UNANCHORED,
3524        };
3525        assert!(!w.is_valid());
3526    }
3527
3528    /// `window_secs == 0` is rejected. A zero-length window
3529    /// has no rolling-window semantics.
3530    #[test]
3531    fn fair_share_window_invalid_rejects_zero_window() {
3532        let w = FairShareWindow {
3533            tenant_id: 1,
3534            priority_class_weight: 100,
3535            window_secs: 0,
3536            min_share_secs: 0,
3537            max_share_secs: 0,
3538            generation: EconomicsGeneration::UNANCHORED,
3539        };
3540        assert!(!w.is_valid());
3541    }
3542
3543    /// Serde round-trip — the typed envelope must serialize
3544    /// and deserialize losslessly so a committed fair-share
3545    /// table on disk reads back identically.
3546    #[cfg(feature = "serde")]
3547    #[test]
3548    fn fair_share_window_serde_round_trip() {
3549        let w = FairShareWindow {
3550            tenant_id: 0xdead_beef_cafe_f00d_1234_5678_9abc_def0,
3551            priority_class_weight: 250,
3552            window_secs: 3600,
3553            min_share_secs: 120,
3554            max_share_secs: 1800,
3555            generation: EconomicsGeneration(7),
3556        };
3557        let json = serde_json::to_string(&w).expect("serialize");
3558        let back: FairShareWindow = serde_json::from_str(&json).expect("deserialize");
3559        assert_eq!(w, back);
3560    }
3561
3562    /// Phase 218.1 slice 145 — fair-share scopes distinguish tenant,
3563    /// project, and priority-class policy without inventing three
3564    /// unrelated table shapes.
3565    #[test]
3566    fn fair_share_scope_kind_labels_are_stable() {
3567        assert_eq!(FairShareScope::Tenant { tenant_id: 7 }.kind(), "tenant");
3568        assert_eq!(FairShareScope::Project { project_id: 11 }.kind(), "project");
3569        assert_eq!(
3570            FairShareScope::PriorityClass {
3571                priority: Priority::Guaranteed
3572            }
3573            .kind(),
3574            "priority_class"
3575        );
3576    }
3577
3578    /// A weighted fair-share table accepts positive weights, a nonzero
3579    /// rolling window, and one entry per scope.
3580    #[test]
3581    fn weighted_fair_share_policy_accepts_tenant_project_and_priority_scopes() {
3582        let policy = WeightedFairSharePolicy {
3583            window_secs: 3600,
3584            generation: EconomicsGeneration(9),
3585            entries: vec![
3586                FairShareWeight {
3587                    scope: FairShareScope::Tenant { tenant_id: 1 },
3588                    weight: 100,
3589                    min_share_secs: 60,
3590                    max_share_secs: 1800,
3591                },
3592                FairShareWeight {
3593                    scope: FairShareScope::Project { project_id: 22 },
3594                    weight: 50,
3595                    min_share_secs: 0,
3596                    max_share_secs: 900,
3597                },
3598                FairShareWeight {
3599                    scope: FairShareScope::PriorityClass {
3600                        priority: Priority::Scavenger,
3601                    },
3602                    weight: 10,
3603                    min_share_secs: 0,
3604                    max_share_secs: 300,
3605                },
3606            ],
3607        };
3608
3609        assert!(policy.is_valid());
3610        assert_eq!(policy.total_weight(), 160);
3611    }
3612
3613    /// Duplicate scopes are rejected instead of using last-writer-wins.
3614    /// Fair-share policy must be reproducible from the committed table.
3615    #[test]
3616    fn weighted_fair_share_policy_rejects_duplicate_scope() {
3617        let scope = FairShareScope::Tenant { tenant_id: 1 };
3618        let policy = WeightedFairSharePolicy {
3619            window_secs: 3600,
3620            generation: EconomicsGeneration(9),
3621            entries: vec![
3622                FairShareWeight {
3623                    scope,
3624                    weight: 100,
3625                    min_share_secs: 0,
3626                    max_share_secs: 100,
3627                },
3628                FairShareWeight {
3629                    scope,
3630                    weight: 200,
3631                    min_share_secs: 0,
3632                    max_share_secs: 200,
3633                },
3634            ],
3635        };
3636
3637        assert!(!policy.is_valid());
3638    }
3639
3640    /// Invalid weights and windows fail closed through validation
3641    /// rather than silently creating a zero-share entry.
3642    #[test]
3643    fn weighted_fair_share_policy_rejects_zero_window_zero_weight_and_bad_bounds() {
3644        let zero_window = WeightedFairSharePolicy {
3645            window_secs: 0,
3646            generation: EconomicsGeneration(1),
3647            entries: vec![FairShareWeight {
3648                scope: FairShareScope::Tenant { tenant_id: 1 },
3649                weight: 100,
3650                min_share_secs: 0,
3651                max_share_secs: 100,
3652            }],
3653        };
3654        assert!(!zero_window.is_valid());
3655
3656        let zero_weight = WeightedFairSharePolicy {
3657            window_secs: 60,
3658            generation: EconomicsGeneration(1),
3659            entries: vec![FairShareWeight {
3660                scope: FairShareScope::Tenant { tenant_id: 1 },
3661                weight: 0,
3662                min_share_secs: 0,
3663                max_share_secs: 100,
3664            }],
3665        };
3666        assert!(!zero_weight.is_valid());
3667
3668        let bad_bounds = WeightedFairSharePolicy {
3669            window_secs: 60,
3670            generation: EconomicsGeneration(1),
3671            entries: vec![FairShareWeight {
3672                scope: FairShareScope::Tenant { tenant_id: 1 },
3673                weight: 100,
3674                min_share_secs: 101,
3675                max_share_secs: 100,
3676            }],
3677        };
3678        assert!(!bad_bounds.is_valid());
3679    }
3680
3681    /// Serde round-trip pins the committed table shape before the
3682    /// scheduler starts persisting it.
3683    #[cfg(feature = "serde")]
3684    #[test]
3685    fn weighted_fair_share_policy_serde_round_trip() {
3686        let policy = WeightedFairSharePolicy {
3687            window_secs: 300,
3688            generation: EconomicsGeneration(12),
3689            entries: vec![
3690                FairShareWeight {
3691                    scope: FairShareScope::Tenant { tenant_id: 99 },
3692                    weight: 100,
3693                    min_share_secs: 10,
3694                    max_share_secs: 200,
3695                },
3696                FairShareWeight {
3697                    scope: FairShareScope::PriorityClass {
3698                        priority: Priority::Guaranteed,
3699                    },
3700                    weight: 300,
3701                    min_share_secs: 100,
3702                    max_share_secs: 300,
3703                },
3704            ],
3705        };
3706
3707        let json = serde_json::to_string(&policy).expect("serialize");
3708        let back: WeightedFairSharePolicy =
3709            serde_json::from_str(&json).expect("deserialize fair-share policy");
3710        assert_eq!(policy, back);
3711    }
3712
3713    /// Phase 218.2 slice 85 — `Priority::as_str()` snake_case
3714    /// labels are wire-format-grade. SIEM rules and dashboard
3715    /// panels (and the audit-chain priority markers) alert off
3716    /// these exact strings. Pin every variant by value.
3717    #[test]
3718    fn priority_as_str_is_stable() {
3719        assert_eq!(Priority::Scavenger.as_str(), "scavenger");
3720        assert_eq!(Priority::Standard.as_str(), "standard");
3721        assert_eq!(Priority::Guaranteed.as_str(), "guaranteed");
3722    }
3723
3724    /// `human_summary()` is the CLI / dashboard rendering
3725    /// surface. Pinning each by value means a renderer can
3726    /// rely on the prose without re-deriving it; it also
3727    /// catches an accidental rename that would shift what
3728    /// operators see.
3729    #[test]
3730    fn priority_human_summary_is_stable() {
3731        assert_eq!(
3732            Priority::Scavenger.human_summary(),
3733            "uses leftover capacity; preempted first when contention rises"
3734        );
3735        assert_eq!(
3736            Priority::Standard.human_summary(),
3737            "uses unreserved capacity; preemptible by guaranteed work"
3738        );
3739        assert_eq!(
3740            Priority::Guaranteed.human_summary(),
3741            "reserved capacity; never preempted by ordinary scheduling"
3742        );
3743    }
3744
3745    /// `human_summary()` carries load-bearing nouns — the
3746    /// reader should be able to tell "what does this priority
3747    /// class mean for preemption" from a glance. Pin the
3748    /// content phrases so a future rewrite that lost the
3749    /// preemption story would fail this lint.
3750    #[test]
3751    fn priority_human_summary_load_bearing_phrases() {
3752        assert!(
3753            Priority::Scavenger.human_summary().contains("preempt"),
3754            "Scavenger summary should mention preemption"
3755        );
3756        assert!(
3757            Priority::Standard.human_summary().contains("preempt"),
3758            "Standard summary should mention preemption"
3759        );
3760        assert!(
3761            Priority::Guaranteed.human_summary().contains("never"),
3762            "Guaranteed summary should mention 'never' (no preemption)"
3763        );
3764    }
3765
3766    /// Three variants, three labels, three summaries — all
3767    /// distinct. A regression where two variants collapsed to
3768    /// the same string would silently merge dashboard rows or
3769    /// SIEM filters; pin the count + distinctness invariant.
3770    #[test]
3771    fn priority_count_and_distinct() {
3772        let all = [
3773            Priority::Scavenger,
3774            Priority::Standard,
3775            Priority::Guaranteed,
3776        ];
3777        assert_eq!(all.len(), 3, "Priority must have exactly 3 variants");
3778
3779        // as_str() values are pairwise distinct.
3780        let labels: Vec<&str> = all.iter().map(|p| p.as_str()).collect();
3781        let mut sorted = labels.clone();
3782        sorted.sort_unstable();
3783        sorted.dedup();
3784        assert_eq!(sorted.len(), labels.len(), "as_str labels must be distinct");
3785
3786        // human_summary() values are pairwise distinct.
3787        let summaries: Vec<&str> = all.iter().map(|p| p.human_summary()).collect();
3788        let mut sorted_s = summaries.clone();
3789        sorted_s.sort_unstable();
3790        sorted_s.dedup();
3791        assert_eq!(
3792            sorted_s.len(),
3793            summaries.len(),
3794            "human_summary strings must be distinct"
3795        );
3796    }
3797
3798    /// Ordering invariant — `Guaranteed > Standard > Scavenger`.
3799    /// Slice 85 relocated the enum from grafos-scheduler/tenant.rs;
3800    /// this pin asserts the move preserved the Ord behavior the
3801    /// scheduler depends on (preemption.rs, victim selection,
3802    /// admission ordering).
3803    #[test]
3804    fn priority_ordering_is_stable() {
3805        assert!(Priority::Guaranteed > Priority::Standard);
3806        assert!(Priority::Standard > Priority::Scavenger);
3807        assert!(Priority::Guaranteed > Priority::Scavenger);
3808
3809        let mut prios = [
3810            Priority::Standard,
3811            Priority::Guaranteed,
3812            Priority::Scavenger,
3813        ];
3814        prios.sort();
3815        assert_eq!(
3816            prios,
3817            [
3818                Priority::Scavenger,
3819                Priority::Standard,
3820                Priority::Guaranteed
3821            ]
3822        );
3823    }
3824
3825    /// `as_str()` and `human_summary()` are intentionally
3826    /// distinct surfaces. SIEM rules alert off `as_str()`;
3827    /// `human_summary()` renders to operators. A consumer that
3828    /// confuses them would surface snake_case to operators or
3829    /// wire prose into a SIEM rule.
3830    #[test]
3831    fn priority_human_summary_differs_from_as_str() {
3832        for p in [
3833            Priority::Scavenger,
3834            Priority::Standard,
3835            Priority::Guaranteed,
3836        ] {
3837            assert_ne!(
3838                p.as_str(),
3839                p.human_summary(),
3840                "{p:?}: as_str and human_summary must be different surfaces"
3841            );
3842        }
3843    }
3844
3845    /// Display impl matches as_str (matches the convention used
3846    /// by the rest of the typed-vocab enums).
3847    #[test]
3848    fn priority_display_matches_as_str() {
3849        assert_eq!(format!("{}", Priority::Scavenger), "scavenger");
3850        assert_eq!(format!("{}", Priority::Standard), "standard");
3851        assert_eq!(format!("{}", Priority::Guaranteed), "guaranteed");
3852    }
3853
3854    /// Phase 218.2 slice 87 — `Priority::from_str` accepts every
3855    /// canonical snake_case label and round-trips through
3856    /// `as_str()`. The CLI/dashboard migration depends on this:
3857    /// operators type the label, the CLI parses to `Priority`,
3858    /// then re-emits via `as_str()` on the wire. A regression
3859    /// where a label parsed to the wrong variant would shift
3860    /// admission/preemption policy invisibly.
3861    #[test]
3862    fn priority_from_str_canonical_labels() {
3863        use core::str::FromStr;
3864        for (label, expected) in [
3865            ("scavenger", Priority::Scavenger),
3866            ("standard", Priority::Standard),
3867            ("guaranteed", Priority::Guaranteed),
3868        ] {
3869            let got = Priority::from_str(label)
3870                .unwrap_or_else(|e| panic!("from_str({label}) failed for canonical label: {e}"));
3871            assert_eq!(got, expected, "label {label} mapped to wrong variant");
3872            assert_eq!(
3873                got.as_str(),
3874                label,
3875                "round-trip from {label} must yield {label}"
3876            );
3877        }
3878    }
3879
3880    /// Phase 218.2 slice 87 — `best_effort` is the documented
3881    /// deprecation alias. The Phase 48.5 hygiene rename moved
3882    /// the variant to `Standard`; the CLI/dashboard surfaces
3883    /// kept emitting `best_effort` until slice 87. The alias
3884    /// stays accepted so existing operator scripts and dashboard
3885    /// cookies don't fail closed during the migration window.
3886    /// Pin the alias mapping so a refactor that drops the alias
3887    /// must do so deliberately (and the carry-over in
3888    /// TODO.md Phase 218.2 stays the gate for that change).
3889    #[test]
3890    fn priority_from_str_accepts_best_effort_deprecation_alias() {
3891        use core::str::FromStr;
3892        assert_eq!(
3893            Priority::from_str("best_effort").expect("alias must parse"),
3894            Priority::Standard,
3895            "best_effort is the deprecation alias for Standard"
3896        );
3897    }
3898
3899    /// Phase 218.2 slice 87 — unknown labels return a typed
3900    /// `ParsePriorityError` carrying the offending input. The
3901    /// error message names the canonical set so a clap
3902    /// value_parser surface or a dashboard validator can echo
3903    /// the operator's typo back with guidance. Fail-closed at
3904    /// parse time prevents a free-form `priority` string from
3905    /// reaching the wire — SIEM rules alert off the typed
3906    /// `as_str()` values, and a `--priority bogus` invocation
3907    /// that quietly fell through would shift those rules.
3908    #[test]
3909    fn priority_from_str_unknown_label_fails_closed() {
3910        use core::str::FromStr;
3911        let err = Priority::from_str("bogus").expect_err("unknown label must fail");
3912        assert_eq!(err.0, "bogus", "error must carry the offending input");
3913        let msg = format!("{err}");
3914        assert!(
3915            msg.contains("bogus"),
3916            "error message must echo the offending input: {msg}"
3917        );
3918        assert!(
3919            msg.contains("scavenger") && msg.contains("standard") && msg.contains("guaranteed"),
3920            "error message must list the canonical set: {msg}"
3921        );
3922    }
3923
3924    /// Phase 218.2 slice 87 — only the one named historical
3925    /// spelling (`best_effort`) is accepted. Variants like
3926    /// `besteffort`, `Best Effort`, `BEST_EFFORT`, or empty
3927    /// string fail closed — the alias is not a free-form
3928    /// loose-match shim, just back-compat for one specific
3929    /// legacy literal.
3930    #[test]
3931    fn priority_from_str_does_not_loose_match_alias_variants() {
3932        use core::str::FromStr;
3933        for bad in [
3934            "besteffort",
3935            "Best Effort",
3936            "BEST_EFFORT",
3937            "",
3938            "Standard",
3939            "STANDARD",
3940        ] {
3941            assert!(
3942                Priority::from_str(bad).is_err(),
3943                "from_str must reject loose-match {bad:?}"
3944            );
3945        }
3946    }
3947
3948    /// `human_summary()` and `as_str()` are intentionally
3949    /// distinct surfaces. `as_str()` is the wire-format-grade
3950    /// label SIEM rules alert off; `human_summary()` is the
3951    /// CLI / dashboard rendering helper. A consumer that
3952    /// confuses the two would surface snake_case to operators
3953    /// or wire `human_summary()` text into a SIEM rule. Pin
3954    /// the distinction so a future refactor doesn't unify
3955    /// them.
3956    #[test]
3957    fn rejection_reason_human_summary_differs_from_as_str() {
3958        for reason in [
3959            RejectionReason::InsufficientCapacity,
3960            RejectionReason::NoEligibleNodes,
3961            RejectionReason::QuotaHardLimitExceeded,
3962            RejectionReason::QuotaBurstExceeded,
3963            RejectionReason::QuotaLeaseCountExceeded,
3964            RejectionReason::QuotaPerNodeLimitExceeded,
3965            RejectionReason::TenantNotFound,
3966            RejectionReason::NodeFenced,
3967            RejectionReason::BudgetExhausted,
3968            RejectionReason::ReservationExhausted,
3969            RejectionReason::PlacementPolicyExcluded,
3970            RejectionReason::NodeAddressUnknown,
3971            RejectionReason::NodeDrained,
3972            RejectionReason::EmptyBundle,
3973            RejectionReason::InsufficientBundleCapacity,
3974            RejectionReason::BundleConstraintViolation,
3975            RejectionReason::HardwareGenerationUnavailable,
3976            RejectionReason::OtherBundleFailure,
3977        ] {
3978            assert_ne!(
3979                reason.as_str(),
3980                reason.human_summary(),
3981                "{reason:?}: as_str and human_summary must be different surfaces"
3982            );
3983        }
3984    }
3985
3986    // -----------------------------------------------------------------------
3987    // Phase 218.3 slice 86 — RevokeState pin tests
3988    // -----------------------------------------------------------------------
3989
3990    /// Phase 218.3 slice 86 — `RevokeState::as_str()` snake_case
3991    /// labels are wire-format-grade. SIEM rules, dashboard panel
3992    /// selectors, and audit-chain markers alert off these exact
3993    /// strings. Pin every variant by value so a future variant
3994    /// rename can't silently shift the wire format.
3995    #[test]
3996    fn revoke_state_as_str_is_stable() {
3997        assert_eq!(RevokeState::Active.as_str(), "active");
3998        assert_eq!(RevokeState::RevokeWarning.as_str(), "revoke_warning");
3999        assert_eq!(RevokeState::GraceRunning.as_str(), "grace_running");
4000        assert_eq!(
4001            RevokeState::CheckpointReported.as_str(),
4002            "checkpoint_reported"
4003        );
4004        assert_eq!(RevokeState::ForcedTeardown.as_str(), "forced_teardown");
4005        assert_eq!(RevokeState::Torndown.as_str(), "torndown");
4006        assert_eq!(RevokeState::Expired.as_str(), "expired");
4007        assert_eq!(RevokeState::Fenced.as_str(), "fenced");
4008        assert_eq!(RevokeState::FailedClosed.as_str(), "failed_closed");
4009    }
4010
4011    /// `human_summary()` is the CLI / dashboard rendering surface.
4012    /// Pinning each by value means a renderer can rely on the prose
4013    /// without re-deriving it; it also catches an accidental rename
4014    /// that would shift what operators see.
4015    #[test]
4016    fn revoke_state_human_summary_is_stable() {
4017        assert_eq!(
4018            RevokeState::Active.human_summary(),
4019            "lease is active; no revoke pending"
4020        );
4021        assert_eq!(
4022            RevokeState::RevokeWarning.human_summary(),
4023            "revoke initiated; workload has been notified"
4024        );
4025        assert_eq!(
4026            RevokeState::GraceRunning.human_summary(),
4027            "grace period running; workload may checkpoint"
4028        );
4029        assert_eq!(
4030            RevokeState::CheckpointReported.human_summary(),
4031            "workload reported checkpoint; cooperative teardown"
4032        );
4033        assert_eq!(
4034            RevokeState::ForcedTeardown.human_summary(),
4035            "forced teardown in progress; no grace or grace exceeded"
4036        );
4037        assert_eq!(
4038            RevokeState::Torndown.human_summary(),
4039            "teardown complete; lease released"
4040        );
4041        assert_eq!(
4042            RevokeState::Expired.human_summary(),
4043            "lease TTL aged out without revoke"
4044        );
4045        assert_eq!(
4046            RevokeState::Fenced.human_summary(),
4047            "teardown failed; resource fenced for forensic clearing"
4048        );
4049        assert_eq!(
4050            RevokeState::FailedClosed.human_summary(),
4051            "fail-closed terminal; lease invariant violated"
4052        );
4053    }
4054
4055    /// `human_summary()` carries load-bearing nouns — the reader
4056    /// should be able to tell what the lifecycle position means
4057    /// from a glance. Pin the content phrases so a future rewrite
4058    /// that lost the lifecycle story would fail this lint.
4059    #[test]
4060    fn revoke_state_human_summary_load_bearing_phrases() {
4061        assert!(
4062            RevokeState::Active.human_summary().contains("active"),
4063            "Active summary should mention 'active'"
4064        );
4065        assert!(
4066            RevokeState::RevokeWarning
4067                .human_summary()
4068                .contains("revoke"),
4069            "RevokeWarning summary should mention 'revoke'"
4070        );
4071        assert!(
4072            RevokeState::GraceRunning.human_summary().contains("grace"),
4073            "GraceRunning summary should mention 'grace'"
4074        );
4075        assert!(
4076            RevokeState::CheckpointReported
4077                .human_summary()
4078                .contains("checkpoint"),
4079            "CheckpointReported summary should mention 'checkpoint'"
4080        );
4081        assert!(
4082            RevokeState::ForcedTeardown
4083                .human_summary()
4084                .contains("forced"),
4085            "ForcedTeardown summary should mention 'forced'"
4086        );
4087        assert!(
4088            RevokeState::Torndown.human_summary().contains("teardown"),
4089            "Torndown summary should mention 'teardown'"
4090        );
4091        assert!(
4092            RevokeState::Expired.human_summary().contains("TTL"),
4093            "Expired summary should mention 'TTL'"
4094        );
4095        assert!(
4096            RevokeState::Fenced.human_summary().contains("fenced"),
4097            "Fenced summary should mention 'fenced'"
4098        );
4099        assert!(
4100            RevokeState::FailedClosed
4101                .human_summary()
4102                .contains("invariant"),
4103            "FailedClosed summary should mention 'invariant'"
4104        );
4105    }
4106
4107    /// Nine variants, nine labels, nine summaries — all distinct.
4108    /// A regression where two variants collapsed to the same string
4109    /// would silently merge dashboard rows or SIEM filters; pin the
4110    /// count + distinctness invariant. Also asserts that `as_str()`
4111    /// values are pairwise disjoint from `human_summary()` values
4112    /// across all variants — a single combined label set must have
4113    /// no collisions, so a confusion between the two surfaces would
4114    /// be caught at build time rather than alert-time.
4115    #[test]
4116    fn revoke_state_count_and_distinct() {
4117        let all = [
4118            RevokeState::Active,
4119            RevokeState::RevokeWarning,
4120            RevokeState::GraceRunning,
4121            RevokeState::CheckpointReported,
4122            RevokeState::ForcedTeardown,
4123            RevokeState::Torndown,
4124            RevokeState::Expired,
4125            RevokeState::Fenced,
4126            RevokeState::FailedClosed,
4127        ];
4128        assert_eq!(all.len(), 9, "RevokeState must have exactly 9 variants");
4129
4130        // as_str() values are pairwise distinct.
4131        let labels: Vec<&str> = all.iter().map(|s| s.as_str()).collect();
4132        let mut sorted = labels.clone();
4133        sorted.sort_unstable();
4134        sorted.dedup();
4135        assert_eq!(sorted.len(), labels.len(), "as_str labels must be distinct");
4136
4137        // human_summary() values are pairwise distinct.
4138        let summaries: Vec<&str> = all.iter().map(|s| s.human_summary()).collect();
4139        let mut sorted_s = summaries.clone();
4140        sorted_s.sort_unstable();
4141        sorted_s.dedup();
4142        assert_eq!(
4143            sorted_s.len(),
4144            summaries.len(),
4145            "human_summary strings must be distinct"
4146        );
4147
4148        // Combined: no as_str() value collides with any
4149        // human_summary() value. Catches a copy-paste regression
4150        // where one surface accidentally aliased the other.
4151        let mut combined: Vec<&str> = labels
4152            .iter()
4153            .copied()
4154            .chain(summaries.iter().copied())
4155            .collect();
4156        combined.sort_unstable();
4157        let before = combined.len();
4158        combined.dedup();
4159        assert_eq!(
4160            combined.len(),
4161            before,
4162            "as_str and human_summary surfaces must not share any string"
4163        );
4164    }
4165
4166    /// Terminal-state set: Torndown / Fenced / FailedClosed /
4167    /// Expired report `is_terminal() == true`; the other five
4168    /// report `false`. Pinning the set means a downstream observer
4169    /// gating on terminal status (drop the lease handle, finalize
4170    /// audit emit, settle billing) stays correct under variant
4171    /// renames.
4172    #[test]
4173    fn revoke_state_terminal_set() {
4174        // Terminals.
4175        assert!(RevokeState::Torndown.is_terminal());
4176        assert!(RevokeState::Fenced.is_terminal());
4177        assert!(RevokeState::FailedClosed.is_terminal());
4178        assert!(RevokeState::Expired.is_terminal());
4179
4180        // Non-terminals.
4181        assert!(!RevokeState::Active.is_terminal());
4182        assert!(!RevokeState::RevokeWarning.is_terminal());
4183        assert!(!RevokeState::GraceRunning.is_terminal());
4184        assert!(!RevokeState::CheckpointReported.is_terminal());
4185        assert!(!RevokeState::ForcedTeardown.is_terminal());
4186    }
4187
4188    /// Legal-transition set: pin every spec arrow plus the two
4189    /// teardown-failure arrows and the FailedClosed
4190    /// invariant-violation sink. Asserting illegal transitions are
4191    /// rejected catches a regression that would silently widen the
4192    /// state machine.
4193    #[test]
4194    fn revoke_state_legal_transitions() {
4195        use RevokeState::*;
4196
4197        // Spec arrows from `active`.
4198        assert!(Active.legal_transition_to(RevokeWarning));
4199        assert!(Active.legal_transition_to(ForcedTeardown));
4200        assert!(Active.legal_transition_to(Expired));
4201        assert!(Active.legal_transition_to(Fenced));
4202
4203        // Spec interior arrows.
4204        assert!(RevokeWarning.legal_transition_to(GraceRunning));
4205        assert!(GraceRunning.legal_transition_to(CheckpointReported));
4206        assert!(CheckpointReported.legal_transition_to(Torndown));
4207        assert!(ForcedTeardown.legal_transition_to(Torndown));
4208        assert!(Expired.legal_transition_to(FailedClosed));
4209
4210        // Teardown-failure arrows (matches LeaseFenced audit-kind).
4211        assert!(CheckpointReported.legal_transition_to(Fenced));
4212        assert!(ForcedTeardown.legal_transition_to(Fenced));
4213
4214        // FailedClosed sink — reachable from any non-terminal.
4215        assert!(Active.legal_transition_to(FailedClosed));
4216        assert!(RevokeWarning.legal_transition_to(FailedClosed));
4217        assert!(GraceRunning.legal_transition_to(FailedClosed));
4218        assert!(CheckpointReported.legal_transition_to(FailedClosed));
4219        assert!(ForcedTeardown.legal_transition_to(FailedClosed));
4220
4221        // Illegal transitions — terminals don't transition out.
4222        assert!(!Torndown.legal_transition_to(Active));
4223        assert!(!Torndown.legal_transition_to(RevokeWarning));
4224        assert!(!Fenced.legal_transition_to(Active));
4225        assert!(!FailedClosed.legal_transition_to(Active));
4226
4227        // Illegal — backward / sideways transitions in the
4228        // forward path.
4229        assert!(!RevokeWarning.legal_transition_to(Active));
4230        assert!(!GraceRunning.legal_transition_to(Active));
4231        assert!(!GraceRunning.legal_transition_to(RevokeWarning));
4232        assert!(!CheckpointReported.legal_transition_to(GraceRunning));
4233        assert!(!CheckpointReported.legal_transition_to(ForcedTeardown));
4234
4235        // Illegal — `Active` cannot directly reach interior
4236        // grace / checkpoint / torndown states (must pass through
4237        // RevokeWarning -> GraceRunning, or ForcedTeardown).
4238        assert!(!Active.legal_transition_to(GraceRunning));
4239        assert!(!Active.legal_transition_to(CheckpointReported));
4240        assert!(!Active.legal_transition_to(Torndown));
4241
4242        // Illegal — Expired routes only to FailedClosed per spec,
4243        // not Torndown or Fenced.
4244        assert!(!Expired.legal_transition_to(Torndown));
4245        assert!(!Expired.legal_transition_to(Fenced));
4246        assert!(!Expired.legal_transition_to(Active));
4247    }
4248
4249    /// `as_str()` and `human_summary()` are intentionally distinct
4250    /// surfaces. SIEM rules alert off `as_str()`; `human_summary()`
4251    /// renders to operators. A consumer that confuses them would
4252    /// surface snake_case to operators or wire prose into a SIEM
4253    /// rule.
4254    #[test]
4255    fn revoke_state_human_summary_differs_from_as_str() {
4256        for state in [
4257            RevokeState::Active,
4258            RevokeState::RevokeWarning,
4259            RevokeState::GraceRunning,
4260            RevokeState::CheckpointReported,
4261            RevokeState::ForcedTeardown,
4262            RevokeState::Torndown,
4263            RevokeState::Expired,
4264            RevokeState::Fenced,
4265            RevokeState::FailedClosed,
4266        ] {
4267            assert_ne!(
4268                state.as_str(),
4269                state.human_summary(),
4270                "{state:?}: as_str and human_summary must be different surfaces"
4271            );
4272        }
4273    }
4274
4275    /// Display impl matches as_str (matches the convention used by
4276    /// the rest of the typed-vocab enums).
4277    #[test]
4278    fn revoke_state_display_matches_as_str() {
4279        assert_eq!(format!("{}", RevokeState::Active), "active");
4280        assert_eq!(format!("{}", RevokeState::RevokeWarning), "revoke_warning");
4281        assert_eq!(format!("{}", RevokeState::GraceRunning), "grace_running");
4282        assert_eq!(
4283            format!("{}", RevokeState::CheckpointReported),
4284            "checkpoint_reported"
4285        );
4286        assert_eq!(
4287            format!("{}", RevokeState::ForcedTeardown),
4288            "forced_teardown"
4289        );
4290        assert_eq!(format!("{}", RevokeState::Torndown), "torndown");
4291        assert_eq!(format!("{}", RevokeState::Expired), "expired");
4292        assert_eq!(format!("{}", RevokeState::Fenced), "fenced");
4293        assert_eq!(format!("{}", RevokeState::FailedClosed), "failed_closed");
4294    }
4295
4296    // -----------------------------------------------------------------------
4297    // Phase 218.3 slice 122 — RevokeGracePolicy pin tests
4298    // -----------------------------------------------------------------------
4299
4300    #[test]
4301    fn slice_122_hard_revoke_policy_is_default_and_has_no_deadline() {
4302        let policy = RevokeGracePolicy::default();
4303        assert_eq!(policy, RevokeGracePolicy::hard_revoke());
4304        assert!(policy.is_hard_revoke());
4305        assert!(!policy.checkpoint_hooks_allowed());
4306        assert_eq!(policy.grace_deadline_unix_secs(1_700_000_000), Ok(None));
4307    }
4308
4309    #[test]
4310    fn slice_122_bounded_grace_policy_accepts_checkpoint_hooks_with_deadline() {
4311        let policy = RevokeGracePolicy::bounded(30, 120, true).unwrap();
4312        assert!(!policy.is_hard_revoke());
4313        assert!(policy.checkpoint_hooks_allowed());
4314        assert_eq!(
4315            policy.grace_deadline_unix_secs(1_700_000_000),
4316            Ok(Some(1_700_000_030))
4317        );
4318    }
4319
4320    #[test]
4321    fn slice_122_grace_policy_rejects_grace_above_maximum() {
4322        assert_eq!(
4323            RevokeGracePolicy::bounded(121, 120, false),
4324            Err(RevokeGracePolicyError::GraceExceedsMaximum)
4325        );
4326    }
4327
4328    #[test]
4329    fn slice_122_grace_policy_rejects_checkpoint_hooks_without_grace() {
4330        assert_eq!(
4331            RevokeGracePolicy::bounded(0, 0, true),
4332            Err(RevokeGracePolicyError::CheckpointHooksRequireGrace)
4333        );
4334    }
4335
4336    #[test]
4337    fn slice_122_grace_deadline_overflow_fails_closed() {
4338        let policy = RevokeGracePolicy::bounded(30, 30, false).unwrap();
4339        assert_eq!(
4340            policy.grace_deadline_unix_secs(u64::MAX - 10),
4341            Err(RevokeGracePolicyError::DeadlineOverflow)
4342        );
4343    }
4344
4345    #[test]
4346    fn slice_122_grace_policy_error_labels_are_stable() {
4347        assert_eq!(
4348            RevokeGracePolicyError::GraceExceedsMaximum.as_str(),
4349            "grace_exceeds_maximum"
4350        );
4351        assert_eq!(
4352            RevokeGracePolicyError::CheckpointHooksRequireGrace.as_str(),
4353            "checkpoint_hooks_require_grace"
4354        );
4355        assert_eq!(
4356            RevokeGracePolicyError::DeadlineOverflow.as_str(),
4357            "deadline_overflow"
4358        );
4359
4360        for err in [
4361            RevokeGracePolicyError::GraceExceedsMaximum,
4362            RevokeGracePolicyError::CheckpointHooksRequireGrace,
4363            RevokeGracePolicyError::DeadlineOverflow,
4364        ] {
4365            assert_eq!(format!("{err}"), err.as_str());
4366            assert_ne!(err.as_str(), err.human_summary());
4367        }
4368    }
4369
4370    // -----------------------------------------------------------------------
4371    // Phase 218.2 slice 88 — Preemptibility pin tests
4372    // -----------------------------------------------------------------------
4373
4374    /// Phase 218.2 slice 88 — `Preemptibility::as_str()` snake_case
4375    /// labels are wire-format-grade. SIEM rules and dashboard panels
4376    /// alert off these exact strings. Pin every variant by value.
4377    #[test]
4378    fn preemptibility_as_str_is_stable() {
4379        assert_eq!(Preemptibility::Preemptible.as_str(), "preemptible");
4380        assert_eq!(Preemptibility::Protected.as_str(), "protected");
4381    }
4382
4383    /// `human_summary()` is the CLI / dashboard rendering surface.
4384    /// Pin each by value so a renderer can rely on the prose without
4385    /// re-deriving it.
4386    #[test]
4387    fn preemptibility_human_summary_is_stable() {
4388        assert_eq!(
4389            Preemptibility::Preemptible.human_summary(),
4390            "accepts preemption per priority rules (default)"
4391        );
4392        assert_eq!(
4393            Preemptibility::Protected.human_summary(),
4394            "non-preemptible by ordinary scheduling; operator-declared"
4395        );
4396    }
4397
4398    /// Two variants, two labels, two summaries — all distinct.
4399    #[test]
4400    fn preemptibility_count_and_distinct() {
4401        let all = [Preemptibility::Preemptible, Preemptibility::Protected];
4402        assert_eq!(all.len(), 2, "Preemptibility must have exactly 2 variants");
4403
4404        let labels: Vec<&str> = all.iter().map(|p| p.as_str()).collect();
4405        let mut sorted = labels.clone();
4406        sorted.sort_unstable();
4407        sorted.dedup();
4408        assert_eq!(sorted.len(), labels.len(), "as_str labels must be distinct");
4409
4410        let summaries: Vec<&str> = all.iter().map(|p| p.human_summary()).collect();
4411        let mut sorted_s = summaries.clone();
4412        sorted_s.sort_unstable();
4413        sorted_s.dedup();
4414        assert_eq!(
4415            sorted_s.len(),
4416            summaries.len(),
4417            "human_summary strings must be distinct"
4418        );
4419    }
4420
4421    /// `allows_preemption()` is the load-bearing predicate the
4422    /// preemption manager will consult in the integration slice. Pin
4423    /// the per-variant truth value so a future variant rename can't
4424    /// silently flip the policy.
4425    #[test]
4426    fn preemptibility_allows_preemption() {
4427        assert!(Preemptibility::Preemptible.allows_preemption());
4428        assert!(!Preemptibility::Protected.allows_preemption());
4429    }
4430
4431    /// Display impl matches as_str (matches the convention used by
4432    /// the rest of the typed-vocab enums).
4433    #[test]
4434    fn preemptibility_display_matches_as_str() {
4435        assert_eq!(format!("{}", Preemptibility::Preemptible), "preemptible");
4436        assert_eq!(format!("{}", Preemptibility::Protected), "protected");
4437    }
4438
4439    /// `as_str()` and `human_summary()` are intentionally distinct
4440    /// surfaces. SIEM rules alert off `as_str()`; `human_summary()`
4441    /// renders to operators.
4442    #[test]
4443    fn preemptibility_human_summary_differs_from_as_str() {
4444        for p in [Preemptibility::Preemptible, Preemptibility::Protected] {
4445            assert_ne!(
4446                p.as_str(),
4447                p.human_summary(),
4448                "{p:?}: as_str and human_summary must be different surfaces"
4449            );
4450        }
4451    }
4452
4453    // -----------------------------------------------------------------------
4454    // Phase 218.2 slice 88 — NonPreemptibleReason pin tests
4455    // -----------------------------------------------------------------------
4456
4457    /// Phase 218.2 slice 88 — `NonPreemptibleReason::as_str()`
4458    /// snake_case labels are wire-format-grade. SIEM rules and the
4459    /// audit chain group on these exact strings. Pin every variant
4460    /// by value so a future variant rename can't silently shift the
4461    /// wire format.
4462    #[test]
4463    fn non_preemptible_reason_as_str_is_stable() {
4464        assert_eq!(
4465            NonPreemptibleReason::CheckpointInProgress.as_str(),
4466            "checkpoint_in_progress"
4467        );
4468        assert_eq!(
4469            NonPreemptibleReason::DataPlaneActive.as_str(),
4470            "data_plane_active"
4471        );
4472        assert_eq!(NonPreemptibleReason::OperatorPin.as_str(), "operator_pin");
4473        assert_eq!(
4474            NonPreemptibleReason::AttestationLocked.as_str(),
4475            "attestation_locked"
4476        );
4477    }
4478
4479    /// `human_summary()` is the CLI / dashboard rendering surface.
4480    /// Pin each by value.
4481    #[test]
4482    fn non_preemptible_reason_human_summary_is_stable() {
4483        assert_eq!(
4484            NonPreemptibleReason::CheckpointInProgress.human_summary(),
4485            "lease is mid-checkpoint; preempting would lose the checkpoint"
4486        );
4487        assert_eq!(
4488            NonPreemptibleReason::DataPlaneActive.human_summary(),
4489            "data-plane binding active; teardown requires coordination"
4490        );
4491        assert_eq!(
4492            NonPreemptibleReason::OperatorPin.human_summary(),
4493            "operator manually pinned this lease as non-preemptible"
4494        );
4495        assert_eq!(
4496            NonPreemptibleReason::AttestationLocked.human_summary(),
4497            "attestation chain requires this specific lease holder"
4498        );
4499    }
4500
4501    /// Four variants, four labels, four summaries — all distinct.
4502    #[test]
4503    fn non_preemptible_reason_count_and_distinct() {
4504        let all = [
4505            NonPreemptibleReason::CheckpointInProgress,
4506            NonPreemptibleReason::DataPlaneActive,
4507            NonPreemptibleReason::OperatorPin,
4508            NonPreemptibleReason::AttestationLocked,
4509        ];
4510        assert_eq!(
4511            all.len(),
4512            4,
4513            "NonPreemptibleReason must have exactly 4 variants"
4514        );
4515
4516        let labels: Vec<&str> = all.iter().map(|r| r.as_str()).collect();
4517        let mut sorted = labels.clone();
4518        sorted.sort_unstable();
4519        sorted.dedup();
4520        assert_eq!(sorted.len(), labels.len(), "as_str labels must be distinct");
4521
4522        let summaries: Vec<&str> = all.iter().map(|r| r.human_summary()).collect();
4523        let mut sorted_s = summaries.clone();
4524        sorted_s.sort_unstable();
4525        sorted_s.dedup();
4526        assert_eq!(
4527            sorted_s.len(),
4528            summaries.len(),
4529            "human_summary strings must be distinct"
4530        );
4531    }
4532
4533    /// `human_summary()` carries load-bearing nouns — the reader
4534    /// should be able to tell why a lease is non-preemptible from a
4535    /// glance. Pin the content phrases so a future rewrite that lost
4536    /// the reason would fail this lint.
4537    #[test]
4538    fn non_preemptible_reason_load_bearing_phrases() {
4539        assert!(
4540            NonPreemptibleReason::CheckpointInProgress
4541                .human_summary()
4542                .contains("checkpoint"),
4543            "CheckpointInProgress summary should mention 'checkpoint'"
4544        );
4545        assert!(
4546            NonPreemptibleReason::DataPlaneActive
4547                .human_summary()
4548                .contains("data-plane"),
4549            "DataPlaneActive summary should mention 'data-plane'"
4550        );
4551        assert!(
4552            NonPreemptibleReason::OperatorPin
4553                .human_summary()
4554                .contains("operator"),
4555            "OperatorPin summary should mention 'operator'"
4556        );
4557        assert!(
4558            NonPreemptibleReason::AttestationLocked
4559                .human_summary()
4560                .contains("attestation"),
4561            "AttestationLocked summary should mention 'attestation'"
4562        );
4563    }
4564
4565    /// Display impl matches as_str (matches the convention used by
4566    /// the rest of the typed-vocab enums).
4567    #[test]
4568    fn non_preemptible_reason_display_matches_as_str() {
4569        assert_eq!(
4570            format!("{}", NonPreemptibleReason::CheckpointInProgress),
4571            "checkpoint_in_progress"
4572        );
4573        assert_eq!(
4574            format!("{}", NonPreemptibleReason::DataPlaneActive),
4575            "data_plane_active"
4576        );
4577        assert_eq!(
4578            format!("{}", NonPreemptibleReason::OperatorPin),
4579            "operator_pin"
4580        );
4581        assert_eq!(
4582            format!("{}", NonPreemptibleReason::AttestationLocked),
4583            "attestation_locked"
4584        );
4585    }
4586
4587    /// `as_str()` and `human_summary()` are intentionally distinct
4588    /// surfaces. SIEM rules alert off `as_str()`; `human_summary()`
4589    /// renders to operators.
4590    #[test]
4591    fn non_preemptible_reason_human_summary_differs_from_as_str() {
4592        for r in [
4593            NonPreemptibleReason::CheckpointInProgress,
4594            NonPreemptibleReason::DataPlaneActive,
4595            NonPreemptibleReason::OperatorPin,
4596            NonPreemptibleReason::AttestationLocked,
4597        ] {
4598            assert_ne!(
4599                r.as_str(),
4600                r.human_summary(),
4601                "{r:?}: as_str and human_summary must be different surfaces"
4602            );
4603        }
4604    }
4605
4606    // -----------------------------------------------------------------------
4607    // Phase 218.5 slice 106 — ResourceBundleSpec primitive pin tests
4608    // -----------------------------------------------------------------------
4609
4610    /// Slice 106 — `HardwareGeneration::as_str()` snake_case labels are
4611    /// wire-format-grade. SIEM rules and dashboard panel selectors
4612    /// alert off these exact strings. Pin every variant by value.
4613    #[test]
4614    fn hardware_generation_as_str_is_stable() {
4615        assert_eq!(HardwareGeneration::NvidiaAmpere.as_str(), "nvidia_ampere");
4616        assert_eq!(HardwareGeneration::NvidiaHopper.as_str(), "nvidia_hopper");
4617        assert_eq!(
4618            HardwareGeneration::NvidiaBlackwell.as_str(),
4619            "nvidia_blackwell"
4620        );
4621        assert_eq!(HardwareGeneration::AmdMi300.as_str(), "amd_mi300");
4622        assert_eq!(HardwareGeneration::AmdMi325.as_str(), "amd_mi325");
4623        assert_eq!(HardwareGeneration::IntelGaudi.as_str(), "intel_gaudi");
4624        assert_eq!(HardwareGeneration::Other.as_str(), "other");
4625    }
4626
4627    /// `human_summary()` is the CLI / dashboard rendering surface.
4628    /// Pin each by value so a renderer can rely on the prose without
4629    /// re-deriving it.
4630    #[test]
4631    fn hardware_generation_human_summary_is_stable() {
4632        assert_eq!(
4633            HardwareGeneration::NvidiaAmpere.human_summary(),
4634            "NVIDIA Ampere (A100, A30, A40)"
4635        );
4636        assert_eq!(
4637            HardwareGeneration::NvidiaHopper.human_summary(),
4638            "NVIDIA Hopper (H100, H200)"
4639        );
4640        assert_eq!(
4641            HardwareGeneration::NvidiaBlackwell.human_summary(),
4642            "NVIDIA Blackwell (B200, GB200)"
4643        );
4644        assert_eq!(
4645            HardwareGeneration::AmdMi300.human_summary(),
4646            "AMD CDNA3 (MI300, MI300X, MI300A)"
4647        );
4648        assert_eq!(
4649            HardwareGeneration::AmdMi325.human_summary(),
4650            "AMD CDNA4 (MI325X and newer)"
4651        );
4652        assert_eq!(
4653            HardwareGeneration::IntelGaudi.human_summary(),
4654            "Intel Gaudi 2 / Gaudi 3"
4655        );
4656        assert_eq!(
4657            HardwareGeneration::Other.human_summary(),
4658            "other / generic accelerator family"
4659        );
4660    }
4661
4662    /// Seven variants, seven labels, seven summaries — all distinct.
4663    #[test]
4664    fn hardware_generation_count_and_distinct() {
4665        let all = [
4666            HardwareGeneration::NvidiaAmpere,
4667            HardwareGeneration::NvidiaHopper,
4668            HardwareGeneration::NvidiaBlackwell,
4669            HardwareGeneration::AmdMi300,
4670            HardwareGeneration::AmdMi325,
4671            HardwareGeneration::IntelGaudi,
4672            HardwareGeneration::Other,
4673        ];
4674        assert_eq!(
4675            all.len(),
4676            7,
4677            "HardwareGeneration must have exactly 7 variants"
4678        );
4679
4680        let labels: Vec<&str> = all.iter().map(|g| g.as_str()).collect();
4681        let mut sorted = labels.clone();
4682        sorted.sort_unstable();
4683        sorted.dedup();
4684        assert_eq!(sorted.len(), labels.len(), "as_str labels must be distinct");
4685
4686        let summaries: Vec<&str> = all.iter().map(|g| g.human_summary()).collect();
4687        let mut sorted_s = summaries.clone();
4688        sorted_s.sort_unstable();
4689        sorted_s.dedup();
4690        assert_eq!(
4691            sorted_s.len(),
4692            summaries.len(),
4693            "human_summary strings must be distinct"
4694        );
4695    }
4696
4697    /// Each generation's summary carries the load-bearing accelerator
4698    /// noun (Ampere / Hopper / Blackwell / MI300 / MI325 / Gaudi).
4699    /// A future rewrite that lost the noun would fail this lint.
4700    #[test]
4701    fn hardware_generation_load_bearing_phrases() {
4702        assert!(
4703            HardwareGeneration::NvidiaAmpere
4704                .human_summary()
4705                .contains("Ampere"),
4706            "NvidiaAmpere summary should mention 'Ampere'"
4707        );
4708        assert!(
4709            HardwareGeneration::NvidiaHopper
4710                .human_summary()
4711                .contains("Hopper"),
4712            "NvidiaHopper summary should mention 'Hopper'"
4713        );
4714        assert!(
4715            HardwareGeneration::NvidiaBlackwell
4716                .human_summary()
4717                .contains("Blackwell"),
4718            "NvidiaBlackwell summary should mention 'Blackwell'"
4719        );
4720        assert!(
4721            HardwareGeneration::AmdMi300
4722                .human_summary()
4723                .contains("MI300"),
4724            "AmdMi300 summary should mention 'MI300'"
4725        );
4726        assert!(
4727            HardwareGeneration::AmdMi325
4728                .human_summary()
4729                .contains("MI325"),
4730            "AmdMi325 summary should mention 'MI325'"
4731        );
4732        assert!(
4733            HardwareGeneration::IntelGaudi
4734                .human_summary()
4735                .contains("Gaudi"),
4736            "IntelGaudi summary should mention 'Gaudi'"
4737        );
4738    }
4739
4740    /// Display impl matches as_str (matches the convention used by
4741    /// the rest of the typed-vocab enums).
4742    #[test]
4743    fn hardware_generation_display_matches_as_str() {
4744        for g in [
4745            HardwareGeneration::NvidiaAmpere,
4746            HardwareGeneration::NvidiaHopper,
4747            HardwareGeneration::NvidiaBlackwell,
4748            HardwareGeneration::AmdMi300,
4749            HardwareGeneration::AmdMi325,
4750            HardwareGeneration::IntelGaudi,
4751            HardwareGeneration::Other,
4752        ] {
4753            assert_eq!(format!("{}", g), g.as_str());
4754        }
4755    }
4756
4757    #[test]
4758    fn hardware_generation_from_str_accepts_canonical_labels() {
4759        use core::str::FromStr;
4760
4761        for generation in [
4762            HardwareGeneration::NvidiaAmpere,
4763            HardwareGeneration::NvidiaHopper,
4764            HardwareGeneration::NvidiaBlackwell,
4765            HardwareGeneration::AmdMi300,
4766            HardwareGeneration::AmdMi325,
4767            HardwareGeneration::IntelGaudi,
4768            HardwareGeneration::Other,
4769        ] {
4770            assert_eq!(
4771                HardwareGeneration::from_str(generation.as_str()),
4772                Ok(generation)
4773            );
4774        }
4775    }
4776
4777    #[test]
4778    fn hardware_generation_from_str_unknown_label_fails_closed() {
4779        use core::str::FromStr;
4780
4781        let err = HardwareGeneration::from_str("nvidia-hopper").expect_err("loose label must fail");
4782        assert_eq!(err, ParseHardwareGenerationError("nvidia-hopper".into()));
4783    }
4784
4785    // -----------------------------------------------------------------------
4786    // Phase 218.1 slice 116 — QuotaSchema primitive pin tests
4787    // -----------------------------------------------------------------------
4788
4789    #[test]
4790    fn quota_schema_default_is_unlimited() {
4791        let schema = QuotaSchema::default();
4792        assert!(schema.is_unlimited());
4793        assert_eq!(
4794            schema.limit_for_resource_kind(crate::ResourceKind::Mem),
4795            None
4796        );
4797        assert_eq!(
4798            schema.limit_for_resource_kind(crate::ResourceKind::Gpu),
4799            None
4800        );
4801    }
4802
4803    #[test]
4804    fn quota_schema_maps_resource_kind_limits() {
4805        let schema = QuotaSchema {
4806            mem_bytes: Some(1024),
4807            cpu_cores: Some(8),
4808            gpu_count: Some(2),
4809            gpu_vram_bytes: Some(80 * 1024 * 1024 * 1024),
4810            gpu_count_by_generation: Vec::new(),
4811            block_bytes: Some(4096),
4812            net_bps: Some(10_000_000_000),
4813            tasklet_concurrency: Some(64),
4814            lease_create_per_minute: Some(120),
4815            burst_credits: None,
4816            fair_share: None,
4817        };
4818
4819        assert!(!schema.is_unlimited());
4820        assert_eq!(
4821            schema.limit_for_resource_kind(crate::ResourceKind::Mem),
4822            Some(1024)
4823        );
4824        assert_eq!(
4825            schema.limit_for_resource_kind(crate::ResourceKind::Cpu),
4826            Some(8)
4827        );
4828        assert_eq!(
4829            schema.limit_for_resource_kind(crate::ResourceKind::Gpu),
4830            Some(2)
4831        );
4832        assert_eq!(
4833            schema.limit_for_resource_kind(crate::ResourceKind::GpuMem),
4834            Some(80 * 1024 * 1024 * 1024)
4835        );
4836        assert_eq!(
4837            schema.limit_for_resource_kind(crate::ResourceKind::Block),
4838            Some(4096)
4839        );
4840        assert_eq!(
4841            schema.limit_for_resource_kind(crate::ResourceKind::Net),
4842            Some(10_000_000_000)
4843        );
4844        assert_eq!(
4845            schema.limit_for_resource_kind(crate::ResourceKind::Tasklet),
4846            Some(64)
4847        );
4848    }
4849
4850    #[test]
4851    fn quota_schema_gpu_generation_limits_are_typed() {
4852        let schema = QuotaSchema {
4853            gpu_count: None,
4854            gpu_count_by_generation: vec![
4855                GpuGenerationQuota {
4856                    generation: HardwareGeneration::NvidiaAmpere,
4857                    count: 2,
4858                },
4859                GpuGenerationQuota {
4860                    generation: HardwareGeneration::NvidiaHopper,
4861                    count: 4,
4862                },
4863            ],
4864            ..QuotaSchema::default()
4865        };
4866
4867        assert_eq!(schema.total_gpu_count(), Some(6));
4868        assert_eq!(
4869            schema.limit_for_resource_kind(crate::ResourceKind::Gpu),
4870            Some(6)
4871        );
4872        assert_eq!(
4873            schema.gpu_limit_for_generation(HardwareGeneration::NvidiaAmpere),
4874            Some(2)
4875        );
4876        assert_eq!(
4877            schema.gpu_limit_for_generation(HardwareGeneration::NvidiaBlackwell),
4878            None
4879        );
4880    }
4881
4882    #[test]
4883    fn quota_schema_total_gpu_count_prefers_explicit_total() {
4884        let schema = QuotaSchema {
4885            gpu_count: Some(10),
4886            gpu_count_by_generation: vec![GpuGenerationQuota {
4887                generation: HardwareGeneration::NvidiaHopper,
4888                count: 4,
4889            }],
4890            ..QuotaSchema::default()
4891        };
4892
4893        assert_eq!(schema.total_gpu_count(), Some(10));
4894    }
4895
4896    #[test]
4897    fn quota_usage_maps_resource_kind_usage() {
4898        let usage = QuotaUsage {
4899            mem_bytes: 1024,
4900            cpu_cores: 8,
4901            gpu_count: 2,
4902            gpu_vram_bytes: 80,
4903            gpu_count_by_generation: vec![GpuGenerationQuota {
4904                generation: HardwareGeneration::NvidiaHopper,
4905                count: 2,
4906            }],
4907            block_bytes: 4096,
4908            net_bps: 10_000,
4909            tasklet_concurrency: 64,
4910            lease_creates_in_window: 12,
4911        };
4912
4913        assert_eq!(
4914            usage.usage_for_resource_kind(crate::ResourceKind::Mem),
4915            1024
4916        );
4917        assert_eq!(usage.usage_for_resource_kind(crate::ResourceKind::Gpu), 2);
4918        assert_eq!(
4919            usage.usage_for_resource_kind(crate::ResourceKind::GpuMem),
4920            80
4921        );
4922        assert_eq!(
4923            usage.gpu_usage_for_generation(HardwareGeneration::NvidiaHopper),
4924            2
4925        );
4926        assert_eq!(
4927            usage.gpu_usage_for_generation(HardwareGeneration::NvidiaAmpere),
4928            0
4929        );
4930    }
4931
4932    #[test]
4933    fn burst_credit_policy_validates_progress_and_window() {
4934        assert!(BurstCreditPolicy {
4935            capacity: 100,
4936            refill_per_minute: 10,
4937            window_secs: 60,
4938        }
4939        .is_valid());
4940
4941        assert!(!BurstCreditPolicy {
4942            capacity: 0,
4943            refill_per_minute: 10,
4944            window_secs: 60,
4945        }
4946        .is_valid());
4947        assert!(!BurstCreditPolicy {
4948            capacity: 100,
4949            refill_per_minute: 0,
4950            window_secs: 60,
4951        }
4952        .is_valid());
4953        assert!(!BurstCreditPolicy {
4954            capacity: 100,
4955            refill_per_minute: 10,
4956            window_secs: 0,
4957        }
4958        .is_valid());
4959    }
4960
4961    #[test]
4962    fn quota_violation_as_str_is_stable() {
4963        assert_eq!(
4964            QuotaViolation::MemBytesExceeded {
4965                limit: 1,
4966                used: 2,
4967                requested: 3,
4968            }
4969            .as_str(),
4970            "mem_bytes_exceeded"
4971        );
4972        assert_eq!(
4973            QuotaViolation::CpuCoresExceeded {
4974                limit: 1,
4975                used: 2,
4976                requested: 3,
4977            }
4978            .as_str(),
4979            "cpu_cores_exceeded"
4980        );
4981        assert_eq!(
4982            QuotaViolation::GpuCountExceeded {
4983                generation: Some(HardwareGeneration::NvidiaHopper),
4984                limit: 1,
4985                used: 2,
4986                requested: 3,
4987            }
4988            .as_str(),
4989            "gpu_count_exceeded"
4990        );
4991        assert_eq!(
4992            QuotaViolation::GpuVramBytesExceeded {
4993                limit: 1,
4994                used: 2,
4995                requested: 3,
4996            }
4997            .as_str(),
4998            "gpu_vram_bytes_exceeded"
4999        );
5000        assert_eq!(
5001            QuotaViolation::BlockBytesExceeded {
5002                limit: 1,
5003                used: 2,
5004                requested: 3,
5005            }
5006            .as_str(),
5007            "block_bytes_exceeded"
5008        );
5009        assert_eq!(
5010            QuotaViolation::NetBpsExceeded {
5011                limit: 1,
5012                used: 2,
5013                requested: 3,
5014            }
5015            .as_str(),
5016            "net_bps_exceeded"
5017        );
5018        assert_eq!(
5019            QuotaViolation::TaskletConcurrencyExceeded {
5020                limit: 1,
5021                used: 2,
5022                requested: 3,
5023            }
5024            .as_str(),
5025            "tasklet_concurrency_exceeded"
5026        );
5027        assert_eq!(
5028            QuotaViolation::LeaseCreateRateExceeded {
5029                limit_per_minute: 1,
5030                used_in_window: 2,
5031                requested: 3,
5032            }
5033            .as_str(),
5034            "lease_create_rate_exceeded"
5035        );
5036        assert_eq!(
5037            QuotaViolation::ActiveLeaseCountExceeded {
5038                limit: 1,
5039                active: 2,
5040                requested: 1,
5041            }
5042            .as_str(),
5043            "active_lease_count_exceeded"
5044        );
5045        assert_eq!(
5046            QuotaViolation::PerNodeCapacityExceeded {
5047                limit: 1,
5048                used: 2,
5049                requested: 3,
5050            }
5051            .as_str(),
5052            "per_node_capacity_exceeded"
5053        );
5054        assert_eq!(
5055            QuotaViolation::BurstCreditExhausted {
5056                capacity: 1,
5057                remaining: 0,
5058                requested: 3,
5059            }
5060            .as_str(),
5061            "burst_credit_exhausted"
5062        );
5063        assert_eq!(
5064            QuotaViolation::FairShareExceeded {
5065                window_secs: 60,
5066                max_share_secs: 10,
5067                used_share_secs: 10,
5068                requested_share_secs: 1,
5069            }
5070            .as_str(),
5071            "fair_share_exceeded"
5072        );
5073        assert_eq!(
5074            QuotaViolation::TenantQuotaMissing.as_str(),
5075            "tenant_quota_missing"
5076        );
5077    }
5078
5079    #[test]
5080    fn quota_violation_display_matches_as_str() {
5081        let violation = QuotaViolation::MemBytesExceeded {
5082            limit: 1,
5083            used: 2,
5084            requested: 3,
5085        };
5086        assert_eq!(format!("{violation}"), violation.as_str());
5087    }
5088
5089    #[test]
5090    fn quota_violation_rejection_reason_mapping_is_stable() {
5091        assert_eq!(
5092            QuotaViolation::TenantQuotaMissing.rejection_reason(),
5093            RejectionReason::TenantNotFound
5094        );
5095        assert_eq!(
5096            QuotaViolation::BurstCreditExhausted {
5097                capacity: 10,
5098                remaining: 0,
5099                requested: 1,
5100            }
5101            .rejection_reason(),
5102            RejectionReason::QuotaBurstExceeded
5103        );
5104        assert_eq!(
5105            QuotaViolation::CpuCoresExceeded {
5106                limit: 1,
5107                used: 1,
5108                requested: 1,
5109            }
5110            .rejection_reason(),
5111            RejectionReason::QuotaHardLimitExceeded
5112        );
5113    }
5114
5115    #[cfg(feature = "serde")]
5116    #[test]
5117    fn quota_schema_serde_round_trip_uses_snake_case_fields() {
5118        let schema = QuotaSchema {
5119            mem_bytes: Some(1024),
5120            gpu_count_by_generation: vec![GpuGenerationQuota {
5121                generation: HardwareGeneration::NvidiaHopper,
5122                count: 2,
5123            }],
5124            burst_credits: Some(BurstCreditPolicy {
5125                capacity: 100,
5126                refill_per_minute: 10,
5127                window_secs: 60,
5128            }),
5129            ..QuotaSchema::default()
5130        };
5131
5132        let json = serde_json::to_string(&schema).expect("serialize quota schema");
5133        assert!(json.contains("\"mem_bytes\":1024"));
5134        assert!(json.contains("\"generation\":\"nvidia_hopper\""));
5135        assert!(json.contains("\"gpu_count_by_generation\""));
5136
5137        let back: QuotaSchema = serde_json::from_str(&json).expect("deserialize quota schema");
5138        assert_eq!(back, schema);
5139    }
5140
5141    /// Slice 106 — `BundleAtomicity::as_str()` snake_case labels are
5142    /// wire-format-grade. Pin every variant.
5143    #[test]
5144    fn bundle_atomicity_as_str_is_stable() {
5145        assert_eq!(BundleAtomicity::AllOrNothing.as_str(), "all_or_nothing");
5146        assert_eq!(BundleAtomicity::BestEffort.as_str(), "best_effort");
5147    }
5148
5149    /// `human_summary()` is the CLI / dashboard rendering surface.
5150    #[test]
5151    fn bundle_atomicity_human_summary_is_stable() {
5152        assert_eq!(
5153            BundleAtomicity::AllOrNothing.human_summary(),
5154            "all-or-nothing: every requirement granted or none"
5155        );
5156        assert_eq!(
5157            BundleAtomicity::BestEffort.human_summary(),
5158            "best-effort: grant as many requirements as fit"
5159        );
5160    }
5161
5162    /// Two variants, two labels — all distinct.
5163    #[test]
5164    fn bundle_atomicity_count_and_distinct() {
5165        let all = [BundleAtomicity::AllOrNothing, BundleAtomicity::BestEffort];
5166        assert_eq!(all.len(), 2, "BundleAtomicity must have exactly 2 variants");
5167
5168        let labels: Vec<&str> = all.iter().map(|b| b.as_str()).collect();
5169        let mut sorted = labels.clone();
5170        sorted.sort_unstable();
5171        sorted.dedup();
5172        assert_eq!(sorted.len(), labels.len(), "as_str labels must be distinct");
5173
5174        let summaries: Vec<&str> = all.iter().map(|b| b.human_summary()).collect();
5175        let mut sorted_s = summaries.clone();
5176        sorted_s.sort_unstable();
5177        sorted_s.dedup();
5178        assert_eq!(
5179            sorted_s.len(),
5180            summaries.len(),
5181            "human_summary strings must be distinct"
5182        );
5183    }
5184
5185    /// The default atomicity is `AllOrNothing` — most bundle workloads
5186    /// are meaningless under a partial grant.
5187    #[test]
5188    fn bundle_atomicity_default_is_all_or_nothing() {
5189        assert_eq!(BundleAtomicity::default(), BundleAtomicity::AllOrNothing);
5190    }
5191
5192    /// Display impl matches as_str.
5193    #[test]
5194    fn bundle_atomicity_display_matches_as_str() {
5195        assert_eq!(
5196            format!("{}", BundleAtomicity::AllOrNothing),
5197            "all_or_nothing"
5198        );
5199        assert_eq!(format!("{}", BundleAtomicity::BestEffort), "best_effort");
5200    }
5201
5202    /// Slice 106 — `BundleAdmissionFailure::as_str()` snake_case labels
5203    /// are wire-format-grade. SIEM rules and the audit chain group on
5204    /// these exact strings. Pin every variant by value.
5205    #[test]
5206    fn bundle_admission_failure_as_str_is_stable() {
5207        assert_eq!(BundleAdmissionFailure::EmptyBundle.as_str(), "empty_bundle");
5208        assert_eq!(
5209            BundleAdmissionFailure::InsufficientBundleCapacity.as_str(),
5210            "insufficient_bundle_capacity"
5211        );
5212        assert_eq!(
5213            BundleAdmissionFailure::BundleConstraintViolation.as_str(),
5214            "bundle_constraint_violation"
5215        );
5216        assert_eq!(
5217            BundleAdmissionFailure::HardwareGenerationUnavailable.as_str(),
5218            "hardware_generation_unavailable"
5219        );
5220        assert_eq!(
5221            BundleAdmissionFailure::OtherBundleFailure.as_str(),
5222            "other_bundle_failure"
5223        );
5224    }
5225
5226    /// `human_summary()` is the CLI / dashboard rendering surface.
5227    #[test]
5228    fn bundle_admission_failure_human_summary_is_stable() {
5229        assert_eq!(
5230            BundleAdmissionFailure::EmptyBundle.human_summary(),
5231            "bundle had zero requirements; nothing to admit"
5232        );
5233        assert_eq!(
5234            BundleAdmissionFailure::InsufficientBundleCapacity.human_summary(),
5235            "insufficient capacity for at least one requirement; all-or-nothing rejected"
5236        );
5237        assert_eq!(
5238            BundleAdmissionFailure::BundleConstraintViolation.human_summary(),
5239            "placement constraint excludes every candidate for at least one requirement"
5240        );
5241        assert_eq!(
5242            BundleAdmissionFailure::HardwareGenerationUnavailable.human_summary(),
5243            "requested hardware generation has no matching inventory in the cluster"
5244        );
5245        assert_eq!(
5246            BundleAdmissionFailure::OtherBundleFailure.human_summary(),
5247            "other bundle admission failure; reserved catch-all"
5248        );
5249    }
5250
5251    /// Five variants, five labels, five summaries — all distinct.
5252    #[test]
5253    fn bundle_admission_failure_count_and_distinct() {
5254        let all = [
5255            BundleAdmissionFailure::EmptyBundle,
5256            BundleAdmissionFailure::InsufficientBundleCapacity,
5257            BundleAdmissionFailure::BundleConstraintViolation,
5258            BundleAdmissionFailure::HardwareGenerationUnavailable,
5259            BundleAdmissionFailure::OtherBundleFailure,
5260        ];
5261        assert_eq!(
5262            all.len(),
5263            5,
5264            "BundleAdmissionFailure must have exactly 5 variants"
5265        );
5266
5267        let labels: Vec<&str> = all.iter().map(|r| r.as_str()).collect();
5268        let mut sorted = labels.clone();
5269        sorted.sort_unstable();
5270        sorted.dedup();
5271        assert_eq!(sorted.len(), labels.len(), "as_str labels must be distinct");
5272
5273        let summaries: Vec<&str> = all.iter().map(|r| r.human_summary()).collect();
5274        let mut sorted_s = summaries.clone();
5275        sorted_s.sort_unstable();
5276        sorted_s.dedup();
5277        assert_eq!(
5278            sorted_s.len(),
5279            summaries.len(),
5280            "human_summary strings must be distinct"
5281        );
5282    }
5283
5284    /// `human_summary()` carries load-bearing nouns so an operator
5285    /// can tell why a bundle admission failed at a glance.
5286    #[test]
5287    fn bundle_admission_failure_load_bearing_phrases() {
5288        assert!(
5289            BundleAdmissionFailure::EmptyBundle
5290                .human_summary()
5291                .contains("empty")
5292                || BundleAdmissionFailure::EmptyBundle
5293                    .human_summary()
5294                    .contains("zero"),
5295            "EmptyBundle summary should mention 'empty' or 'zero'"
5296        );
5297        assert!(
5298            BundleAdmissionFailure::InsufficientBundleCapacity
5299                .human_summary()
5300                .contains("capacity"),
5301            "InsufficientBundleCapacity summary should mention 'capacity'"
5302        );
5303        assert!(
5304            BundleAdmissionFailure::BundleConstraintViolation
5305                .human_summary()
5306                .contains("placement")
5307                || BundleAdmissionFailure::BundleConstraintViolation
5308                    .human_summary()
5309                    .contains("constraint"),
5310            "BundleConstraintViolation summary should mention 'placement' or 'constraint'"
5311        );
5312        assert!(
5313            BundleAdmissionFailure::HardwareGenerationUnavailable
5314                .human_summary()
5315                .contains("hardware generation")
5316                || BundleAdmissionFailure::HardwareGenerationUnavailable
5317                    .human_summary()
5318                    .contains("inventory"),
5319            "HardwareGenerationUnavailable summary should mention 'hardware generation' or 'inventory'"
5320        );
5321        assert!(
5322            BundleAdmissionFailure::OtherBundleFailure
5323                .human_summary()
5324                .contains("catch-all")
5325                || BundleAdmissionFailure::OtherBundleFailure
5326                    .human_summary()
5327                    .contains("other"),
5328            "OtherBundleFailure summary should mention 'catch-all' or 'other'"
5329        );
5330    }
5331
5332    /// Display impl matches as_str.
5333    #[test]
5334    fn bundle_admission_failure_display_matches_as_str() {
5335        for r in [
5336            BundleAdmissionFailure::EmptyBundle,
5337            BundleAdmissionFailure::InsufficientBundleCapacity,
5338            BundleAdmissionFailure::BundleConstraintViolation,
5339            BundleAdmissionFailure::HardwareGenerationUnavailable,
5340            BundleAdmissionFailure::OtherBundleFailure,
5341        ] {
5342            assert_eq!(format!("{}", r), r.as_str());
5343        }
5344    }
5345
5346    #[test]
5347    fn bundle_admission_failure_rejection_reason_fold_is_stable() {
5348        let cases = [
5349            (
5350                BundleAdmissionFailure::EmptyBundle,
5351                RejectionReason::EmptyBundle,
5352            ),
5353            (
5354                BundleAdmissionFailure::InsufficientBundleCapacity,
5355                RejectionReason::InsufficientBundleCapacity,
5356            ),
5357            (
5358                BundleAdmissionFailure::BundleConstraintViolation,
5359                RejectionReason::BundleConstraintViolation,
5360            ),
5361            (
5362                BundleAdmissionFailure::HardwareGenerationUnavailable,
5363                RejectionReason::HardwareGenerationUnavailable,
5364            ),
5365            (
5366                BundleAdmissionFailure::OtherBundleFailure,
5367                RejectionReason::OtherBundleFailure,
5368            ),
5369        ];
5370
5371        for (bundle_failure, rejection_reason) in cases {
5372            assert_eq!(bundle_failure.rejection_reason(), rejection_reason);
5373            assert_eq!(
5374                bundle_failure.as_str(),
5375                rejection_reason.as_str(),
5376                "bundle fold must preserve the stable admissions label"
5377            );
5378        }
5379    }
5380
5381    // -----------------------------------------------------------------------
5382    // Phase 218.5 slice 108 — BundleDecision pin tests
5383    // -----------------------------------------------------------------------
5384
5385    /// Slice 108 — `BundleDecision::as_str()` snake_case labels are
5386    /// wire-format-grade. SIEM rules and the audit chain group on
5387    /// these exact strings. Pin every variant by value.
5388    #[test]
5389    fn bundle_decision_as_str_is_stable() {
5390        assert_eq!(BundleDecision::Approved.as_str(), "approved");
5391        assert_eq!(
5392            BundleDecision::DeniedAllOrNothing.as_str(),
5393            "denied_all_or_nothing"
5394        );
5395        assert_eq!(BundleDecision::DeniedEmpty.as_str(), "denied_empty");
5396        assert_eq!(
5397            BundleDecision::PartialBestEffort.as_str(),
5398            "partial_best_effort"
5399        );
5400    }
5401
5402    /// Slice 108 — four variants. Adding a new outcome shape is
5403    /// wire-format-grade.
5404    #[test]
5405    fn bundle_decision_has_exactly_four_variants() {
5406        let all = [
5407            BundleDecision::Approved,
5408            BundleDecision::DeniedAllOrNothing,
5409            BundleDecision::DeniedEmpty,
5410            BundleDecision::PartialBestEffort,
5411        ];
5412        assert_eq!(all.len(), 4, "BundleDecision must have exactly 4 variants");
5413        let labels: Vec<&str> = all.iter().map(|d| d.as_str()).collect();
5414        let mut sorted = labels.clone();
5415        sorted.sort_unstable();
5416        sorted.dedup();
5417        assert_eq!(sorted.len(), labels.len(), "as_str labels must be distinct");
5418    }
5419
5420    /// Slice 108 — `Display` impl matches `as_str()`.
5421    #[test]
5422    fn bundle_decision_display_matches_as_str() {
5423        for d in [
5424            BundleDecision::Approved,
5425            BundleDecision::DeniedAllOrNothing,
5426            BundleDecision::DeniedEmpty,
5427            BundleDecision::PartialBestEffort,
5428        ] {
5429            assert_eq!(format!("{}", d), d.as_str());
5430        }
5431    }
5432
5433    /// Slice 108 — `human_summary()` is a distinct surface from
5434    /// `as_str()`.
5435    #[test]
5436    fn bundle_decision_human_summary_differs_from_as_str() {
5437        for d in [
5438            BundleDecision::Approved,
5439            BundleDecision::DeniedAllOrNothing,
5440            BundleDecision::DeniedEmpty,
5441            BundleDecision::PartialBestEffort,
5442        ] {
5443            assert_ne!(
5444                d.as_str(),
5445                d.human_summary(),
5446                "{d:?}: as_str and human_summary must differ"
5447            );
5448        }
5449    }
5450
5451    /// `as_str()` and `human_summary()` are intentionally distinct
5452    /// surfaces.
5453    #[test]
5454    fn bundle_admission_failure_human_summary_differs_from_as_str() {
5455        for r in [
5456            BundleAdmissionFailure::EmptyBundle,
5457            BundleAdmissionFailure::InsufficientBundleCapacity,
5458            BundleAdmissionFailure::BundleConstraintViolation,
5459            BundleAdmissionFailure::HardwareGenerationUnavailable,
5460            BundleAdmissionFailure::OtherBundleFailure,
5461        ] {
5462            assert_ne!(
5463                r.as_str(),
5464                r.human_summary(),
5465                "{r:?}: as_str and human_summary must be different surfaces"
5466            );
5467        }
5468    }
5469
5470    /// `ResourceBundleSpec::default()` is an empty `AllOrNothing`
5471    /// bundle. Pin so a future Default impl change can't shift the
5472    /// scheduler's interpretation of "default bundle".
5473    #[test]
5474    fn resource_bundle_spec_default_is_empty_all_or_nothing() {
5475        let spec = ResourceBundleSpec::default();
5476        assert!(
5477            spec.requirements.is_empty(),
5478            "Default ResourceBundleSpec must have no requirements"
5479        );
5480        assert_eq!(
5481            spec.atomicity,
5482            BundleAtomicity::AllOrNothing,
5483            "Default ResourceBundleSpec atomicity must be AllOrNothing"
5484        );
5485    }
5486
5487    /// A `ResourceRequirement` can declare an optional hardware
5488    /// generation. Pin so the field stays `Option<HardwareGeneration>`
5489    /// (a future widening to a free-form String would fail this build).
5490    #[test]
5491    fn resource_requirement_can_have_optional_hardware_generation() {
5492        // No generation constraint.
5493        let any = ResourceRequirement {
5494            kind: crate::ResourceKind::Gpu,
5495            capacity: 80 * 1024 * 1024 * 1024,
5496            hardware_generation: None,
5497        };
5498        assert!(any.hardware_generation.is_none());
5499
5500        // Pinned to a specific generation.
5501        let h100 = ResourceRequirement {
5502            kind: crate::ResourceKind::Gpu,
5503            capacity: 80 * 1024 * 1024 * 1024,
5504            hardware_generation: Some(HardwareGeneration::NvidiaHopper),
5505        };
5506        assert_eq!(
5507            h100.hardware_generation,
5508            Some(HardwareGeneration::NvidiaHopper)
5509        );
5510    }
5511
5512    /// Slice 106 — pin the canonical JSON wire shape of a
5513    /// `ResourceBundleSpec` round-trip. Field names are snake_case
5514    /// (struct fields are already snake_case in Rust); enum variants
5515    /// inside the spec serialize as snake_case thanks to the
5516    /// slice-90 `rename_all` discipline.
5517    #[cfg(feature = "serde")]
5518    #[test]
5519    fn resource_bundle_spec_round_trips_through_serde() {
5520        let spec = ResourceBundleSpec {
5521            requirements: vec![
5522                ResourceRequirement {
5523                    kind: crate::ResourceKind::Cpu,
5524                    capacity: 8,
5525                    hardware_generation: None,
5526                },
5527                ResourceRequirement {
5528                    kind: crate::ResourceKind::Gpu,
5529                    capacity: 80 * 1024 * 1024 * 1024,
5530                    hardware_generation: Some(HardwareGeneration::NvidiaHopper),
5531                },
5532            ],
5533            atomicity: BundleAtomicity::AllOrNothing,
5534        };
5535        let json = serde_json::to_string(&spec).expect("serialize");
5536        // Pin load-bearing wire substrings (snake_case discipline).
5537        assert!(json.contains("\"atomicity\":\"all_or_nothing\""), "{json}");
5538        assert!(
5539            json.contains("\"hardware_generation\":\"nvidia_hopper\""),
5540            "{json}"
5541        );
5542        assert!(json.contains("\"hardware_generation\":null"), "{json}");
5543        let back: ResourceBundleSpec = serde_json::from_str(&json).expect("deserialize");
5544        assert_eq!(back, spec);
5545    }
5546
5547    // -----------------------------------------------------------------------
5548    // Phase 218-222 slice 90 — wire-format-drift closure
5549    //
5550    // Every typed enum in this module now carries
5551    // `#[serde(rename_all = "snake_case")]` plus per-variant
5552    // `#[serde(alias = "PascalCase")]` for back-compat with pre-slice-90
5553    // records on disk. The tests below pin both directions of the migration
5554    // window so a future refactor cannot:
5555    //   - silently drop the `rename_all` (would re-introduce PascalCase
5556    //     drift), or
5557    //   - drop the per-variant alias attributes (would break legacy chain
5558    //     parsing during the migration window).
5559    // The migration window's exit criterion is tracked as a sub-bullet
5560    // under TODO.md Phase 218.2 — alias removal is a separate slice.
5561    // -----------------------------------------------------------------------
5562
5563    /// Slice 90 — every variant of every typed enum that has a
5564    /// snake_case `as_str()` serializes through serde to the SAME
5565    /// snake_case string. The `as_str()` and serde paths are now
5566    /// the single source of truth on the JSON wire.
5567    ///
5568    /// `EvidenceLabel` is excluded from this single-source pin because
5569    /// its `as_str()` returns operator-prose with spaces (intentional
5570    /// for the docs-lint surface) — its serde shape is canonical
5571    /// snake_case but it does not match `as_str()` byte-for-byte. A
5572    /// dedicated `evidence_label_serde_round_trip` test below pins
5573    /// the EvidenceLabel wire shape independently.
5574    #[cfg(feature = "serde")]
5575    #[test]
5576    fn snake_case_serde_matches_as_str_round_trip() {
5577        macro_rules! pin {
5578            ($($value:expr),* $(,)?) => {{
5579                $({
5580                    let v = $value;
5581                    let json = serde_json::to_string(&v).expect("serialize");
5582                    let expected = format!("\"{}\"", v.as_str());
5583                    assert_eq!(
5584                        json, expected,
5585                        "{:?}: serde wire shape must match as_str() (slice 90 \
5586                         single-source-of-truth)",
5587                        v
5588                    );
5589                })*
5590            }};
5591        }
5592
5593        pin!(
5594            // PreemptionReason
5595            PreemptionReason::PriorityPreemption,
5596            PreemptionReason::QuotaRebalance,
5597            PreemptionReason::BurstCreditExhausted,
5598            PreemptionReason::BudgetExhausted,
5599            PreemptionReason::CostCapEviction,
5600            PreemptionReason::OperatorDrain,
5601            PreemptionReason::OperatorMigProfileChange,
5602            PreemptionReason::MaintenanceWindow,
5603            PreemptionReason::PolicyViolationRecovery,
5604            // Priority
5605            Priority::Scavenger,
5606            Priority::Standard,
5607            Priority::Guaranteed,
5608            // RejectionReason
5609            RejectionReason::InsufficientCapacity,
5610            RejectionReason::NoEligibleNodes,
5611            RejectionReason::QuotaHardLimitExceeded,
5612            RejectionReason::QuotaBurstExceeded,
5613            RejectionReason::QuotaLeaseCountExceeded,
5614            RejectionReason::QuotaPerNodeLimitExceeded,
5615            RejectionReason::TenantNotFound,
5616            RejectionReason::NodeFenced,
5617            RejectionReason::BudgetExhausted,
5618            RejectionReason::ReservationExhausted,
5619            RejectionReason::PlacementPolicyExcluded,
5620            RejectionReason::NodeAddressUnknown,
5621            RejectionReason::NodeDrained,
5622            RejectionReason::EmptyBundle,
5623            RejectionReason::InsufficientBundleCapacity,
5624            RejectionReason::BundleConstraintViolation,
5625            RejectionReason::HardwareGenerationUnavailable,
5626            RejectionReason::OtherBundleFailure,
5627            // EgressEnforcement
5628            EgressEnforcement::FabricEnforced,
5629            EgressEnforcement::HostRuntimeIntegration,
5630            EgressEnforcement::OperatorControlled,
5631            EgressEnforcement::Unsupported,
5632            // EgressTarget
5633            EgressTarget::WasmTasklet,
5634            EgressTarget::NativeProgram,
5635            EgressTarget::ContainerAdjacent,
5636            EgressTarget::KubernetesDra,
5637            EgressTarget::BareMetal,
5638            // EconomicsSource
5639            EconomicsSource::OperatorStaticConfig,
5640            EconomicsSource::ProviderPriceSheet,
5641            EconomicsSource::ProviderUsageExport,
5642            EconomicsSource::MeasuredPowerTelemetry,
5643            EconomicsSource::ThirdPartyCarbonFeed,
5644            EconomicsSource::DerivedEstimate,
5645            // ObservationConfidence
5646            ObservationConfidence::Low,
5647            ObservationConfidence::Medium,
5648            ObservationConfidence::High,
5649            // RevokeState
5650            RevokeState::Active,
5651            RevokeState::RevokeWarning,
5652            RevokeState::GraceRunning,
5653            RevokeState::CheckpointReported,
5654            RevokeState::ForcedTeardown,
5655            RevokeState::Torndown,
5656            RevokeState::Expired,
5657            RevokeState::Fenced,
5658            RevokeState::FailedClosed,
5659            // Preemptibility
5660            Preemptibility::Preemptible,
5661            Preemptibility::Protected,
5662            // NonPreemptibleReason
5663            NonPreemptibleReason::CheckpointInProgress,
5664            NonPreemptibleReason::DataPlaneActive,
5665            NonPreemptibleReason::OperatorPin,
5666            NonPreemptibleReason::AttestationLocked,
5667            // Phase 218.5 slice 106 — bundle vocabulary
5668            HardwareGeneration::NvidiaAmpere,
5669            HardwareGeneration::NvidiaHopper,
5670            HardwareGeneration::NvidiaBlackwell,
5671            HardwareGeneration::AmdMi300,
5672            HardwareGeneration::AmdMi325,
5673            HardwareGeneration::IntelGaudi,
5674            HardwareGeneration::Other,
5675            BundleAtomicity::AllOrNothing,
5676            BundleAtomicity::BestEffort,
5677            BundleAdmissionFailure::EmptyBundle,
5678            BundleAdmissionFailure::InsufficientBundleCapacity,
5679            BundleAdmissionFailure::BundleConstraintViolation,
5680            BundleAdmissionFailure::HardwareGenerationUnavailable,
5681            BundleAdmissionFailure::OtherBundleFailure,
5682            // Phase 218.5 slice 108 — BundleDecision summary
5683            BundleDecision::Approved,
5684            BundleDecision::DeniedAllOrNothing,
5685            BundleDecision::DeniedEmpty,
5686            BundleDecision::PartialBestEffort,
5687            // AuditEventKind
5688            AuditEventKind::CapabilityIssued,
5689            AuditEventKind::CapabilityRevoked,
5690            AuditEventKind::LeaseAllocated,
5691            AuditEventKind::LeaseRenewed,
5692            AuditEventKind::LeaseReleased,
5693            AuditEventKind::LeaseExpired,
5694            AuditEventKind::LeaseTorndown,
5695            AuditEventKind::LeaseFenced,
5696            AuditEventKind::AdmissionDecided,
5697            AuditEventKind::Preempted,
5698            AuditEventKind::DrainInitiated,
5699            AuditEventKind::ChainAnchored,
5700            AuditEventKind::SoftModeEnabled,
5701            AuditEventKind::TenantCreated,
5702            AuditEventKind::TenantDeleted,
5703            AuditEventKind::TenantQuotaUpdated,
5704            AuditEventKind::ProviderConformanceRecorded,
5705            AuditEventKind::ProviderBootstrapTokenIssued,
5706            AuditEventKind::ProviderBootstrapExchanged,
5707            AuditEventKind::ProviderCellIdentityIssued,
5708            AuditEventKind::ProviderCellIdentityRotated,
5709            AuditEventKind::ProviderCellIdentityRevoked,
5710            AuditEventKind::BearerTokenIssued,
5711            AuditEventKind::BearerTokenRevoked,
5712            AuditEventKind::SchedulerPromoted,
5713            AuditEventKind::BillingRateCardInstalled,
5714        );
5715    }
5716
5717    /// Slice 90 — every variant of every typed enum also DESERIALIZES
5718    /// from its `as_str()` value (the canonical snake_case wire shape).
5719    /// Together with `snake_case_serde_matches_as_str_round_trip`, this
5720    /// is the symmetric round-trip pin for every variant.
5721    #[cfg(feature = "serde")]
5722    #[test]
5723    fn snake_case_serde_deserialize_round_trip() {
5724        macro_rules! pin {
5725            ($ty:ty: $($value:expr),* $(,)?) => {{
5726                $({
5727                    let v = $value;
5728                    let s = format!("\"{}\"", v.as_str());
5729                    let back: $ty = serde_json::from_str(&s)
5730                        .expect(&format!("must deserialize from snake_case for {:?}", v));
5731                    assert_eq!(back, v, "round-trip mismatch for {:?}", v);
5732                })*
5733            }};
5734        }
5735
5736        pin!(PreemptionReason:
5737            PreemptionReason::PriorityPreemption,
5738            PreemptionReason::QuotaRebalance,
5739            PreemptionReason::BurstCreditExhausted,
5740            PreemptionReason::BudgetExhausted,
5741            PreemptionReason::CostCapEviction,
5742            PreemptionReason::OperatorDrain,
5743            PreemptionReason::OperatorMigProfileChange,
5744            PreemptionReason::MaintenanceWindow,
5745            PreemptionReason::PolicyViolationRecovery,
5746        );
5747        pin!(Priority:
5748            Priority::Scavenger,
5749            Priority::Standard,
5750            Priority::Guaranteed,
5751        );
5752        pin!(RejectionReason:
5753            RejectionReason::InsufficientCapacity,
5754            RejectionReason::NoEligibleNodes,
5755            RejectionReason::QuotaHardLimitExceeded,
5756            RejectionReason::QuotaBurstExceeded,
5757            RejectionReason::QuotaLeaseCountExceeded,
5758            RejectionReason::QuotaPerNodeLimitExceeded,
5759            RejectionReason::TenantNotFound,
5760            RejectionReason::NodeFenced,
5761            RejectionReason::BudgetExhausted,
5762            RejectionReason::ReservationExhausted,
5763            RejectionReason::PlacementPolicyExcluded,
5764            RejectionReason::NodeAddressUnknown,
5765            RejectionReason::NodeDrained,
5766            RejectionReason::EmptyBundle,
5767            RejectionReason::InsufficientBundleCapacity,
5768            RejectionReason::BundleConstraintViolation,
5769            RejectionReason::HardwareGenerationUnavailable,
5770            RejectionReason::OtherBundleFailure,
5771        );
5772        pin!(EgressEnforcement:
5773            EgressEnforcement::FabricEnforced,
5774            EgressEnforcement::HostRuntimeIntegration,
5775            EgressEnforcement::OperatorControlled,
5776            EgressEnforcement::Unsupported,
5777        );
5778        pin!(EgressTarget:
5779            EgressTarget::WasmTasklet,
5780            EgressTarget::NativeProgram,
5781            EgressTarget::ContainerAdjacent,
5782            EgressTarget::KubernetesDra,
5783            EgressTarget::BareMetal,
5784        );
5785        pin!(EconomicsSource:
5786            EconomicsSource::OperatorStaticConfig,
5787            EconomicsSource::ProviderPriceSheet,
5788            EconomicsSource::ProviderUsageExport,
5789            EconomicsSource::MeasuredPowerTelemetry,
5790            EconomicsSource::ThirdPartyCarbonFeed,
5791            EconomicsSource::DerivedEstimate,
5792        );
5793        pin!(ObservationConfidence:
5794            ObservationConfidence::Low,
5795            ObservationConfidence::Medium,
5796            ObservationConfidence::High,
5797        );
5798        pin!(RevokeState:
5799            RevokeState::Active,
5800            RevokeState::RevokeWarning,
5801            RevokeState::GraceRunning,
5802            RevokeState::CheckpointReported,
5803            RevokeState::ForcedTeardown,
5804            RevokeState::Torndown,
5805            RevokeState::Expired,
5806            RevokeState::Fenced,
5807            RevokeState::FailedClosed,
5808        );
5809        pin!(Preemptibility:
5810            Preemptibility::Preemptible,
5811            Preemptibility::Protected,
5812        );
5813        pin!(NonPreemptibleReason:
5814            NonPreemptibleReason::CheckpointInProgress,
5815            NonPreemptibleReason::DataPlaneActive,
5816            NonPreemptibleReason::OperatorPin,
5817            NonPreemptibleReason::AttestationLocked,
5818        );
5819        // Phase 218.5 slice 106 — bundle vocabulary deserialize coverage.
5820        // No PascalCase alias attributes (no legacy wire data for the new
5821        // primitive — they have never been on disk), so this round-trip
5822        // pin is one-direction snake_case only.
5823        pin!(HardwareGeneration:
5824            HardwareGeneration::NvidiaAmpere,
5825            HardwareGeneration::NvidiaHopper,
5826            HardwareGeneration::NvidiaBlackwell,
5827            HardwareGeneration::AmdMi300,
5828            HardwareGeneration::AmdMi325,
5829            HardwareGeneration::IntelGaudi,
5830            HardwareGeneration::Other,
5831        );
5832        pin!(BundleAtomicity:
5833            BundleAtomicity::AllOrNothing,
5834            BundleAtomicity::BestEffort,
5835        );
5836        pin!(BundleAdmissionFailure:
5837            BundleAdmissionFailure::EmptyBundle,
5838            BundleAdmissionFailure::InsufficientBundleCapacity,
5839            BundleAdmissionFailure::BundleConstraintViolation,
5840            BundleAdmissionFailure::HardwareGenerationUnavailable,
5841            BundleAdmissionFailure::OtherBundleFailure,
5842        );
5843        // Phase 218.5 slice 108 — BundleDecision deserialize coverage.
5844        // No PascalCase alias attributes (new primitive, never on disk),
5845        // so snake_case only.
5846        pin!(BundleDecision:
5847            BundleDecision::Approved,
5848            BundleDecision::DeniedAllOrNothing,
5849            BundleDecision::DeniedEmpty,
5850            BundleDecision::PartialBestEffort,
5851        );
5852        pin!(AuditEventKind:
5853            AuditEventKind::CapabilityIssued,
5854            AuditEventKind::CapabilityRevoked,
5855            AuditEventKind::LeaseAllocated,
5856            AuditEventKind::LeaseRenewed,
5857            AuditEventKind::LeaseReleased,
5858            AuditEventKind::LeaseExpired,
5859            AuditEventKind::LeaseTorndown,
5860            AuditEventKind::LeaseFenced,
5861            AuditEventKind::AdmissionDecided,
5862            AuditEventKind::Preempted,
5863            AuditEventKind::DrainInitiated,
5864            AuditEventKind::ChainAnchored,
5865            AuditEventKind::SoftModeEnabled,
5866            AuditEventKind::TenantCreated,
5867            AuditEventKind::TenantDeleted,
5868            AuditEventKind::TenantQuotaUpdated,
5869            AuditEventKind::ProviderConformanceRecorded,
5870            AuditEventKind::ProviderBootstrapTokenIssued,
5871            AuditEventKind::ProviderBootstrapExchanged,
5872            AuditEventKind::ProviderCellIdentityIssued,
5873            AuditEventKind::ProviderCellIdentityRotated,
5874            AuditEventKind::ProviderCellIdentityRevoked,
5875            AuditEventKind::BearerTokenIssued,
5876            AuditEventKind::BearerTokenRevoked,
5877            AuditEventKind::SchedulerPromoted,
5878            AuditEventKind::BillingRateCardInstalled,
5879        );
5880    }
5881
5882    /// Slice 90 — pre-slice-90 records on disk carry the
5883    /// PascalCase variant form (`"AdmissionDecided"`, `"Standard"`,
5884    /// etc.). The migration window guarantees those records still
5885    /// parse: every variant is reachable from BOTH the canonical
5886    /// snake_case label AND the legacy PascalCase form. This pin
5887    /// proves the back-compat alias attributes are wired on every
5888    /// variant of every typed enum — without it, dropping an alias
5889    /// silently breaks legacy chain parsing.
5890    ///
5891    /// Helper macro pattern: the PascalCase form is the variant's
5892    /// Debug name (which is what `{:?}` emits for a unit variant).
5893    /// We synthesize the JSON literal from `format!("{:?}", v)`.
5894    #[cfg(feature = "serde")]
5895    #[test]
5896    fn pascal_case_legacy_alias_round_trip() {
5897        macro_rules! pin {
5898            ($ty:ty: $($value:expr),* $(,)?) => {{
5899                $({
5900                    let v = $value;
5901                    let pascal = format!("{:?}", v);
5902                    let json = format!("\"{}\"", pascal);
5903                    let back: $ty = serde_json::from_str(&json).unwrap_or_else(|e| {
5904                        panic!("legacy PascalCase {} must parse for {:?}: {}", json, v, e)
5905                    });
5906                    assert_eq!(back, v, "alias mismatch for legacy {} -> {:?}", json, v);
5907                })*
5908            }};
5909        }
5910
5911        pin!(PreemptionReason:
5912            PreemptionReason::PriorityPreemption,
5913            PreemptionReason::QuotaRebalance,
5914            PreemptionReason::BurstCreditExhausted,
5915            PreemptionReason::BudgetExhausted,
5916            PreemptionReason::CostCapEviction,
5917            PreemptionReason::OperatorDrain,
5918            PreemptionReason::OperatorMigProfileChange,
5919            PreemptionReason::MaintenanceWindow,
5920            PreemptionReason::PolicyViolationRecovery,
5921        );
5922        pin!(Priority:
5923            Priority::Scavenger,
5924            Priority::Standard,
5925            Priority::Guaranteed,
5926        );
5927        pin!(EvidenceLabel:
5928            EvidenceLabel::DesignTarget,
5929            EvidenceLabel::UnitIntegrationEvidence,
5930            EvidenceLabel::LabEvidence,
5931            EvidenceLabel::StagedProviderEvidence,
5932            EvidenceLabel::DesignPartnerEvidence,
5933            EvidenceLabel::ProductionEvidence,
5934        );
5935        pin!(RejectionReason:
5936            RejectionReason::InsufficientCapacity,
5937            RejectionReason::NoEligibleNodes,
5938            RejectionReason::QuotaHardLimitExceeded,
5939            RejectionReason::QuotaBurstExceeded,
5940            RejectionReason::QuotaLeaseCountExceeded,
5941            RejectionReason::QuotaPerNodeLimitExceeded,
5942            RejectionReason::TenantNotFound,
5943            RejectionReason::NodeFenced,
5944            RejectionReason::BudgetExhausted,
5945            RejectionReason::ReservationExhausted,
5946            RejectionReason::PlacementPolicyExcluded,
5947            RejectionReason::NodeAddressUnknown,
5948            RejectionReason::NodeDrained,
5949            RejectionReason::EmptyBundle,
5950            RejectionReason::InsufficientBundleCapacity,
5951            RejectionReason::BundleConstraintViolation,
5952            RejectionReason::HardwareGenerationUnavailable,
5953            RejectionReason::OtherBundleFailure,
5954        );
5955        pin!(EgressEnforcement:
5956            EgressEnforcement::FabricEnforced,
5957            EgressEnforcement::HostRuntimeIntegration,
5958            EgressEnforcement::OperatorControlled,
5959            EgressEnforcement::Unsupported,
5960        );
5961        pin!(EgressTarget:
5962            EgressTarget::WasmTasklet,
5963            EgressTarget::NativeProgram,
5964            EgressTarget::ContainerAdjacent,
5965            EgressTarget::KubernetesDra,
5966            EgressTarget::BareMetal,
5967        );
5968        pin!(EconomicsSource:
5969            EconomicsSource::OperatorStaticConfig,
5970            EconomicsSource::ProviderPriceSheet,
5971            EconomicsSource::ProviderUsageExport,
5972            EconomicsSource::MeasuredPowerTelemetry,
5973            EconomicsSource::ThirdPartyCarbonFeed,
5974            EconomicsSource::DerivedEstimate,
5975        );
5976        pin!(ObservationConfidence:
5977            ObservationConfidence::Low,
5978            ObservationConfidence::Medium,
5979            ObservationConfidence::High,
5980        );
5981        pin!(RevokeState:
5982            RevokeState::Active,
5983            RevokeState::RevokeWarning,
5984            RevokeState::GraceRunning,
5985            RevokeState::CheckpointReported,
5986            RevokeState::ForcedTeardown,
5987            RevokeState::Torndown,
5988            RevokeState::Expired,
5989            RevokeState::Fenced,
5990            RevokeState::FailedClosed,
5991        );
5992        pin!(Preemptibility:
5993            Preemptibility::Preemptible,
5994            Preemptibility::Protected,
5995        );
5996        pin!(NonPreemptibleReason:
5997            NonPreemptibleReason::CheckpointInProgress,
5998            NonPreemptibleReason::DataPlaneActive,
5999            NonPreemptibleReason::OperatorPin,
6000            NonPreemptibleReason::AttestationLocked,
6001        );
6002        pin!(AuditEventKind:
6003            AuditEventKind::CapabilityIssued,
6004            AuditEventKind::CapabilityRevoked,
6005            AuditEventKind::LeaseAllocated,
6006            AuditEventKind::LeaseRenewed,
6007            AuditEventKind::LeaseReleased,
6008            AuditEventKind::LeaseExpired,
6009            AuditEventKind::LeaseTorndown,
6010            AuditEventKind::LeaseFenced,
6011            AuditEventKind::AdmissionDecided,
6012            AuditEventKind::Preempted,
6013            AuditEventKind::DrainInitiated,
6014            AuditEventKind::ChainAnchored,
6015            AuditEventKind::SoftModeEnabled,
6016            AuditEventKind::TenantCreated,
6017            AuditEventKind::TenantDeleted,
6018            AuditEventKind::TenantQuotaUpdated,
6019            AuditEventKind::ProviderConformanceRecorded,
6020            AuditEventKind::ProviderBootstrapTokenIssued,
6021            AuditEventKind::ProviderBootstrapExchanged,
6022            AuditEventKind::ProviderCellIdentityIssued,
6023            AuditEventKind::ProviderCellIdentityRotated,
6024            AuditEventKind::ProviderCellIdentityRevoked,
6025            AuditEventKind::BearerTokenIssued,
6026            AuditEventKind::BearerTokenRevoked,
6027            AuditEventKind::SchedulerPromoted,
6028            AuditEventKind::BillingRateCardInstalled,
6029        );
6030    }
6031
6032    /// Slice 90 — `EvidenceLabel` is excluded from the
6033    /// `as_str()` single-source-of-truth pin because its
6034    /// `as_str()` returns operator-prose with spaces and slashes
6035    /// (`"design target"`, `"unit/integration evidence"`) for the
6036    /// docs-lint surface. Pin its serde wire shape independently:
6037    /// canonical snake_case from `rename_all`, alias to legacy
6038    /// PascalCase. The serde JSON wire is distinct from the
6039    /// docs-lint string surface — both are valid by design.
6040    #[cfg(feature = "serde")]
6041    #[test]
6042    fn evidence_label_serde_wire_shape() {
6043        // Canonical snake_case wire shape.
6044        for (variant, expected) in [
6045            (EvidenceLabel::DesignTarget, "design_target"),
6046            (
6047                EvidenceLabel::UnitIntegrationEvidence,
6048                "unit_integration_evidence",
6049            ),
6050            (EvidenceLabel::LabEvidence, "lab_evidence"),
6051            (
6052                EvidenceLabel::StagedProviderEvidence,
6053                "staged_provider_evidence",
6054            ),
6055            (
6056                EvidenceLabel::DesignPartnerEvidence,
6057                "design_partner_evidence",
6058            ),
6059            (EvidenceLabel::ProductionEvidence, "production_evidence"),
6060        ] {
6061            let json = serde_json::to_string(&variant).expect("serialize");
6062            assert_eq!(
6063                json,
6064                format!("\"{}\"", expected),
6065                "{:?} wire shape mismatch",
6066                variant
6067            );
6068            let back: EvidenceLabel = serde_json::from_str(&json).expect("deserialize canonical");
6069            assert_eq!(back, variant);
6070        }
6071    }
6072
6073    /// Slice 90 — typo protection: an unknown JSON string for any
6074    /// typed enum fails closed at deserialize time. Without this
6075    /// pin a future refactor that relaxed serde to accept arbitrary
6076    /// strings would silently propagate typos through the wire.
6077    #[cfg(feature = "serde")]
6078    #[test]
6079    fn snake_case_serde_rejects_unknown_strings() {
6080        assert!(serde_json::from_str::<Priority>("\"bogus\"").is_err());
6081        assert!(serde_json::from_str::<AuditEventKind>("\"not_a_kind\"").is_err());
6082        assert!(serde_json::from_str::<RevokeState>("\"in_between\"").is_err());
6083        assert!(serde_json::from_str::<RejectionReason>("\"placeholder\"").is_err());
6084        assert!(serde_json::from_str::<Preemptibility>("\"\"").is_err());
6085        assert!(serde_json::from_str::<NonPreemptibleReason>("\"unknown\"").is_err());
6086        // Phase 218.5 slice 106 — bundle vocabulary fails closed on
6087        // unknown strings (no String escape hatch).
6088        assert!(serde_json::from_str::<HardwareGeneration>("\"unknown_gpu\"").is_err());
6089        assert!(serde_json::from_str::<BundleAtomicity>("\"maybe\"").is_err());
6090        assert!(serde_json::from_str::<BundleAdmissionFailure>("\"no_reason\"").is_err());
6091    }
6092}