1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(tag = "kind", rename_all = "snake_case")]
17pub enum ContextRefId {
18 Memory {
20 memory_id: MemoryId,
22 },
23 Principle {
25 principle_id: PrincipleId,
27 },
28 Event {
30 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct SelectedContextRef {
48 pub ref_id: ContextRefId,
50 pub summary: String,
52 pub scope: Vec<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub confidence: Option<u8>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub authority: Option<String>,
60 pub runtime_mode: RuntimeMode,
62 pub authority_class: AuthorityClass,
64 pub proof_state: ClaimProofState,
66 pub claim_ceiling: ClaimCeiling,
68 pub provenance_class: ProvenanceClass,
70 pub semantic_trust: SemanticTrustClass,
72 pub downgrade_reasons: Vec<String>,
74 pub selection_reason: String,
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub raw_event_payload: Option<serde_json::Value>,
79}
80
81#[derive(Debug, Clone, PartialEq)]
83pub struct ContextRefCandidate {
84 pub ref_id: ContextRefId,
86 pub summary: String,
88 pub scope: Vec<String>,
90 pub confidence: Option<u8>,
92 pub authority: Option<String>,
94 pub runtime_mode: RuntimeMode,
96 pub authority_class: AuthorityClass,
98 pub proof_state: ClaimProofState,
100 pub requested_ceiling: ClaimCeiling,
102 pub provenance_class: ProvenanceClass,
104 pub semantic_trust: SemanticTrustClass,
106 pub selection_reason: String,
108 pub sensitivity: Sensitivity,
110 pub raw_event_payload: Option<serde_json::Value>,
112}
113
114impl ContextRefCandidate {
115 #[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 #[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 #[must_use]
145 pub fn with_sensitivity(mut self, sensitivity: Sensitivity) -> Self {
146 self.sensitivity = sensitivity;
147 self
148 }
149
150 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183pub struct PackExclusion {
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub ref_id: Option<ContextRefId>,
187 pub ref_kind: String,
189 pub reason: ExclusionReason,
191 pub rationale: String,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct PackConflict {
198 pub contradiction_id: ContradictionId,
200 #[serde(default = "default_conflict_posture")]
202 pub posture: BoundaryContradictionState,
203 pub refs: Vec<ContextRefId>,
205 pub summary: String,
207}
208
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
211pub struct ContextPack {
212 pub context_pack_id: ContextPackId,
214 pub task: String,
216 pub max_tokens: usize,
218 pub pack_mode: PackMode,
220 pub redaction_policy: RedactionPolicy,
222 pub selected_refs: Vec<SelectedContextRef>,
224 pub active_doctrine_ids: Vec<DoctrineId>,
226 pub conflicts: Vec<PackConflict>,
228 pub exclusions: Vec<PackExclusion>,
230 pub selection_audit: SelectionAudit,
232}
233
234impl ContextPack {
235 #[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 #[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 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#[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 #[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 #[must_use]
376 pub fn pack_mode(mut self, pack_mode: PackMode) -> Self {
377 self.pack_mode = pack_mode;
378 self
379 }
380
381 #[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 #[must_use]
390 pub fn select_ref(mut self, candidate: ContextRefCandidate) -> Self {
391 self.selected_candidates.push(candidate);
392 self
393 }
394
395 #[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 #[must_use]
404 pub fn conflict(mut self, conflict: PackConflict) -> Self {
405 self.conflicts.push(conflict);
406 self
407 }
408
409 #[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 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}