Skip to main content

cortex_context/
pack.rs

1//! Context pack domain model and builder.
2
3use cortex_core::{
4    compose_policy_outcomes, AuthorityClass, BoundaryContradictionState, ClaimCeiling,
5    ClaimProofState, ContextPackId, ContradictionId, CoreError, CoreResult, DoctrineId, EventId,
6    MemoryId, PolicyContribution, PolicyDecision, PolicyOutcome, PrincipleId, ProvenanceClass,
7    ReportableClaim, RuntimeMode, SemanticTrustClass,
8};
9use serde::{Deserialize, Serialize};
10
11use crate::audit::{ExcludedAuditEntry, ExclusionReason, IncludedAuditEntry, SelectionAudit};
12use crate::redaction::{PackMode, RawEventPayloadPolicy, RedactionPolicy, Sensitivity};
13
14/// Typed reference included in or considered for a context pack.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(tag = "kind", rename_all = "snake_case")]
17pub enum ContextRefId {
18    /// Durable memory ref.
19    Memory {
20        /// Memory identifier.
21        memory_id: MemoryId,
22    },
23    /// Principle hypothesis ref.
24    Principle {
25        /// Principle identifier.
26        principle_id: PrincipleId,
27    },
28    /// Raw event lineage ref.
29    Event {
30        /// Event identifier.
31        event_id: EventId,
32    },
33}
34
35impl ContextRefId {
36    pub(crate) fn kind_name(&self) -> &'static str {
37        match self {
38            Self::Memory { .. } => "memory",
39            Self::Principle { .. } => "principle",
40            Self::Event { .. } => "event",
41        }
42    }
43}
44
45/// Ref selected for inclusion in a pack.
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct SelectedContextRef {
48    /// Selected ref id.
49    pub ref_id: ContextRefId,
50    /// Redacted/abstracted summary safe for the pack's declared policy.
51    pub summary: String,
52    /// Scope fields describing where this ref applies.
53    pub scope: Vec<String>,
54    /// Confidence as a percentage, when known.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub confidence: Option<u8>,
57    /// Authority tier or source label, when known.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub authority: Option<String>,
60    /// Runtime mode bounding this ref's authority claim.
61    pub runtime_mode: RuntimeMode,
62    /// Authority class used for ceiling calculation.
63    pub authority_class: AuthorityClass,
64    /// Proof closure state for this ref.
65    pub proof_state: ClaimProofState,
66    /// Effective claim ceiling after weakest-link downgrades.
67    pub claim_ceiling: ClaimCeiling,
68    /// Semantic provenance family for this ref.
69    pub provenance_class: ProvenanceClass,
70    /// Semantic trust class for this ref.
71    pub semantic_trust: SemanticTrustClass,
72    /// Human-readable downgrade reasons.
73    pub downgrade_reasons: Vec<String>,
74    /// Why this ref was selected.
75    pub selection_reason: String,
76    /// Raw event payload, present only for operator-mode explicit opt-in.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub raw_event_payload: Option<serde_json::Value>,
79}
80
81/// Candidate selected by an upstream retrieval/memory layer.
82#[derive(Debug, Clone, PartialEq)]
83pub struct ContextRefCandidate {
84    /// Candidate ref id.
85    pub ref_id: ContextRefId,
86    /// Summary or abstracted content.
87    pub summary: String,
88    /// Scope fields.
89    pub scope: Vec<String>,
90    /// Confidence as a percentage, when known.
91    pub confidence: Option<u8>,
92    /// Authority tier or source label, when known.
93    pub authority: Option<String>,
94    /// Runtime mode bounding this candidate's authority claim.
95    pub runtime_mode: RuntimeMode,
96    /// Authority class used for ceiling calculation.
97    pub authority_class: AuthorityClass,
98    /// Proof closure state for this candidate.
99    pub proof_state: ClaimProofState,
100    /// Requested claim ceiling before weakest-link downgrade.
101    pub requested_ceiling: ClaimCeiling,
102    /// Semantic provenance family for this candidate.
103    pub provenance_class: ProvenanceClass,
104    /// Semantic trust class for this candidate.
105    pub semantic_trust: SemanticTrustClass,
106    /// Selection reason.
107    pub selection_reason: String,
108    /// Sensitivity tier used by redaction.
109    pub sensitivity: Sensitivity,
110    /// Optional raw event payload from upstream.
111    pub raw_event_payload: Option<serde_json::Value>,
112}
113
114impl ContextRefCandidate {
115    /// Build a minimal candidate from a ref and summary.
116    #[must_use]
117    pub fn new(ref_id: ContextRefId, summary: impl Into<String>) -> Self {
118        Self {
119            ref_id,
120            summary: summary.into(),
121            scope: Vec::new(),
122            confidence: None,
123            authority: None,
124            runtime_mode: RuntimeMode::LocalUnsigned,
125            authority_class: AuthorityClass::Derived,
126            proof_state: ClaimProofState::Unknown,
127            requested_ceiling: ClaimCeiling::LocalUnsigned,
128            provenance_class: ProvenanceClass::RuntimeDerived,
129            semantic_trust: SemanticTrustClass::CandidateOnly,
130            selection_reason: "selected_by_caller".to_string(),
131            sensitivity: Sensitivity::Internal,
132            raw_event_payload: None,
133        }
134    }
135
136    /// Attach raw event payload supplied by upstream lineage.
137    #[must_use]
138    pub fn with_raw_event_payload(mut self, raw_event_payload: serde_json::Value) -> Self {
139        self.raw_event_payload = Some(raw_event_payload);
140        self
141    }
142
143    /// Attach sensitivity tier.
144    #[must_use]
145    pub fn with_sensitivity(mut self, sensitivity: Sensitivity) -> Self {
146        self.sensitivity = sensitivity;
147        self
148    }
149
150    /// Attach explicit claim metadata for context-pack truth ceilings.
151    #[must_use]
152    pub fn with_claim_metadata(
153        mut self,
154        runtime_mode: RuntimeMode,
155        authority_class: AuthorityClass,
156        proof_state: ClaimProofState,
157        requested_ceiling: ClaimCeiling,
158    ) -> Self {
159        self.runtime_mode = runtime_mode;
160        self.authority_class = authority_class;
161        self.proof_state = proof_state;
162        self.requested_ceiling = requested_ceiling;
163        self.provenance_class = provenance_from_authority_class(authority_class);
164        self.semantic_trust = semantic_trust_from_claim_metadata(authority_class, proof_state);
165        self
166    }
167
168    /// Attach explicit semantic provenance/trust metadata.
169    #[must_use]
170    pub const fn with_semantic_metadata(
171        mut self,
172        provenance_class: ProvenanceClass,
173        semantic_trust: SemanticTrustClass,
174    ) -> Self {
175        self.provenance_class = provenance_class;
176        self.semantic_trust = semantic_trust;
177        self
178    }
179}
180
181/// Explicit exclusion recorded on the pack.
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct PackExclusion {
184    /// Excluded ref when policy permits recording the identifier.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub ref_id: Option<ContextRefId>,
187    /// Ref kind retained even when the identifier is redacted.
188    pub ref_kind: String,
189    /// Why this ref was excluded.
190    pub reason: ExclusionReason,
191    /// Human-readable rationale.
192    pub rationale: String,
193}
194
195/// Conflict surfaced to the consumer.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct PackConflict {
198    /// Contradiction identifier.
199    pub contradiction_id: ContradictionId,
200    /// Advisory contradiction posture supplied by retrieval/memory resolution.
201    #[serde(default = "default_conflict_posture")]
202    pub posture: BoundaryContradictionState,
203    /// Refs participating in the conflict.
204    pub refs: Vec<ContextRefId>,
205    /// Compact conflict summary.
206    pub summary: String,
207}
208
209/// Bounded injection object with citations and exclusions.
210#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct ContextPack {
212    /// Context pack id.
213    pub context_pack_id: ContextPackId,
214    /// Task this pack was assembled for.
215    pub task: String,
216    /// Maximum token budget requested by caller.
217    pub max_tokens: usize,
218    /// Pack mode recorded per BUILD_SPEC §2.2.
219    pub pack_mode: PackMode,
220    /// Redaction policy recorded per BUILD_SPEC §2.2.
221    pub redaction_policy: RedactionPolicy,
222    /// Selected memory/principle/event refs.
223    pub selected_refs: Vec<SelectedContextRef>,
224    /// Active promoted doctrine ids.
225    pub active_doctrine_ids: Vec<DoctrineId>,
226    /// Surfaced conflicts.
227    pub conflicts: Vec<PackConflict>,
228    /// Explicit exclusions.
229    pub exclusions: Vec<PackExclusion>,
230    /// Selection audit for included and excluded refs.
231    pub selection_audit: SelectionAudit,
232}
233
234impl ContextPack {
235    /// Conservative posture for surfaced contradiction state.
236    #[must_use]
237    pub fn contradiction_posture(&self) -> BoundaryContradictionState {
238        if self
239            .conflicts
240            .iter()
241            .any(|conflict| conflict.posture == BoundaryContradictionState::Blocked)
242        {
243            BoundaryContradictionState::Blocked
244        } else if self
245            .conflicts
246            .iter()
247            .any(|conflict| conflict.posture == BoundaryContradictionState::Unknown)
248        {
249            BoundaryContradictionState::Unknown
250        } else if self
251            .conflicts
252            .iter()
253            .any(|conflict| conflict.posture == BoundaryContradictionState::MultiHypothesis)
254        {
255            BoundaryContradictionState::MultiHypothesis
256        } else {
257            BoundaryContradictionState::Resolved
258        }
259    }
260
261    /// Derive the ADR 0026 policy decision for this built pack.
262    #[must_use]
263    pub fn policy_decision(&self) -> PolicyDecision {
264        let mut contributions = vec![PolicyContribution::new(
265            "context_pack.builder.valid",
266            PolicyOutcome::Allow,
267            "context pack built under declared pack/redaction policy",
268        )
269        .expect("static policy contribution is valid")];
270
271        if self.pack_mode == PackMode::External
272            && self.redaction_policy.raw_event_payloads != RawEventPayloadPolicy::Excluded
273        {
274            contributions.push(
275                PolicyContribution::new(
276                    "context_pack.redaction.external_raw_payload",
277                    PolicyOutcome::Reject,
278                    "external context packs must exclude raw event payloads",
279                )
280                .expect("static policy contribution is valid"),
281            );
282        }
283
284        if self.pack_mode == PackMode::External
285            && self
286                .selected_refs
287                .iter()
288                .any(|selected| selected.raw_event_payload.is_some())
289        {
290            contributions.push(
291                PolicyContribution::new(
292                    "context_pack.redaction.raw_payload_leak",
293                    PolicyOutcome::Reject,
294                    "external context pack selected refs must not carry raw payloads",
295                )
296                .expect("static policy contribution is valid"),
297            );
298        }
299
300        if !self.conflicts.is_empty() {
301            contributions.push(
302                PolicyContribution::new(
303                    "context_pack.conflict_present",
304                    PolicyOutcome::Quarantine,
305                    "context pack contains surfaced conflicts and must not be treated as clean authority",
306                )
307                .expect("static policy contribution is valid"),
308            );
309        }
310
311        compose_policy_outcomes(contributions, None)
312    }
313
314    /// Fail closed before using this pack in the default external/trusted path.
315    ///
316    /// Building a pack may still be useful for diagnostics, but default
317    /// consumers must not silently use a pack whose composed policy is
318    /// `Reject` or `Quarantine`.
319    pub fn require_default_use_allowed(&self) -> CoreResult<()> {
320        let policy = self.policy_decision();
321        match policy.final_outcome {
322            PolicyOutcome::Reject | PolicyOutcome::Quarantine => {
323                Err(CoreError::Validation(format!(
324                    "context pack default use blocked by policy outcome {:?}",
325                    policy.final_outcome
326                )))
327            }
328            PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => Ok(()),
329        }
330    }
331}
332
333const fn default_conflict_posture() -> BoundaryContradictionState {
334    BoundaryContradictionState::Unknown
335}
336
337/// Builder for context packs.
338#[derive(Debug, Clone)]
339pub struct ContextPackBuilder {
340    task: String,
341    max_tokens: usize,
342    pack_mode: PackMode,
343    include_raw_event_payloads: bool,
344    selected_candidates: Vec<ContextRefCandidate>,
345    active_doctrine_ids: Vec<DoctrineId>,
346    conflicts: Vec<PackConflict>,
347    exclusions: Vec<ExclusionCandidate>,
348}
349
350#[derive(Debug, Clone, PartialEq)]
351struct ExclusionCandidate {
352    ref_id: ContextRefId,
353    reason: ExclusionReason,
354    rationale: String,
355    sensitivity: Sensitivity,
356}
357
358impl ContextPackBuilder {
359    /// Create a builder with default external redacted/abstracted policy.
360    #[must_use]
361    pub fn new(task: impl Into<String>, max_tokens: usize) -> Self {
362        Self {
363            task: task.into(),
364            max_tokens,
365            pack_mode: PackMode::External,
366            include_raw_event_payloads: false,
367            selected_candidates: Vec::new(),
368            active_doctrine_ids: Vec::new(),
369            conflicts: Vec::new(),
370            exclusions: Vec::new(),
371        }
372    }
373
374    /// Set pack mode. External remains the default.
375    #[must_use]
376    pub fn pack_mode(mut self, pack_mode: PackMode) -> Self {
377        self.pack_mode = pack_mode;
378        self
379    }
380
381    /// Explicitly opt into raw payloads for operator mode.
382    #[must_use]
383    pub fn include_raw_event_payloads_in_operator_mode(mut self) -> Self {
384        self.include_raw_event_payloads = true;
385        self
386    }
387
388    /// Add a candidate selected by upstream retrieval/memory logic.
389    #[must_use]
390    pub fn select_ref(mut self, candidate: ContextRefCandidate) -> Self {
391        self.selected_candidates.push(candidate);
392        self
393    }
394
395    /// Add an active doctrine id.
396    #[must_use]
397    pub fn active_doctrine(mut self, doctrine_id: DoctrineId) -> Self {
398        self.active_doctrine_ids.push(doctrine_id);
399        self
400    }
401
402    /// Add a surfaced conflict.
403    #[must_use]
404    pub fn conflict(mut self, conflict: PackConflict) -> Self {
405        self.conflicts.push(conflict);
406        self
407    }
408
409    /// Record an explicitly excluded candidate ref.
410    #[must_use]
411    pub fn exclude_ref(
412        mut self,
413        ref_id: ContextRefId,
414        reason: ExclusionReason,
415        rationale: impl Into<String>,
416        sensitivity: Sensitivity,
417    ) -> Self {
418        self.exclusions.push(ExclusionCandidate {
419            ref_id,
420            reason,
421            rationale: rationale.into(),
422            sensitivity,
423        });
424        self
425    }
426
427    /// Build the context pack without model calls or runtime execution.
428    pub fn build(self) -> CoreResult<ContextPack> {
429        if self.task.trim().is_empty() {
430            return Err(CoreError::Validation(
431                "context pack task must not be empty".to_string(),
432            ));
433        }
434
435        if self.max_tokens == 0 {
436            return Err(CoreError::Validation(
437                "context pack max_tokens must be greater than zero".to_string(),
438            ));
439        }
440
441        let redaction_policy = self.redaction_policy()?;
442        let selected_refs = self.selected_refs(&redaction_policy);
443        let exclusions = self.pack_exclusions();
444        let estimated_tokens = estimate_pack_tokens(&self.task, &selected_refs, &exclusions);
445
446        if estimated_tokens > self.max_tokens {
447            return Err(CoreError::Validation(format!(
448                "context pack estimated token count {estimated_tokens} exceeds budget {}",
449                self.max_tokens
450            )));
451        }
452
453        let mut selection_audit =
454            SelectionAudit::new(self.pack_mode, redaction_policy.clone(), estimated_tokens);
455        selection_audit.included = self.included_audit();
456        selection_audit.exclusions = self.excluded_audit();
457
458        Ok(ContextPack {
459            context_pack_id: ContextPackId::new(),
460            task: self.task,
461            max_tokens: self.max_tokens,
462            pack_mode: self.pack_mode,
463            redaction_policy,
464            selected_refs,
465            active_doctrine_ids: self.active_doctrine_ids,
466            conflicts: self.conflicts,
467            exclusions,
468            selection_audit,
469        })
470    }
471
472    fn redaction_policy(&self) -> CoreResult<RedactionPolicy> {
473        match (self.pack_mode, self.include_raw_event_payloads) {
474            (PackMode::External, false) | (PackMode::Operator, false) => {
475                Ok(RedactionPolicy::external_default())
476            }
477            (PackMode::Operator, true) => Ok(RedactionPolicy::operator_with_raw_payload_opt_in()),
478            (PackMode::External, true) => Err(CoreError::Validation(
479                "raw event payload opt-in requires operator pack mode".to_string(),
480            )),
481        }
482    }
483
484    fn selected_refs(&self, redaction_policy: &RedactionPolicy) -> Vec<SelectedContextRef> {
485        self.selected_candidates
486            .iter()
487            .map(|candidate| {
488                let include_raw_payload = redaction_policy.raw_event_payloads
489                    == RawEventPayloadPolicy::OperatorOptIn
490                    && candidate.sensitivity != Sensitivity::Secret;
491                let claim = ReportableClaim::new(
492                    "context pack selected ref",
493                    candidate.runtime_mode,
494                    candidate.authority_class,
495                    candidate.proof_state,
496                    candidate.requested_ceiling,
497                );
498                let claim_ceiling = claim
499                    .effective_ceiling()
500                    .min(candidate.provenance_class.claim_ceiling())
501                    .min(candidate.semantic_trust.claim_ceiling());
502                let mut downgrade_reasons = claim.downgrade_reasons().to_vec();
503                if claim_ceiling < claim.effective_ceiling() {
504                    downgrade_reasons.push(format!(
505                        "semantic trust {:?} and provenance {:?} limit authority claims",
506                        candidate.semantic_trust, candidate.provenance_class
507                    ));
508                }
509                SelectedContextRef {
510                    ref_id: candidate.ref_id.clone(),
511                    summary: candidate.summary.clone(),
512                    scope: candidate.scope.clone(),
513                    confidence: candidate.confidence,
514                    authority: candidate.authority.clone(),
515                    runtime_mode: claim.runtime_mode(),
516                    authority_class: claim.authority_class(),
517                    proof_state: claim.proof_state(),
518                    claim_ceiling,
519                    provenance_class: candidate.provenance_class,
520                    semantic_trust: candidate.semantic_trust,
521                    downgrade_reasons,
522                    selection_reason: candidate.selection_reason.clone(),
523                    raw_event_payload: include_raw_payload
524                        .then(|| candidate.raw_event_payload.clone())
525                        .flatten(),
526                }
527            })
528            .collect()
529    }
530
531    fn pack_exclusions(&self) -> Vec<PackExclusion> {
532        self.exclusions
533            .iter()
534            .map(|exclusion| {
535                let ref_id = match (self.pack_mode, exclusion.sensitivity) {
536                    (PackMode::External, Sensitivity::Personal | Sensitivity::Secret) => None,
537                    _ => Some(exclusion.ref_id.clone()),
538                };
539                PackExclusion {
540                    ref_kind: exclusion.ref_id.kind_name().to_string(),
541                    ref_id,
542                    reason: exclusion.reason,
543                    rationale: exclusion.rationale.clone(),
544                }
545            })
546            .collect()
547    }
548
549    fn included_audit(&self) -> Vec<IncludedAuditEntry> {
550        self.selected_candidates
551            .iter()
552            .map(|candidate| IncludedAuditEntry {
553                ref_id: candidate.ref_id.clone(),
554                rule_id: "context_pack.builder.selected_by_caller.v1".to_string(),
555                reason: candidate.selection_reason.clone(),
556                sensitivity: candidate.sensitivity,
557            })
558            .collect()
559    }
560
561    fn excluded_audit(&self) -> Vec<ExcludedAuditEntry> {
562        self.exclusions
563            .iter()
564            .map(|exclusion| {
565                let ref_id = match (self.pack_mode, exclusion.sensitivity) {
566                    (PackMode::External, Sensitivity::Personal | Sensitivity::Secret) => None,
567                    _ => Some(exclusion.ref_id.clone()),
568                };
569                ExcludedAuditEntry {
570                    ref_kind: exclusion.ref_id.kind_name().to_string(),
571                    ref_id,
572                    reason: exclusion.reason,
573                    rule_id: "context_pack.builder.excluded_by_caller.v1".to_string(),
574                    rationale: exclusion.rationale.clone(),
575                    sensitivity: exclusion.sensitivity,
576                }
577            })
578            .collect()
579    }
580}
581
582const fn provenance_from_authority_class(authority_class: AuthorityClass) -> ProvenanceClass {
583    match authority_class {
584        AuthorityClass::Untrusted => ProvenanceClass::UnknownProvenance,
585        AuthorityClass::Derived => ProvenanceClass::RuntimeDerived,
586        AuthorityClass::Observed | AuthorityClass::Verified => ProvenanceClass::ToolObserved,
587        AuthorityClass::Operator => ProvenanceClass::OperatorAttested,
588    }
589}
590
591const fn semantic_trust_from_claim_metadata(
592    authority_class: AuthorityClass,
593    proof_state: ClaimProofState,
594) -> SemanticTrustClass {
595    match (authority_class, proof_state) {
596        (AuthorityClass::Operator, ClaimProofState::FullChainVerified)
597        | (
598            AuthorityClass::Verified | AuthorityClass::Observed,
599            ClaimProofState::FullChainVerified,
600        ) => SemanticTrustClass::SingleFamily,
601        (AuthorityClass::Derived, _) => SemanticTrustClass::CandidateOnly,
602        _ => SemanticTrustClass::Unknown,
603    }
604}
605
606fn estimate_pack_tokens(
607    task: &str,
608    selected_refs: &[SelectedContextRef],
609    exclusions: &[PackExclusion],
610) -> usize {
611    let chars = task.len()
612        + selected_refs
613            .iter()
614            .map(|r| r.summary.len() + r.scope.iter().map(String::len).sum::<usize>())
615            .sum::<usize>()
616        + exclusions
617            .iter()
618            .map(|e| e.rationale.len() + e.ref_kind.len())
619            .sum::<usize>();
620    chars.div_ceil(4).max(1)
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use serde_json::json;
627
628    fn event_ref() -> ContextRefId {
629        ContextRefId::Event {
630            event_id: EventId::new(),
631        }
632    }
633
634    fn memory_ref() -> ContextRefId {
635        ContextRefId::Memory {
636            memory_id: MemoryId::new(),
637        }
638    }
639
640    #[test]
641    fn default_external_pack_excludes_raw_event_payload_fields() {
642        let pack = ContextPackBuilder::new("prepare safe context", 512)
643            .select_ref(
644                ContextRefCandidate::new(event_ref(), "operator prefers concise summaries")
645                    .with_raw_event_payload(json!({
646                        "payload_json": {
647                            "private_text": "do not export"
648                        }
649                    })),
650            )
651            .build()
652            .expect("build external pack");
653
654        let serialized = serde_json::to_value(&pack).expect("serialize pack");
655        assert_eq!(serialized["pack_mode"], json!("external"));
656        assert_eq!(
657            serialized["redaction_policy"]["raw_event_payloads"],
658            json!("excluded")
659        );
660        assert!(!contains_object_key(&serialized, "raw_event_payload"));
661        assert!(!contains_object_key(&serialized, "payload_json"));
662    }
663
664    #[test]
665    fn default_external_pack_records_mode_policy_and_exclusion() {
666        let pack = ContextPackBuilder::new("prepare safe context", 512)
667            .select_ref(ContextRefCandidate::new(
668                memory_ref(),
669                "prefer explicit evidence",
670            ))
671            .exclude_ref(
672                event_ref(),
673                ExclusionReason::RedactionPolicy,
674                "raw private event omitted from external pack",
675                Sensitivity::Personal,
676            )
677            .build()
678            .expect("build external pack");
679
680        assert_eq!(pack.pack_mode, PackMode::External);
681        assert_eq!(pack.redaction_policy, RedactionPolicy::external_default());
682        assert_eq!(pack.exclusions.len(), 1);
683        assert_eq!(pack.exclusions[0].reason, ExclusionReason::RedactionPolicy);
684        assert_eq!(pack.exclusions[0].ref_id, None);
685        assert_eq!(pack.selection_audit.exclusions.len(), 1);
686        assert_eq!(pack.selection_audit.exclusions[0].ref_id, None);
687        assert_eq!(pack.selection_audit.pack_mode, PackMode::External);
688        assert_eq!(
689            pack.selection_audit.redaction_policy,
690            RedactionPolicy::external_default()
691        );
692        assert_eq!(pack.policy_decision().final_outcome, PolicyOutcome::Allow);
693    }
694
695    #[test]
696    fn selected_refs_expose_truth_ceiling_metadata() {
697        let pack = ContextPackBuilder::new("prepare safe context", 512)
698            .select_ref(ContextRefCandidate::new(
699                memory_ref(),
700                "prefer explicit evidence",
701            ))
702            .build()
703            .expect("build external pack");
704
705        let selected = &pack.selected_refs[0];
706        assert_eq!(selected.runtime_mode, RuntimeMode::LocalUnsigned);
707        assert_eq!(selected.authority_class, AuthorityClass::Derived);
708        assert_eq!(selected.proof_state, ClaimProofState::Unknown);
709        assert_eq!(selected.claim_ceiling, ClaimCeiling::DevOnly);
710        assert_eq!(selected.provenance_class, ProvenanceClass::RuntimeDerived);
711        assert_eq!(selected.semantic_trust, SemanticTrustClass::CandidateOnly);
712        assert!(selected
713            .downgrade_reasons
714            .iter()
715            .any(|reason| reason.contains("proof state Unknown")));
716
717        let serialized = serde_json::to_value(&pack).expect("serialize pack");
718        assert_eq!(
719            serialized["selected_refs"][0]["proof_state"],
720            json!("unknown")
721        );
722        assert_eq!(
723            serialized["selected_refs"][0]["claim_ceiling"],
724            json!("dev_only")
725        );
726        assert_eq!(
727            serialized["selected_refs"][0]["provenance_class"],
728            json!("runtime_derived")
729        );
730        assert_eq!(
731            serialized["selected_refs"][0]["semantic_trust"],
732            json!("candidate_only")
733        );
734    }
735
736    #[test]
737    fn selected_refs_expose_explicit_semantic_trust_metadata() {
738        let pack = ContextPackBuilder::new("prepare safe context", 512)
739            .select_ref(
740                ContextRefCandidate::new(memory_ref(), "operator falsified support")
741                    .with_claim_metadata(
742                        RuntimeMode::AuthorityGrade,
743                        AuthorityClass::Operator,
744                        ClaimProofState::FullChainVerified,
745                        ClaimCeiling::AuthorityGrade,
746                    )
747                    .with_semantic_metadata(
748                        ProvenanceClass::OperatorAttested,
749                        SemanticTrustClass::FalsificationTested,
750                    ),
751            )
752            .build()
753            .expect("build external pack");
754
755        let selected = &pack.selected_refs[0];
756        assert_eq!(selected.provenance_class, ProvenanceClass::OperatorAttested);
757        assert_eq!(
758            selected.semantic_trust,
759            SemanticTrustClass::FalsificationTested
760        );
761        assert_eq!(selected.claim_ceiling, ClaimCeiling::AuthorityGrade);
762    }
763
764    #[test]
765    fn conflict_pack_policy_decision_is_quarantine() {
766        let ref_id = memory_ref();
767        let pack = ContextPackBuilder::new("prepare conflicted context", 512)
768            .select_ref(ContextRefCandidate::new(
769                ref_id.clone(),
770                "conflicted memory",
771            ))
772            .conflict(PackConflict {
773                contradiction_id: ContradictionId::new(),
774                posture: BoundaryContradictionState::Blocked,
775                refs: vec![ref_id],
776                summary: "memory conflicts with a newer claim".into(),
777            })
778            .build()
779            .expect("build pack");
780
781        let policy = pack.policy_decision();
782
783        assert_eq!(policy.final_outcome, PolicyOutcome::Quarantine);
784        assert_eq!(
785            policy.contributing[0].rule_id.as_str(),
786            "context_pack.conflict_present"
787        );
788    }
789
790    #[test]
791    fn conflict_pack_fails_closed_for_default_use() {
792        let ref_id = memory_ref();
793        let pack = ContextPackBuilder::new("prepare conflicted context", 512)
794            .select_ref(ContextRefCandidate::new(
795                ref_id.clone(),
796                "conflicted memory",
797            ))
798            .conflict(PackConflict {
799                contradiction_id: ContradictionId::new(),
800                posture: BoundaryContradictionState::Blocked,
801                refs: vec![ref_id],
802                summary: "memory conflicts with a newer claim".into(),
803            })
804            .build()
805            .expect("build diagnostic pack");
806
807        let err = pack
808            .require_default_use_allowed()
809            .expect_err("conflicted pack must not be default-usable");
810        assert!(
811            err.to_string().contains("Quarantine"),
812            "default-use failure should expose the policy outcome: {err}"
813        );
814    }
815
816    #[test]
817    fn clean_external_pack_is_default_usable() {
818        let pack = ContextPackBuilder::new("prepare safe context", 512)
819            .select_ref(ContextRefCandidate::new(
820                memory_ref(),
821                "prefer explicit evidence",
822            ))
823            .build()
824            .expect("build external pack");
825
826        pack.require_default_use_allowed()
827            .expect("clean pack is default-usable");
828    }
829
830    #[test]
831    fn conflict_posture_carries_unknown_and_multi_hypothesis_advisories() {
832        let unknown_ref = memory_ref();
833        let multi_hypothesis_ref = event_ref();
834        let pack = ContextPackBuilder::new("prepare conflicted context", 768)
835            .select_ref(ContextRefCandidate::new(
836                unknown_ref.clone(),
837                "conflict resolver could not prove precedence",
838            ))
839            .conflict(PackConflict {
840                contradiction_id: ContradictionId::new(),
841                posture: BoundaryContradictionState::Unknown,
842                refs: vec![unknown_ref],
843                summary: "precedence proof is missing".into(),
844            })
845            .conflict(PackConflict {
846                contradiction_id: ContradictionId::new(),
847                posture: BoundaryContradictionState::MultiHypothesis,
848                refs: vec![multi_hypothesis_ref],
849                summary: "both hypotheses remain live".into(),
850            })
851            .build()
852            .expect("build diagnostic pack");
853
854        assert_eq!(
855            pack.contradiction_posture(),
856            BoundaryContradictionState::Unknown
857        );
858        assert_eq!(
859            pack.policy_decision().final_outcome,
860            PolicyOutcome::Quarantine
861        );
862
863        let serialized = serde_json::to_value(&pack).expect("serialize pack");
864        assert_eq!(serialized["conflicts"][0]["posture"], json!("unknown"));
865        assert_eq!(
866            serialized["conflicts"][1]["posture"],
867            json!("multi_hypothesis")
868        );
869    }
870
871    fn contains_object_key(value: &serde_json::Value, needle: &str) -> bool {
872        match value {
873            serde_json::Value::Object(map) => map
874                .iter()
875                .any(|(key, value)| key == needle || contains_object_key(value, needle)),
876            serde_json::Value::Array(values) => values
877                .iter()
878                .any(|value| contains_object_key(value, needle)),
879            _ => false,
880        }
881    }
882}