Skip to main content

cortex_context/
axiom.rs

1//! AXIOM constraint export for context packs.
2//!
3//! Cortex can supply context to AXIOM agents, but the pack must also carry the
4//! limits AXIOM has to obey: truth ceiling, unknown proof state, conflicts,
5//! redaction boundaries, and the absence of execution authority.
6
7use cortex_core::{
8    AllowedClaimLanguage, AxiomConstraint, AxiomConstraintKind, AxiomConstraintSeverity,
9    BoundaryContradictionState, BoundaryQuarantineState, BoundaryRedactionState, ClaimCeiling,
10    ClaimProofState, ContextPackId, CortexAxiomConstraintEnvelopeV1, FailingEdge, PolicyOutcome,
11    ProofClosureReport, ProofEdgeFailure, ProofEdgeKind, ProvenanceClass,
12};
13use serde::{Deserialize, Serialize};
14
15use crate::pack::{ContextPack, ContextRefId};
16use crate::redaction::{ContentRedaction, PackMode, RawEventPayloadPolicy};
17
18/// Constraint export from one Cortex context pack to AXIOM.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct AxiomContextExport {
21    /// Context pack id the constraints apply to.
22    pub context_pack_id: ContextPackId,
23    /// Task the pack was built for.
24    pub task: String,
25    /// Weakest effective claim ceiling across selected refs and pack state.
26    pub claim_ceiling: ClaimCeiling,
27    /// ADR 0026 policy outcome for the built pack.
28    pub policy_outcome: PolicyOutcome,
29    /// Selected refs whose proof state is unknown.
30    pub unknown_refs: Vec<ContextRefId>,
31    /// Selected refs whose proof state is partial or broken.
32    pub limited_proof_refs: Vec<ContextRefId>,
33    /// Conflict ids surfaced in the context pack.
34    pub conflict_refs: Vec<String>,
35    /// Redaction limits AXIOM must not infer around.
36    pub redaction_limits: Vec<String>,
37    /// Constraints AXIOM must obey.
38    pub constraints: Vec<AxiomConstraint>,
39}
40
41/// Export AXIOM constraints from a context pack.
42#[must_use]
43pub fn axiom_export_for_pack(pack: &ContextPack) -> AxiomContextExport {
44    let unknown_refs = pack
45        .selected_refs
46        .iter()
47        .filter(|selected| selected.proof_state == ClaimProofState::Unknown)
48        .map(|selected| selected.ref_id.clone())
49        .collect::<Vec<_>>();
50    let limited_proof_refs = pack
51        .selected_refs
52        .iter()
53        .filter(|selected| selected.proof_state != ClaimProofState::FullChainVerified)
54        .map(|selected| selected.ref_id.clone())
55        .collect::<Vec<_>>();
56    let conflict_refs = pack
57        .conflicts
58        .iter()
59        .map(|conflict| conflict.contradiction_id.to_string())
60        .collect::<Vec<_>>();
61    let redaction_limits = redaction_limits(pack);
62    let claim_ceiling = pack_claim_ceiling(pack);
63    let policy = pack.policy_decision();
64    let mut constraints = Vec::new();
65
66    constraints.push(AxiomConstraint::new(
67        AxiomConstraintKind::NoExecutionAuthority,
68        AxiomConstraintSeverity::Hard,
69        "context pack constraints do not grant execution authority or tool permission",
70    ));
71    constraints.push(AxiomConstraint::new(
72        AxiomConstraintKind::TruthCeiling,
73        AxiomConstraintSeverity::Limit,
74        format!("AXIOM output must not claim above {claim_ceiling:?} for this pack"),
75    ));
76
77    if !unknown_refs.is_empty() {
78        constraints.push(
79            AxiomConstraint::new(
80                AxiomConstraintKind::ProofStateLimit,
81                AxiomConstraintSeverity::Limit,
82                "one or more selected refs have unknown proof state",
83            )
84            .with_refs(refs_to_strings(&unknown_refs)),
85        );
86    }
87
88    if !limited_proof_refs.is_empty() {
89        constraints.push(
90            AxiomConstraint::new(
91                AxiomConstraintKind::ForbidPromotionShapedOutput,
92                AxiomConstraintSeverity::Hard,
93                "limited proof state forbids promotion-shaped output",
94            )
95            .with_refs(refs_to_strings(&limited_proof_refs)),
96        );
97    }
98
99    if !conflict_refs.is_empty() {
100        constraints.push(
101            AxiomConstraint::new(
102                AxiomConstraintKind::ConflictPresent,
103                AxiomConstraintSeverity::Hard,
104                "context pack contains unresolved or surfaced conflicts",
105            )
106            .with_refs(conflict_refs.clone()),
107        );
108        constraints.push(AxiomConstraint::new(
109            AxiomConstraintKind::ForbidPromotionShapedOutput,
110            AxiomConstraintSeverity::Hard,
111            "conflicting context forbids promotion-shaped output",
112        ));
113    }
114
115    if claim_ceiling < ClaimCeiling::SignedLocalLedger {
116        constraints.push(AxiomConstraint::new(
117            AxiomConstraintKind::LowTrust,
118            AxiomConstraintSeverity::Hard,
119            "low trust or unsigned context forbids durable authority claims",
120        ));
121        constraints.push(AxiomConstraint::new(
122            AxiomConstraintKind::ForbidPromotionShapedOutput,
123            AxiomConstraintSeverity::Hard,
124            "low-trust context forbids promotion-shaped output",
125        ));
126    }
127
128    if !redaction_limits.is_empty() {
129        constraints.push(AxiomConstraint::new(
130            AxiomConstraintKind::RedactionBoundary,
131            AxiomConstraintSeverity::Hard,
132            "redaction policy forbids inferring or reconstructing omitted raw context",
133        ));
134    }
135
136    AxiomContextExport {
137        context_pack_id: pack.context_pack_id,
138        task: pack.task.clone(),
139        claim_ceiling,
140        policy_outcome: policy.final_outcome,
141        unknown_refs,
142        limited_proof_refs,
143        conflict_refs,
144        redaction_limits,
145        constraints,
146    }
147}
148
149/// Build the ADR 0040 Cortex -> pai-axiom constraint envelope for a pack.
150///
151/// The envelope is stricter than the legacy [`AxiomContextExport`]: it carries
152/// semantic trust, provenance, proof closure, contradiction, quarantine,
153/// redaction, claim-language, and forbidden-use fields in one versioned shape.
154#[must_use]
155pub fn constraint_envelope_for_pack(pack: &ContextPack) -> CortexAxiomConstraintEnvelopeV1 {
156    let export = axiom_export_for_pack(pack);
157    let proof_report = proof_report_for_pack(pack);
158    let provenance_class = weakest_provenance_for_pack(pack);
159    let semantic_trust = weakest_semantic_trust_for_pack(pack);
160
161    let mut envelope = CortexAxiomConstraintEnvelopeV1::new(
162        pack.context_pack_id,
163        proof_report,
164        export.claim_ceiling,
165        semantic_trust,
166        provenance_class,
167    );
168    envelope.contradiction_state = contradiction_state_for_pack(pack);
169    envelope.quarantine_state = quarantine_state_for_pack(pack, export.policy_outcome);
170    envelope.redaction_state = redaction_state_for_pack(pack);
171    envelope.allowed_claim_language =
172        allowed_claim_language_for_quarantine(envelope.quarantine_state);
173    envelope.constraints = export.constraints;
174    envelope
175}
176
177fn pack_claim_ceiling(pack: &ContextPack) -> ClaimCeiling {
178    let selected_ceiling = ClaimCeiling::weakest(pack.selected_refs.iter().map(|selected| {
179        selected
180            .claim_ceiling
181            .min(selected.proof_state.claim_ceiling())
182            .min(selected.runtime_mode.claim_ceiling())
183            .min(selected.authority_class.claim_ceiling())
184            .min(selected.provenance_class.claim_ceiling())
185            .min(selected.semantic_trust.claim_ceiling())
186    }))
187    .unwrap_or(ClaimCeiling::DevOnly);
188
189    if pack.conflicts.is_empty() {
190        selected_ceiling
191    } else {
192        selected_ceiling.min(ClaimCeiling::DevOnly)
193    }
194}
195
196fn proof_report_for_pack(pack: &ContextPack) -> ProofClosureReport {
197    if pack
198        .selected_refs
199        .iter()
200        .all(|selected| selected.proof_state == ClaimProofState::FullChainVerified)
201    {
202        return ProofClosureReport::full_chain_verified(Vec::new());
203    }
204
205    let mut failures = Vec::new();
206    for selected in &pack.selected_refs {
207        match selected.proof_state {
208            ClaimProofState::FullChainVerified => {}
209            ClaimProofState::Broken => failures.push(FailingEdge::broken(
210                ProofEdgeKind::ContextPackLink,
211                ref_to_string(&selected.ref_id),
212                pack.context_pack_id.to_string(),
213                ProofEdgeFailure::Mismatch,
214                "selected context ref has broken proof state",
215            )),
216            ClaimProofState::Partial => failures.push(FailingEdge::unresolved(
217                ProofEdgeKind::ContextPackLink,
218                ref_to_string(&selected.ref_id),
219                "selected context ref has partial proof state",
220            )),
221            ClaimProofState::Unknown => failures.push(FailingEdge::missing(
222                ProofEdgeKind::ContextPackLink,
223                ref_to_string(&selected.ref_id),
224                "selected context ref has unknown proof state",
225            )),
226        }
227    }
228
229    ProofClosureReport::from_edges(Vec::new(), failures)
230}
231
232fn weakest_provenance_for_pack(pack: &ContextPack) -> ProvenanceClass {
233    pack.selected_refs
234        .iter()
235        .map(|selected| selected.provenance_class)
236        .min()
237        .unwrap_or(ProvenanceClass::UnknownProvenance)
238}
239
240fn weakest_semantic_trust_for_pack(pack: &ContextPack) -> cortex_core::SemanticTrustClass {
241    pack.selected_refs
242        .iter()
243        .map(|selected| selected.semantic_trust)
244        .min()
245        .unwrap_or(cortex_core::SemanticTrustClass::Unknown)
246}
247
248fn contradiction_state_for_pack(pack: &ContextPack) -> BoundaryContradictionState {
249    pack.contradiction_posture()
250}
251
252fn quarantine_state_for_pack(
253    pack: &ContextPack,
254    policy_outcome: PolicyOutcome,
255) -> BoundaryQuarantineState {
256    match policy_outcome {
257        PolicyOutcome::Reject => BoundaryQuarantineState::Contaminated,
258        PolicyOutcome::Quarantine => BoundaryQuarantineState::Quarantined,
259        PolicyOutcome::Allow | PolicyOutcome::Warn | PolicyOutcome::BreakGlass => {
260            if pack.conflicts.is_empty() {
261                BoundaryQuarantineState::Clean
262            } else {
263                BoundaryQuarantineState::DiagnosticOnly
264            }
265        }
266    }
267}
268
269fn allowed_claim_language_for_quarantine(
270    quarantine_state: BoundaryQuarantineState,
271) -> Vec<AllowedClaimLanguage> {
272    match quarantine_state {
273        BoundaryQuarantineState::Clean | BoundaryQuarantineState::DiagnosticOnly => {
274            cortex_core::default_allowed_claim_language()
275        }
276        BoundaryQuarantineState::Quarantined | BoundaryQuarantineState::Contaminated => vec![
277            AllowedClaimLanguage::Constraint,
278            AllowedClaimLanguage::ResidualRisk,
279            AllowedClaimLanguage::VerificationRequest,
280            AllowedClaimLanguage::Refusal,
281        ],
282    }
283}
284
285fn redaction_state_for_pack(pack: &ContextPack) -> BoundaryRedactionState {
286    if pack.pack_mode == PackMode::Operator
287        && pack.redaction_policy.raw_event_payloads == RawEventPayloadPolicy::OperatorOptIn
288    {
289        BoundaryRedactionState::RawOperatorOptIn
290    } else if pack.redaction_policy.content == ContentRedaction::Abstracted {
291        BoundaryRedactionState::Abstracted
292    } else if pack.redaction_policy.raw_event_payloads == RawEventPayloadPolicy::Excluded {
293        BoundaryRedactionState::Redacted
294    } else {
295        BoundaryRedactionState::ExportSafe
296    }
297}
298
299fn redaction_limits(pack: &ContextPack) -> Vec<String> {
300    let mut limits = Vec::new();
301    if pack.redaction_policy.content == ContentRedaction::Abstracted {
302        limits.push("content_abstracted".to_string());
303    }
304    if pack.redaction_policy.raw_event_payloads == RawEventPayloadPolicy::Excluded {
305        limits.push("raw_event_payloads_excluded".to_string());
306    }
307    if !pack.exclusions.is_empty() {
308        limits.push("explicit_exclusions_present".to_string());
309    }
310    limits
311}
312
313fn refs_to_strings(refs: &[ContextRefId]) -> Vec<String> {
314    refs.iter().map(ref_to_string).collect()
315}
316
317fn ref_to_string(ref_id: &ContextRefId) -> String {
318    match ref_id {
319        ContextRefId::Memory { memory_id } => format!("memory:{memory_id}"),
320        ContextRefId::Principle { principle_id } => format!("principle:{principle_id}"),
321        ContextRefId::Event { event_id } => format!("event:{event_id}"),
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use cortex_core::{
328        AllowedClaimLanguage, AuthorityClass, BoundaryContradictionState, BoundaryQuarantineState,
329        BoundaryRedactionState, ClaimCeiling, ClaimProofState, ContradictionId, EventId,
330        ForbiddenBoundaryUse, ProvenanceClass, RuntimeMode, SemanticTrustClass,
331        CORTEX_TO_AXIOM_CONSTRAINT_ENVELOPE_V1,
332    };
333
334    use super::*;
335    use crate::{ContextPackBuilder, ContextRefCandidate, PackConflict, Sensitivity};
336
337    fn event_ref() -> ContextRefId {
338        ContextRefId::Event {
339            event_id: EventId::new(),
340        }
341    }
342
343    #[test]
344    fn axiom_export_includes_truth_ceiling_and_unknowns() {
345        let pack = ContextPackBuilder::new("prepare constrained AXIOM work", 512)
346            .select_ref(ContextRefCandidate::new(
347                event_ref(),
348                "unverified candidate context",
349            ))
350            .build()
351            .expect("build pack");
352
353        let export = axiom_export_for_pack(&pack);
354
355        assert_eq!(export.claim_ceiling, ClaimCeiling::DevOnly);
356        assert_eq!(export.policy_outcome, PolicyOutcome::Allow);
357        assert_eq!(export.unknown_refs.len(), 1);
358        assert!(export
359            .constraints
360            .iter()
361            .any(|constraint| constraint.kind == AxiomConstraintKind::TruthCeiling));
362        assert!(export
363            .constraints
364            .iter()
365            .any(|constraint| constraint.kind == AxiomConstraintKind::ProofStateLimit));
366    }
367
368    #[test]
369    fn low_trust_conflicting_pack_forbids_promotion_shaped_output() {
370        let ref_id = event_ref();
371        let pack = ContextPackBuilder::new("prepare constrained AXIOM work", 512)
372            .select_ref(
373                ContextRefCandidate::new(ref_id.clone(), "conflicting candidate context")
374                    .with_claim_metadata(
375                        RuntimeMode::LocalUnsigned,
376                        AuthorityClass::Derived,
377                        ClaimProofState::Partial,
378                        ClaimCeiling::AuthorityGrade,
379                    ),
380            )
381            .conflict(PackConflict {
382                contradiction_id: ContradictionId::new(),
383                posture: BoundaryContradictionState::Blocked,
384                refs: vec![ref_id],
385                summary: "candidate context conflicts with another memory".to_string(),
386            })
387            .build()
388            .expect("build pack");
389
390        let export = axiom_export_for_pack(&pack);
391
392        assert_eq!(export.claim_ceiling, ClaimCeiling::DevOnly);
393        assert_eq!(export.policy_outcome, PolicyOutcome::Quarantine);
394        assert!(!export.conflict_refs.is_empty());
395        assert!(export.constraints.iter().any(|constraint| {
396            constraint.kind == AxiomConstraintKind::ForbidPromotionShapedOutput
397                && constraint.severity == AxiomConstraintSeverity::Hard
398        }));
399    }
400
401    #[test]
402    fn exported_constraints_do_not_grant_execution_authority() {
403        let pack = ContextPackBuilder::new("prepare constrained AXIOM work", 512)
404            .select_ref(
405                ContextRefCandidate::new(event_ref(), "verified context")
406                    .with_claim_metadata(
407                        RuntimeMode::AuthorityGrade,
408                        AuthorityClass::Operator,
409                        ClaimProofState::FullChainVerified,
410                        ClaimCeiling::AuthorityGrade,
411                    )
412                    .with_semantic_metadata(
413                        ProvenanceClass::OperatorAttested,
414                        SemanticTrustClass::FalsificationTested,
415                    ),
416            )
417            .build()
418            .expect("build pack");
419
420        let export = axiom_export_for_pack(&pack);
421
422        assert!(export.constraints.iter().any(|constraint| {
423            constraint.kind == AxiomConstraintKind::NoExecutionAuthority
424                && constraint.severity == AxiomConstraintSeverity::Hard
425        }));
426        assert_eq!(export.claim_ceiling, ClaimCeiling::AuthorityGrade);
427    }
428
429    #[test]
430    fn redaction_policy_exports_boundary_constraints() {
431        let pack = ContextPackBuilder::new("prepare constrained AXIOM work", 512)
432            .select_ref(
433                ContextRefCandidate::new(event_ref(), "private context")
434                    .with_sensitivity(Sensitivity::Personal),
435            )
436            .build()
437            .expect("build pack");
438
439        let export = axiom_export_for_pack(&pack);
440
441        assert!(export
442            .redaction_limits
443            .contains(&"raw_event_payloads_excluded".to_string()));
444        assert!(export
445            .constraints
446            .iter()
447            .any(|constraint| constraint.kind == AxiomConstraintKind::RedactionBoundary));
448    }
449
450    #[test]
451    fn constraint_envelope_for_conflicted_pack_blocks_authority_uses() {
452        let ref_id = event_ref();
453        let pack = ContextPackBuilder::new("prepare constrained AXIOM work", 512)
454            .select_ref(
455                ContextRefCandidate::new(ref_id.clone(), "conflicted candidate context")
456                    .with_claim_metadata(
457                        RuntimeMode::LocalUnsigned,
458                        AuthorityClass::Derived,
459                        ClaimProofState::Partial,
460                        ClaimCeiling::AuthorityGrade,
461                    ),
462            )
463            .conflict(PackConflict {
464                contradiction_id: ContradictionId::new(),
465                posture: BoundaryContradictionState::MultiHypothesis,
466                refs: vec![ref_id],
467                summary: "candidate context conflicts with another memory".to_string(),
468            })
469            .build()
470            .expect("build pack");
471
472        let envelope = constraint_envelope_for_pack(&pack);
473
474        assert_eq!(
475            envelope.envelope_type,
476            CORTEX_TO_AXIOM_CONSTRAINT_ENVELOPE_V1
477        );
478        assert_eq!(
479            envelope.contradiction_state,
480            BoundaryContradictionState::MultiHypothesis
481        );
482        assert_eq!(
483            envelope.quarantine_state,
484            BoundaryQuarantineState::Quarantined
485        );
486        assert_eq!(envelope.redaction_state, BoundaryRedactionState::Abstracted);
487        assert_eq!(envelope.semantic_trust, SemanticTrustClass::CandidateOnly);
488        assert_eq!(envelope.provenance_class, ProvenanceClass::RuntimeDerived);
489        assert_eq!(envelope.truth_ceiling, ClaimCeiling::DevOnly);
490        assert!(envelope
491            .forbidden_uses
492            .contains(&ForbiddenBoundaryUse::Promotion));
493        assert!(envelope
494            .forbidden_uses
495            .contains(&ForbiddenBoundaryUse::TrustedHistory));
496        assert!(envelope
497            .forbidden_uses
498            .contains(&ForbiddenBoundaryUse::Release));
499        assert!(!envelope
500            .allowed_claim_language
501            .contains(&AllowedClaimLanguage::CandidateClaim));
502        assert!(!envelope
503            .allowed_claim_language
504            .contains(&AllowedClaimLanguage::EvidenceReference));
505        assert!(envelope
506            .allowed_claim_language
507            .contains(&AllowedClaimLanguage::VerificationRequest));
508        assert!(envelope
509            .allowed_claim_language
510            .contains(&AllowedClaimLanguage::Refusal));
511    }
512
513    #[test]
514    fn constraint_envelope_carries_operator_raw_redaction_state() {
515        let pack = ContextPackBuilder::new("prepare operator-only AXIOM work", 512)
516            .pack_mode(crate::PackMode::Operator)
517            .include_raw_event_payloads_in_operator_mode()
518            .select_ref(
519                ContextRefCandidate::new(event_ref(), "operator raw context")
520                    .with_sensitivity(Sensitivity::Internal)
521                    .with_raw_event_payload(serde_json::json!({"payload": "operator local"}))
522                    .with_claim_metadata(
523                        RuntimeMode::AuthorityGrade,
524                        AuthorityClass::Operator,
525                        ClaimProofState::FullChainVerified,
526                        ClaimCeiling::AuthorityGrade,
527                    )
528                    .with_semantic_metadata(
529                        ProvenanceClass::OperatorAttested,
530                        SemanticTrustClass::FalsificationTested,
531                    ),
532            )
533            .build()
534            .expect("build pack");
535
536        let envelope = constraint_envelope_for_pack(&pack);
537
538        assert_eq!(
539            envelope.redaction_state,
540            BoundaryRedactionState::RawOperatorOptIn
541        );
542        assert_eq!(envelope.provenance_class, ProvenanceClass::OperatorAttested);
543        assert_eq!(
544            envelope.semantic_trust,
545            SemanticTrustClass::FalsificationTested
546        );
547    }
548
549    #[test]
550    fn rejected_pack_allows_only_diagnostic_claim_language() {
551        let mut pack = ContextPackBuilder::new("prepare rejected AXIOM work", 512)
552            .select_ref(
553                ContextRefCandidate::new(event_ref(), "raw external context")
554                    .with_raw_event_payload(serde_json::json!({"payload": "must not leak"})),
555            )
556            .build()
557            .expect("build pack");
558        pack.redaction_policy.raw_event_payloads = RawEventPayloadPolicy::OperatorOptIn;
559
560        let envelope = constraint_envelope_for_pack(&pack);
561
562        assert_eq!(
563            envelope.quarantine_state,
564            BoundaryQuarantineState::Contaminated
565        );
566        assert!(!envelope
567            .allowed_claim_language
568            .contains(&AllowedClaimLanguage::CandidateClaim));
569        assert!(!envelope
570            .allowed_claim_language
571            .contains(&AllowedClaimLanguage::EvidenceReference));
572        assert!(envelope
573            .allowed_claim_language
574            .contains(&AllowedClaimLanguage::Constraint));
575        assert!(envelope
576            .allowed_claim_language
577            .contains(&AllowedClaimLanguage::ResidualRisk));
578        assert!(envelope
579            .allowed_claim_language
580            .contains(&AllowedClaimLanguage::VerificationRequest));
581        assert!(envelope
582            .allowed_claim_language
583            .contains(&AllowedClaimLanguage::Refusal));
584    }
585}