Skip to main content

semantic_memory_forge/
envelope.rs

1#![allow(deprecated)]
2
3//! Forge-owned export envelope schema and metadata.
4//!
5//! Forge owns raw verification truth. Export envelopes are Forge-owned artifacts
6//! that flow through the bridge into memory.
7//!
8//! V3 is the canonical export contract. Within V3:
9//! - grouping semantics (`claim_family_id`, `assertion_group_id`,
10//!   `relation_group_id`) are canonical when the source can supply them,
11//! - `constraint_seed_kind` is canonical for relation/episode-style kernel
12//!   seeds but may remain absent on thin claim exports,
13//! - lineage such as `supersedes_*` and `derivation_seed_ids` must only be
14//!   emitted when the source bundle actually knows them,
15//! - absent semantics are an explicit thin-export state, not permission to
16//!   invent missing truth downstream.
17
18use crate::bundle::EvidenceBundle;
19use crate::{ClaimStateV13, ContradictionWitnessV1, RetractionRecordV1, SupportSetV1};
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23use stack_ids::{
24    AssertionGroupId, ClaimFamilyId, ClaimId, ClaimVersionId, ConstraintGroupId, ContentDigest,
25    ContradictionGroupId, DigestBuilder, EntityId, EnvelopeId, EpisodeId, JointEvidenceGroupId,
26    RelationGroupId, RelationVersionId, ScopeKey, TraceCtx,
27};
28use std::any::type_name;
29use thiserror::Error;
30use tracing::warn;
31
32/// Authority marker for Forge-produced exports.
33#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
34#[serde(rename_all = "snake_case")]
35pub enum ExportAuthority {
36    /// Standard Forge verification pipeline.
37    Forge,
38    /// External authority (e.g., manual import, third-party tool).
39    External { name: String },
40}
41
42impl ExportAuthority {
43    /// Returns the stable authority label carried into exported envelopes.
44    pub fn as_str(&self) -> &str {
45        match self {
46            Self::Forge => "forge",
47            Self::External { name } => name,
48        }
49    }
50}
51
52/// Metadata about a Forge export operation.
53#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
54pub struct ForgeExportMeta {
55    /// Authority that produced the export.
56    pub authority: ExportAuthority,
57    /// Verification run identifier.
58    pub run_id: Option<String>,
59    /// Whether direct-write was used (exceptional, auditable, disabled by default).
60    pub direct_write: bool,
61    /// Comparability snapshot version at time of export.
62    pub comparability_snapshot_version: Option<String>,
63    /// When the export was produced.
64    pub exported_at: String,
65}
66
67/// Schema version for the legacy compatibility export envelope.
68///
69/// Phase status: migration-only
70/// Removal condition: remove when all consumers have migrated to `ExportEnvelopeV3`
71#[deprecated(
72    since = "0.2.0",
73    note = "ExportEnvelopeV1 is compatibility-only. Use ExportEnvelopeV3 as the canonical export contract."
74)]
75pub const EXPORT_ENVELOPE_V1_SCHEMA: &str = "export_envelope_v1";
76/// Schema version for ExportEnvelopeV2.
77pub const EXPORT_ENVELOPE_V2_SCHEMA: &str = "export_envelope_v2";
78/// Schema version for the richer kernel-ready export envelope.
79pub const EXPORT_ENVELOPE_V3_SCHEMA: &str = "export_envelope_v3";
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
82#[serde(rename_all = "snake_case")]
83pub enum ConstraintSeedKind {
84    Hyperedge,
85    MutualExclusion,
86    TemporalCoherence,
87    CausalRefutation,
88    NuisanceDisclosure,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
92#[serde(rename_all = "snake_case")]
93pub enum CausalRoleHint {
94    Treatment,
95    Outcome,
96    Confounder,
97    Instrument,
98    EffectModifier,
99    Unknown,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
103#[serde(rename_all = "snake_case")]
104pub enum ProjectionVisibilityClass {
105    #[default]
106    Standard,
107    Restricted,
108    AuditOnly,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
112#[serde(rename_all = "snake_case")]
113pub enum ExportConfidenceClass {
114    Verified,
115    Reviewed,
116    Heuristic,
117    #[default]
118    ThinExport,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
122pub struct NuisanceSnapshot {
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub environment_fingerprint: Option<String>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub toolchain_version: Option<String>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub dependency_set_hash: Option<String>,
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub scope_mismatch_markers: Vec<String>,
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub measurement_notes: Vec<String>,
133    #[serde(default, skip_serializing_if = "Vec::is_empty")]
134    pub selection_bias_markers: Vec<String>,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
138pub struct ExportRecordSemanticsV3 {
139    /// Canonical claim lineage group when the exporter knows the claim family.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub claim_family_id: Option<ClaimFamilyId>,
142    /// Canonical assertion/hyperedge grouping for claim-version style records.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub assertion_group_id: Option<AssertionGroupId>,
145    /// Canonical relation grouping for relation-version style records.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub relation_group_id: Option<RelationGroupId>,
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub joint_evidence_group_id: Option<JointEvidenceGroupId>,
150    /// Optional because thin claim exports may group via assertion/family IDs
151    /// without emitting an explicit kernel seed kind.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub constraint_seed_kind: Option<ConstraintSeedKind>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub treatment_hint: Option<CausalRoleHint>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub outcome_hint: Option<CausalRoleHint>,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub confounder_hint: Option<CausalRoleHint>,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub instrument_hint: Option<CausalRoleHint>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub effect_modifier_hint: Option<CausalRoleHint>,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub contradiction_candidate_group_id: Option<ContradictionGroupId>,
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub mutual_exclusion_group_id: Option<ConstraintGroupId>,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub comparability_snapshot_version: Option<String>,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub nuisance_snapshot: Option<NuisanceSnapshot>,
172    pub projection_visibility_class: ProjectionVisibilityClass,
173    pub export_confidence_class: ExportConfidenceClass,
174    /// Exporter-supplied lineage only. Bridge/runtime/kernel must not invent it.
175    #[serde(default, skip_serializing_if = "Vec::is_empty")]
176    pub derivation_seed_ids: Vec<String>,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub review_priority_hint: Option<String>,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
182pub struct ExportRecordV3 {
183    pub record: ExportRecord,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub semantics: Option<ExportRecordSemanticsV3>,
186}
187
188/// Errors produced by Forge export schema validation.
189#[derive(Debug, Error)]
190pub enum ExportEnvelopeError {
191    /// The export envelope is structurally invalid.
192    #[error("invalid envelope: {reason}")]
193    InvalidEnvelope { reason: String },
194
195    /// Schema version mismatch.
196    #[error("incompatible version: expected {expected}, got {actual}")]
197    IncompatibleVersion { expected: String, actual: String },
198
199    /// Content digest does not match computed value.
200    #[error("digest mismatch: expected {expected}, got {actual}")]
201    DigestMismatch { expected: String, actual: String },
202
203    /// Failed to compute content digest.
204    #[error("digest computation failed: {reason}")]
205    DigestComputationFailed { reason: String },
206}
207
208impl ExportEnvelopeError {
209    /// Returns a stable string discriminant for this envelope error kind.
210    pub fn kind(&self) -> &'static str {
211        match self {
212            Self::InvalidEnvelope { .. } => "invalid_envelope",
213            Self::IncompatibleVersion { .. } => "incompatible_version",
214            Self::DigestMismatch { .. } => "digest_mismatch",
215            Self::DigestComputationFailed { .. } => "digest_computation_failed",
216        }
217    }
218}
219
220/// Forge-owned export envelope containing projection data.
221///
222/// The content digest covers: `source_authority`, `scope_key`, and all
223/// `records` (serialized with canonical JSON). The `envelope_id`,
224/// `exported_at`, and `trace_ctx` are excluded from the digest because
225/// they are metadata about the export, not the projection content.
226///
227/// Phase status: migration-only
228/// Removal condition: remove when all consumers have migrated to `ExportEnvelopeV3`
229#[deprecated(
230    since = "0.2.0",
231    note = "ExportEnvelopeV1 is compatibility-only. Use ExportEnvelopeV3 as the canonical export contract."
232)]
233#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
234pub struct ExportEnvelopeV1 {
235    /// Unique envelope identity, assigned by Forge.
236    pub envelope_id: EnvelopeId,
237    /// Always `"export_envelope_v1"` for this version.
238    pub schema_version: String,
239    /// BLAKE3 content digest for idempotent deduplication.
240    pub content_digest: ContentDigest,
241    /// Forge or other producing authority.
242    pub source_authority: String,
243    /// Target scope for all records in this envelope.
244    pub scope_key: ScopeKey,
245    /// Cross-crate trace context.
246    pub trace_ctx: Option<TraceCtx>,
247    /// ISO 8601 timestamp of when this envelope was exported.
248    pub exported_at: String,
249    /// The export records.
250    pub records: Vec<ExportRecord>,
251}
252
253impl ExportEnvelopeV1 {
254    /// Validate envelope structure.
255    pub fn validate(&self) -> Result<(), ExportEnvelopeError> {
256        validate_envelope_fields(
257            &self.envelope_id,
258            &self.schema_version,
259            EXPORT_ENVELOPE_V1_SCHEMA,
260            &self.source_authority,
261            &self.scope_key,
262            &self.records,
263        )?;
264
265        let computed =
266            Self::compute_digest(&self.source_authority, &self.scope_key, &self.records)?;
267        if computed != self.content_digest {
268            return Err(ExportEnvelopeError::DigestMismatch {
269                expected: self.content_digest.hex().to_string(),
270                actual: computed.hex().to_string(),
271            });
272        }
273
274        Ok(())
275    }
276
277    /// Compute the canonical content digest for this envelope's content.
278    pub fn compute_digest(
279        source_authority: &str,
280        scope_key: &ScopeKey,
281        records: &[ExportRecord],
282    ) -> Result<ContentDigest, ExportEnvelopeError> {
283        compute_digest_inner(source_authority, scope_key, records, None, None)
284    }
285}
286
287/// Forge-owned export envelope containing projection data plus the canonical
288/// evidence bundle that produced those projections.
289///
290/// The bridge remains deterministic and non-semantic: it may transform the
291/// projection records and carry the source schema version forward, but it does
292/// not reinterpret or mutate the embedded evidence bundle. For digest
293/// stability, envelope/export timestamps, `ForgeExportMeta::exported_at`, and
294/// the embedded bundle's `created_at` timestamp are treated as metadata rather
295/// than projection content.
296#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
297pub struct ExportEnvelopeV2 {
298    /// Unique envelope identity, assigned by Forge.
299    pub envelope_id: EnvelopeId,
300    /// Always `"export_envelope_v2"` for this version.
301    pub schema_version: String,
302    /// BLAKE3 content digest for idempotent deduplication.
303    pub content_digest: ContentDigest,
304    /// Forge or other producing authority.
305    pub source_authority: String,
306    /// Target scope for all records in this envelope.
307    pub scope_key: ScopeKey,
308    /// Cross-crate trace context.
309    pub trace_ctx: Option<TraceCtx>,
310    /// ISO 8601 timestamp of when this envelope was exported.
311    pub exported_at: String,
312    /// Optional export metadata retained as first-class provenance.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub export_meta: Option<ForgeExportMeta>,
315    /// Canonical Forge evidence substrate backing the projected records.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub evidence_bundle: Option<EvidenceBundle>,
318    /// The export records.
319    pub records: Vec<ExportRecord>,
320}
321
322/// CLIB-013: Canonical Forge export envelope (v3).
323///
324/// ## Field categories
325///
326/// **Core fields** (always populated):
327/// `envelope_id`, `schema_version`, `content_digest`, `source_authority`,
328/// `scope_key`, `exported_at`, `records`.
329///
330/// **Active v13 fields** (populated by Forge when support-algebra artifacts exist):
331/// `support_sets`, `contradiction_witnesses`, `retraction_records`, `claim_states_v13`.
332///
333/// **v14 extension points** (schema-reserved for mechanism-runtime / experiment lanes;
334/// populated only when the owning lane has produced matching artifacts):
335/// `intervention_bundles_v14` through `rollback_decisions_v14`.
336///
337/// **v15 extension points** (schema-reserved for attestation-exchange / admission lanes;
338/// populated only when the owning lane has produced matching artifacts):
339/// `attestation_envelopes_v15` through `disclosure_budgets_v15`.
340///
341/// Extension fields use `Vec<Value>` + `skip_serializing_if = "Vec::is_empty"` so they
342/// carry zero overhead in serialized envelopes when unused.
343#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
344pub struct ExportEnvelopeV3 {
345    /// Unique envelope identity, assigned by Forge.
346    pub envelope_id: EnvelopeId,
347    /// Always `"export_envelope_v3"` for this version.
348    pub schema_version: String,
349    /// BLAKE3 content digest for idempotent deduplication.
350    pub content_digest: ContentDigest,
351    /// Forge or other producing authority.
352    pub source_authority: String,
353    /// Target scope for all records in this envelope.
354    pub scope_key: ScopeKey,
355    /// Cross-crate trace context.
356    pub trace_ctx: Option<TraceCtx>,
357    /// ISO 8601 timestamp of when this envelope was exported.
358    pub exported_at: String,
359    /// Optional export metadata retained as first-class provenance.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub export_meta: Option<ForgeExportMeta>,
362    /// Canonical Forge evidence substrate backing the projected records.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub evidence_bundle: Option<EvidenceBundle>,
365    /// Additive v13 support algebra artifacts owned by Forge.
366    #[serde(default, skip_serializing_if = "Vec::is_empty")]
367    pub support_sets: Vec<SupportSetV1>,
368    /// Additive v13 contradiction witnesses owned by Forge.
369    #[serde(default, skip_serializing_if = "Vec::is_empty")]
370    pub contradiction_witnesses: Vec<ContradictionWitnessV1>,
371    /// Additive v13 retraction lineage artifacts owned by Forge.
372    #[serde(default, skip_serializing_if = "Vec::is_empty")]
373    pub retraction_records: Vec<RetractionRecordV1>,
374    /// Additive v13 claim-state artifacts owned by Forge.
375    #[serde(default, skip_serializing_if = "Vec::is_empty")]
376    pub claim_states_v13: Vec<ClaimStateV13>,
377    /// Additive v14 intervention artifacts preserved verbatim from the owner lane.
378    #[serde(default, skip_serializing_if = "Vec::is_empty")]
379    pub intervention_bundles_v14: Vec<Value>,
380    #[serde(default, skip_serializing_if = "Vec::is_empty")]
381    pub outcome_schemas_v14: Vec<Value>,
382    #[serde(default, skip_serializing_if = "Vec::is_empty")]
383    pub cohort_contracts_v14: Vec<Value>,
384    #[serde(default, skip_serializing_if = "Vec::is_empty")]
385    pub counterfactual_slices_v14: Vec<Value>,
386    #[serde(default, skip_serializing_if = "Vec::is_empty")]
387    pub experiment_cases_v14: Vec<Value>,
388    #[serde(default, skip_serializing_if = "Vec::is_empty")]
389    pub comparability_matrices_v14: Vec<Value>,
390    #[serde(default, skip_serializing_if = "Vec::is_empty")]
391    pub decision_traces_v14: Vec<Value>,
392    #[serde(default, skip_serializing_if = "Vec::is_empty")]
393    pub refuter_suites_v14: Vec<Value>,
394    #[serde(default, skip_serializing_if = "Vec::is_empty")]
395    pub refuter_results_v14: Vec<Value>,
396    #[serde(default, skip_serializing_if = "Vec::is_empty")]
397    pub experiment_budgets_v14: Vec<Value>,
398    #[serde(default, skip_serializing_if = "Vec::is_empty")]
399    pub rollout_decisions_v14: Vec<Value>,
400    #[serde(default, skip_serializing_if = "Vec::is_empty")]
401    pub rollback_decisions_v14: Vec<Value>,
402    /// Additive v15 attestation and admission artifacts preserved verbatim.
403    #[serde(default, skip_serializing_if = "Vec::is_empty")]
404    pub attestation_envelopes_v15: Vec<Value>,
405    #[serde(default, skip_serializing_if = "Vec::is_empty")]
406    pub trust_root_sets_v15: Vec<Value>,
407    #[serde(default, skip_serializing_if = "Vec::is_empty")]
408    pub artifact_admission_policies_v15: Vec<Value>,
409    #[serde(default, skip_serializing_if = "Vec::is_empty")]
410    pub transparency_receipts_v15: Vec<Value>,
411    #[serde(default, skip_serializing_if = "Vec::is_empty")]
412    pub attestation_revocations_v15: Vec<Value>,
413    #[serde(default, skip_serializing_if = "Vec::is_empty")]
414    pub attestation_supersessions_v15: Vec<Value>,
415    #[serde(default, skip_serializing_if = "Vec::is_empty")]
416    pub remote_oracle_leases_v15: Vec<Value>,
417    #[serde(default, skip_serializing_if = "Vec::is_empty")]
418    pub remote_slice_requests_v15: Vec<Value>,
419    #[serde(default, skip_serializing_if = "Vec::is_empty")]
420    pub remote_slice_results_v15: Vec<Value>,
421    #[serde(default, skip_serializing_if = "Vec::is_empty")]
422    pub cross_runtime_replay_tickets_v15: Vec<Value>,
423    #[serde(default, skip_serializing_if = "Vec::is_empty")]
424    pub dispute_bundles_v15: Vec<Value>,
425    #[serde(default, skip_serializing_if = "Vec::is_empty")]
426    pub disclosure_policies_v15: Vec<Value>,
427    #[serde(default, skip_serializing_if = "Vec::is_empty")]
428    pub disclosure_budgets_v15: Vec<Value>,
429    /// Rich projection records carrying kernel-relevant export semantics.
430    pub records: Vec<ExportRecordV3>,
431}
432
433impl ExportEnvelopeV2 {
434    /// Validate envelope structure.
435    pub fn validate(&self) -> Result<(), ExportEnvelopeError> {
436        validate_envelope_fields(
437            &self.envelope_id,
438            &self.schema_version,
439            EXPORT_ENVELOPE_V2_SCHEMA,
440            &self.source_authority,
441            &self.scope_key,
442            &self.records,
443        )?;
444
445        let computed = Self::compute_digest(
446            &self.source_authority,
447            &self.scope_key,
448            &self.records,
449            self.export_meta.as_ref(),
450            self.evidence_bundle.as_ref(),
451        )?;
452        if computed != self.content_digest {
453            return Err(ExportEnvelopeError::DigestMismatch {
454                expected: self.content_digest.hex().to_string(),
455                actual: computed.hex().to_string(),
456            });
457        }
458
459        Ok(())
460    }
461
462    /// Compute the canonical content digest for this envelope's content.
463    pub fn compute_digest(
464        source_authority: &str,
465        scope_key: &ScopeKey,
466        records: &[ExportRecord],
467        export_meta: Option<&ForgeExportMeta>,
468        evidence_bundle: Option<&EvidenceBundle>,
469    ) -> Result<ContentDigest, ExportEnvelopeError> {
470        compute_digest_inner(
471            source_authority,
472            scope_key,
473            records,
474            export_meta,
475            evidence_bundle,
476        )
477    }
478}
479
480impl ExportEnvelopeV3 {
481    /// Validates the canonical v3 export envelope and its computed digest.
482    pub fn validate(&self) -> Result<(), ExportEnvelopeError> {
483        validate_envelope_fields_v3(
484            &self.envelope_id,
485            &self.schema_version,
486            EXPORT_ENVELOPE_V3_SCHEMA,
487            &self.source_authority,
488            &self.scope_key,
489            &self.records,
490        )?;
491
492        let computed = Self::compute_digest(
493            &self.source_authority,
494            &self.scope_key,
495            &self.records,
496            self.export_meta.as_ref(),
497            self.evidence_bundle.as_ref(),
498        )?;
499        let computed = Self::compute_digest_with_v13(
500            computed,
501            &self.support_sets,
502            &self.contradiction_witnesses,
503            &self.retraction_records,
504            &self.claim_states_v13,
505        )?;
506        let computed = Self::compute_digest_with_endgame(
507            computed,
508            &self.intervention_bundles_v14,
509            &self.outcome_schemas_v14,
510            &self.cohort_contracts_v14,
511            &self.counterfactual_slices_v14,
512            &self.experiment_cases_v14,
513            &self.comparability_matrices_v14,
514            &self.decision_traces_v14,
515            &self.refuter_suites_v14,
516            &self.refuter_results_v14,
517            &self.experiment_budgets_v14,
518            &self.rollout_decisions_v14,
519            &self.rollback_decisions_v14,
520            &self.attestation_envelopes_v15,
521            &self.trust_root_sets_v15,
522            &self.artifact_admission_policies_v15,
523            &self.transparency_receipts_v15,
524            &self.attestation_revocations_v15,
525            &self.attestation_supersessions_v15,
526            &self.remote_oracle_leases_v15,
527            &self.remote_slice_requests_v15,
528            &self.remote_slice_results_v15,
529            &self.cross_runtime_replay_tickets_v15,
530            &self.dispute_bundles_v15,
531            &self.disclosure_policies_v15,
532            &self.disclosure_budgets_v15,
533        )?;
534        if computed != self.content_digest {
535            return Err(ExportEnvelopeError::DigestMismatch {
536                expected: self.content_digest.hex().to_string(),
537                actual: computed.hex().to_string(),
538            });
539        }
540
541        Ok(())
542    }
543
544    /// Computes the canonical digest for the v3 envelope content before additive v13/v15 groups.
545    pub fn compute_digest(
546        source_authority: &str,
547        scope_key: &ScopeKey,
548        records: &[ExportRecordV3],
549        export_meta: Option<&ForgeExportMeta>,
550        evidence_bundle: Option<&EvidenceBundle>,
551    ) -> Result<ContentDigest, ExportEnvelopeError> {
552        compute_digest_inner(
553            source_authority,
554            scope_key,
555            records,
556            export_meta,
557            evidence_bundle,
558        )
559    }
560
561    /// Extends a base digest with additive v13 support-algebra artifacts.
562    pub fn compute_digest_with_v13(
563        base_digest: ContentDigest,
564        support_sets: &[SupportSetV1],
565        contradiction_witnesses: &[ContradictionWitnessV1],
566        retraction_records: &[RetractionRecordV1],
567        claim_states_v13: &[ClaimStateV13],
568    ) -> Result<ContentDigest, ExportEnvelopeError> {
569        if support_sets.is_empty()
570            && contradiction_witnesses.is_empty()
571            && retraction_records.is_empty()
572            && claim_states_v13.is_empty()
573        {
574            return Ok(base_digest);
575        }
576
577        let mut builder = DigestBuilder::new();
578        builder
579            .update_json(&base_digest)
580            .map_err(digest_computation_failed)?;
581        if !support_sets.is_empty() {
582            builder.separator();
583            builder
584                .update_json(support_sets)
585                .map_err(digest_computation_failed)?;
586        }
587        if !contradiction_witnesses.is_empty() {
588            builder.separator();
589            builder
590                .update_json(contradiction_witnesses)
591                .map_err(digest_computation_failed)?;
592        }
593        if !retraction_records.is_empty() {
594            builder.separator();
595            builder
596                .update_json(retraction_records)
597                .map_err(digest_computation_failed)?;
598        }
599        if !claim_states_v13.is_empty() {
600            builder.separator();
601            builder
602                .update_json(claim_states_v13)
603                .map_err(digest_computation_failed)?;
604        }
605        Ok(builder.finalize())
606    }
607
608    #[allow(clippy::too_many_arguments)]
609    /// Extends a base digest with additive v14/v15 endgame artifact groups.
610    pub fn compute_digest_with_endgame(
611        base_digest: ContentDigest,
612        intervention_bundles_v14: &[Value],
613        outcome_schemas_v14: &[Value],
614        cohort_contracts_v14: &[Value],
615        counterfactual_slices_v14: &[Value],
616        experiment_cases_v14: &[Value],
617        comparability_matrices_v14: &[Value],
618        decision_traces_v14: &[Value],
619        refuter_suites_v14: &[Value],
620        refuter_results_v14: &[Value],
621        experiment_budgets_v14: &[Value],
622        rollout_decisions_v14: &[Value],
623        rollback_decisions_v14: &[Value],
624        attestation_envelopes_v15: &[Value],
625        trust_root_sets_v15: &[Value],
626        artifact_admission_policies_v15: &[Value],
627        transparency_receipts_v15: &[Value],
628        attestation_revocations_v15: &[Value],
629        attestation_supersessions_v15: &[Value],
630        remote_oracle_leases_v15: &[Value],
631        remote_slice_requests_v15: &[Value],
632        remote_slice_results_v15: &[Value],
633        cross_runtime_replay_tickets_v15: &[Value],
634        dispute_bundles_v15: &[Value],
635        disclosure_policies_v15: &[Value],
636        disclosure_budgets_v15: &[Value],
637    ) -> Result<ContentDigest, ExportEnvelopeError> {
638        let groups: [Option<serde_json::Value>; 25] = [
639            (!intervention_bundles_v14.is_empty())
640                .then(|| serde_json::json!(intervention_bundles_v14)),
641            (!outcome_schemas_v14.is_empty()).then(|| serde_json::json!(outcome_schemas_v14)),
642            (!cohort_contracts_v14.is_empty()).then(|| serde_json::json!(cohort_contracts_v14)),
643            (!counterfactual_slices_v14.is_empty())
644                .then(|| serde_json::json!(counterfactual_slices_v14)),
645            (!experiment_cases_v14.is_empty()).then(|| serde_json::json!(experiment_cases_v14)),
646            (!comparability_matrices_v14.is_empty())
647                .then(|| serde_json::json!(comparability_matrices_v14)),
648            (!decision_traces_v14.is_empty()).then(|| serde_json::json!(decision_traces_v14)),
649            (!refuter_suites_v14.is_empty()).then(|| serde_json::json!(refuter_suites_v14)),
650            (!refuter_results_v14.is_empty()).then(|| serde_json::json!(refuter_results_v14)),
651            (!experiment_budgets_v14.is_empty()).then(|| serde_json::json!(experiment_budgets_v14)),
652            (!rollout_decisions_v14.is_empty()).then(|| serde_json::json!(rollout_decisions_v14)),
653            (!rollback_decisions_v14.is_empty()).then(|| serde_json::json!(rollback_decisions_v14)),
654            (!attestation_envelopes_v15.is_empty())
655                .then(|| serde_json::json!(attestation_envelopes_v15)),
656            (!trust_root_sets_v15.is_empty()).then(|| serde_json::json!(trust_root_sets_v15)),
657            (!artifact_admission_policies_v15.is_empty())
658                .then(|| serde_json::json!(artifact_admission_policies_v15)),
659            (!transparency_receipts_v15.is_empty())
660                .then(|| serde_json::json!(transparency_receipts_v15)),
661            (!attestation_revocations_v15.is_empty())
662                .then(|| serde_json::json!(attestation_revocations_v15)),
663            (!attestation_supersessions_v15.is_empty())
664                .then(|| serde_json::json!(attestation_supersessions_v15)),
665            (!remote_oracle_leases_v15.is_empty())
666                .then(|| serde_json::json!(remote_oracle_leases_v15)),
667            (!remote_slice_requests_v15.is_empty())
668                .then(|| serde_json::json!(remote_slice_requests_v15)),
669            (!remote_slice_results_v15.is_empty())
670                .then(|| serde_json::json!(remote_slice_results_v15)),
671            (!cross_runtime_replay_tickets_v15.is_empty())
672                .then(|| serde_json::json!(cross_runtime_replay_tickets_v15)),
673            (!dispute_bundles_v15.is_empty()).then(|| serde_json::json!(dispute_bundles_v15)),
674            (!disclosure_policies_v15.is_empty())
675                .then(|| serde_json::json!(disclosure_policies_v15)),
676            (!disclosure_budgets_v15.is_empty()).then(|| serde_json::json!(disclosure_budgets_v15)),
677        ];
678
679        if groups.iter().all(Option::is_none) {
680            return Ok(base_digest);
681        }
682
683        let mut builder = DigestBuilder::new();
684        builder
685            .update_json(&base_digest)
686            .map_err(digest_computation_failed)?;
687        for group in groups.into_iter().flatten() {
688            builder.separator();
689            builder
690                .update_json(&group)
691                .map_err(digest_computation_failed)?;
692        }
693        Ok(builder.finalize())
694    }
695}
696
697impl ExportEnvelopeV2 {
698    /// Enrich a V2 envelope into a V3 envelope by lifting any already-exported
699    /// metadata into typed kernel semantics. Missing fields remain absent.
700    pub fn enrich_to_v3(&self) -> Result<ExportEnvelopeV3, ExportEnvelopeError> {
701        self.validate()?;
702
703        let records = self
704            .records
705            .iter()
706            .cloned()
707            .map(|record| ExportRecordV3::enrich(record, self.export_meta.as_ref()))
708            .collect::<Vec<_>>();
709
710        Ok(ExportEnvelopeV3 {
711            envelope_id: self.envelope_id.clone(),
712            schema_version: EXPORT_ENVELOPE_V3_SCHEMA.into(),
713            content_digest: ExportEnvelopeV3::compute_digest(
714                &self.source_authority,
715                &self.scope_key,
716                &records,
717                self.export_meta.as_ref(),
718                self.evidence_bundle.as_ref(),
719            )?,
720            source_authority: self.source_authority.clone(),
721            scope_key: self.scope_key.clone(),
722            trace_ctx: self.trace_ctx.clone(),
723            exported_at: self.exported_at.clone(),
724            export_meta: self.export_meta.clone(),
725            evidence_bundle: self.evidence_bundle.clone(),
726            support_sets: vec![],
727            contradiction_witnesses: vec![],
728            retraction_records: vec![],
729            claim_states_v13: vec![],
730            intervention_bundles_v14: vec![],
731            outcome_schemas_v14: vec![],
732            cohort_contracts_v14: vec![],
733            counterfactual_slices_v14: vec![],
734            experiment_cases_v14: vec![],
735            comparability_matrices_v14: vec![],
736            decision_traces_v14: vec![],
737            refuter_suites_v14: vec![],
738            refuter_results_v14: vec![],
739            experiment_budgets_v14: vec![],
740            rollout_decisions_v14: vec![],
741            rollback_decisions_v14: vec![],
742            attestation_envelopes_v15: vec![],
743            trust_root_sets_v15: vec![],
744            artifact_admission_policies_v15: vec![],
745            transparency_receipts_v15: vec![],
746            attestation_revocations_v15: vec![],
747            attestation_supersessions_v15: vec![],
748            remote_oracle_leases_v15: vec![],
749            remote_slice_requests_v15: vec![],
750            remote_slice_results_v15: vec![],
751            cross_runtime_replay_tickets_v15: vec![],
752            dispute_bundles_v15: vec![],
753            disclosure_policies_v15: vec![],
754            disclosure_budgets_v15: vec![],
755            records,
756        })
757    }
758}
759
760impl ExportRecordV3 {
761    /// Enriches a legacy export record with any typed v3 semantics the source already carried.
762    pub fn enrich(record: ExportRecord, export_meta: Option<&ForgeExportMeta>) -> Self {
763        let semantics = ExportRecordSemanticsV3::from_record(&record, export_meta);
764        Self { record, semantics }
765    }
766}
767
768impl ExportRecordSemanticsV3 {
769    /// Extracts typed v3 semantics from the source record metadata when present.
770    pub fn from_record(
771        record: &ExportRecord,
772        export_meta: Option<&ForgeExportMeta>,
773    ) -> Option<Self> {
774        let metadata = match record {
775            ExportRecord::Claim(claim) => claim.metadata.as_ref(),
776            ExportRecord::Relation(relation) => relation.metadata.as_ref(),
777            ExportRecord::Episode(episode) => episode.metadata.as_ref(),
778            ExportRecord::EntityAlias(alias) => alias.match_evidence.as_ref(),
779            ExportRecord::EvidenceRef(evidence) => evidence.metadata.as_ref(),
780        };
781
782        let semantics_root = metadata
783            .and_then(|metadata| metadata.get("kernel_semantics_v3"))
784            .or(metadata);
785
786        let claim_family_id = semantics_root
787            .and_then(|root| json_string(root, "claim_family_id"))
788            .map(ClaimFamilyId::new);
789        let assertion_group_id = semantics_root
790            .and_then(|root| json_string(root, "assertion_group_id"))
791            .map(AssertionGroupId::new);
792        let relation_group_id = semantics_root
793            .and_then(|root| json_string(root, "relation_group_id"))
794            .map(RelationGroupId::new);
795        let joint_evidence_group_id = semantics_root
796            .and_then(|root| json_string(root, "joint_evidence_group_id"))
797            .map(JointEvidenceGroupId::new);
798        let contradiction_candidate_group_id = semantics_root
799            .and_then(|root| json_string(root, "contradiction_candidate_group_id"))
800            .map(ContradictionGroupId::new);
801        let mutual_exclusion_group_id = semantics_root
802            .and_then(|root| json_string(root, "mutual_exclusion_group_id"))
803            .map(ConstraintGroupId::new);
804        let constraint_seed_kind =
805            semantics_root.and_then(|root| json_enum(root, "constraint_seed_kind"));
806        let treatment_hint = semantics_root.and_then(|root| json_enum(root, "treatment_hint"));
807        let outcome_hint = semantics_root.and_then(|root| json_enum(root, "outcome_hint"));
808        let confounder_hint = semantics_root.and_then(|root| json_enum(root, "confounder_hint"));
809        let instrument_hint = semantics_root.and_then(|root| json_enum(root, "instrument_hint"));
810        let effect_modifier_hint =
811            semantics_root.and_then(|root| json_enum(root, "effect_modifier_hint"));
812        let nuisance_snapshot =
813            semantics_root.and_then(|root| json_object_enum(root, "nuisance_snapshot"));
814        let projection_visibility_class = semantics_root
815            .and_then(|root| json_enum(root, "projection_visibility_class"))
816            .unwrap_or_default();
817        let export_confidence_class = semantics_root
818            .and_then(|root| json_enum(root, "export_confidence_class"))
819            .unwrap_or_else(|| {
820                if semantics_root.is_some() || export_meta.is_some() {
821                    ExportConfidenceClass::Reviewed
822                } else {
823                    ExportConfidenceClass::ThinExport
824                }
825            });
826        let comparability_snapshot_version = semantics_root
827            .and_then(|root| json_string(root, "comparability_snapshot_version"))
828            .or_else(|| export_meta.and_then(|meta| meta.comparability_snapshot_version.clone()));
829        let derivation_seed_ids = semantics_root
830            .and_then(|root| root.get("derivation_seed_ids"))
831            .and_then(serde_json::Value::as_array)
832            .map(|items| {
833                items
834                    .iter()
835                    .filter_map(serde_json::Value::as_str)
836                    .map(str::to_string)
837                    .collect::<Vec<_>>()
838            })
839            .unwrap_or_default();
840        let review_priority_hint =
841            semantics_root.and_then(|root| json_string(root, "review_priority_hint"));
842
843        let has_any = claim_family_id.is_some()
844            || assertion_group_id.is_some()
845            || relation_group_id.is_some()
846            || joint_evidence_group_id.is_some()
847            || constraint_seed_kind.is_some()
848            || treatment_hint.is_some()
849            || outcome_hint.is_some()
850            || confounder_hint.is_some()
851            || instrument_hint.is_some()
852            || effect_modifier_hint.is_some()
853            || contradiction_candidate_group_id.is_some()
854            || mutual_exclusion_group_id.is_some()
855            || comparability_snapshot_version.is_some()
856            || nuisance_snapshot.is_some()
857            || !derivation_seed_ids.is_empty()
858            || review_priority_hint.is_some()
859            || semantics_root.is_some();
860
861        has_any.then_some(Self {
862            claim_family_id,
863            assertion_group_id,
864            relation_group_id,
865            joint_evidence_group_id,
866            constraint_seed_kind,
867            treatment_hint,
868            outcome_hint,
869            confounder_hint,
870            instrument_hint,
871            effect_modifier_hint,
872            contradiction_candidate_group_id,
873            mutual_exclusion_group_id,
874            comparability_snapshot_version,
875            nuisance_snapshot,
876            projection_visibility_class,
877            export_confidence_class,
878            derivation_seed_ids,
879            review_priority_hint,
880        })
881    }
882}
883
884impl From<ExportEnvelopeV1> for ExportEnvelopeV2 {
885    fn from(value: ExportEnvelopeV1) -> Self {
886        Self {
887            envelope_id: value.envelope_id,
888            schema_version: EXPORT_ENVELOPE_V2_SCHEMA.into(),
889            content_digest: value.content_digest,
890            source_authority: value.source_authority,
891            scope_key: value.scope_key,
892            trace_ctx: value.trace_ctx,
893            exported_at: value.exported_at,
894            export_meta: None,
895            evidence_bundle: None,
896            records: value.records,
897        }
898    }
899}
900
901impl std::convert::TryFrom<ExportEnvelopeV2> for ExportEnvelopeV1 {
902    type Error = ExportEnvelopeError;
903
904    fn try_from(value: ExportEnvelopeV2) -> Result<Self, Self::Error> {
905        value.validate()?;
906        let content_digest = ExportEnvelopeV1::compute_digest(
907            &value.source_authority,
908            &value.scope_key,
909            &value.records,
910        )?;
911        Ok(Self {
912            envelope_id: value.envelope_id,
913            schema_version: EXPORT_ENVELOPE_V1_SCHEMA.into(),
914            content_digest,
915            source_authority: value.source_authority,
916            scope_key: value.scope_key,
917            trace_ctx: value.trace_ctx,
918            exported_at: value.exported_at,
919            records: value.records,
920        })
921    }
922}
923
924impl std::convert::TryFrom<ExportEnvelopeV2> for ExportEnvelopeV3 {
925    type Error = ExportEnvelopeError;
926
927    fn try_from(value: ExportEnvelopeV2) -> Result<Self, Self::Error> {
928        value.enrich_to_v3()
929    }
930}
931
932impl std::convert::TryFrom<ExportEnvelopeV3> for ExportEnvelopeV2 {
933    type Error = ExportEnvelopeError;
934
935    fn try_from(value: ExportEnvelopeV3) -> Result<Self, Self::Error> {
936        value.validate()?;
937
938        let ExportEnvelopeV3 {
939            envelope_id,
940            source_authority,
941            scope_key,
942            trace_ctx,
943            exported_at,
944            export_meta,
945            evidence_bundle,
946            support_sets,
947            contradiction_witnesses,
948            retraction_records,
949            claim_states_v13,
950            intervention_bundles_v14,
951            outcome_schemas_v14,
952            cohort_contracts_v14,
953            counterfactual_slices_v14,
954            experiment_cases_v14,
955            comparability_matrices_v14,
956            decision_traces_v14,
957            refuter_suites_v14,
958            refuter_results_v14,
959            experiment_budgets_v14,
960            rollout_decisions_v14,
961            rollback_decisions_v14,
962            attestation_envelopes_v15,
963            trust_root_sets_v15,
964            artifact_admission_policies_v15,
965            transparency_receipts_v15,
966            attestation_revocations_v15,
967            attestation_supersessions_v15,
968            remote_oracle_leases_v15,
969            remote_slice_requests_v15,
970            remote_slice_results_v15,
971            cross_runtime_replay_tickets_v15,
972            dispute_bundles_v15,
973            disclosure_policies_v15,
974            disclosure_budgets_v15,
975            records,
976            ..
977        } = value;
978        let records = records
979            .into_iter()
980            .map(|record| record.record)
981            .collect::<Vec<_>>();
982        let content_digest = ExportEnvelopeV2::compute_digest(
983            &source_authority,
984            &scope_key,
985            &records,
986            export_meta.as_ref(),
987            evidence_bundle.as_ref(),
988        )?;
989
990        if !support_sets.is_empty()
991            || !contradiction_witnesses.is_empty()
992            || !retraction_records.is_empty()
993            || !claim_states_v13.is_empty()
994            || !intervention_bundles_v14.is_empty()
995            || !outcome_schemas_v14.is_empty()
996            || !cohort_contracts_v14.is_empty()
997            || !counterfactual_slices_v14.is_empty()
998            || !experiment_cases_v14.is_empty()
999            || !comparability_matrices_v14.is_empty()
1000            || !decision_traces_v14.is_empty()
1001            || !refuter_suites_v14.is_empty()
1002            || !refuter_results_v14.is_empty()
1003            || !experiment_budgets_v14.is_empty()
1004            || !rollout_decisions_v14.is_empty()
1005            || !rollback_decisions_v14.is_empty()
1006            || !attestation_envelopes_v15.is_empty()
1007            || !trust_root_sets_v15.is_empty()
1008            || !artifact_admission_policies_v15.is_empty()
1009            || !transparency_receipts_v15.is_empty()
1010            || !attestation_revocations_v15.is_empty()
1011            || !attestation_supersessions_v15.is_empty()
1012            || !remote_oracle_leases_v15.is_empty()
1013            || !remote_slice_requests_v15.is_empty()
1014            || !remote_slice_results_v15.is_empty()
1015            || !cross_runtime_replay_tickets_v15.is_empty()
1016            || !dispute_bundles_v15.is_empty()
1017            || !disclosure_policies_v15.is_empty()
1018            || !disclosure_budgets_v15.is_empty()
1019        {
1020            warn!("dropping additive v13/v14/v15 artifacts while down-converting ExportEnvelopeV3 to V2");
1021        }
1022
1023        Ok(Self {
1024            envelope_id,
1025            schema_version: EXPORT_ENVELOPE_V2_SCHEMA.into(),
1026            content_digest,
1027            source_authority,
1028            scope_key,
1029            trace_ctx,
1030            exported_at,
1031            export_meta,
1032            evidence_bundle,
1033            records,
1034        })
1035    }
1036}
1037
1038fn validate_envelope_fields(
1039    envelope_id: &EnvelopeId,
1040    schema_version: &str,
1041    expected_schema_version: &str,
1042    source_authority: &str,
1043    scope_key: &ScopeKey,
1044    records: &[ExportRecord],
1045) -> Result<(), ExportEnvelopeError> {
1046    if envelope_id.is_empty() {
1047        return Err(ExportEnvelopeError::InvalidEnvelope {
1048            reason: "envelope_id must not be empty".into(),
1049        });
1050    }
1051    if schema_version != expected_schema_version {
1052        return Err(ExportEnvelopeError::IncompatibleVersion {
1053            expected: expected_schema_version.into(),
1054            actual: schema_version.to_string(),
1055        });
1056    }
1057    if source_authority.is_empty() {
1058        return Err(ExportEnvelopeError::InvalidEnvelope {
1059            reason: "source_authority must not be empty".into(),
1060        });
1061    }
1062    if scope_key.namespace.is_empty() {
1063        return Err(ExportEnvelopeError::InvalidEnvelope {
1064            reason: "scope_key.namespace must not be empty".into(),
1065        });
1066    }
1067    if records.is_empty() {
1068        return Err(ExportEnvelopeError::InvalidEnvelope {
1069            reason: "envelope must contain at least one record".into(),
1070        });
1071    }
1072    for (i, record) in records.iter().enumerate() {
1073        record
1074            .validate()
1075            .map_err(|reason| ExportEnvelopeError::InvalidEnvelope {
1076                reason: format!("record[{i}]: {reason}"),
1077            })?;
1078    }
1079    Ok(())
1080}
1081
1082fn digest_computation_failed(reason: impl ToString) -> ExportEnvelopeError {
1083    ExportEnvelopeError::DigestComputationFailed {
1084        reason: reason.to_string(),
1085    }
1086}
1087
1088fn sanitized_json_for_digest<T, F>(
1089    value: &T,
1090    sanitize: F,
1091) -> Result<serde_json::Value, ExportEnvelopeError>
1092where
1093    T: Serialize,
1094    F: FnOnce(&mut serde_json::Map<String, serde_json::Value>),
1095{
1096    let mut json = serde_json::to_value(value).map_err(digest_computation_failed)?;
1097    if let serde_json::Value::Object(ref mut map) = json {
1098        sanitize(map);
1099    }
1100    Ok(json)
1101}
1102
1103fn compute_digest_inner<R>(
1104    source_authority: &str,
1105    scope_key: &ScopeKey,
1106    records: &[R],
1107    export_meta: Option<&ForgeExportMeta>,
1108    evidence_bundle: Option<&EvidenceBundle>,
1109) -> Result<ContentDigest, ExportEnvelopeError>
1110where
1111    R: Serialize,
1112{
1113    let mut builder = DigestBuilder::new();
1114    builder.update_str(source_authority).separator();
1115    builder
1116        .update_json(scope_key)
1117        .map_err(digest_computation_failed)?;
1118    builder.separator();
1119    builder
1120        .update_json(records)
1121        .map_err(digest_computation_failed)?;
1122    if let Some(meta) = export_meta {
1123        builder.separator();
1124        let meta_value = sanitized_json_for_digest(meta, |map| {
1125            map.remove("exported_at");
1126        })?;
1127        builder
1128            .update_json(&meta_value)
1129            .map_err(digest_computation_failed)?;
1130    }
1131    if let Some(bundle) = evidence_bundle {
1132        builder.separator();
1133        let bundle_value = sanitized_json_for_digest(bundle, |map| {
1134            map.remove("created_at");
1135        })?;
1136        builder
1137            .update_json(&bundle_value)
1138            .map_err(digest_computation_failed)?;
1139    }
1140    Ok(builder.finalize())
1141}
1142
1143fn json_string(value: &serde_json::Value, key: &str) -> Option<String> {
1144    value.get(key)?.as_str().map(str::to_string)
1145}
1146
1147fn json_enum<T>(value: &serde_json::Value, key: &str) -> Option<T>
1148where
1149    T: for<'de> Deserialize<'de>,
1150{
1151    json_metadata_field(value, key, true)
1152}
1153
1154fn json_object_enum<T>(value: &serde_json::Value, key: &str) -> Option<T>
1155where
1156    T: for<'de> Deserialize<'de>,
1157{
1158    json_metadata_field(value, key, false)
1159}
1160
1161fn json_metadata_field<T>(value: &serde_json::Value, key: &str, required_like: bool) -> Option<T>
1162where
1163    T: for<'de> Deserialize<'de>,
1164{
1165    let raw = value.get(key)?;
1166    match serde_json::from_value(raw.clone()) {
1167        Ok(value) => Some(value),
1168        Err(err) => {
1169            let expectation = if required_like { "enum" } else { "object" };
1170            warn!(
1171                key = key,
1172                value_kind = raw.to_string(),
1173                target_type = type_name::<T>(),
1174                expected = expectation,
1175                error = %err,
1176                "present but unreadable kernel metadata field dropped in V2->V3 enrichment"
1177            );
1178            None
1179        }
1180    }
1181}
1182
1183fn validate_envelope_fields_v3(
1184    envelope_id: &EnvelopeId,
1185    schema_version: &str,
1186    expected_schema_version: &str,
1187    source_authority: &str,
1188    scope_key: &ScopeKey,
1189    records: &[ExportRecordV3],
1190) -> Result<(), ExportEnvelopeError> {
1191    if envelope_id.is_empty() {
1192        return Err(ExportEnvelopeError::InvalidEnvelope {
1193            reason: "envelope_id must not be empty".into(),
1194        });
1195    }
1196    if schema_version != expected_schema_version {
1197        return Err(ExportEnvelopeError::IncompatibleVersion {
1198            expected: expected_schema_version.into(),
1199            actual: schema_version.to_string(),
1200        });
1201    }
1202    if source_authority.is_empty() {
1203        return Err(ExportEnvelopeError::InvalidEnvelope {
1204            reason: "source_authority must not be empty".into(),
1205        });
1206    }
1207    if scope_key.namespace.is_empty() {
1208        return Err(ExportEnvelopeError::InvalidEnvelope {
1209            reason: "scope_key.namespace must not be empty".into(),
1210        });
1211    }
1212    if records.is_empty() {
1213        return Err(ExportEnvelopeError::InvalidEnvelope {
1214            reason: "envelope must contain at least one record".into(),
1215        });
1216    }
1217    for (i, record) in records.iter().enumerate() {
1218        record
1219            .record
1220            .validate()
1221            .map_err(|reason| ExportEnvelopeError::InvalidEnvelope {
1222                reason: format!("record[{i}]: {reason}"),
1223            })?;
1224    }
1225    Ok(())
1226}
1227
1228/// A single record within an export envelope.
1229#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1230#[serde(tag = "kind", rename_all = "snake_case")]
1231pub enum ExportRecord {
1232    /// A claim (knowledge assertion) from verified Forge output.
1233    Claim(ExportClaim),
1234    /// A relation (edge between entities) from verified Forge output.
1235    Relation(ExportRelation),
1236    /// An episode (causal record) from Forge experiments.
1237    Episode(ExportEpisode),
1238    /// An entity alias/merge suggestion from entity resolution.
1239    EntityAlias(ExportEntityAlias),
1240    /// An evidence reference for audit purposes.
1241    EvidenceRef(ExportEvidenceRef),
1242}
1243
1244impl ExportRecord {
1245    fn validate(&self) -> Result<(), String> {
1246        match self {
1247            Self::Claim(c) => {
1248                if c.predicate.is_empty() {
1249                    return Err("claim predicate must not be empty".into());
1250                }
1251                if c.subject_entity_id.is_empty() {
1252                    return Err("claim subject_entity_id must not be empty".into());
1253                }
1254                if !c.confidence.is_finite() {
1255                    return Err("claim confidence must be finite".into());
1256                }
1257            }
1258            Self::Relation(r) => {
1259                if r.predicate.is_empty() {
1260                    return Err("relation predicate must not be empty".into());
1261                }
1262                if r.subject_entity_id.is_empty() {
1263                    return Err("relation subject_entity_id must not be empty".into());
1264                }
1265                if !r.confidence.is_finite() {
1266                    return Err("relation confidence must be finite".into());
1267                }
1268            }
1269            Self::Episode(e) => {
1270                if e.effect_type.is_empty() {
1271                    return Err("episode effect_type must not be empty".into());
1272                }
1273                if !e.confidence.is_finite() {
1274                    return Err("episode confidence must be finite".into());
1275                }
1276            }
1277            Self::EntityAlias(a) => {
1278                if a.canonical_entity_id.is_empty() {
1279                    return Err("entity alias canonical_entity_id must not be empty".into());
1280                }
1281                if a.alias_text.is_empty() {
1282                    return Err("entity alias text must not be empty".into());
1283                }
1284                if !a.confidence.is_finite() {
1285                    return Err("entity alias confidence must be finite".into());
1286                }
1287            }
1288            Self::EvidenceRef(e) => {
1289                if e.claim_id.is_empty() {
1290                    return Err("evidence ref claim_id must not be empty".into());
1291                }
1292                if e.fetch_handle.is_empty() {
1293                    return Err("evidence ref fetch_handle must not be empty".into());
1294                }
1295            }
1296        }
1297        Ok(())
1298    }
1299}
1300
1301/// A claim exported from Forge.
1302#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1303pub struct ExportClaim {
1304    /// Optional pre-assigned claim ID (Forge may leave this for memory to assign).
1305    pub claim_id: Option<ClaimId>,
1306    /// Optional pre-assigned claim version ID.
1307    ///
1308    /// V2 exporters use this to preserve exact version identity end-to-end.
1309    /// V1 exporters normally leave it unset and let the bridge mint a version.
1310    #[serde(default, skip_serializing_if = "Option::is_none")]
1311    pub claim_version_id: Option<ClaimVersionId>,
1312    /// The entity this claim is about.
1313    pub subject_entity_id: EntityId,
1314    /// The predicate (e.g. "has_property", "is_type_of").
1315    pub predicate: String,
1316    /// The object anchor (value, entity ref, or literal).
1317    pub object_anchor: serde_json::Value,
1318    /// Validity start time (ISO 8601). None = always valid.
1319    pub valid_from: Option<String>,
1320    /// Validity end time (ISO 8601). None = open-ended.
1321    pub valid_to: Option<String>,
1322    /// Source confidence (0.0 - 1.0).
1323    pub confidence: f32,
1324    /// The content text for embedding/search.
1325    pub content: String,
1326    /// Projection family (e.g. "forge_verification", "manual").
1327    pub projection_family: String,
1328    /// Claim-level supersession pointer retained for compatibility/audit.
1329    pub supersedes_claim_id: Option<ClaimId>,
1330    /// Version-level supersession pointer when the exporter knows it.
1331    #[serde(default)]
1332    pub supersedes_claim_version_id: Option<ClaimVersionId>,
1333    /// Additional metadata.
1334    pub metadata: Option<serde_json::Value>,
1335}
1336
1337/// A relation exported from Forge.
1338#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1339pub struct ExportRelation {
1340    /// Optional pre-assigned relation version identity.
1341    ///
1342    /// V2 exporters use this to preserve exact version identity end-to-end.
1343    /// V1 exporters normally leave it unset and let the bridge mint a version.
1344    #[serde(default, skip_serializing_if = "Option::is_none")]
1345    pub relation_version_id: Option<RelationVersionId>,
1346    /// The subject entity.
1347    pub subject_entity_id: EntityId,
1348    /// The relation predicate (e.g. "depends_on", "authored_by").
1349    pub predicate: String,
1350    /// The object anchor (typically another entity ID or literal).
1351    pub object_anchor: serde_json::Value,
1352    /// Validity start time (ISO 8601).
1353    pub valid_from: Option<String>,
1354    /// Validity end time (ISO 8601).
1355    pub valid_to: Option<String>,
1356    /// Source confidence.
1357    pub confidence: f32,
1358    /// Projection family.
1359    pub projection_family: String,
1360    /// Linked claim or episode.
1361    pub source_claim_id: Option<ClaimId>,
1362    pub source_episode_id: Option<EpisodeId>,
1363    /// Whether this supersedes a previous relation version.
1364    pub supersedes_relation_version_id: Option<RelationVersionId>,
1365    /// Additional metadata.
1366    pub metadata: Option<serde_json::Value>,
1367}
1368
1369/// An episode exported from Forge.
1370#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1371pub struct ExportEpisode {
1372    /// Episode identity.
1373    pub episode_id: Option<EpisodeId>,
1374    /// Linked document.
1375    pub document_id: String,
1376    /// Cause IDs (facts/chunks/messages).
1377    pub cause_ids: Vec<String>,
1378    /// Type of effect.
1379    pub effect_type: String,
1380    /// Outcome assessment.
1381    pub outcome: String,
1382    /// Confidence in causal link.
1383    pub confidence: f32,
1384    /// Experiment ID if experimentally verified.
1385    pub experiment_id: Option<String>,
1386    /// Additional metadata.
1387    pub metadata: Option<serde_json::Value>,
1388}
1389
1390/// An entity alias suggestion from Forge.
1391#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1392pub struct ExportEntityAlias {
1393    /// The canonical entity this alias maps to.
1394    pub canonical_entity_id: EntityId,
1395    /// The alias text.
1396    pub alias_text: String,
1397    /// Source of the alias (e.g. "forge_extraction", "manual").
1398    pub alias_source: String,
1399    /// Match evidence (opaque blob for audit).
1400    pub match_evidence: Option<serde_json::Value>,
1401    /// Confidence in the alias mapping.
1402    pub confidence: f32,
1403    /// Scope semantics for this alias.
1404    pub scope: Option<ScopeKey>,
1405    /// If this alias participates in a merge lineage.
1406    #[serde(default, skip_serializing_if = "Option::is_none")]
1407    pub superseded_by_entity_id: Option<EntityId>,
1408    /// If this alias came from a split lineage.
1409    #[serde(default, skip_serializing_if = "Option::is_none")]
1410    pub split_from_entity_id: Option<EntityId>,
1411}
1412
1413/// An evidence reference for audit purposes.
1414///
1415/// Evidence refs are opaque by default. Their fetch path is explicit
1416/// and audit-only — no automatic dereferencing in normal query paths.
1417#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1418pub struct ExportEvidenceRef {
1419    /// The claim this evidence supports.
1420    pub claim_id: ClaimId,
1421    /// Version-local linkage (nullable, per delta §5.5).
1422    pub claim_version_id: Option<ClaimVersionId>,
1423    /// Canonical raw-evidence fetch handle (opaque URI or key).
1424    pub fetch_handle: String,
1425    /// Source authority for the evidence.
1426    pub source_authority: String,
1427    /// Additional metadata for audit.
1428    pub metadata: Option<serde_json::Value>,
1429}
1430
1431#[cfg(test)]
1432#[path = "envelope_tests.rs"]
1433mod tests;