Skip to main content

axiom_truth/
truth_package.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3// See LICENSE file in the project root for full license information.
4
5//! Truth Package spine types.
6//!
7//! This module is the v0.15 Axiom release surface for JTBD-as-source package
8//! generation, verifier reports, observation adapter receipts, lineage checks,
9//! and decoder calibration. It intentionally stays app-neutral: apps own raw
10//! transcripts and adapters, Helm owns operator surfaces, Organism owns
11//! Formation selection, and Converge owns promotion authority.
12
13use crate::jtbd::JTBDMetadata;
14use crate::{CompileError, compile_intent, parse_truth_document};
15use chrono::{DateTime, Duration, Utc};
16use converge_pack::{ContextFact, FactEvidenceRef, FactTraceLink};
17use organism_pack::{ForbiddenAction, IntentPacket};
18use serde::{Deserialize, Serialize};
19use serde_json::json;
20use sha2::{Digest, Sha256};
21use std::collections::{BTreeMap, BTreeSet};
22use uuid::Uuid;
23
24const DECODER_VERSION: &str = "0.10.0";
25const TRUTH_VERSION: &str = "v1";
26/// Deterministic epoch used as the anchor for generated `.truths` `Expires:`
27/// values. The IntentPacket's `expires` timestamp is `EPOCH + time_budget`
28/// when the JTBD declares a budget, and `EPOCH` otherwise. The runtime carries
29/// the budget separately via `IntentPacket.context["time_budget_seconds"]`.
30const DETERMINISTIC_EXPIRES_EPOCH: &str = "2099-01-01T00:00:00Z";
31
32/// JTBD-declared time budget the runtime must enforce for a job.
33///
34/// v0.11 carries only a duration in seconds. Richer expiry semantics
35/// (deadline-relative-to-event, business-day windows, etc.) are deferred until
36/// a marquee job demonstrates they are needed. Presence makes the
37/// `TimeBudgetExhausted` stop reason reachable on a real run, which in turn
38/// makes the `Exhausted` verdict reachable in `AxiomRunReport`.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
40#[serde(transparent)]
41pub struct TimeBudget(u64);
42
43impl TimeBudget {
44    pub const fn from_seconds(seconds: u64) -> Self {
45        Self(seconds)
46    }
47
48    pub const fn from_minutes(minutes: u64) -> Self {
49        Self(minutes * 60)
50    }
51
52    pub const fn from_hours(hours: u64) -> Self {
53        Self(hours * 3600)
54    }
55
56    pub const fn as_seconds(self) -> u64 {
57        self.0
58    }
59}
60
61fn deterministic_expires_line(budget: Option<TimeBudget>) -> String {
62    let epoch: DateTime<Utc> = DateTime::parse_from_rfc3339(DETERMINISTIC_EXPIRES_EPOCH)
63        .expect("DETERMINISTIC_EXPIRES_EPOCH is a valid RFC-3339 timestamp")
64        .with_timezone(&Utc);
65    let expires = match budget {
66        Some(b) => {
67            let seconds = i64::try_from(b.as_seconds()).unwrap_or(i64::MAX);
68            epoch + Duration::seconds(seconds)
69        }
70        None => epoch,
71    };
72    expires.format("%Y-%m-%dT%H:%M:%SZ").to_string()
73}
74
75/// Structured JTBD input supplied by a human or a higher-level authoring UI.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct JtbdInput {
78    /// Stable job key used as the root of package-local clause IDs.
79    pub key: String,
80    /// Actor trying to make progress.
81    pub actor: String,
82    /// Functional job the actor wants done.
83    pub functional_job: String,
84    /// Downstream value or risk reduction the job serves.
85    pub so_that: String,
86    /// Evidence that must exist before the job can be treated as satisfied.
87    #[serde(default)]
88    pub evidence_required: Vec<ClauseInput>,
89    /// Failure modes the package must guard against.
90    #[serde(default)]
91    pub failure_modes: Vec<ClauseInput>,
92    /// Optional time budget the runtime must enforce. When present, the
93    /// generated `.truths` `Expires:` is anchored at `EPOCH + time_budget` and
94    /// the IntentPacket context carries `time_budget_seconds` so a runtime can
95    /// produce `TimeBudgetExhausted` honestly. Absent budgets fall back to the
96    /// unbounded epoch sentinel.
97    #[serde(default)]
98    pub time_budget: Option<TimeBudget>,
99}
100
101impl JtbdInput {
102    pub fn new(
103        key: impl Into<String>,
104        actor: impl Into<String>,
105        functional_job: impl Into<String>,
106        so_that: impl Into<String>,
107    ) -> Self {
108        Self {
109            key: key.into(),
110            actor: actor.into(),
111            functional_job: functional_job.into(),
112            so_that: so_that.into(),
113            evidence_required: Vec::new(),
114            failure_modes: Vec::new(),
115            time_budget: None,
116        }
117    }
118
119    /// Builder helper: attach a time budget to a JTBD input.
120    #[must_use]
121    pub fn with_time_budget(mut self, budget: TimeBudget) -> Self {
122        self.time_budget = Some(budget);
123        self
124    }
125
126    /// Convert legacy `.truths` JTBD metadata into the new structured source
127    /// shape. The caller supplies the package-local job key.
128    pub fn from_metadata(key: impl Into<String>, metadata: &JTBDMetadata) -> Self {
129        Self {
130            key: key.into(),
131            actor: metadata.actor.clone(),
132            functional_job: metadata.job_functional.clone(),
133            so_that: metadata.so_that.clone(),
134            evidence_required: metadata
135                .evidence_required
136                .iter()
137                .cloned()
138                .map(ClauseInput::new)
139                .collect(),
140            failure_modes: metadata
141                .failure_modes
142                .iter()
143                .cloned()
144                .map(ClauseInput::new)
145                .collect(),
146            time_budget: None,
147        }
148    }
149}
150
151/// A clause in a collection field. Explicit keys preserve identity across
152/// substantial wording changes; absent keys are derived deterministically from
153/// canonical text.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct ClauseInput {
156    pub key: Option<String>,
157    pub text: String,
158}
159
160impl ClauseInput {
161    pub fn new(text: impl Into<String>) -> Self {
162        Self {
163            key: None,
164            text: text.into(),
165        }
166    }
167
168    pub fn with_key(key: impl Into<String>, text: impl Into<String>) -> Self {
169        Self {
170            key: Some(key.into()),
171            text: text.into(),
172        }
173    }
174}
175
176/// Canonical structured JTBD document with deterministic clause IDs.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct JtbdDocument {
179    pub key: String,
180    pub clauses: Vec<JtbdClause>,
181    /// JTBD-declared time budget the runtime must enforce. Not a clause —
182    /// budgets are policy, not job content — but participates in deterministic
183    /// package regeneration through the `.truths` `Expires:` value and the
184    /// IntentPacket context.
185    #[serde(default)]
186    pub time_budget: Option<TimeBudget>,
187}
188
189impl JtbdDocument {
190    pub fn from_input(input: JtbdInput) -> Result<Self, TruthPackageError> {
191        let key = normalized_key(&input.key, "job");
192        let mut clauses = vec![
193            scalar_clause(&key, JtbdClauseKind::Actor, "actor", input.actor)?,
194            scalar_clause(
195                &key,
196                JtbdClauseKind::FunctionalJob,
197                "functional_job",
198                input.functional_job,
199            )?,
200            scalar_clause(&key, JtbdClauseKind::SoThat, "so_that", input.so_that)?,
201        ];
202
203        clauses.extend(collection_clauses(
204            &key,
205            JtbdClauseKind::EvidenceRequired,
206            "evidence",
207            input.evidence_required,
208        )?);
209        clauses.extend(collection_clauses(
210            &key,
211            JtbdClauseKind::FailureMode,
212            "failure",
213            input.failure_modes,
214        )?);
215
216        ensure_unique_clause_ids(&clauses)?;
217
218        Ok(Self {
219            key,
220            clauses,
221            time_budget: input.time_budget,
222        })
223    }
224
225    pub fn clause_ids(&self) -> impl Iterator<Item = &ClauseId> {
226        self.clauses.iter().map(|clause| &clause.id)
227    }
228
229    pub fn clause(&self, id: &ClauseId) -> Option<&JtbdClause> {
230        self.clauses.iter().find(|clause| &clause.id == id)
231    }
232
233    pub fn clauses_by_kind(&self, kind: JtbdClauseKind) -> impl Iterator<Item = &JtbdClause> {
234        self.clauses
235            .iter()
236            .filter(move |clause| clause.kind == kind)
237    }
238
239    fn known_clause_ids(&self) -> BTreeSet<ClauseId> {
240        self.clause_ids().cloned().collect()
241    }
242}
243
244/// One canonical JTBD clause.
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246pub struct JtbdClause {
247    pub id: ClauseId,
248    pub kind: JtbdClauseKind,
249    pub key: String,
250    pub text: String,
251    pub canonical_text: String,
252    pub fingerprint: ClauseFingerprint,
253}
254
255/// Clause category used by deterministic decoder rules.
256#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
257#[serde(rename_all = "snake_case")]
258pub enum JtbdClauseKind {
259    Actor,
260    FunctionalJob,
261    SoThat,
262    EvidenceRequired,
263    FailureMode,
264}
265
266/// Deterministic, package-local clause address.
267#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
268#[serde(transparent)]
269pub struct ClauseId(String);
270
271impl ClauseId {
272    pub fn new(root_key: &str, path: &str) -> Self {
273        Self(format!("jtbd.{root_key}.{path}"))
274    }
275
276    pub fn as_str(&self) -> &str {
277        &self.0
278    }
279}
280
281impl std::fmt::Display for ClauseId {
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        f.write_str(&self.0)
284    }
285}
286
287/// SHA-256 hash of canonical clause text.
288#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
289#[serde(transparent)]
290pub struct ClauseFingerprint(String);
291
292impl ClauseFingerprint {
293    pub fn from_text(text: &str) -> Self {
294        let canonical = canonicalize_clause_text(text);
295        let mut hasher = Sha256::new();
296        hasher.update(canonical.as_bytes());
297        Self(hex_lower(&hasher.finalize()))
298    }
299
300    pub fn as_str(&self) -> &str {
301        &self.0
302    }
303
304    pub fn short(&self) -> &str {
305        &self.0[..12]
306    }
307}
308
309/// Artifact identity in a Truth Package lineage map.
310#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
311#[serde(transparent)]
312pub struct ArtifactId(String);
313
314impl ArtifactId {
315    pub fn new(value: impl Into<String>) -> Self {
316        Self(value.into())
317    }
318
319    pub fn as_str(&self) -> &str {
320        &self.0
321    }
322}
323
324impl std::fmt::Display for ArtifactId {
325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        f.write_str(&self.0)
327    }
328}
329
330/// Deterministic Truth Package identity.
331#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
332#[serde(transparent)]
333pub struct TruthPackageId(String);
334
335impl TruthPackageId {
336    pub fn new(value: impl Into<String>) -> Self {
337        Self(value.into())
338    }
339
340    pub fn as_str(&self) -> &str {
341        &self.0
342    }
343}
344
345impl std::fmt::Display for TruthPackageId {
346    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347        f.write_str(&self.0)
348    }
349}
350
351/// Artifact category for lineage review.
352#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub enum ArtifactKind {
355    TruthPackageManifest,
356    TruthProjection,
357    Scenario,
358    Predicate,
359    PolicyRequirement,
360    InvariantArtifact,
361    SimulationCase,
362    CalibrationSuggestion,
363    CalibrationConcern,
364    ReplayProfile,
365    IntentField,
366    ProofObligation,
367    VerifierExpectation,
368}
369
370/// Deterministic Truth Package manifest produced by the Axiom decoder.
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct TruthPackage {
373    pub package_id: TruthPackageId,
374    pub truth_version: String,
375    pub source_jtbd: JtbdDocument,
376    pub generated_truths: String,
377    pub artifacts: TruthPackageArtifacts,
378    pub intent_packet: IntentPacket,
379    pub proof_obligations: Vec<ProofObligation>,
380    pub verifier_spec: VerifierSpec,
381    pub replay_profile: ReplayProfile,
382    pub lineage: LineageMap,
383}
384
385impl TruthPackage {
386    /// Return the immutable generated `.truths` projection as a versioned view.
387    pub fn base_projection(&self) -> TruthProjectionVersion {
388        let source_clause_ids: Vec<ClauseId> = self.source_jtbd.clause_ids().cloned().collect();
389        TruthProjectionVersion {
390            package_id: self.package_id.clone(),
391            base_truth_version: self.truth_version.clone(),
392            projection_version: self.truth_version.clone(),
393            truths: self.generated_truths.clone(),
394            source: TruthProjectionSource::BaseGenerated,
395            lineage: ArtifactLineage::new(
396                ArtifactId::new(format!("truth_projection.{}", self.source_jtbd.key)),
397                ArtifactKind::TruthProjection,
398                source_clause_ids,
399                "truth_projection.v0",
400                DECODER_VERSION,
401                &self.source_jtbd,
402            ),
403        }
404    }
405
406    /// Apply a human-authored `.truths` projection overlay without mutating the
407    /// immutable generated package. The returned projection is a versioned view
408    /// over the package, not a replacement for `generated_truths`.
409    pub fn apply_projection_overlay(
410        &self,
411        overlay: TruthProjectionOverlay,
412    ) -> Result<TruthProjectionVersion, TruthOverlayError> {
413        apply_truth_projection_overlay(self, overlay)
414    }
415
416    /// Compute the post-run verdict for an `AxiomRunObservation` against this
417    /// package's verifier spec.
418    ///
419    /// The verdict reflects only what can be judged from the observation. Deep
420    /// authority recompute, invariant enforcement, and promotion gating remain
421    /// Converge's responsibility — this function reads what the run reported
422    /// and decides whether the contract was kept.
423    ///
424    /// Precedence:
425    /// 1. Promoted facts citing unknown clauses are a lineage violation →
426    ///    `Invalid`.
427    /// 2. Forbidden action text observed in promoted fact summaries or replay
428    ///    notes → `Invalid`. Sharper enforcement runs through typed invariant
429    ///    violations carried by the observed stop reason.
430    /// 3. Unexpected stop reason → categorize: invalid-class variants
431    ///    (`InvariantViolated`, `PromotionRejected`, `RuntimeError`,
432    ///    `AgentRefused`) → `Invalid`; budget exhaustion → `Exhausted`;
433    ///    HITL or human intervention → `Blocked`; everything else → `Invalid`.
434    /// 4. Expected stop reason: every `EvidenceRequired` clause must be cited
435    ///    by at least one promoted fact's `source_clause_ids`; otherwise the
436    ///    verdict is `Invalid` (the contract specified evidence the run did
437    ///    not produce). All conditions met → `Satisfied`.
438    pub fn verify(&self, observation: &AxiomRunObservation) -> AxiomRunVerdict {
439        compute_verdict(self, observation)
440    }
441}
442
443/// Artifact groups reserved by the Truth Package contract.
444///
445/// The release decoder fills the deterministic spine plus reviewed calibration
446/// suggestion and concern artifacts. Later decoders can populate richer
447/// scenario, predicate, policy, simulation, and invariant artifacts without
448/// changing package identity rules.
449#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
450pub struct TruthPackageArtifacts {
451    pub scenarios: Vec<GeneratedArtifact>,
452    pub predicates: Vec<GeneratedArtifact>,
453    pub policy_requirements: Vec<GeneratedArtifact>,
454    pub evidence_expectations: Vec<GeneratedArtifact>,
455    pub simulation_cases: Vec<GeneratedArtifact>,
456    pub invariant_expectations: Vec<GeneratedArtifact>,
457    #[serde(default)]
458    pub calibration_suggestions: Vec<GeneratedArtifact>,
459    /// Reviewed decoder *concerns* — clauses the runtime was required to
460    /// satisfy but did not. v0.15 introduces this as a typed signal: future
461    /// decoded packages may add prompts, defaults, warnings, or alternate
462    /// scaffolding for these clause shapes. The source JTBD's evidence
463    /// requirements are never weakened by accepting a concern.
464    #[serde(default)]
465    pub calibration_concerns: Vec<GeneratedArtifact>,
466}
467
468/// Reviewable generated artifact summary.
469#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
470pub struct GeneratedArtifact {
471    pub artifact_id: ArtifactId,
472    pub artifact_kind: ArtifactKind,
473    pub summary: String,
474    pub source_clause_ids: Vec<ClauseId>,
475}
476
477/// Human-authored override for the generated `.truths` projection.
478///
479/// The overlay is separate from the package so regeneration remains idempotent:
480/// the same JTBD still produces the same base package, and reviewable human
481/// edits live in their own versioned artifact.
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
483pub struct TruthProjectionOverlay {
484    pub overlay_id: ArtifactId,
485    pub target_package_id: TruthPackageId,
486    pub target_truth_version: String,
487    pub projection_version: String,
488    pub edited_truths: String,
489    pub reason: String,
490    pub source_clause_ids: Vec<ClauseId>,
491}
492
493impl TruthProjectionOverlay {
494    pub fn new(
495        target_package_id: TruthPackageId,
496        target_truth_version: impl Into<String>,
497        projection_version: impl Into<String>,
498        edited_truths: impl Into<String>,
499        reason: impl Into<String>,
500        source_clause_ids: Vec<ClauseId>,
501    ) -> Self {
502        let target_truth_version = target_truth_version.into();
503        let projection_version = projection_version.into();
504        let edited_truths = edited_truths.into();
505        let overlay_id = overlay_id_for(
506            &target_package_id,
507            &target_truth_version,
508            &projection_version,
509            &edited_truths,
510        );
511        Self {
512            overlay_id,
513            target_package_id,
514            target_truth_version,
515            projection_version,
516            edited_truths,
517            reason: reason.into(),
518            source_clause_ids,
519        }
520    }
521}
522
523/// Versioned `.truths` projection view. `BaseGenerated` is the deterministic
524/// package output; `OverlayApplied` is a human-authored edit layered above it.
525#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
526pub struct TruthProjectionVersion {
527    pub package_id: TruthPackageId,
528    pub base_truth_version: String,
529    pub projection_version: String,
530    pub truths: String,
531    pub source: TruthProjectionSource,
532    pub lineage: ArtifactLineage,
533}
534
535#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
536#[serde(rename_all = "snake_case")]
537pub enum TruthProjectionSource {
538    BaseGenerated,
539    OverlayApplied {
540        overlay_id: ArtifactId,
541        reason: String,
542    },
543}
544
545/// Obligation that must be checked by the verifier or by downstream runtime
546/// evidence.
547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548pub struct ProofObligation {
549    pub artifact_id: ArtifactId,
550    pub kind: ProofObligationKind,
551    pub description: String,
552    pub source_clause_ids: Vec<ClauseId>,
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
556#[serde(rename_all = "snake_case")]
557pub enum ProofObligationKind {
558    EvidenceRequired,
559    FailureGuard,
560}
561
562/// Post-run expectations. This is data only; live run wiring is deferred.
563#[derive(Debug, Clone, Serialize, Deserialize)]
564pub struct VerifierSpec {
565    pub expected_stop_reasons: BTreeSet<ExpectedStopReason>,
566    pub required_evidence: Vec<String>,
567    pub forbidden_actions: Vec<ForbiddenAction>,
568    pub satisfaction_conditions: Vec<String>,
569}
570
571#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
572#[serde(rename_all = "snake_case")]
573pub enum ExpectedStopReason {
574    Converged,
575    CriteriaMet,
576    UserCancelled,
577    HumanInterventionRequired,
578    CycleBudgetExhausted,
579    FactBudgetExhausted,
580    TokenBudgetExhausted,
581    TimeBudgetExhausted,
582    InvariantViolated,
583    PromotionRejected,
584    RuntimeError,
585    AgentRefused,
586    HitlGatePending,
587}
588
589/// Deterministic replay metadata for the package spine.
590#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
591pub struct ReplayProfile {
592    pub profile_id: ArtifactId,
593    pub deterministic: bool,
594    pub input_clause_ids: Vec<ClauseId>,
595}
596
597/// Post-run verifier verdict emitted by Axiom after comparing an observed run
598/// against a Truth Package's verifier spec.
599#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
600#[serde(rename_all = "snake_case")]
601pub enum AxiomRunVerdict {
602    Satisfied,
603    Blocked,
604    Exhausted,
605    Invalid,
606}
607
608/// Converge stop reason shape captured without depending on Converge internals.
609#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
610#[serde(tag = "kind", rename_all = "snake_case")]
611pub enum ObservedStopReason {
612    Converged,
613    CriteriaMet {
614        criteria: Vec<String>,
615    },
616    UserCancelled,
617    HumanInterventionRequired {
618        criteria: Vec<String>,
619        approval_refs: Vec<String>,
620    },
621    CycleBudgetExhausted {
622        cycles_executed: u32,
623        limit: u32,
624    },
625    FactBudgetExhausted {
626        facts_count: u32,
627        limit: u32,
628    },
629    TokenBudgetExhausted {
630        tokens_consumed: u64,
631        limit: u64,
632    },
633    TimeBudgetExhausted {
634        duration_ms: u64,
635        limit_ms: u64,
636    },
637    InvariantViolated {
638        class: String,
639        name: String,
640        reason: String,
641    },
642    PromotionRejected {
643        proposal_id: String,
644        reason: String,
645    },
646    RuntimeError {
647        message: String,
648        category: String,
649    },
650    AgentRefused {
651        agent_id: String,
652        reason: String,
653    },
654    HitlGatePending {
655        gate_id: String,
656        proposal_id: String,
657        summary: String,
658        agent_id: String,
659        cycle: u32,
660    },
661}
662
663impl ObservedStopReason {
664    pub fn expectation_kind(&self) -> ExpectedStopReason {
665        match self {
666            Self::Converged => ExpectedStopReason::Converged,
667            Self::CriteriaMet { .. } => ExpectedStopReason::CriteriaMet,
668            Self::UserCancelled => ExpectedStopReason::UserCancelled,
669            Self::HumanInterventionRequired { .. } => ExpectedStopReason::HumanInterventionRequired,
670            Self::CycleBudgetExhausted { .. } => ExpectedStopReason::CycleBudgetExhausted,
671            Self::FactBudgetExhausted { .. } => ExpectedStopReason::FactBudgetExhausted,
672            Self::TokenBudgetExhausted { .. } => ExpectedStopReason::TokenBudgetExhausted,
673            Self::TimeBudgetExhausted { .. } => ExpectedStopReason::TimeBudgetExhausted,
674            Self::InvariantViolated { .. } => ExpectedStopReason::InvariantViolated,
675            Self::PromotionRejected { .. } => ExpectedStopReason::PromotionRejected,
676            Self::RuntimeError { .. } => ExpectedStopReason::RuntimeError,
677            Self::AgentRefused { .. } => ExpectedStopReason::AgentRefused,
678            Self::HitlGatePending { .. } => ExpectedStopReason::HitlGatePending,
679        }
680    }
681
682    pub fn matches_expected(&self, expected: &BTreeSet<ExpectedStopReason>) -> bool {
683        expected.contains(&self.expectation_kind())
684    }
685
686    pub fn is_budget_exhausted(&self) -> bool {
687        matches!(
688            self,
689            Self::CycleBudgetExhausted { .. }
690                | Self::FactBudgetExhausted { .. }
691                | Self::TokenBudgetExhausted { .. }
692                | Self::TimeBudgetExhausted { .. }
693        )
694    }
695
696    pub fn is_blocked(&self) -> bool {
697        matches!(
698            self,
699            Self::HumanInterventionRequired { .. } | Self::HitlGatePending { .. }
700        )
701    }
702
703    pub fn is_invalid(&self) -> bool {
704        matches!(
705            self,
706            Self::InvariantViolated { .. }
707                | Self::PromotionRejected { .. }
708                | Self::RuntimeError { .. }
709                | Self::AgentRefused { .. }
710        )
711    }
712}
713
714/// One promoted fact as rendered by an Axiom run report.
715#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
716pub struct PromotedFactRecord {
717    pub context_key: String,
718    pub fact_id: String,
719    pub summary: String,
720    pub source_clause_ids: Vec<ClauseId>,
721    pub evidence_refs: Vec<EvidenceRefRecord>,
722    pub trace_link: Option<TraceLinkRecord>,
723    #[serde(default)]
724    pub promotion_authority: Option<PromotionAuthorityRecord>,
725}
726
727impl PromotedFactRecord {
728    pub fn from_context_fact(fact: &ContextFact, source_clause_ids: Vec<ClauseId>) -> Self {
729        let promotion = fact.promotion_record();
730        Self {
731            context_key: format!("{:?}", fact.key()),
732            fact_id: fact.id().as_str().to_string(),
733            summary: fact.text().map_or_else(
734                || format!("{} v{}", fact.payload_family(), fact.payload_version()),
735                ToString::to_string,
736            ),
737            source_clause_ids,
738            evidence_refs: promotion
739                .evidence_refs()
740                .iter()
741                .map(evidence_ref_record)
742                .collect(),
743            trace_link: Some(trace_link_record(promotion.trace_link())),
744            promotion_authority: Some(PromotionAuthorityRecord {
745                gate_id: promotion.gate_id().as_str().to_string(),
746                policy_version_hash: promotion.policy_version_hash().to_hex(),
747                approver_id: promotion.approver().id().as_str().to_string(),
748                approver_kind: format!("{:?}", promotion.approver().kind()),
749            }),
750        }
751    }
752}
753
754/// Evidence reference attached to a promoted fact.
755#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
756pub struct EvidenceRefRecord {
757    pub evidence_id: String,
758    pub source: String,
759}
760
761/// Replay trace link attached to a promoted fact.
762#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
763pub struct TraceLinkRecord {
764    pub trace_id: String,
765    pub location: Option<String>,
766    pub replayable: bool,
767}
768
769/// Promotion authority Converge observed when a fact became authoritative.
770///
771/// This is report evidence, not delegated authority. Axiom declares
772/// requirements in the Truth Package; Converge still recomputes authority at
773/// promotion and records the gate/policy identity here.
774#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
775pub struct PromotionAuthorityRecord {
776    pub gate_id: String,
777    pub policy_version_hash: String,
778    pub approver_id: String,
779    pub approver_kind: String,
780}
781
782/// Integrity proof summary captured from the Converge run boundary.
783#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
784pub struct RunIntegrityProof {
785    pub merkle_root: String,
786    pub clock_time: u64,
787    pub fact_count: usize,
788    pub algorithm: String,
789}
790
791impl RunIntegrityProof {
792    pub fn sha256_merkle(
793        merkle_root: impl Into<String>,
794        clock_time: u64,
795        fact_count: usize,
796    ) -> Self {
797        Self {
798            merkle_root: merkle_root.into(),
799            clock_time,
800            fact_count,
801            algorithm: "sha256-merkle".to_string(),
802        }
803    }
804}
805
806/// Wiring-free observation of a run that Axiom can package into a report.
807#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
808pub struct AxiomRunObservation {
809    pub stop_reason: ObservedStopReason,
810    pub promoted_facts: Vec<PromotedFactRecord>,
811    pub integrity: RunIntegrityProof,
812    pub replay_notes: Vec<String>,
813    #[serde(default)]
814    pub run_stages: Vec<AxiomRunStageRecord>,
815}
816
817impl AxiomRunObservation {
818    pub fn from_stages(
819        stop_reason: ObservedStopReason,
820        integrity: RunIntegrityProof,
821        replay_notes: Vec<String>,
822        run_stages: Vec<AxiomRunStageRecord>,
823    ) -> Self {
824        let promoted_facts = run_stages
825            .iter()
826            .flat_map(|stage| stage.promoted_facts.iter().cloned())
827            .collect();
828
829        Self {
830            stop_reason,
831            promoted_facts,
832            integrity,
833            replay_notes,
834            run_stages,
835        }
836    }
837}
838
839/// One reportable execution stage inside a larger job run.
840///
841/// A job may run through more than one Converge boundary. For example, a
842/// dynamic design Formation can converge before a selected work Formation runs.
843/// The top-level report still carries the overall observed stop reason; stages
844/// preserve the intermediate stop reasons, promoted facts, trace links, and
845/// integrity proofs that explain how the job got there.
846#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
847pub struct AxiomRunStageRecord {
848    pub stage_id: String,
849    pub formation_id: Option<String>,
850    pub observed_stop_reason: ObservedStopReason,
851    pub promoted_facts: Vec<PromotedFactRecord>,
852    pub integrity: RunIntegrityProof,
853    pub replay_notes: Vec<String>,
854}
855
856/// Result status for an app-specific adapter that maps raw app/runtime output
857/// into an [`AxiomRunObservation`].
858#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
859#[serde(rename_all = "snake_case")]
860pub enum ObservationAdapterStatus {
861    Succeeded,
862    Rejected,
863}
864
865impl ObservationAdapterStatus {
866    pub const fn as_str(self) -> &'static str {
867        match self {
868            Self::Succeeded => "succeeded",
869            Self::Rejected => "rejected",
870        }
871    }
872}
873
874/// App-neutral input used to construct an [`ObservationAdapterReceipt`].
875///
876/// Apps still own raw transcript schemas and mapping logic. This input names
877/// the deterministic audit envelope Axiom and Helm can share without importing
878/// app crates or storing raw transcript bodies.
879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
880pub struct ObservationAdapterReceiptInput {
881    pub adapter_id: String,
882    pub adapter_version: String,
883    pub status: ObservationAdapterStatus,
884    pub source_app: String,
885    pub source_run_id: String,
886    pub source_transcript_ref: String,
887    pub source_transcript_hash: String,
888    pub package_id: TruthPackageId,
889    pub truth_version: String,
890    pub domain_hint: String,
891    pub observation_hash: Option<String>,
892    pub mapped_fact_ids: Vec<String>,
893    pub mapped_clause_ids: Vec<ClauseId>,
894    pub dropped_source_fields: Vec<String>,
895    pub warnings: Vec<String>,
896    pub errors: Vec<String>,
897    pub replay_notes: Vec<String>,
898}
899
900/// Deterministic audit receipt for an app adapter execution.
901///
902/// A successful adapter should pair this receipt with the produced
903/// [`AxiomRunObservation`]. A rejected adapter should emit the receipt with
904/// `status: Rejected`, explicit errors, and no observation hash. The receipt is
905/// intentionally backlink-oriented: it stores ids, refs, hashes, mapped fact
906/// ids, and mapped clause ids, but not raw app transcript bodies.
907#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
908pub struct ObservationAdapterReceipt {
909    pub receipt_id: ArtifactId,
910    pub adapter_id: String,
911    pub adapter_version: String,
912    pub status: ObservationAdapterStatus,
913    pub source_app: String,
914    pub source_run_id: String,
915    pub source_transcript_ref: String,
916    pub source_transcript_hash: String,
917    pub package_id: TruthPackageId,
918    pub truth_version: String,
919    pub domain_hint: String,
920    pub observation_hash: Option<String>,
921    pub mapped_fact_ids: Vec<String>,
922    pub mapped_clause_ids: Vec<ClauseId>,
923    pub dropped_source_fields: Vec<String>,
924    pub warnings: Vec<String>,
925    pub errors: Vec<String>,
926    pub replay_notes: Vec<String>,
927}
928
929impl ObservationAdapterReceipt {
930    pub fn new(input: ObservationAdapterReceiptInput) -> Self {
931        let receipt_id = observation_adapter_receipt_id(&input);
932        Self {
933            receipt_id,
934            adapter_id: input.adapter_id,
935            adapter_version: input.adapter_version,
936            status: input.status,
937            source_app: input.source_app,
938            source_run_id: input.source_run_id,
939            source_transcript_ref: input.source_transcript_ref,
940            source_transcript_hash: input.source_transcript_hash,
941            package_id: input.package_id,
942            truth_version: input.truth_version,
943            domain_hint: input.domain_hint,
944            observation_hash: input.observation_hash,
945            mapped_fact_ids: input.mapped_fact_ids,
946            mapped_clause_ids: input.mapped_clause_ids,
947            dropped_source_fields: input.dropped_source_fields,
948            warnings: input.warnings,
949            errors: input.errors,
950            replay_notes: input.replay_notes,
951        }
952    }
953}
954
955fn evidence_ref_record(evidence_ref: &FactEvidenceRef) -> EvidenceRefRecord {
956    match evidence_ref {
957        FactEvidenceRef::Observation(id) => EvidenceRefRecord {
958            evidence_id: id.as_str().to_string(),
959            source: "observation".to_string(),
960        },
961        FactEvidenceRef::HumanApproval(id) => EvidenceRefRecord {
962            evidence_id: id.as_str().to_string(),
963            source: "human_approval".to_string(),
964        },
965        FactEvidenceRef::Derived(id) => EvidenceRefRecord {
966            evidence_id: id.as_str().to_string(),
967            source: "derived".to_string(),
968        },
969    }
970}
971
972fn trace_link_record(trace_link: &FactTraceLink) -> TraceLinkRecord {
973    match trace_link {
974        FactTraceLink::Local(local) => TraceLinkRecord {
975            trace_id: local.trace_id().as_str().to_string(),
976            location: Some(format!("span:{}", local.span_id().as_str())),
977            replayable: true,
978        },
979        FactTraceLink::Remote(remote) => TraceLinkRecord {
980            trace_id: remote.reference().as_str().to_string(),
981            location: Some(remote.system().as_str().to_string()),
982            replayable: false,
983        },
984    }
985}
986
987/// Auditable post-run report over an app-neutral observation.
988///
989/// Callers adapt app or runtime output into [`AxiomRunObservation`], then use
990/// [`AxiomRunReport::verify`] to compute the verdict against a
991/// [`TruthPackage`]. Axiom owns the normalized verifier semantics; callers own
992/// raw transcripts and adapter execution.
993#[derive(Debug, Clone, Serialize, Deserialize)]
994pub struct AxiomRunReport {
995    pub package_id: TruthPackageId,
996    pub truth_version: String,
997    pub intent_packet_id: Uuid,
998    pub source_clause_ids: Vec<ClauseId>,
999    pub verifier_spec: VerifierSpec,
1000    pub verdict: AxiomRunVerdict,
1001    pub observed_stop_reason: ObservedStopReason,
1002    pub promoted_facts: Vec<PromotedFactRecord>,
1003    pub integrity: RunIntegrityProof,
1004    pub replay_profile_id: ArtifactId,
1005    pub replay_notes: Vec<String>,
1006    #[serde(default)]
1007    pub run_stages: Vec<AxiomRunStageRecord>,
1008}
1009
1010impl AxiomRunReport {
1011    pub fn from_observation(
1012        package: &TruthPackage,
1013        verdict: AxiomRunVerdict,
1014        observation: AxiomRunObservation,
1015    ) -> Self {
1016        Self {
1017            package_id: package.package_id.clone(),
1018            truth_version: package.truth_version.clone(),
1019            intent_packet_id: package.intent_packet.id,
1020            source_clause_ids: package.source_jtbd.clause_ids().cloned().collect(),
1021            verifier_spec: package.verifier_spec.clone(),
1022            verdict,
1023            observed_stop_reason: observation.stop_reason,
1024            promoted_facts: observation.promoted_facts,
1025            integrity: observation.integrity,
1026            replay_profile_id: package.replay_profile.profile_id.clone(),
1027            replay_notes: observation.replay_notes,
1028            run_stages: observation.run_stages,
1029        }
1030    }
1031
1032    /// Compute the verdict for `observation` against `package`'s verifier spec
1033    /// and build the corresponding `AxiomRunReport`.
1034    ///
1035    /// This is the canonical v0.11 entry point: callers with a raw
1036    /// `AxiomRunObservation` (hand-built, replayed, or adapted from a Converge
1037    /// run record) should prefer `verify` to `from_observation`. The latter
1038    /// remains available for adapters that have already computed the verdict
1039    /// elsewhere.
1040    pub fn verify(package: &TruthPackage, observation: AxiomRunObservation) -> Self {
1041        let verdict = package.verify(&observation);
1042        Self::from_observation(package, verdict, observation)
1043    }
1044
1045    pub fn expected_stop_reason_matched(&self) -> bool {
1046        self.observed_stop_reason
1047            .matches_expected(&self.verifier_spec.expected_stop_reasons)
1048    }
1049
1050    pub fn stage(&self, stage_id: &str) -> Option<&AxiomRunStageRecord> {
1051        self.run_stages
1052            .iter()
1053            .find(|stage| stage.stage_id == stage_id)
1054    }
1055
1056    /// Prove that every promoted fact in this report traces back to the source
1057    /// JTBD clauses it served and that the truth version is consistent.
1058    ///
1059    /// Checks performed:
1060    /// - report `package_id` matches `package.package_id`
1061    /// - report `truth_version` matches `package.truth_version`
1062    /// - every `source_clause_ids` entry on every promoted fact is a known
1063    ///   clause in the package
1064    /// - every promoted fact cites at least one `EvidenceRequired` or
1065    ///   `FailureMode` clause it serves (scope-only facts that cite only
1066    ///   `Actor` / `FunctionalJob` / `SoThat` are rejected — facts must
1067    ///   discharge an evidence requirement or guard a failure mode)
1068    ///
1069    /// On success returns a `FactLineageAudit` summarizing which evidence and
1070    /// failure-mode clauses were covered by the run's facts.
1071    pub fn audit_fact_lineage(
1072        &self,
1073        package: &TruthPackage,
1074    ) -> Result<FactLineageAudit, FactLineageAuditError> {
1075        if self.package_id != package.package_id {
1076            return Err(FactLineageAuditError::PackageMismatch {
1077                report: self.package_id.clone(),
1078                package: package.package_id.clone(),
1079            });
1080        }
1081        if self.truth_version != package.truth_version {
1082            return Err(FactLineageAuditError::TruthVersionMismatch {
1083                report: self.truth_version.clone(),
1084                package: package.truth_version.clone(),
1085            });
1086        }
1087
1088        let known_ids: BTreeSet<&ClauseId> = package
1089            .source_jtbd
1090            .clauses
1091            .iter()
1092            .map(|clause| &clause.id)
1093            .collect();
1094        let evidence_ids: BTreeSet<&ClauseId> = package
1095            .source_jtbd
1096            .clauses_by_kind(JtbdClauseKind::EvidenceRequired)
1097            .map(|clause| &clause.id)
1098            .collect();
1099        let failure_ids: BTreeSet<&ClauseId> = package
1100            .source_jtbd
1101            .clauses_by_kind(JtbdClauseKind::FailureMode)
1102            .map(|clause| &clause.id)
1103            .collect();
1104
1105        let mut evidence_coverage: BTreeSet<ClauseId> = BTreeSet::new();
1106        let mut failure_coverage: BTreeSet<ClauseId> = BTreeSet::new();
1107
1108        for fact in &self.promoted_facts {
1109            let mut serves_contract = false;
1110            for clause_id in &fact.source_clause_ids {
1111                if !known_ids.contains(clause_id) {
1112                    return Err(FactLineageAuditError::UnknownClause {
1113                        fact_id: fact.fact_id.clone(),
1114                        clause_id: clause_id.clone(),
1115                    });
1116                }
1117                if evidence_ids.contains(clause_id) {
1118                    evidence_coverage.insert(clause_id.clone());
1119                    serves_contract = true;
1120                }
1121                if failure_ids.contains(clause_id) {
1122                    failure_coverage.insert(clause_id.clone());
1123                    serves_contract = true;
1124                }
1125            }
1126            if !serves_contract {
1127                return Err(FactLineageAuditError::ScopeOnlyFact {
1128                    fact_id: fact.fact_id.clone(),
1129                });
1130            }
1131        }
1132
1133        Ok(FactLineageAudit {
1134            package_id: package.package_id.clone(),
1135            truth_version: package.truth_version.clone(),
1136            facts_audited: self.promoted_facts.len(),
1137            evidence_coverage,
1138            failure_coverage,
1139        })
1140    }
1141}
1142
1143/// Summary of a successful fact-lineage audit. Captures which evidence and
1144/// failure-mode clauses the run's promoted facts covered.
1145#[derive(Debug, Clone, PartialEq, Eq)]
1146pub struct FactLineageAudit {
1147    pub package_id: TruthPackageId,
1148    pub truth_version: String,
1149    pub facts_audited: usize,
1150    pub evidence_coverage: BTreeSet<ClauseId>,
1151    pub failure_coverage: BTreeSet<ClauseId>,
1152}
1153
1154#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1155pub enum FactLineageAuditError {
1156    #[error("report package_id {report:?} does not match audit package {package:?}")]
1157    PackageMismatch {
1158        report: TruthPackageId,
1159        package: TruthPackageId,
1160    },
1161    #[error("report truth_version {report} does not match audit package {package}")]
1162    TruthVersionMismatch { report: String, package: String },
1163    #[error("promoted fact {fact_id} cites clause {clause_id} not present in the package")]
1164    UnknownClause {
1165        fact_id: String,
1166        clause_id: ClauseId,
1167    },
1168    #[error(
1169        "promoted fact {fact_id} does not cite any evidence_required or failure_mode clause it serves"
1170    )]
1171    ScopeOnlyFact { fact_id: String },
1172}
1173
1174/// Audited verifier outcome that can seed decoder calibration.
1175///
1176/// This is decoder learning input only. It deliberately carries report,
1177/// clause, verifier, and promotion-policy evidence; it carries no Formation
1178/// choice, authority recomputation result, or specialist execution handle.
1179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1180pub struct LearningEpisode {
1181    pub episode_id: ArtifactId,
1182    pub source_run_id: String,
1183    pub domain_hint: String,
1184    pub package_id: TruthPackageId,
1185    pub truth_version: String,
1186    pub verdict: AxiomRunVerdict,
1187    pub observed_stop_reason: String,
1188    pub source_clause_signals: Vec<LearningClauseSignal>,
1189    /// Audit trail only: the run-time fact IDs that contributed to this
1190    /// episode's clause coverage. Not consumed when generating calibration
1191    /// records — priors are derived from `source_clause_signals`. Carried so
1192    /// future operators can trace a calibration record back to the specific
1193    /// promoted facts that justified it. Do not remove without replacing the
1194    /// audit hook.
1195    pub promoted_fact_ids: Vec<String>,
1196    pub promotion_policy_hashes: Vec<String>,
1197    pub verifier_required_evidence: Vec<String>,
1198    pub verifier_forbidden_actions: Vec<String>,
1199}
1200
1201impl LearningEpisode {
1202    pub fn from_report(
1203        source_run_id: impl Into<String>,
1204        domain_hint: impl Into<String>,
1205        package: &TruthPackage,
1206        report: &AxiomRunReport,
1207        audit: &FactLineageAudit,
1208    ) -> Self {
1209        let source_run_id = source_run_id.into();
1210        let domain_hint = domain_hint.into();
1211        let mut promotion_policy_hashes: Vec<String> = report
1212            .promoted_facts
1213            .iter()
1214            .filter_map(|fact| fact.promotion_authority.as_ref())
1215            .map(|authority| authority.policy_version_hash.clone())
1216            .collect();
1217        promotion_policy_hashes.sort();
1218        promotion_policy_hashes.dedup();
1219
1220        let source_clause_signals = package
1221            .source_jtbd
1222            .clauses
1223            .iter()
1224            .map(|clause| {
1225                let evidence = audit.evidence_coverage.contains(&clause.id);
1226                let failure_guard = audit.failure_coverage.contains(&clause.id);
1227                let coverage_status = match (evidence, failure_guard) {
1228                    (true, true) => ClauseCoverageStatus::CoveredAsEvidenceAndFailureGuard,
1229                    (true, false) => ClauseCoverageStatus::CoveredAsEvidence,
1230                    (false, true) => ClauseCoverageStatus::CoveredAsFailureGuard,
1231                    (false, false) => ClauseCoverageStatus::Uncovered,
1232                };
1233                LearningClauseSignal {
1234                    clause_id: clause.id.clone(),
1235                    clause_kind: clause.kind,
1236                    fingerprint: clause.fingerprint.clone(),
1237                    coverage_status,
1238                }
1239            })
1240            .collect();
1241
1242        let episode_id = learning_episode_id(
1243            &source_run_id,
1244            &domain_hint,
1245            &package.package_id,
1246            &package.truth_version,
1247            report.verdict,
1248        );
1249
1250        Self {
1251            episode_id,
1252            source_run_id,
1253            domain_hint,
1254            package_id: package.package_id.clone(),
1255            truth_version: package.truth_version.clone(),
1256            verdict: report.verdict,
1257            observed_stop_reason: format!("{:?}", report.observed_stop_reason),
1258            source_clause_signals,
1259            promoted_fact_ids: report
1260                .promoted_facts
1261                .iter()
1262                .map(|fact| fact.fact_id.clone())
1263                .collect(),
1264            promotion_policy_hashes,
1265            verifier_required_evidence: report.verifier_spec.required_evidence.clone(),
1266            verifier_forbidden_actions: report
1267                .verifier_spec
1268                .forbidden_actions
1269                .iter()
1270                .map(|forbidden| forbidden.action.clone())
1271                .collect(),
1272        }
1273    }
1274}
1275
1276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1277pub struct LearningClauseSignal {
1278    pub clause_id: ClauseId,
1279    pub clause_kind: JtbdClauseKind,
1280    pub fingerprint: ClauseFingerprint,
1281    pub coverage_status: ClauseCoverageStatus,
1282}
1283
1284/// Whether a clause was cited by promoted facts during the run that produced
1285/// the surrounding `LearningEpisode`.
1286///
1287/// v0.15 replaces the v0.13 pair of booleans (`covered_as_evidence` plus
1288/// `covered_as_failure_guard`) with this enum so an `Uncovered`
1289/// `EvidenceRequired` clause can be expressed as a typed signal — the input
1290/// to v0.15's calibration *concern* records.
1291#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1292#[serde(rename_all = "snake_case")]
1293pub enum ClauseCoverageStatus {
1294    /// No promoted fact cited this clause. For `EvidenceRequired` clauses
1295    /// this is the "missing evidence" signal v0.15 learns from. For scope
1296    /// clauses (`Actor`, `FunctionalJob`, `SoThat`) this is the normal
1297    /// resting state and is ignored by calibration.
1298    #[default]
1299    Uncovered,
1300    /// At least one promoted fact cited this clause as evidence and none as
1301    /// a failure guard.
1302    CoveredAsEvidence,
1303    /// At least one promoted fact cited this clause as a failure guard and
1304    /// none as evidence.
1305    CoveredAsFailureGuard,
1306    /// At least one promoted fact cited this clause in both roles.
1307    CoveredAsEvidenceAndFailureGuard,
1308}
1309
1310impl ClauseCoverageStatus {
1311    /// True when the clause appeared as evidence in any promoted fact.
1312    pub fn was_covered_as_evidence(self) -> bool {
1313        matches!(
1314            self,
1315            Self::CoveredAsEvidence | Self::CoveredAsEvidenceAndFailureGuard
1316        )
1317    }
1318
1319    /// True when the clause appeared as a failure guard in any promoted fact.
1320    pub fn was_covered_as_failure_guard(self) -> bool {
1321        matches!(
1322            self,
1323            Self::CoveredAsFailureGuard | Self::CoveredAsEvidenceAndFailureGuard
1324        )
1325    }
1326
1327    /// True when no promoted fact cited this clause.
1328    pub fn is_uncovered(self) -> bool {
1329        matches!(self, Self::Uncovered)
1330    }
1331}
1332
1333#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
1334pub struct CalibrationKey {
1335    pub clause_kind: JtbdClauseKind,
1336    pub normalized_clause_shape: String,
1337    pub domain_hint: String,
1338    pub decoder_rule_id: String,
1339    pub fingerprint_class: String,
1340}
1341
1342impl CalibrationKey {
1343    pub fn for_clause(
1344        clause: &JtbdClause,
1345        domain_hint: impl Into<String>,
1346        decoder_rule_id: impl Into<String>,
1347    ) -> Self {
1348        Self {
1349            clause_kind: clause.kind,
1350            normalized_clause_shape: canonicalize_clause_text(&clause.canonical_text),
1351            domain_hint: domain_hint.into(),
1352            decoder_rule_id: decoder_rule_id.into(),
1353            fingerprint_class: clause.fingerprint.short().to_string(),
1354        }
1355    }
1356}
1357
1358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1359pub struct CalibrationValue {
1360    pub suggested_evidence_templates: Vec<String>,
1361    pub suggested_failure_scenarios: Vec<String>,
1362    pub suggested_policy_requirements: Vec<String>,
1363    pub suggested_verifier_expectations: Vec<String>,
1364    /// Absolute confidence in this prior, expressed in basis points (1 bp =
1365    /// 0.01%, range 0–10_000). v0.13 derives the value from the source
1366    /// episode's verdict via `confidence_for_verdict`: 9_000 (90%) for
1367    /// `Satisfied`, 7_000 (70%) for `Blocked` or `Invalid`, 4_000 (40%) for
1368    /// `Exhausted`. The scale is absolute, not relative or decaying — v0.14+
1369    /// review workflows are expected to adjust it on accept/reject and may
1370    /// introduce decay later. Operators reviewing a calibration record can
1371    /// read this directly to gauge how much to trust the prior.
1372    pub confidence_basis_points: u16,
1373    pub rationale: String,
1374    pub source_episode_ids: Vec<ArtifactId>,
1375    /// Whether this prior reinforces a working pattern or raises a concern
1376    /// about an unsatisfied evidence requirement.
1377    ///
1378    /// v0.15 introduces this field. `#[serde(default)]` preserves the
1379    /// v0.13/v0.14 wire format: persisted records without this field
1380    /// deserialize as `Reinforcement`, which matches their semantics. Accepted
1381    /// `Reinforcement` records add `CalibrationSuggestion` artifacts to a
1382    /// regenerated package; accepted `Concern` records add
1383    /// `CalibrationConcern` artifacts. Neither path modifies the source
1384    /// JTBD's evidence requirements or forbidden actions.
1385    #[serde(default)]
1386    pub signal_kind: CalibrationSignalKind,
1387}
1388
1389/// Whether a calibration record reinforces a covered clause or raises a
1390/// concern about an uncovered one.
1391#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
1392#[serde(rename_all = "snake_case")]
1393pub enum CalibrationSignalKind {
1394    /// The decoder should keep reaching for this prior — a clause shape that
1395    /// was covered by a promoted fact and contributed to a successful (or
1396    /// otherwise informative) run. Default to preserve v0.13/v0.14
1397    /// semantics for records persisted before v0.15.
1398    #[default]
1399    Reinforcement,
1400    /// The clause shape was required (or forbidden) and the run did not
1401    /// produce a satisfying citation. The decoder should treat this as a
1402    /// warning, prompt, default-expectation candidate, or alternate
1403    /// scaffolding hook on the next package generation. The source JTBD
1404    /// remains authoritative.
1405    Concern,
1406}
1407
1408#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1409#[serde(rename_all = "snake_case")]
1410pub enum CalibrationStatus {
1411    Proposed,
1412    Accepted,
1413    Rejected,
1414    Reset,
1415}
1416
1417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1418pub struct CalibrationRecord {
1419    pub record_id: ArtifactId,
1420    pub key: CalibrationKey,
1421    pub value: CalibrationValue,
1422    pub status: CalibrationStatus,
1423    pub review_note: Option<String>,
1424}
1425
1426impl CalibrationRecord {
1427    pub fn with_status(
1428        mut self,
1429        status: CalibrationStatus,
1430        review_note: impl Into<String>,
1431    ) -> Self {
1432        self.status = status;
1433        self.review_note = Some(review_note.into());
1434        self
1435    }
1436
1437    pub fn accepted(mut self, review_note: impl Into<String>) -> Self {
1438        self.status = CalibrationStatus::Accepted;
1439        self.review_note = Some(review_note.into());
1440        self
1441    }
1442}
1443
1444#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1445pub struct CalibrationTable {
1446    pub records: Vec<CalibrationRecord>,
1447}
1448
1449impl CalibrationTable {
1450    pub fn new(mut records: Vec<CalibrationRecord>) -> Self {
1451        records.sort_by(|left, right| left.record_id.as_str().cmp(right.record_id.as_str()));
1452        Self { records }
1453    }
1454
1455    pub fn accepted_for_clause<'a>(
1456        &'a self,
1457        clause: &JtbdClause,
1458        domain_hint: &str,
1459        decoder_rule_id: &str,
1460    ) -> impl Iterator<Item = &'a CalibrationRecord> {
1461        let key = CalibrationKey::for_clause(clause, domain_hint, decoder_rule_id);
1462        self.records
1463            .iter()
1464            .filter(move |record| record.status == CalibrationStatus::Accepted && record.key == key)
1465    }
1466
1467    /// Serialize the table as JSON Lines — one `CalibrationRecord` per line,
1468    /// in record-id order. The output is byte-deterministic for any table
1469    /// holding the same set of records: `to_jsonl` sorts borrowed references
1470    /// at serialization time, so tables constructed without `new()` (raw
1471    /// struct literal, direct `Deserialize`, or hand-mutated `records`)
1472    /// still produce canonical output. Suitable for content-addressable
1473    /// storage, append-only audit logs, or git-tracked operator review
1474    /// workflows.
1475    pub fn to_jsonl(&self) -> String {
1476        let mut sorted: Vec<&CalibrationRecord> = self.records.iter().collect();
1477        sorted.sort_by(|left, right| left.record_id.as_str().cmp(right.record_id.as_str()));
1478        let mut out = String::new();
1479        for record in sorted {
1480            let line = serde_json::to_string(record)
1481                .expect("calibration record serializes (every field is serde-derived)");
1482            out.push_str(&line);
1483            out.push('\n');
1484        }
1485        out
1486    }
1487
1488    /// Accept a proposed (or previously reviewed) calibration record.
1489    ///
1490    /// The `note` is mandatory — empty or whitespace-only notes raise
1491    /// `CalibrationReviewError::EmptyNote`. Accepting an already-accepted
1492    /// record is allowed; the new note replaces the previous one. Records
1493    /// not present in the table raise `RecordNotFound`.
1494    pub fn accept(
1495        &mut self,
1496        record_id: &ArtifactId,
1497        note: impl Into<String>,
1498    ) -> Result<(), CalibrationReviewError> {
1499        self.review(record_id, CalibrationStatus::Accepted, note.into())
1500    }
1501
1502    /// Reject a calibration record. The decoder will never enrich a package
1503    /// with a rejected record, regardless of the originating episode's verdict.
1504    pub fn reject(
1505        &mut self,
1506        record_id: &ArtifactId,
1507        note: impl Into<String>,
1508    ) -> Result<(), CalibrationReviewError> {
1509        self.review(record_id, CalibrationStatus::Rejected, note.into())
1510    }
1511
1512    /// Reset a calibration record. Reset records do not enrich packages, but
1513    /// the next matching learning episode may re-propose them — unlike
1514    /// `Rejected`, which signals "this prior was wrong" rather than "this
1515    /// prior is stale."
1516    pub fn reset(
1517        &mut self,
1518        record_id: &ArtifactId,
1519        note: impl Into<String>,
1520    ) -> Result<(), CalibrationReviewError> {
1521        self.review(record_id, CalibrationStatus::Reset, note.into())
1522    }
1523
1524    fn review(
1525        &mut self,
1526        record_id: &ArtifactId,
1527        status: CalibrationStatus,
1528        note: String,
1529    ) -> Result<(), CalibrationReviewError> {
1530        if note.trim().is_empty() {
1531            return Err(CalibrationReviewError::EmptyNote {
1532                record_id: record_id.clone(),
1533                status,
1534            });
1535        }
1536        let record = self
1537            .records
1538            .iter_mut()
1539            .find(|record| &record.record_id == record_id)
1540            .ok_or_else(|| CalibrationReviewError::RecordNotFound {
1541                record_id: record_id.clone(),
1542            })?;
1543        record.status = status;
1544        record.review_note = Some(note);
1545        Ok(())
1546    }
1547
1548    /// Parse a JSON Lines calibration table. Blank lines are skipped; every
1549    /// non-blank line must deserialize into a `CalibrationRecord`. Duplicate
1550    /// `record_id` values are rejected. The returned table is re-sorted by
1551    /// `CalibrationTable::new`, so JSONL round-trip is order-stable even if
1552    /// the input was hand-edited out of order.
1553    pub fn from_jsonl(input: &str) -> Result<Self, CalibrationPersistenceError> {
1554        let mut records = Vec::new();
1555        let mut seen_ids: BTreeSet<ArtifactId> = BTreeSet::new();
1556        for (index, raw_line) in input.lines().enumerate() {
1557            let line = raw_line.trim();
1558            if line.is_empty() {
1559                continue;
1560            }
1561            let record: CalibrationRecord = serde_json::from_str(line).map_err(|err| {
1562                CalibrationPersistenceError::InvalidLine {
1563                    line_number: index + 1,
1564                    message: err.to_string(),
1565                }
1566            })?;
1567            if !seen_ids.insert(record.record_id.clone()) {
1568                return Err(CalibrationPersistenceError::DuplicateRecord {
1569                    record_id: record.record_id.clone(),
1570                });
1571            }
1572            records.push(record);
1573        }
1574        Ok(Self::new(records))
1575    }
1576}
1577
1578const CALIBRATION_DECODER_RULE_ID: &str = "decoder_calibration.v0.13";
1579
1580pub fn calibration_records_from_learning_episode(
1581    package: &TruthPackage,
1582    episode: &LearningEpisode,
1583) -> Result<Vec<CalibrationRecord>, CalibrationError> {
1584    if package.package_id != episode.package_id {
1585        return Err(CalibrationError::PackageMismatch {
1586            package: package.package_id.clone(),
1587            episode: episode.package_id.clone(),
1588        });
1589    }
1590    if package.truth_version != episode.truth_version {
1591        return Err(CalibrationError::TruthVersionMismatch {
1592            package: package.truth_version.clone(),
1593            episode: episode.truth_version.clone(),
1594        });
1595    }
1596
1597    let mut records = Vec::new();
1598    for signal in &episode.source_clause_signals {
1599        let clause = package
1600            .source_jtbd
1601            .clause(&signal.clause_id)
1602            .ok_or_else(|| CalibrationError::UnknownClause {
1603                clause_id: signal.clause_id.clone(),
1604            })?;
1605
1606        if !signal.coverage_status.is_uncovered() {
1607            records.push(reinforcement_record_for_clause(
1608                package, episode, clause, signal,
1609            ));
1610        } else if should_emit_concern(clause.kind, episode.verdict) {
1611            records.push(concern_record_for_clause(package, episode, clause));
1612        }
1613    }
1614    Ok(records)
1615}
1616
1617/// v0.15 — should `calibration_records_from_learning_episode` emit a `Concern`
1618/// record for an uncovered clause given the run's verdict?
1619///
1620/// Concerns only fire for `EvidenceRequired` clauses (uncovered failure modes
1621/// are normal: most failure modes don't get explicitly cited by promoted
1622/// facts; only the ones a fact actively guards against do). Verdicts that
1623/// trigger concerns:
1624///
1625/// - `Invalid` — missing evidence is one root cause of an invalid verdict;
1626///   strongest signal.
1627/// - `Blocked` — a gate stopped the run before evidence could arrive; still
1628///   useful signal about which clause shapes recurrently delay promotion.
1629/// - `Satisfied` — no required clause was truly missing (a Satisfied run
1630///   means all evidence was cited), so no concern.
1631/// - `Exhausted` — budget ran out before evidence could arrive; the signal
1632///   is real but noisier than `Invalid` / `Blocked`. Deferred to a future
1633///   milestone if the noise turns out to be tolerable.
1634fn should_emit_concern(clause_kind: JtbdClauseKind, verdict: AxiomRunVerdict) -> bool {
1635    clause_kind == JtbdClauseKind::EvidenceRequired
1636        && matches!(verdict, AxiomRunVerdict::Invalid | AxiomRunVerdict::Blocked)
1637}
1638
1639fn reinforcement_record_for_clause(
1640    package: &TruthPackage,
1641    episode: &LearningEpisode,
1642    clause: &JtbdClause,
1643    signal: &LearningClauseSignal,
1644) -> CalibrationRecord {
1645    let key = CalibrationKey::for_clause(clause, &episode.domain_hint, CALIBRATION_DECODER_RULE_ID);
1646    let mut value = CalibrationValue {
1647        suggested_evidence_templates: Vec::new(),
1648        suggested_failure_scenarios: Vec::new(),
1649        suggested_policy_requirements: Vec::new(),
1650        suggested_verifier_expectations: Vec::new(),
1651        confidence_basis_points: confidence_for_verdict(episode.verdict),
1652        rationale: format!(
1653            "learned from {} verdict {:?} for package {}",
1654            episode.source_run_id, episode.verdict, package.package_id
1655        ),
1656        source_episode_ids: vec![episode.episode_id.clone()],
1657        signal_kind: CalibrationSignalKind::Reinforcement,
1658    };
1659
1660    if signal.coverage_status.was_covered_as_evidence() {
1661        value
1662            .suggested_evidence_templates
1663            .push(clause.canonical_text.clone());
1664        value.suggested_policy_requirements.push(format!(
1665            "require before promotion: {}",
1666            clause.canonical_text
1667        ));
1668        value
1669            .suggested_verifier_expectations
1670            .push(clause.canonical_text.clone());
1671    }
1672    if signal.coverage_status.was_covered_as_failure_guard() {
1673        value
1674            .suggested_failure_scenarios
1675            .push(clause.canonical_text.clone());
1676        value.suggested_policy_requirements.push(format!(
1677            "forbid promotion when observed: {}",
1678            clause.canonical_text
1679        ));
1680        value
1681            .suggested_verifier_expectations
1682            .push(format!("forbidden action: {}", clause.canonical_text));
1683    }
1684
1685    let record_id = calibration_record_id(&key, &episode.episode_id);
1686    CalibrationRecord {
1687        record_id,
1688        key,
1689        value,
1690        status: CalibrationStatus::Proposed,
1691        review_note: None,
1692    }
1693}
1694
1695fn concern_record_for_clause(
1696    package: &TruthPackage,
1697    episode: &LearningEpisode,
1698    clause: &JtbdClause,
1699) -> CalibrationRecord {
1700    let key = CalibrationKey::for_clause(clause, &episode.domain_hint, CALIBRATION_DECODER_RULE_ID);
1701    let value = CalibrationValue {
1702        // Concern records do not suggest templates/scenarios/policy/verifier
1703        // entries — they raise a typed warning about an uncovered clause.
1704        // The decoder consults `signal_kind` and produces a different
1705        // artifact kind on apply.
1706        suggested_evidence_templates: Vec::new(),
1707        suggested_failure_scenarios: Vec::new(),
1708        suggested_policy_requirements: Vec::new(),
1709        suggested_verifier_expectations: Vec::new(),
1710        confidence_basis_points: confidence_for_concern(episode.verdict),
1711        rationale: format!(
1712            "concern from {} verdict {:?} for package {}: evidence requirement \"{}\" was not cited by any promoted fact",
1713            episode.source_run_id, episode.verdict, package.package_id, clause.canonical_text
1714        ),
1715        source_episode_ids: vec![episode.episode_id.clone()],
1716        signal_kind: CalibrationSignalKind::Concern,
1717    };
1718
1719    let record_id = calibration_record_id(&key, &episode.episode_id);
1720    CalibrationRecord {
1721        record_id,
1722        key,
1723        value,
1724        status: CalibrationStatus::Proposed,
1725        review_note: None,
1726    }
1727}
1728
1729fn confidence_for_concern(verdict: AxiomRunVerdict) -> u16 {
1730    match verdict {
1731        // Invalid runs give the strongest concern signal: a contract was
1732        // declared and demonstrably not met.
1733        AxiomRunVerdict::Invalid => 8_000,
1734        // Blocked runs are weaker: the evidence might still arrive after
1735        // the HITL gate opens.
1736        AxiomRunVerdict::Blocked => 5_500,
1737        // The other verdicts do not emit concern records today; fall through
1738        // to a low default so any future caller sees a conservative weight.
1739        AxiomRunVerdict::Satisfied | AxiomRunVerdict::Exhausted => 3_000,
1740    }
1741}
1742
1743fn confidence_for_verdict(verdict: AxiomRunVerdict) -> u16 {
1744    match verdict {
1745        AxiomRunVerdict::Satisfied => 9_000,
1746        AxiomRunVerdict::Blocked | AxiomRunVerdict::Invalid => 7_000,
1747        AxiomRunVerdict::Exhausted => 4_000,
1748    }
1749}
1750
1751fn learning_episode_id(
1752    source_run_id: &str,
1753    domain_hint: &str,
1754    package_id: &TruthPackageId,
1755    truth_version: &str,
1756    verdict: AxiomRunVerdict,
1757) -> ArtifactId {
1758    let mut hasher = Sha256::new();
1759    hasher.update(source_run_id.as_bytes());
1760    hasher.update(b"\0");
1761    hasher.update(domain_hint.as_bytes());
1762    hasher.update(b"\0");
1763    hasher.update(package_id.as_str().as_bytes());
1764    hasher.update(b"\0");
1765    hasher.update(truth_version.as_bytes());
1766    hasher.update(b"\0");
1767    hasher.update(format!("{verdict:?}").as_bytes());
1768    ArtifactId::new(format!(
1769        "learning_episode.{}",
1770        hex_lower(&hasher.finalize())
1771    ))
1772}
1773
1774fn observation_adapter_receipt_id(input: &ObservationAdapterReceiptInput) -> ArtifactId {
1775    let serialized = serde_json::to_vec(input)
1776        .expect("ObservationAdapterReceiptInput has no fallible serialization fields");
1777    let mut hasher = Sha256::new();
1778    hasher.update(serialized);
1779    ArtifactId::new(format!(
1780        "observation_adapter_receipt.{}",
1781        hex_lower(&hasher.finalize())
1782    ))
1783}
1784
1785fn calibration_record_id(key: &CalibrationKey, episode_id: &ArtifactId) -> ArtifactId {
1786    let mut hasher = Sha256::new();
1787    hasher.update(format!("{:?}", key.clause_kind).as_bytes());
1788    hasher.update(b"\0");
1789    hasher.update(key.normalized_clause_shape.as_bytes());
1790    hasher.update(b"\0");
1791    hasher.update(key.domain_hint.as_bytes());
1792    hasher.update(b"\0");
1793    hasher.update(key.decoder_rule_id.as_bytes());
1794    hasher.update(b"\0");
1795    hasher.update(key.fingerprint_class.as_bytes());
1796    hasher.update(b"\0");
1797    hasher.update(episode_id.as_str().as_bytes());
1798    ArtifactId::new(format!(
1799        "calibration_record.{}",
1800        hex_lower(&hasher.finalize())
1801    ))
1802}
1803
1804fn calibration_summary(value: &CalibrationValue) -> String {
1805    match value.signal_kind {
1806        CalibrationSignalKind::Reinforcement => {
1807            if let Some(first) = value.suggested_evidence_templates.first() {
1808                return format!("suggests evidence template: {first}");
1809            }
1810            if let Some(first) = value.suggested_failure_scenarios.first() {
1811                return format!("suggests failure scenario: {first}");
1812            }
1813            if let Some(first) = value.suggested_policy_requirements.first() {
1814                return format!("suggests policy requirement: {first}");
1815            }
1816            "reinforces reviewed decoder prior".to_string()
1817        }
1818        CalibrationSignalKind::Concern => {
1819            format!("raises decoder concern: {}", value.rationale)
1820        }
1821    }
1822}
1823
1824pub fn apply_decoder_calibration(
1825    mut package: TruthPackage,
1826    table: &CalibrationTable,
1827    domain_hint: &str,
1828) -> Result<TruthPackage, CalibrationError> {
1829    let baseline_required_evidence = package.verifier_spec.required_evidence.clone();
1830    // ForbiddenAction (from organism_pack) does not derive PartialEq, so the
1831    // non-weakening invariant compares serialized JSON values instead.
1832    let baseline_forbidden_actions = serde_json::to_value(&package.verifier_spec.forbidden_actions)
1833        .expect("forbidden actions serialize for invariant baseline");
1834
1835    let mut suggestions = Vec::new();
1836    let mut concerns = Vec::new();
1837    for clause in &package.source_jtbd.clauses {
1838        for record in table.accepted_for_clause(clause, domain_hint, CALIBRATION_DECODER_RULE_ID) {
1839            let (artifact_kind, id_prefix) = match record.value.signal_kind {
1840                CalibrationSignalKind::Reinforcement => (
1841                    ArtifactKind::CalibrationSuggestion,
1842                    "calibration_suggestion",
1843                ),
1844                CalibrationSignalKind::Concern => {
1845                    (ArtifactKind::CalibrationConcern, "calibration_concern")
1846                }
1847            };
1848            let artifact = GeneratedArtifact {
1849                artifact_id: ArtifactId::new(format!(
1850                    "{id_prefix}.{}.{}",
1851                    clause.key,
1852                    record.record_id.as_str()
1853                )),
1854                artifact_kind,
1855                summary: format!(
1856                    "calibration {} {}",
1857                    record.record_id.as_str(),
1858                    calibration_summary(&record.value)
1859                ),
1860                source_clause_ids: vec![clause.id.clone()],
1861            };
1862            package.lineage.artifacts.push(ArtifactLineage::new(
1863                artifact.artifact_id.clone(),
1864                artifact_kind,
1865                artifact.source_clause_ids.clone(),
1866                format!("decoder_calibration.{}", record.record_id.as_str()),
1867                DECODER_VERSION,
1868                &package.source_jtbd,
1869            ));
1870            match record.value.signal_kind {
1871                CalibrationSignalKind::Reinforcement => suggestions.push(artifact),
1872                CalibrationSignalKind::Concern => concerns.push(artifact),
1873            }
1874        }
1875    }
1876
1877    package
1878        .artifacts
1879        .calibration_suggestions
1880        .extend(suggestions);
1881    package.artifacts.calibration_concerns.extend(concerns);
1882    package.lineage.validate_closure(&package.source_jtbd)?;
1883
1884    // Non-weakening invariant — accepting calibration records must never
1885    // change the source JTBD's declared evidence requirements or forbidden
1886    // actions. Concern records propose decoder-side affordances, not
1887    // contract relaxation.
1888    debug_assert_eq!(
1889        package.verifier_spec.required_evidence, baseline_required_evidence,
1890        "calibration must not modify verifier_spec.required_evidence",
1891    );
1892    debug_assert_eq!(
1893        serde_json::to_value(&package.verifier_spec.forbidden_actions)
1894            .expect("forbidden actions serialize for invariant check"),
1895        baseline_forbidden_actions,
1896        "calibration must not modify verifier_spec.forbidden_actions",
1897    );
1898
1899    Ok(package)
1900}
1901
1902#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1903pub enum CalibrationError {
1904    #[error("calibration package_id {package:?} does not match episode package_id {episode:?}")]
1905    PackageMismatch {
1906        package: TruthPackageId,
1907        episode: TruthPackageId,
1908    },
1909    #[error("calibration truth version {package} does not match episode truth version {episode}")]
1910    TruthVersionMismatch { package: String, episode: String },
1911    #[error("calibration episode references unknown clause {clause_id}")]
1912    UnknownClause { clause_id: ClauseId },
1913    #[error(transparent)]
1914    Lineage(#[from] LineageError),
1915}
1916
1917/// Errors raised while loading a persisted calibration table from JSONL.
1918#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1919pub enum CalibrationPersistenceError {
1920    #[error("calibration line {line_number} did not parse: {message}")]
1921    InvalidLine { line_number: usize, message: String },
1922    #[error("duplicate calibration record id {record_id}")]
1923    DuplicateRecord { record_id: ArtifactId },
1924}
1925
1926/// Errors raised when applying an operator review action to a calibration
1927/// table.
1928#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1929pub enum CalibrationReviewError {
1930    #[error("calibration record {record_id} not found in table")]
1931    RecordNotFound { record_id: ArtifactId },
1932    #[error("review action {status:?} on {record_id} requires a non-empty note")]
1933    EmptyNote {
1934        record_id: ArtifactId,
1935        status: CalibrationStatus,
1936    },
1937}
1938
1939fn compute_verdict(package: &TruthPackage, observation: &AxiomRunObservation) -> AxiomRunVerdict {
1940    let spec = &package.verifier_spec;
1941
1942    // 1. Lineage closure: every cited clause must belong to this package.
1943    let known_clause_ids: BTreeSet<&ClauseId> = package
1944        .source_jtbd
1945        .clauses
1946        .iter()
1947        .map(|clause| &clause.id)
1948        .collect();
1949    let lineage_violation = observation
1950        .promoted_facts
1951        .iter()
1952        .flat_map(|fact| fact.source_clause_ids.iter())
1953        .any(|id| !known_clause_ids.contains(id));
1954    if lineage_violation {
1955        return AxiomRunVerdict::Invalid;
1956    }
1957
1958    // 2. Forbidden action observed — best-effort substring match against
1959    //    promoted fact summaries and replay notes. Typed invariant violations
1960    //    on the stop reason are the stronger signal; this catches textual
1961    //    reports that name a forbidden outcome without raising a typed one.
1962    let forbidden_observed = spec.forbidden_actions.iter().any(|forbidden| {
1963        let needle = forbidden.action.as_str();
1964        observation
1965            .promoted_facts
1966            .iter()
1967            .any(|fact| fact.summary.contains(needle))
1968            || observation
1969                .replay_notes
1970                .iter()
1971                .any(|note| note.contains(needle))
1972            || observation
1973                .run_stages
1974                .iter()
1975                .any(|stage| stage.replay_notes.iter().any(|note| note.contains(needle)))
1976    });
1977    if forbidden_observed {
1978        return AxiomRunVerdict::Invalid;
1979    }
1980
1981    // 3. Unexpected stop reason — categorize the deviation.
1982    if !observation
1983        .stop_reason
1984        .matches_expected(&spec.expected_stop_reasons)
1985    {
1986        if observation.stop_reason.is_invalid() {
1987            return AxiomRunVerdict::Invalid;
1988        }
1989        if observation.stop_reason.is_budget_exhausted() {
1990            return AxiomRunVerdict::Exhausted;
1991        }
1992        if observation.stop_reason.is_blocked() {
1993            return AxiomRunVerdict::Blocked;
1994        }
1995        return AxiomRunVerdict::Invalid;
1996    }
1997
1998    // 4. Expected stop reason — every required-evidence clause must be cited
1999    //    by at least one promoted fact.
2000    let required_evidence_ids: BTreeSet<&ClauseId> = package
2001        .source_jtbd
2002        .clauses_by_kind(JtbdClauseKind::EvidenceRequired)
2003        .map(|clause| &clause.id)
2004        .collect();
2005    let cited_clause_ids: BTreeSet<&ClauseId> = observation
2006        .promoted_facts
2007        .iter()
2008        .flat_map(|fact| fact.source_clause_ids.iter())
2009        .collect();
2010    if !required_evidence_ids.is_subset(&cited_clause_ids) {
2011        return AxiomRunVerdict::Invalid;
2012    }
2013
2014    AxiomRunVerdict::Satisfied
2015}
2016
2017/// One artifact-to-clause lineage statement.
2018#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2019pub struct ArtifactLineage {
2020    pub artifact_id: ArtifactId,
2021    pub artifact_kind: ArtifactKind,
2022    pub source_clause_ids: Vec<ClauseId>,
2023    pub decoder_rule_id: String,
2024    pub decoder_version: String,
2025    pub input_fingerprints: Vec<ClauseFingerprint>,
2026}
2027
2028impl ArtifactLineage {
2029    pub fn new(
2030        artifact_id: ArtifactId,
2031        artifact_kind: ArtifactKind,
2032        source_clause_ids: Vec<ClauseId>,
2033        decoder_rule_id: impl Into<String>,
2034        decoder_version: impl Into<String>,
2035        document: &JtbdDocument,
2036    ) -> Self {
2037        let input_fingerprints = source_clause_ids
2038            .iter()
2039            .filter_map(|id| document.clause(id))
2040            .map(|clause| clause.fingerprint.clone())
2041            .collect();
2042
2043        Self {
2044            artifact_id,
2045            artifact_kind,
2046            source_clause_ids,
2047            decoder_rule_id: decoder_rule_id.into(),
2048            decoder_version: decoder_version.into(),
2049            input_fingerprints,
2050        }
2051    }
2052}
2053
2054/// Explicit disposition for a clause that is not used by an artifact yet.
2055#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2056#[serde(rename_all = "snake_case")]
2057pub enum ClauseDisposition {
2058    Deferred { reason: String },
2059    Rejected { reason: String },
2060}
2061
2062/// Bidirectional closure check for clause-to-artifact custody.
2063#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
2064pub struct LineageMap {
2065    pub artifacts: Vec<ArtifactLineage>,
2066    pub clause_dispositions: BTreeMap<ClauseId, ClauseDisposition>,
2067}
2068
2069impl LineageMap {
2070    pub fn single_artifact_from_document(
2071        document: &JtbdDocument,
2072        artifact_id: ArtifactId,
2073        artifact_kind: ArtifactKind,
2074        decoder_rule_id: impl Into<String>,
2075        decoder_version: impl Into<String>,
2076    ) -> Self {
2077        let source_clause_ids: Vec<ClauseId> = document.clause_ids().cloned().collect();
2078        Self {
2079            artifacts: vec![ArtifactLineage::new(
2080                artifact_id,
2081                artifact_kind,
2082                source_clause_ids,
2083                decoder_rule_id,
2084                decoder_version,
2085                document,
2086            )],
2087            clause_dispositions: BTreeMap::new(),
2088        }
2089    }
2090
2091    pub fn validate_closure(&self, document: &JtbdDocument) -> Result<(), LineageError> {
2092        let known = document.known_clause_ids();
2093        let mut accounted = BTreeSet::new();
2094
2095        for artifact in &self.artifacts {
2096            if artifact.source_clause_ids.is_empty() {
2097                return Err(LineageError::ArtifactWithoutSource {
2098                    artifact_id: artifact.artifact_id.clone(),
2099                });
2100            }
2101
2102            for clause_id in &artifact.source_clause_ids {
2103                if !known.contains(clause_id) {
2104                    return Err(LineageError::UnknownArtifactClause {
2105                        artifact_id: artifact.artifact_id.clone(),
2106                        clause_id: clause_id.clone(),
2107                    });
2108                }
2109                accounted.insert(clause_id.clone());
2110            }
2111        }
2112
2113        for clause_id in self.clause_dispositions.keys() {
2114            if !known.contains(clause_id) {
2115                return Err(LineageError::UnknownDispositionClause {
2116                    clause_id: clause_id.clone(),
2117                });
2118            }
2119            accounted.insert(clause_id.clone());
2120        }
2121
2122        for clause_id in &known {
2123            if !accounted.contains(clause_id) {
2124                return Err(LineageError::UnaccountedClause {
2125                    clause_id: clause_id.clone(),
2126                });
2127            }
2128        }
2129
2130        Ok(())
2131    }
2132}
2133
2134/// Errors while normalizing the Truth Package spine.
2135#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2136pub enum TruthPackageError {
2137    #[error("empty JTBD clause: {field}")]
2138    EmptyClause { field: String },
2139    #[error("duplicate clause id: {id}")]
2140    DuplicateClauseId { id: ClauseId },
2141}
2142
2143/// Errors while validating a lineage map.
2144#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
2145pub enum LineageError {
2146    #[error("artifact {artifact_id} has no source clauses")]
2147    ArtifactWithoutSource { artifact_id: ArtifactId },
2148    #[error("artifact {artifact_id} references unknown clause {clause_id}")]
2149    UnknownArtifactClause {
2150        artifact_id: ArtifactId,
2151        clause_id: ClauseId,
2152    },
2153    #[error("clause disposition references unknown clause {clause_id}")]
2154    UnknownDispositionClause { clause_id: ClauseId },
2155    #[error("clause {clause_id} is not used, deferred, or rejected")]
2156    UnaccountedClause { clause_id: ClauseId },
2157}
2158
2159#[derive(Debug, thiserror::Error)]
2160pub enum TruthOverlayError {
2161    #[error("overlay targets package {target}, but package is {actual}")]
2162    PackageMismatch {
2163        target: TruthPackageId,
2164        actual: TruthPackageId,
2165    },
2166    #[error("overlay targets truth version {target}, but package version is {actual}")]
2167    TruthVersionMismatch { target: String, actual: String },
2168    #[error("overlay projection version must not be empty")]
2169    EmptyProjectionVersion,
2170    #[error("overlay reason must not be empty")]
2171    EmptyReason,
2172    #[error("overlay must name at least one source clause")]
2173    MissingSourceClauses,
2174    #[error("overlay references unknown clause {clause_id}")]
2175    UnknownSourceClause { clause_id: ClauseId },
2176    #[error("overlay .truths projection did not parse: {message}")]
2177    TruthProjectionParse { message: String },
2178}
2179
2180/// Decode a structured JTBD into the first deterministic Truth Package spine.
2181///
2182/// This intentionally uses rule-based generation only. LLM-backed enrichment
2183/// can be added later, but must feed this deterministic manifest builder.
2184pub fn decode_jtbd(input: JtbdInput) -> Result<TruthPackage, DecodeJtbdError> {
2185    let document = JtbdDocument::from_input(input)?;
2186    let package_id = package_id_for_document(&document);
2187    let generated_truths = generate_truth_projection(&document);
2188    let parsed_truth = parse_truth_document(&generated_truths).map_err(|err| {
2189        DecodeJtbdError::TruthProjectionParse {
2190            message: err.to_string(),
2191        }
2192    })?;
2193    let mut intent_packet = compile_intent(&parsed_truth)?;
2194    intent_packet.id = deterministic_uuid(package_id.as_str());
2195    intent_packet.context = json!({
2196        "truth_package_id": package_id.as_str(),
2197        "truth_version": TRUTH_VERSION,
2198        "source_clause_ids": document
2199            .clause_ids()
2200            .map(ClauseId::as_str)
2201            .collect::<Vec<_>>(),
2202    });
2203    if let Some(budget) = document.time_budget
2204        && let Some(ctx) = intent_packet.context.as_object_mut()
2205    {
2206        ctx.insert(
2207            "time_budget_seconds".to_string(),
2208            json!(budget.as_seconds()),
2209        );
2210    }
2211
2212    let artifacts = build_artifacts(&document);
2213    let proof_obligations = build_proof_obligations(&document);
2214    let verifier_spec = build_verifier_spec(&document);
2215    let replay_profile = ReplayProfile {
2216        profile_id: ArtifactId::new(format!("replay_profile.{}", document.key)),
2217        deterministic: true,
2218        input_clause_ids: document.clause_ids().cloned().collect(),
2219    };
2220    let lineage = build_lineage_map(
2221        &document,
2222        &artifacts,
2223        &proof_obligations,
2224        &verifier_spec,
2225        &replay_profile,
2226    );
2227    lineage.validate_closure(&document)?;
2228
2229    Ok(TruthPackage {
2230        package_id,
2231        truth_version: TRUTH_VERSION.to_string(),
2232        source_jtbd: document,
2233        generated_truths,
2234        artifacts,
2235        intent_packet,
2236        proof_obligations,
2237        verifier_spec,
2238        replay_profile,
2239        lineage,
2240    })
2241}
2242
2243pub fn apply_truth_projection_overlay(
2244    package: &TruthPackage,
2245    overlay: TruthProjectionOverlay,
2246) -> Result<TruthProjectionVersion, TruthOverlayError> {
2247    if overlay.target_package_id != package.package_id {
2248        return Err(TruthOverlayError::PackageMismatch {
2249            target: overlay.target_package_id,
2250            actual: package.package_id.clone(),
2251        });
2252    }
2253    if overlay.target_truth_version != package.truth_version {
2254        return Err(TruthOverlayError::TruthVersionMismatch {
2255            target: overlay.target_truth_version,
2256            actual: package.truth_version.clone(),
2257        });
2258    }
2259    if canonicalize_clause_text(&overlay.projection_version).is_empty() {
2260        return Err(TruthOverlayError::EmptyProjectionVersion);
2261    }
2262    if canonicalize_clause_text(&overlay.reason).is_empty() {
2263        return Err(TruthOverlayError::EmptyReason);
2264    }
2265    if overlay.source_clause_ids.is_empty() {
2266        return Err(TruthOverlayError::MissingSourceClauses);
2267    }
2268
2269    let known = package.source_jtbd.known_clause_ids();
2270    for clause_id in &overlay.source_clause_ids {
2271        if !known.contains(clause_id) {
2272            return Err(TruthOverlayError::UnknownSourceClause {
2273                clause_id: clause_id.clone(),
2274            });
2275        }
2276    }
2277
2278    parse_truth_document(&overlay.edited_truths).map_err(|err| {
2279        TruthOverlayError::TruthProjectionParse {
2280            message: err.to_string(),
2281        }
2282    })?;
2283
2284    let lineage = ArtifactLineage::new(
2285        overlay.overlay_id.clone(),
2286        ArtifactKind::TruthProjection,
2287        overlay.source_clause_ids.clone(),
2288        "truth_projection_overlay.v0",
2289        DECODER_VERSION,
2290        &package.source_jtbd,
2291    );
2292
2293    Ok(TruthProjectionVersion {
2294        package_id: package.package_id.clone(),
2295        base_truth_version: package.truth_version.clone(),
2296        projection_version: overlay.projection_version,
2297        truths: overlay.edited_truths,
2298        source: TruthProjectionSource::OverlayApplied {
2299            overlay_id: overlay.overlay_id,
2300            reason: overlay.reason,
2301        },
2302        lineage,
2303    })
2304}
2305
2306#[derive(Debug, thiserror::Error)]
2307pub enum DecodeJtbdError {
2308    #[error(transparent)]
2309    TruthPackage(#[from] TruthPackageError),
2310    #[error("generated truth projection did not parse: {message}")]
2311    TruthProjectionParse { message: String },
2312    #[error(transparent)]
2313    Intent(#[from] CompileError),
2314    #[error(transparent)]
2315    Lineage(#[from] LineageError),
2316}
2317
2318pub fn canonicalize_clause_text(text: &str) -> String {
2319    text.split_whitespace().collect::<Vec<_>>().join(" ")
2320}
2321
2322fn package_id_for_document(document: &JtbdDocument) -> TruthPackageId {
2323    let mut hasher = Sha256::new();
2324    hasher.update(document.key.as_bytes());
2325    for clause in &document.clauses {
2326        hasher.update(clause.id.as_str().as_bytes());
2327        hasher.update(clause.fingerprint.as_str().as_bytes());
2328    }
2329    let digest = hex_lower(&hasher.finalize());
2330    TruthPackageId::new(format!("truth_package.{}.{}", document.key, &digest[..12]))
2331}
2332
2333fn overlay_id_for(
2334    package_id: &TruthPackageId,
2335    target_truth_version: &str,
2336    projection_version: &str,
2337    edited_truths: &str,
2338) -> ArtifactId {
2339    let mut hasher = Sha256::new();
2340    hasher.update(package_id.as_str().as_bytes());
2341    hasher.update(target_truth_version.as_bytes());
2342    hasher.update(projection_version.as_bytes());
2343    hasher.update(canonicalize_clause_text(edited_truths).as_bytes());
2344    let digest = hex_lower(&hasher.finalize());
2345    ArtifactId::new(format!("truth_projection_overlay.{}", &digest[..12]))
2346}
2347
2348fn generate_truth_projection(document: &JtbdDocument) -> String {
2349    let actor = required_clause(document, JtbdClauseKind::Actor);
2350    let functional_job = required_clause(document, JtbdClauseKind::FunctionalJob);
2351    let so_that = required_clause(document, JtbdClauseKind::SoThat);
2352    let evidence: Vec<&JtbdClause> = document
2353        .clauses_by_kind(JtbdClauseKind::EvidenceRequired)
2354        .collect();
2355    let failures: Vec<&JtbdClause> = document
2356        .clauses_by_kind(JtbdClauseKind::FailureMode)
2357        .collect();
2358
2359    let mut out = String::new();
2360    push_line(
2361        &mut out,
2362        &format!("Truth: {}", sentence_title(&functional_job.canonical_text)),
2363    );
2364    push_line(&mut out, "");
2365    push_line(&mut out, "  Intent:");
2366    push_line(
2367        &mut out,
2368        &format!("    Outcome: {}", so_that.canonical_text),
2369    );
2370    push_line(&mut out, "");
2371    push_line(&mut out, "  Authority:");
2372    push_line(&mut out, &format!("    Actor: {}", actor.canonical_text));
2373    push_line(
2374        &mut out,
2375        &format!("    May: attempt {}", functional_job.canonical_text),
2376    );
2377    push_line(
2378        &mut out,
2379        &format!(
2380            "    Expires: {}",
2381            deterministic_expires_line(document.time_budget)
2382        ),
2383    );
2384    if !failures.is_empty() {
2385        push_line(&mut out, "");
2386        push_line(&mut out, "  Constraint:");
2387        for failure in &failures {
2388            push_line(
2389                &mut out,
2390                &format!("    Must Not: {}", failure.canonical_text),
2391            );
2392        }
2393    }
2394    push_line(&mut out, "");
2395    push_line(&mut out, "  Evidence:");
2396    for item in &evidence {
2397        push_line(&mut out, &format!("    Requires: {}", item.canonical_text));
2398    }
2399    push_line(&mut out, "    Provenance: axiom_truth_package");
2400    push_line(&mut out, "    Audit: truth_package_lineage");
2401    push_line(&mut out, "");
2402    push_line(&mut out, "  @acceptance @invariant");
2403    push_line(
2404        &mut out,
2405        "  Scenario: Job is satisfied by required evidence",
2406    );
2407    push_line(&mut out, "    Given the actor has the declared job");
2408    push_line(&mut out, "    When the system evaluates the truth package");
2409    push_line(
2410        &mut out,
2411        "    Then the declared outcome is supported by required evidence",
2412    );
2413    out
2414}
2415
2416fn build_artifacts(document: &JtbdDocument) -> TruthPackageArtifacts {
2417    let all_clause_ids: Vec<ClauseId> = document.clause_ids().cloned().collect();
2418    let actor = required_clause(document, JtbdClauseKind::Actor);
2419    let functional_job = required_clause(document, JtbdClauseKind::FunctionalJob);
2420    let scenarios = vec![GeneratedArtifact {
2421        artifact_id: ArtifactId::new(format!("scenario.{}.satisfaction", document.key)),
2422        artifact_kind: ArtifactKind::Scenario,
2423        summary: "Job is satisfied by required evidence".to_string(),
2424        source_clause_ids: all_clause_ids.clone(),
2425    }];
2426    let policy_requirements = build_policy_requirement_artifacts(document, actor, functional_job);
2427    let evidence_expectations = document
2428        .clauses_by_kind(JtbdClauseKind::EvidenceRequired)
2429        .map(|clause| GeneratedArtifact {
2430            artifact_id: ArtifactId::new(format!("evidence_expectation.{}", clause.key)),
2431            artifact_kind: ArtifactKind::VerifierExpectation,
2432            summary: clause.canonical_text.clone(),
2433            source_clause_ids: vec![clause.id.clone()],
2434        })
2435        .collect();
2436    let invariant_expectations = document
2437        .clauses_by_kind(JtbdClauseKind::FailureMode)
2438        .map(|clause| GeneratedArtifact {
2439            artifact_id: ArtifactId::new(format!("invariant_expectation.{}", clause.key)),
2440            artifact_kind: ArtifactKind::InvariantArtifact,
2441            summary: format!("forbid {}", clause.canonical_text),
2442            source_clause_ids: vec![clause.id.clone()],
2443        })
2444        .collect();
2445    let simulation_cases = vec![GeneratedArtifact {
2446        artifact_id: ArtifactId::new(format!("simulation_case.{}.baseline", document.key)),
2447        artifact_kind: ArtifactKind::SimulationCase,
2448        summary: "baseline deterministic package readiness case".to_string(),
2449        source_clause_ids: all_clause_ids,
2450    }];
2451
2452    TruthPackageArtifacts {
2453        scenarios,
2454        predicates: Vec::new(),
2455        policy_requirements,
2456        evidence_expectations,
2457        simulation_cases,
2458        invariant_expectations,
2459        calibration_suggestions: Vec::new(),
2460        calibration_concerns: Vec::new(),
2461    }
2462}
2463
2464fn build_policy_requirement_artifacts(
2465    document: &JtbdDocument,
2466    actor: &JtbdClause,
2467    functional_job: &JtbdClause,
2468) -> Vec<GeneratedArtifact> {
2469    let mut artifacts = vec![GeneratedArtifact {
2470        artifact_id: ArtifactId::new(format!("policy_requirement.{}.authority", document.key)),
2471        artifact_kind: ArtifactKind::PolicyRequirement,
2472        summary: format!(
2473            "authority envelope: {} may attempt {} only through current Converge promotion policy",
2474            actor.canonical_text, functional_job.canonical_text
2475        ),
2476        source_clause_ids: vec![actor.id.clone(), functional_job.id.clone()],
2477    }];
2478
2479    artifacts.extend(
2480        document
2481            .clauses_by_kind(JtbdClauseKind::EvidenceRequired)
2482            .map(|clause| GeneratedArtifact {
2483                artifact_id: ArtifactId::new(format!(
2484                    "policy_requirement.{}.evidence.{}",
2485                    document.key, clause.key
2486                )),
2487                artifact_kind: ArtifactKind::PolicyRequirement,
2488                summary: format!("require before promotion: {}", clause.canonical_text),
2489                source_clause_ids: vec![clause.id.clone()],
2490            }),
2491    );
2492
2493    artifacts.extend(
2494        document
2495            .clauses_by_kind(JtbdClauseKind::FailureMode)
2496            .map(|clause| GeneratedArtifact {
2497                artifact_id: ArtifactId::new(format!(
2498                    "policy_requirement.{}.failure.{}",
2499                    document.key, clause.key
2500                )),
2501                artifact_kind: ArtifactKind::PolicyRequirement,
2502                summary: format!("forbid promotion when observed: {}", clause.canonical_text),
2503                source_clause_ids: vec![clause.id.clone()],
2504            }),
2505    );
2506
2507    artifacts
2508}
2509
2510fn build_proof_obligations(document: &JtbdDocument) -> Vec<ProofObligation> {
2511    let mut obligations = Vec::new();
2512    obligations.extend(
2513        document
2514            .clauses_by_kind(JtbdClauseKind::EvidenceRequired)
2515            .map(|clause| ProofObligation {
2516                artifact_id: ArtifactId::new(format!("proof_obligation.evidence.{}", clause.key)),
2517                kind: ProofObligationKind::EvidenceRequired,
2518                description: format!("evidence required: {}", clause.canonical_text),
2519                source_clause_ids: vec![clause.id.clone()],
2520            }),
2521    );
2522    obligations.extend(
2523        document
2524            .clauses_by_kind(JtbdClauseKind::FailureMode)
2525            .map(|clause| ProofObligation {
2526                artifact_id: ArtifactId::new(format!("proof_obligation.failure.{}", clause.key)),
2527                kind: ProofObligationKind::FailureGuard,
2528                description: format!("failure mode must be guarded: {}", clause.canonical_text),
2529                source_clause_ids: vec![clause.id.clone()],
2530            }),
2531    );
2532    obligations
2533}
2534
2535fn build_verifier_spec(document: &JtbdDocument) -> VerifierSpec {
2536    let required_evidence = document
2537        .clauses_by_kind(JtbdClauseKind::EvidenceRequired)
2538        .map(|clause| clause.canonical_text.clone())
2539        .collect();
2540    let forbidden_actions = document
2541        .clauses_by_kind(JtbdClauseKind::FailureMode)
2542        .map(|clause| ForbiddenAction {
2543            action: clause.canonical_text.clone(),
2544            reason: "failure_mode".to_string(),
2545        })
2546        .collect();
2547    let satisfaction_conditions = vec![
2548        required_clause(document, JtbdClauseKind::SoThat)
2549            .canonical_text
2550            .clone(),
2551    ];
2552
2553    VerifierSpec {
2554        expected_stop_reasons: BTreeSet::from([
2555            ExpectedStopReason::Converged,
2556            ExpectedStopReason::CriteriaMet,
2557        ]),
2558        required_evidence,
2559        forbidden_actions,
2560        satisfaction_conditions,
2561    }
2562}
2563
2564fn build_lineage_map(
2565    document: &JtbdDocument,
2566    artifacts: &TruthPackageArtifacts,
2567    proof_obligations: &[ProofObligation],
2568    verifier_spec: &VerifierSpec,
2569    replay_profile: &ReplayProfile,
2570) -> LineageMap {
2571    let all_clause_ids: Vec<ClauseId> = document.clause_ids().cloned().collect();
2572    let mut lineages = vec![
2573        ArtifactLineage::new(
2574            ArtifactId::new(format!("manifest.{}", document.key)),
2575            ArtifactKind::TruthPackageManifest,
2576            all_clause_ids.clone(),
2577            "truth_package_manifest.v0",
2578            DECODER_VERSION,
2579            document,
2580        ),
2581        ArtifactLineage::new(
2582            ArtifactId::new(format!("truth_projection.{}", document.key)),
2583            ArtifactKind::TruthProjection,
2584            all_clause_ids.clone(),
2585            "truth_projection.v0",
2586            DECODER_VERSION,
2587            document,
2588        ),
2589        ArtifactLineage::new(
2590            ArtifactId::new(format!("intent_packet.{}", document.key)),
2591            ArtifactKind::IntentField,
2592            all_clause_ids.clone(),
2593            "intent_packet.v0",
2594            DECODER_VERSION,
2595            document,
2596        ),
2597        ArtifactLineage::new(
2598            ArtifactId::new(format!("verifier_spec.{}", document.key)),
2599            ArtifactKind::VerifierExpectation,
2600            verifier_source_clause_ids(document, verifier_spec),
2601            "verifier_spec.v0",
2602            DECODER_VERSION,
2603            document,
2604        ),
2605        ArtifactLineage::new(
2606            replay_profile.profile_id.clone(),
2607            ArtifactKind::ReplayProfile,
2608            replay_profile.input_clause_ids.clone(),
2609            "replay_profile.v0",
2610            DECODER_VERSION,
2611            document,
2612        ),
2613    ];
2614
2615    for artifact in generated_artifact_iter(artifacts) {
2616        lineages.push(ArtifactLineage::new(
2617            artifact.artifact_id.clone(),
2618            artifact.artifact_kind,
2619            artifact.source_clause_ids.clone(),
2620            "generated_artifact.v0",
2621            DECODER_VERSION,
2622            document,
2623        ));
2624    }
2625    for obligation in proof_obligations {
2626        lineages.push(ArtifactLineage::new(
2627            obligation.artifact_id.clone(),
2628            ArtifactKind::ProofObligation,
2629            obligation.source_clause_ids.clone(),
2630            "proof_obligation.v0",
2631            DECODER_VERSION,
2632            document,
2633        ));
2634    }
2635
2636    LineageMap {
2637        artifacts: lineages,
2638        clause_dispositions: BTreeMap::new(),
2639    }
2640}
2641
2642fn generated_artifact_iter(
2643    artifacts: &TruthPackageArtifacts,
2644) -> impl Iterator<Item = &GeneratedArtifact> {
2645    artifacts
2646        .scenarios
2647        .iter()
2648        .chain(artifacts.predicates.iter())
2649        .chain(artifacts.policy_requirements.iter())
2650        .chain(artifacts.evidence_expectations.iter())
2651        .chain(artifacts.simulation_cases.iter())
2652        .chain(artifacts.invariant_expectations.iter())
2653        .chain(artifacts.calibration_suggestions.iter())
2654        .chain(artifacts.calibration_concerns.iter())
2655}
2656
2657fn verifier_source_clause_ids(
2658    document: &JtbdDocument,
2659    verifier_spec: &VerifierSpec,
2660) -> Vec<ClauseId> {
2661    let mut ids = BTreeSet::new();
2662    ids.insert(required_clause(document, JtbdClauseKind::SoThat).id.clone());
2663    if !verifier_spec.required_evidence.is_empty() {
2664        ids.extend(
2665            document
2666                .clauses_by_kind(JtbdClauseKind::EvidenceRequired)
2667                .map(|clause| clause.id.clone()),
2668        );
2669    }
2670    if !verifier_spec.forbidden_actions.is_empty() {
2671        ids.extend(
2672            document
2673                .clauses_by_kind(JtbdClauseKind::FailureMode)
2674                .map(|clause| clause.id.clone()),
2675        );
2676    }
2677    ids.into_iter().collect()
2678}
2679
2680fn required_clause(document: &JtbdDocument, kind: JtbdClauseKind) -> &JtbdClause {
2681    document
2682        .clauses_by_kind(kind)
2683        .next()
2684        .expect("JtbdDocument always contains scalar clauses")
2685}
2686
2687fn deterministic_uuid(seed: &str) -> Uuid {
2688    let mut hasher = Sha256::new();
2689    hasher.update(seed.as_bytes());
2690    let digest = hasher.finalize();
2691    let mut bytes = [0_u8; 16];
2692    bytes.copy_from_slice(&digest[..16]);
2693    bytes[6] = (bytes[6] & 0x0f) | 0x50;
2694    bytes[8] = (bytes[8] & 0x3f) | 0x80;
2695    Uuid::from_bytes(bytes)
2696}
2697
2698fn sentence_title(value: &str) -> String {
2699    let mut chars = value.chars();
2700    let Some(first) = chars.next() else {
2701        return "Untitled job".to_string();
2702    };
2703    format!("{}{}", first.to_uppercase(), chars.as_str())
2704}
2705
2706fn push_line(buffer: &mut String, line: &str) {
2707    buffer.push_str(line);
2708    buffer.push('\n');
2709}
2710
2711fn scalar_clause(
2712    root_key: &str,
2713    kind: JtbdClauseKind,
2714    path: &str,
2715    text: String,
2716) -> Result<JtbdClause, TruthPackageError> {
2717    build_clause(root_key, kind, path, path, text)
2718}
2719
2720fn collection_clauses(
2721    root_key: &str,
2722    kind: JtbdClauseKind,
2723    path_prefix: &str,
2724    inputs: Vec<ClauseInput>,
2725) -> Result<Vec<JtbdClause>, TruthPackageError> {
2726    let mut implicit_counts = BTreeMap::<String, usize>::new();
2727    for input in &inputs {
2728        if input.key.is_none() {
2729            let canonical = canonicalize_clause_text(&input.text);
2730            let fingerprint = ClauseFingerprint::from_text(&canonical);
2731            let key = implicit_clause_key(&canonical, &fingerprint);
2732            *implicit_counts.entry(key).or_default() += 1;
2733        }
2734    }
2735
2736    let mut clauses = Vec::with_capacity(inputs.len());
2737    for input in inputs {
2738        let canonical = canonicalize_clause_text(&input.text);
2739        let fingerprint = ClauseFingerprint::from_text(&canonical);
2740        let explicit = input.key.is_some();
2741        let base_key = input.key.map_or_else(
2742            || implicit_clause_key(&canonical, &fingerprint),
2743            |key| normalized_key(&key, "clause"),
2744        );
2745        let key = if explicit || implicit_counts.get(&base_key).copied().unwrap_or(0) <= 1 {
2746            base_key
2747        } else {
2748            format!("{base_key}_{}", fingerprint.short())
2749        };
2750        let path = format!("{path_prefix}.{key}");
2751        clauses.push(build_clause(root_key, kind, &path, &key, input.text)?);
2752    }
2753
2754    clauses.sort_by(|left, right| left.id.cmp(&right.id));
2755    ensure_unique_clause_ids(&clauses)?;
2756    Ok(clauses)
2757}
2758
2759fn build_clause(
2760    root_key: &str,
2761    kind: JtbdClauseKind,
2762    path: &str,
2763    key: &str,
2764    text: String,
2765) -> Result<JtbdClause, TruthPackageError> {
2766    let canonical_text = canonicalize_clause_text(&text);
2767    if canonical_text.is_empty() {
2768        return Err(TruthPackageError::EmptyClause {
2769            field: path.to_string(),
2770        });
2771    }
2772
2773    Ok(JtbdClause {
2774        id: ClauseId::new(root_key, path),
2775        kind,
2776        key: key.to_string(),
2777        text,
2778        fingerprint: ClauseFingerprint::from_text(&canonical_text),
2779        canonical_text,
2780    })
2781}
2782
2783fn ensure_unique_clause_ids(clauses: &[JtbdClause]) -> Result<(), TruthPackageError> {
2784    let mut seen = BTreeSet::new();
2785    for clause in clauses {
2786        if !seen.insert(clause.id.clone()) {
2787            return Err(TruthPackageError::DuplicateClauseId {
2788                id: clause.id.clone(),
2789            });
2790        }
2791    }
2792    Ok(())
2793}
2794
2795fn implicit_clause_key(canonical_text: &str, fingerprint: &ClauseFingerprint) -> String {
2796    let slug = slugify(canonical_text);
2797    if slug.is_empty() {
2798        format!("clause_{}", fingerprint.short())
2799    } else {
2800        slug
2801    }
2802}
2803
2804fn normalized_key(value: &str, fallback_prefix: &str) -> String {
2805    let slug = slugify(value);
2806    if slug.is_empty() {
2807        let fingerprint = ClauseFingerprint::from_text(value);
2808        format!("{fallback_prefix}_{}", fingerprint.short())
2809    } else {
2810        slug
2811    }
2812}
2813
2814fn slugify(value: &str) -> String {
2815    let mut out = String::new();
2816    let mut last_was_separator = false;
2817
2818    for ch in value.chars() {
2819        if ch.is_ascii_alphanumeric() {
2820            out.push(ch.to_ascii_lowercase());
2821            last_was_separator = false;
2822        } else if !last_was_separator {
2823            out.push('_');
2824            last_was_separator = true;
2825        }
2826    }
2827
2828    out.trim_matches('_').to_string()
2829}
2830
2831fn hex_lower(bytes: &[u8]) -> String {
2832    use std::fmt::Write as _;
2833
2834    let mut out = String::with_capacity(bytes.len() * 2);
2835    for byte in bytes {
2836        write!(&mut out, "{byte:02x}").expect("writing to String cannot fail");
2837    }
2838    out
2839}
2840
2841#[cfg(test)]
2842mod tests {
2843    use super::*;
2844
2845    fn vendor_input() -> JtbdInput {
2846        JtbdInput {
2847            key: "Vendor Commitment".to_string(),
2848            actor: "finance controller".to_string(),
2849            functional_job: "approve a vendor commitment".to_string(),
2850            so_that: "spend is traceable and policy-compliant".to_string(),
2851            evidence_required: vec![
2852                ClauseInput::new("vendor assessment"),
2853                ClauseInput::with_key("po", "purchase order"),
2854            ],
2855            failure_modes: vec![
2856                ClauseInput::new("bypassed approval"),
2857                ClauseInput::new("missing audit trail"),
2858            ],
2859            time_budget: None,
2860        }
2861    }
2862
2863    #[test]
2864    fn deterministic_clause_ids_do_not_depend_on_collection_order() {
2865        let mut reordered = vendor_input();
2866        reordered.evidence_required.reverse();
2867        reordered.failure_modes.reverse();
2868
2869        let original = JtbdDocument::from_input(vendor_input()).unwrap();
2870        let reordered = JtbdDocument::from_input(reordered).unwrap();
2871
2872        assert_eq!(original, reordered);
2873
2874        let ids: Vec<&str> = original.clause_ids().map(ClauseId::as_str).collect();
2875        assert!(ids.contains(&"jtbd.vendor_commitment.actor"));
2876        assert!(ids.contains(&"jtbd.vendor_commitment.functional_job"));
2877        assert!(ids.contains(&"jtbd.vendor_commitment.so_that"));
2878        assert!(ids.contains(&"jtbd.vendor_commitment.evidence.vendor_assessment"));
2879        assert!(ids.contains(&"jtbd.vendor_commitment.evidence.po"));
2880        assert!(ids.contains(&"jtbd.vendor_commitment.failure.bypassed_approval"));
2881        assert!(!ids.iter().any(|id| id.contains("[0]") || id.contains(".0")));
2882    }
2883
2884    #[test]
2885    fn explicit_clause_key_preserves_id_while_fingerprint_changes() {
2886        let mut first = vendor_input();
2887        first.evidence_required = vec![ClauseInput::with_key("risk_review", "risk review")];
2888
2889        let mut second = first.clone();
2890        second.evidence_required = vec![ClauseInput::with_key(
2891            "risk_review",
2892            "fresh risk review with policy citations",
2893        )];
2894
2895        let first = JtbdDocument::from_input(first).unwrap();
2896        let second = JtbdDocument::from_input(second).unwrap();
2897
2898        let first_clause = first
2899            .clauses
2900            .iter()
2901            .find(|clause| clause.key == "risk_review")
2902            .unwrap();
2903        let second_clause = second
2904            .clauses
2905            .iter()
2906            .find(|clause| clause.key == "risk_review")
2907            .unwrap();
2908
2909        assert_eq!(first_clause.id, second_clause.id);
2910        assert_ne!(first_clause.fingerprint, second_clause.fingerprint);
2911    }
2912
2913    #[test]
2914    fn lineage_map_closure_requires_every_clause_to_be_accounted_for() {
2915        let document = JtbdDocument::from_input(vendor_input()).unwrap();
2916        let map = LineageMap::single_artifact_from_document(
2917            &document,
2918            ArtifactId::new("truth_projection.vendor_commitment.v1"),
2919            ArtifactKind::TruthProjection,
2920            "truth_projection.v0",
2921            "0.10.0",
2922        );
2923
2924        assert!(map.validate_closure(&document).is_ok());
2925
2926        let mut missing_one = map.clone();
2927        missing_one.artifacts[0].source_clause_ids.pop();
2928        assert!(matches!(
2929            missing_one.validate_closure(&document),
2930            Err(LineageError::UnaccountedClause { .. })
2931        ));
2932
2933        let mut unknown = map;
2934        unknown.artifacts[0]
2935            .source_clause_ids
2936            .push(ClauseId::new("vendor_commitment", "evidence.unknown"));
2937        assert!(matches!(
2938            unknown.validate_closure(&document),
2939            Err(LineageError::UnknownArtifactClause { .. })
2940        ));
2941    }
2942
2943    #[test]
2944    fn decode_jtbd_builds_parseable_package_spine() {
2945        let package = decode_jtbd(vendor_input()).unwrap();
2946
2947        assert!(
2948            package
2949                .generated_truths
2950                .contains("Truth: Approve a vendor commitment")
2951        );
2952        assert!(
2953            package
2954                .generated_truths
2955                .contains("Requires: purchase order")
2956        );
2957        assert!(
2958            package
2959                .generated_truths
2960                .contains("Must Not: bypassed approval")
2961        );
2962        assert_eq!(package.truth_version, TRUTH_VERSION);
2963        assert_eq!(
2964            package.intent_packet.outcome,
2965            "spend is traceable and policy-compliant"
2966        );
2967        assert_eq!(
2968            package
2969                .intent_packet
2970                .context
2971                .get("truth_package_id")
2972                .and_then(serde_json::Value::as_str),
2973            Some(package.package_id.as_str())
2974        );
2975        assert_eq!(package.verifier_spec.required_evidence.len(), 2);
2976        assert_eq!(package.verifier_spec.forbidden_actions.len(), 2);
2977        assert_eq!(package.proof_obligations.len(), 4);
2978        assert!(
2979            package
2980                .lineage
2981                .validate_closure(&package.source_jtbd)
2982                .is_ok()
2983        );
2984    }
2985
2986    #[test]
2987    fn decode_jtbd_is_deterministic_for_same_semantic_input() {
2988        let mut reordered = vendor_input();
2989        reordered.evidence_required.reverse();
2990        reordered.failure_modes.reverse();
2991
2992        let first = decode_jtbd(vendor_input()).unwrap();
2993        let second = decode_jtbd(reordered).unwrap();
2994
2995        assert_eq!(first.package_id, second.package_id);
2996        assert_eq!(first.intent_packet.id, second.intent_packet.id);
2997        assert_eq!(first.generated_truths, second.generated_truths);
2998        assert_eq!(
2999            serde_json::to_value(&first).unwrap(),
3000            serde_json::to_value(&second).unwrap()
3001        );
3002    }
3003
3004    #[test]
3005    fn truth_projection_overlay_versions_without_mutating_package() {
3006        let package = decode_jtbd(vendor_input()).unwrap();
3007        let original_truths = package.generated_truths.clone();
3008        let mut edited_truths = package.generated_truths.clone();
3009        edited_truths.push_str("    And a finance owner can review the evidence\n");
3010        let actor_clause = package
3011            .source_jtbd
3012            .clauses_by_kind(JtbdClauseKind::Actor)
3013            .next()
3014            .unwrap()
3015            .id
3016            .clone();
3017        let overlay = TruthProjectionOverlay::new(
3018            package.package_id.clone(),
3019            package.truth_version.clone(),
3020            "v1.operator-review",
3021            edited_truths.clone(),
3022            "operator clarified review evidence",
3023            vec![actor_clause.clone()],
3024        );
3025
3026        let applied = package.apply_projection_overlay(overlay.clone()).unwrap();
3027
3028        assert_eq!(package.generated_truths, original_truths);
3029        assert_eq!(applied.truths, edited_truths);
3030        assert_eq!(applied.projection_version, "v1.operator-review");
3031        assert_eq!(
3032            applied.source,
3033            TruthProjectionSource::OverlayApplied {
3034                overlay_id: overlay.overlay_id,
3035                reason: "operator clarified review evidence".to_string(),
3036            }
3037        );
3038        assert_eq!(applied.lineage.source_clause_ids, vec![actor_clause]);
3039        assert_eq!(package.base_projection().truths, original_truths);
3040    }
3041
3042    #[test]
3043    fn truth_projection_overlay_rejects_mismatches_and_unknown_clauses() {
3044        let package = decode_jtbd(vendor_input()).unwrap();
3045        let known_clause = package.source_jtbd.clause_ids().next().unwrap().clone();
3046        let valid_overlay = TruthProjectionOverlay::new(
3047            package.package_id.clone(),
3048            package.truth_version.clone(),
3049            "v1.operator-review",
3050            package.generated_truths.clone(),
3051            "operator clarified review evidence",
3052            vec![known_clause],
3053        );
3054
3055        let mut wrong_package = valid_overlay.clone();
3056        wrong_package.target_package_id = TruthPackageId::new("truth_package.other");
3057        assert!(matches!(
3058            package.apply_projection_overlay(wrong_package),
3059            Err(TruthOverlayError::PackageMismatch { .. })
3060        ));
3061
3062        let mut wrong_version = valid_overlay.clone();
3063        wrong_version.target_truth_version = "v0".to_string();
3064        assert!(matches!(
3065            package.apply_projection_overlay(wrong_version),
3066            Err(TruthOverlayError::TruthVersionMismatch { .. })
3067        ));
3068
3069        let mut unknown_clause = valid_overlay.clone();
3070        unknown_clause.source_clause_ids =
3071            vec![ClauseId::new("vendor_commitment", "actor.missing")];
3072        assert!(matches!(
3073            package.apply_projection_overlay(unknown_clause),
3074            Err(TruthOverlayError::UnknownSourceClause { .. })
3075        ));
3076
3077        let mut invalid_truth = valid_overlay;
3078        invalid_truth.edited_truths = "Truth: Broken\n\n  Unknown:\n    Value: bad\n".to_string();
3079        assert!(matches!(
3080            package.apply_projection_overlay(invalid_truth),
3081            Err(TruthOverlayError::TruthProjectionParse { .. })
3082        ));
3083    }
3084
3085    #[test]
3086    fn observed_stop_reason_matches_expected_set() {
3087        let expected = BTreeSet::from([
3088            ExpectedStopReason::Converged,
3089            ExpectedStopReason::CriteriaMet,
3090        ]);
3091
3092        let criteria_met = ObservedStopReason::CriteriaMet {
3093            criteria: vec!["evidence_ready".to_string()],
3094        };
3095        let exhausted = ObservedStopReason::TokenBudgetExhausted {
3096            tokens_consumed: 10_001,
3097            limit: 10_000,
3098        };
3099
3100        assert!(criteria_met.matches_expected(&expected));
3101        assert!(!exhausted.matches_expected(&expected));
3102        assert!(exhausted.is_budget_exhausted());
3103        assert_eq!(
3104            ObservedStopReason::HitlGatePending {
3105                gate_id: "gate-1".to_string(),
3106                proposal_id: "proposal-1".to_string(),
3107                summary: "approval required".to_string(),
3108                agent_id: "truth-policy-gate".to_string(),
3109                cycle: 2,
3110            }
3111            .expectation_kind(),
3112            ExpectedStopReason::HitlGatePending
3113        );
3114    }
3115
3116    #[test]
3117    fn axiom_run_report_carries_stop_reason_facts_and_integrity() {
3118        let package = decode_jtbd(vendor_input()).unwrap();
3119        let evidence_clause = package
3120            .source_jtbd
3121            .clauses_by_kind(JtbdClauseKind::EvidenceRequired)
3122            .next()
3123            .unwrap()
3124            .id
3125            .clone();
3126        let observation = AxiomRunObservation {
3127            stop_reason: ObservedStopReason::Converged,
3128            promoted_facts: vec![PromotedFactRecord {
3129                context_key: "Evidence".to_string(),
3130                fact_id: "fact.vendor_assessment".to_string(),
3131                summary: "vendor assessment present".to_string(),
3132                source_clause_ids: vec![evidence_clause.clone()],
3133                evidence_refs: vec![EvidenceRefRecord {
3134                    evidence_id: "evidence.vendor_assessment".to_string(),
3135                    source: "axiom_truth_package".to_string(),
3136                }],
3137                trace_link: Some(TraceLinkRecord {
3138                    trace_id: "trace.vendor_commitment.1".to_string(),
3139                    location: Some("fixture://vendor_commitment".to_string()),
3140                    replayable: true,
3141                }),
3142                promotion_authority: None,
3143            }],
3144            integrity: RunIntegrityProof::sha256_merkle("sha256:abc123", 7, 5),
3145            replay_notes: vec!["deterministic replay profile matched".to_string()],
3146            run_stages: Vec::new(),
3147        };
3148
3149        let report =
3150            AxiomRunReport::from_observation(&package, AxiomRunVerdict::Satisfied, observation);
3151
3152        assert_eq!(report.package_id, package.package_id);
3153        assert_eq!(report.truth_version, "v1");
3154        assert_eq!(report.intent_packet_id, package.intent_packet.id);
3155        assert_eq!(report.verdict, AxiomRunVerdict::Satisfied);
3156        assert!(report.expected_stop_reason_matched());
3157        assert_eq!(report.promoted_facts.len(), 1);
3158        assert_eq!(
3159            report.promoted_facts[0].source_clause_ids,
3160            vec![evidence_clause]
3161        );
3162        assert_eq!(report.integrity.merkle_root, "sha256:abc123");
3163        assert_eq!(report.integrity.clock_time, 7);
3164        assert_eq!(report.integrity.fact_count, 5);
3165        assert_eq!(
3166            report.source_clause_ids.len(),
3167            package.source_jtbd.clauses.len()
3168        );
3169        assert_eq!(
3170            serde_json::to_value(&report).unwrap()["observed_stop_reason"]["kind"],
3171            "converged"
3172        );
3173    }
3174
3175    fn evidence_clause_id(package: &TruthPackage, key: &str) -> ClauseId {
3176        package
3177            .source_jtbd
3178            .clauses_by_kind(JtbdClauseKind::EvidenceRequired)
3179            .find(|clause| clause.key == key)
3180            .map_or_else(
3181                || panic!("missing evidence clause {key}"),
3182                |clause| clause.id.clone(),
3183            )
3184    }
3185
3186    fn promoted_fact(
3187        context_key: &str,
3188        fact_id: &str,
3189        summary: &str,
3190        source_clause_ids: Vec<ClauseId>,
3191    ) -> PromotedFactRecord {
3192        PromotedFactRecord {
3193            context_key: context_key.to_string(),
3194            fact_id: fact_id.to_string(),
3195            summary: summary.to_string(),
3196            source_clause_ids,
3197            evidence_refs: vec![EvidenceRefRecord {
3198                evidence_id: format!("evidence.{fact_id}"),
3199                source: "test_fixture".to_string(),
3200            }],
3201            trace_link: Some(TraceLinkRecord {
3202                trace_id: format!("trace.{fact_id}"),
3203                location: Some("test://verifier".to_string()),
3204                replayable: true,
3205            }),
3206            promotion_authority: None,
3207        }
3208    }
3209
3210    fn satisfying_observation(package: &TruthPackage) -> AxiomRunObservation {
3211        let promoted_facts: Vec<PromotedFactRecord> = package
3212            .source_jtbd
3213            .clauses_by_kind(JtbdClauseKind::EvidenceRequired)
3214            .map(|clause| {
3215                promoted_fact(
3216                    "Evidence",
3217                    &format!("fact.{}", clause.key),
3218                    &format!("{} observed", clause.canonical_text),
3219                    vec![clause.id.clone()],
3220                )
3221            })
3222            .collect();
3223        AxiomRunObservation {
3224            stop_reason: ObservedStopReason::Converged,
3225            promoted_facts,
3226            integrity: RunIntegrityProof::sha256_merkle("sha256:test", 1, 1),
3227            replay_notes: vec!["deterministic".to_string()],
3228            run_stages: Vec::new(),
3229        }
3230    }
3231
3232    #[test]
3233    fn verify_satisfied_when_evidence_complete_and_stop_expected() {
3234        let package = decode_jtbd(vendor_input()).unwrap();
3235        let observation = satisfying_observation(&package);
3236
3237        assert_eq!(package.verify(&observation), AxiomRunVerdict::Satisfied);
3238
3239        let report = AxiomRunReport::verify(&package, observation);
3240        assert_eq!(report.verdict, AxiomRunVerdict::Satisfied);
3241        assert!(report.expected_stop_reason_matched());
3242    }
3243
3244    #[test]
3245    fn verify_invalid_when_promoted_fact_cites_unknown_clause() {
3246        let package = decode_jtbd(vendor_input()).unwrap();
3247        let mut observation = satisfying_observation(&package);
3248        observation.promoted_facts.push(promoted_fact(
3249            "Evidence",
3250            "fact.unknown",
3251            "stray fact with no real clause",
3252            vec![ClauseId::new("vendor_commitment", "evidence.missing")],
3253        ));
3254
3255        assert_eq!(package.verify(&observation), AxiomRunVerdict::Invalid);
3256    }
3257
3258    #[test]
3259    fn verify_invalid_when_forbidden_action_text_appears_in_summary() {
3260        let package = decode_jtbd(vendor_input()).unwrap();
3261        let mut observation = satisfying_observation(&package);
3262        observation.promoted_facts.push(promoted_fact(
3263            "Diagnostic",
3264            "fact.violation",
3265            "bypassed approval detected on commitment ABC",
3266            vec![evidence_clause_id(&package, "vendor_assessment")],
3267        ));
3268
3269        assert_eq!(package.verify(&observation), AxiomRunVerdict::Invalid);
3270    }
3271
3272    #[test]
3273    fn verify_invalid_when_forbidden_action_text_appears_in_replay_note() {
3274        let package = decode_jtbd(vendor_input()).unwrap();
3275        let mut observation = satisfying_observation(&package);
3276        observation
3277            .replay_notes
3278            .push("missing audit trail surfaced post-run".to_string());
3279
3280        assert_eq!(package.verify(&observation), AxiomRunVerdict::Invalid);
3281    }
3282
3283    #[test]
3284    fn verify_exhausted_when_unexpected_budget_exhaustion() {
3285        let package = decode_jtbd(vendor_input()).unwrap();
3286        let mut observation = satisfying_observation(&package);
3287        observation.stop_reason = ObservedStopReason::TokenBudgetExhausted {
3288            tokens_consumed: 1_000_000,
3289            limit: 100_000,
3290        };
3291
3292        assert_eq!(package.verify(&observation), AxiomRunVerdict::Exhausted);
3293    }
3294
3295    #[test]
3296    fn verify_blocked_when_unexpected_hitl_gate_pending() {
3297        let package = decode_jtbd(vendor_input()).unwrap();
3298        let mut observation = satisfying_observation(&package);
3299        observation.stop_reason = ObservedStopReason::HitlGatePending {
3300            gate_id: "gate-1".to_string(),
3301            proposal_id: "proposal-1".to_string(),
3302            summary: "approval required".to_string(),
3303            agent_id: "policy-gate".to_string(),
3304            cycle: 3,
3305        };
3306
3307        assert_eq!(package.verify(&observation), AxiomRunVerdict::Blocked);
3308    }
3309
3310    #[test]
3311    fn verify_invalid_when_invariant_violated() {
3312        let package = decode_jtbd(vendor_input()).unwrap();
3313        let mut observation = satisfying_observation(&package);
3314        observation.stop_reason = ObservedStopReason::InvariantViolated {
3315            class: "structural".to_string(),
3316            name: "spend_authority".to_string(),
3317            reason: "ceiling exceeded".to_string(),
3318        };
3319
3320        assert_eq!(package.verify(&observation), AxiomRunVerdict::Invalid);
3321    }
3322
3323    #[test]
3324    fn verify_invalid_when_expected_stop_but_evidence_missing() {
3325        let package = decode_jtbd(vendor_input()).unwrap();
3326        let evidence = evidence_clause_id(&package, "vendor_assessment");
3327        // Cite only vendor_assessment but not the second `po` evidence clause.
3328        let observation = AxiomRunObservation {
3329            stop_reason: ObservedStopReason::Converged,
3330            promoted_facts: vec![promoted_fact(
3331                "Evidence",
3332                "fact.vendor_assessment",
3333                "vendor assessment captured",
3334                vec![evidence],
3335            )],
3336            integrity: RunIntegrityProof::sha256_merkle("sha256:test", 1, 1),
3337            replay_notes: vec![],
3338            run_stages: Vec::new(),
3339        };
3340
3341        assert_eq!(package.verify(&observation), AxiomRunVerdict::Invalid);
3342    }
3343
3344    #[test]
3345    fn time_budget_absent_uses_epoch_sentinel_and_omits_context_field() {
3346        let package = decode_jtbd(vendor_input()).unwrap();
3347
3348        assert!(
3349            package
3350                .generated_truths
3351                .contains("Expires: 2099-01-01T00:00:00Z")
3352        );
3353        assert_eq!(package.source_jtbd.time_budget, None);
3354        assert!(
3355            package
3356                .intent_packet
3357                .context
3358                .get("time_budget_seconds")
3359                .is_none()
3360        );
3361    }
3362
3363    #[test]
3364    fn time_budget_shifts_expires_and_populates_intent_context() {
3365        let input = vendor_input().with_time_budget(TimeBudget::from_minutes(45));
3366        let package = decode_jtbd(input).unwrap();
3367
3368        assert!(
3369            package
3370                .generated_truths
3371                .contains("Expires: 2099-01-01T00:45:00Z")
3372        );
3373        assert_eq!(
3374            package.source_jtbd.time_budget,
3375            Some(TimeBudget::from_minutes(45))
3376        );
3377        assert_eq!(
3378            package.intent_packet.context["time_budget_seconds"],
3379            serde_json::json!(45 * 60)
3380        );
3381    }
3382
3383    #[test]
3384    fn time_budget_preserves_decode_determinism() {
3385        let first =
3386            decode_jtbd(vendor_input().with_time_budget(TimeBudget::from_hours(2))).unwrap();
3387        let second =
3388            decode_jtbd(vendor_input().with_time_budget(TimeBudget::from_hours(2))).unwrap();
3389
3390        assert_eq!(first.package_id, second.package_id);
3391        assert_eq!(first.intent_packet.id, second.intent_packet.id);
3392        assert_eq!(first.generated_truths, second.generated_truths);
3393        assert_eq!(
3394            serde_json::to_value(&first).unwrap(),
3395            serde_json::to_value(&second).unwrap()
3396        );
3397    }
3398
3399    #[test]
3400    fn audit_fact_lineage_succeeds_when_facts_cite_evidence_clauses() {
3401        let package = decode_jtbd(vendor_input()).unwrap();
3402        let report = AxiomRunReport::verify(&package, satisfying_observation(&package));
3403
3404        let audit = report.audit_fact_lineage(&package).unwrap();
3405        assert_eq!(audit.package_id, package.package_id);
3406        assert_eq!(audit.truth_version, package.truth_version);
3407        assert_eq!(
3408            audit.facts_audited,
3409            package.verifier_spec.required_evidence.len()
3410        );
3411        assert_eq!(audit.evidence_coverage.len(), 2);
3412        assert!(audit.failure_coverage.is_empty());
3413    }
3414
3415    #[test]
3416    fn audit_fact_lineage_rejects_unknown_clause() {
3417        let package = decode_jtbd(vendor_input()).unwrap();
3418        let mut observation = satisfying_observation(&package);
3419        observation.promoted_facts.push(promoted_fact(
3420            "Evidence",
3421            "fact.unknown",
3422            "stray fact",
3423            vec![ClauseId::new("vendor_commitment", "evidence.missing")],
3424        ));
3425        // Use from_observation directly so the verdict path doesn't short-circuit
3426        // before audit_fact_lineage sees the unknown clause.
3427        let report =
3428            AxiomRunReport::from_observation(&package, AxiomRunVerdict::Satisfied, observation);
3429
3430        match report.audit_fact_lineage(&package) {
3431            Err(FactLineageAuditError::UnknownClause { fact_id, .. }) => {
3432                assert_eq!(fact_id, "fact.unknown");
3433            }
3434            other => panic!("expected UnknownClause, got {other:?}"),
3435        }
3436    }
3437
3438    #[test]
3439    fn audit_fact_lineage_rejects_fact_that_cites_only_scope_clauses() {
3440        let package = decode_jtbd(vendor_input()).unwrap();
3441        let actor_clause = package
3442            .source_jtbd
3443            .clauses_by_kind(JtbdClauseKind::Actor)
3444            .next()
3445            .unwrap()
3446            .id
3447            .clone();
3448        let observation = AxiomRunObservation {
3449            stop_reason: ObservedStopReason::Converged,
3450            promoted_facts: vec![promoted_fact(
3451                "Diagnostic",
3452                "fact.scope-only",
3453                "actor identity confirmed",
3454                vec![actor_clause],
3455            )],
3456            integrity: RunIntegrityProof::sha256_merkle("sha256:test", 1, 1),
3457            replay_notes: vec![],
3458            run_stages: Vec::new(),
3459        };
3460        let report =
3461            AxiomRunReport::from_observation(&package, AxiomRunVerdict::Satisfied, observation);
3462
3463        match report.audit_fact_lineage(&package) {
3464            Err(FactLineageAuditError::ScopeOnlyFact { fact_id }) => {
3465                assert_eq!(fact_id, "fact.scope-only");
3466            }
3467            other => panic!("expected ScopeOnlyFact, got {other:?}"),
3468        }
3469    }
3470
3471    #[test]
3472    fn audit_fact_lineage_rejects_package_id_mismatch() {
3473        let package = decode_jtbd(vendor_input()).unwrap();
3474        let mut report = AxiomRunReport::verify(&package, satisfying_observation(&package));
3475        report.package_id = TruthPackageId::new("truth_package.other");
3476
3477        assert!(matches!(
3478            report.audit_fact_lineage(&package),
3479            Err(FactLineageAuditError::PackageMismatch { .. })
3480        ));
3481    }
3482
3483    #[test]
3484    fn audit_fact_lineage_rejects_truth_version_mismatch() {
3485        let package = decode_jtbd(vendor_input()).unwrap();
3486        let mut report = AxiomRunReport::verify(&package, satisfying_observation(&package));
3487        report.truth_version = "v0".to_string();
3488
3489        assert!(matches!(
3490            report.audit_fact_lineage(&package),
3491            Err(FactLineageAuditError::TruthVersionMismatch { .. })
3492        ));
3493    }
3494}