Skip to main content

axon/pcc/
checker.rs

1//! §Fase 51.a — the INDEPENDENT proof checker (the consumer side).
2//!
3//! This is the trust boundary of Proof-Carrying Code. A consumer who
4//! does NOT trust the axon compiler runs [`check_proof`] against the
5//! artifact + the proof term and gets an unforgeable verdict. The
6//! checker is small, total, and never-panics on arbitrary proof input
7//! — it is the only piece a verifier must audit.
8//!
9//! ## D51.2 — independence (the soundness contract)
10//!
11//! The checker RE-DERIVES the property from the artifact and verifies
12//! the witness against that re-derivation. It NEVER accepts a witness
13//! field on faith. A forged witness — one claiming `shield_present:
14//! true` for an endpoint whose `shield_ref` is empty, or omitting a
15//! phantom class from `unknown_classes` — is rejected because the
16//! checker recomputes those facts from the IR and compares. The
17//! adversarial tests (`forged_*_rejected`) are first-class gates.
18//!
19//! ## D51.1 — digest binding
20//!
21//! Before checking any property the checker recomputes the artifact
22//! digest and rejects a mismatch ([`CheckOutcome::DigestMismatch`]).
23//! A proof minted for program A cannot be replayed against program B.
24
25use crate::ir_nodes::IRProgram;
26
27use super::generate::{
28    artifact_digest, derive_capability_containment_witness,
29    derive_capability_isolation_witness, derive_compliance_coverage_witness,
30    derive_effect_row_soundness_witness, derive_endpoint_retry_witness,
31    derive_shield_halt_witness, derive_socket_credit_witness,
32    derive_tool_call_soundness_witness,
33};
34use super::proof_term::{ProofTerm, PropertyClass, ResourceBoundsWitness, Witness};
35
36/// The closed verdict catalog. Total over every proof shape — no
37/// default / panic path.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum CheckOutcome {
40    /// The property holds for this artifact and the witness matches the
41    /// checker's independent re-derivation.
42    Verified,
43    /// The witness matches the artifact but the property does NOT hold
44    /// (an honest defect — e.g. compliance declared with no resolvable
45    /// shield, or a phantom regulatory class), OR the witness DISAGREES
46    /// with the artifact (a forged proof). `reason` distinguishes.
47    Refuted { reason: String },
48    /// The proof's `artifact_digest` does not match the supplied
49    /// artifact — the proof is about a different program.
50    DigestMismatch,
51    /// The proof's property/witness pairing is not one this checker
52    /// version understands (forward-compat for §51.b-e classes).
53    UnknownProperty,
54}
55
56/// §51.a — verify a [`ProofTerm`] against the artifact it claims to be
57/// about. Independent of the producer (D51.2). Total + never-panics.
58pub fn check_proof(proof: &ProofTerm, ir: &IRProgram) -> CheckOutcome {
59    // (1) D51.1 — digest binding. Recompute; reject a mismatch before
60    // looking at the witness at all.
61    if proof.artifact_digest != artifact_digest(ir) {
62        return CheckOutcome::DigestMismatch;
63    }
64    // (2) dispatch on the (property, witness) pairing. A mismatched
65    // pairing (e.g. property says ComplianceCoverage but the witness is
66    // an EffectRowSoundness witness) is a malformed proof — neither
67    // property is actually witnessed — so it is UnknownProperty, not a
68    // silent accept.
69    match (&proof.property, &proof.witness) {
70        (PropertyClass::ComplianceCoverage, Witness::ComplianceCoverage(w)) => {
71            check_compliance_coverage(w, ir)
72        }
73        (PropertyClass::EffectRowSoundness, Witness::EffectRowSoundness(w)) => {
74            check_effect_row_soundness(w, ir)
75        }
76        (PropertyClass::CapabilityIsolation, Witness::CapabilityIsolation(w)) => {
77            check_capability_isolation(w, ir)
78        }
79        (PropertyClass::ResourceBounds, Witness::ResourceBounds(w)) => {
80            check_resource_bounds(w, ir)
81        }
82        (PropertyClass::ShieldHaltGuarantee, Witness::ShieldHaltGuarantee(w)) => {
83            check_shield_halt_guarantee(w, ir)
84        }
85        (PropertyClass::CapabilityContainment, Witness::CapabilityContainment(w)) => {
86            check_capability_containment(w, ir)
87        }
88        (PropertyClass::ToolCallSoundness, Witness::ToolCallSoundness(w)) => {
89            check_tool_call_soundness(w, ir)
90        }
91        _ => CheckOutcome::UnknownProperty,
92    }
93}
94
95/// §52.a — the result of checking one proof inside a bundle.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct ProofCheck {
98    /// Position of the proof in `ProofBundle::proofs`.
99    pub index: usize,
100    /// The property class the proof claims.
101    pub property: PropertyClass,
102    /// The witness subject (endpoint / tool / store / shield name).
103    pub subject: String,
104    /// The independent checker's verdict.
105    pub outcome: CheckOutcome,
106}
107
108/// §52.a — the deployability report for a whole [`ProofBundle`].
109///
110/// Aggregates the per-proof [`check_proof`] outcomes so a consumer (the
111/// enterprise deploy gate, the `axon pcc verify` CLI) has ONE trusted
112/// predicate for "is this bundle deployable." The policy — what counts
113/// as deployable — lives HERE, on the checker side, never in consumer
114/// glue (D52.1): a bundle is deployable iff every proof `Verified`.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct BundleReport {
117    /// One entry per proof, in bundle order.
118    pub results: Vec<ProofCheck>,
119}
120
121impl BundleReport {
122    /// True iff EVERY proof verified. Fail-closed: an empty bundle is
123    /// vacuously verified (nothing to refute), and any non-`Verified`
124    /// outcome (`Refuted` / `DigestMismatch` / `UnknownProperty`) makes
125    /// the whole bundle non-deployable (D52.2).
126    pub fn all_verified(&self) -> bool {
127        self.results
128            .iter()
129            .all(|r| r.outcome == CheckOutcome::Verified)
130    }
131
132    /// Every check that did NOT verify — the reasons a deploy gate
133    /// surfaces when it rejects.
134    pub fn refutations(&self) -> Vec<&ProofCheck> {
135        self.results
136            .iter()
137            .filter(|r| r.outcome != CheckOutcome::Verified)
138            .collect()
139    }
140}
141
142/// §52.a — check every proof in a bundle against the artifact,
143/// independently (each proof re-derived via [`check_proof`]). The
144/// bundle's own `artifact_digest` is advisory only — the authoritative
145/// binding is the per-proof digest [`check_proof`] re-verifies, so a
146/// bundle whose proofs are about a different artifact reports
147/// `DigestMismatch` per proof (not a silent pass).
148pub fn check_bundle(bundle: &super::proof_term::ProofBundle, ir: &IRProgram) -> BundleReport {
149    let results = bundle
150        .proofs
151        .iter()
152        .enumerate()
153        .map(|(index, proof)| ProofCheck {
154            index,
155            property: proof.property.clone(),
156            subject: proof.witness.subject_name().to_string(),
157            outcome: check_proof(proof, ir),
158        })
159        .collect();
160    BundleReport { results }
161}
162
163/// §51.a — re-derive the compliance-coverage witness from the artifact
164/// and verify it against the proof's witness, then render the verdict.
165///
166/// The order matters for soundness: we recompute the witness FIRST
167/// (independent of the proof's claims), reject any disagreement as a
168/// forgery, and only THEN decide Verified vs Refuted on the
169/// re-derived facts.
170fn check_compliance_coverage(
171    claimed: &super::proof_term::ComplianceCoverageWitness,
172    ir: &IRProgram,
173) -> CheckOutcome {
174    // Locate the endpoint named in the witness. Absent ⟹ the proof is
175    // about an endpoint not in this artifact (forged / stale).
176    let ep = match ir.endpoints.iter().find(|e| e.name == claimed.endpoint_name) {
177        Some(e) => e,
178        None => {
179            return CheckOutcome::Refuted {
180                reason: format!(
181                    "endpoint '{}' not present in artifact",
182                    claimed.endpoint_name
183                ),
184            }
185        }
186    };
187
188    // Re-derive independently — do NOT trust the witness (D51.2).
189    let actual = derive_compliance_coverage_witness(
190        &ep.name,
191        &ep.compliance,
192        &ep.shield_ref,
193        ir,
194    );
195
196    // Forgery detection: every witness field must equal the checker's
197    // re-derivation. Any disagreement ⟹ the proof lies about the
198    // artifact.
199    if actual != *claimed {
200        return CheckOutcome::Refuted {
201            reason: "witness disagrees with artifact re-derivation (forged or stale proof)"
202                .to_string(),
203        };
204    }
205
206    // Verdict on the re-derived (trusted) facts.
207    if !actual.shield_present {
208        return CheckOutcome::Refuted {
209            reason: format!(
210                "endpoint '{}' declares compliance {:?} but has no resolvable shield (shield_ref={:?})",
211                actual.endpoint_name, actual.required_classes, actual.shield_ref
212            ),
213        };
214    }
215    if !actual.unknown_classes.is_empty() {
216        return CheckOutcome::Refuted {
217            reason: format!(
218                "endpoint '{}' declares unknown regulatory class(es) {:?} (not in the closed registry)",
219                actual.endpoint_name, actual.unknown_classes
220            ),
221        };
222    }
223    if !actual.uncovered_classes.is_empty() {
224        return CheckOutcome::Refuted {
225            reason: format!(
226                "endpoint '{}' requires regulatory class(es) {:?} that its shield '{}' does not provide (shield provides {:?})",
227                actual.endpoint_name,
228                actual.uncovered_classes,
229                actual.shield_ref,
230                actual.provided_classes
231            ),
232        };
233    }
234    CheckOutcome::Verified
235}
236
237/// §51.b — re-derive the effect-row witness from the named tool and
238/// verify it against the proof, then render the verdict. Independent
239/// of the producer (D51.2): the checker re-reads `tool.effect_row` from
240/// the artifact and recomputes every fact; a forged witness is rejected
241/// because the recomputation disagrees.
242fn check_effect_row_soundness(
243    claimed: &super::proof_term::EffectRowSoundnessWitness,
244    ir: &IRProgram,
245) -> CheckOutcome {
246    let tool = match ir.tools.iter().find(|t| t.name == claimed.tool_name) {
247        Some(t) => t,
248        None => {
249            return CheckOutcome::Refuted {
250                reason: format!(
251                    "tool '{}' not present in artifact",
252                    claimed.tool_name
253                ),
254            }
255        }
256    };
257
258    // Re-derive independently — do NOT trust the witness. §Fase 53.d:
259    // the extension provenance members are re-derived from the SAME
260    // artifact (`ir`), so an extension-declared base verifies, while a
261    // base no extension declares still refutes. Soundness invariant #1:
262    // the verifier reads the artifact's own `extensions`, never an
263    // external registry.
264    let ext_members = super::generate::extension_effect_members(ir);
265    let actual =
266        derive_effect_row_soundness_witness(&tool.name, &tool.effect_row, &ext_members);
267
268    if actual != *claimed {
269        return CheckOutcome::Refuted {
270            reason: "witness disagrees with artifact re-derivation (forged or stale proof)"
271                .to_string(),
272        };
273    }
274
275    // Verdict on the re-derived facts.
276    if !actual.unknown_bases.is_empty() {
277        return CheckOutcome::Refuted {
278            reason: format!(
279                "tool '{}' declares effect entr(ies) with unknown base(s) {:?} (not in the closed effect catalog)",
280                actual.tool_name, actual.unknown_bases
281            ),
282        };
283    }
284    if !actual.missing_qualifier.is_empty() {
285        return CheckOutcome::Refuted {
286            reason: format!(
287                "tool '{}' declares qualifier-requiring effect(s) {:?} without a qualifier (bare stream/trust is unenforceable)",
288                actual.tool_name, actual.missing_qualifier
289            ),
290        };
291    }
292    if !actual.invalid_stream_qualifier.is_empty() {
293        return CheckOutcome::Refuted {
294            reason: format!(
295                "tool '{}' declares stream effect(s) {:?} with an invalid backpressure policy qualifier",
296                actual.tool_name, actual.invalid_stream_qualifier
297            ),
298        };
299    }
300    if actual.purity_violation {
301        return CheckOutcome::Refuted {
302            reason: format!(
303                "tool '{}' declares `pure` alongside other effects {:?} — a pure tool cannot be effectful",
304                actual.tool_name, actual.declared_effects
305            ),
306        };
307    }
308    CheckOutcome::Verified
309}
310
311/// §51.c — re-derive the capability-gate witness from the named store
312/// and verify it against the proof, then render the verdict.
313/// Independent of the producer (D51.2): the checker re-reads
314/// `store.capability` from the artifact and re-runs the §32.g grammar
315/// validator; a forged witness (claiming `malformed: false` for a
316/// broken gate) is rejected because the recomputation disagrees.
317fn check_capability_isolation(
318    claimed: &super::proof_term::CapabilityIsolationWitness,
319    ir: &IRProgram,
320) -> CheckOutcome {
321    let store = match ir
322        .axonstore_specs
323        .iter()
324        .find(|s| s.name == claimed.store_name)
325    {
326        Some(s) => s,
327        None => {
328            return CheckOutcome::Refuted {
329                reason: format!(
330                    "axonstore '{}' not present in artifact",
331                    claimed.store_name
332                ),
333            }
334        }
335    };
336
337    // Re-derive independently — do NOT trust the witness.
338    let actual = derive_capability_isolation_witness(&store.name, &store.capability);
339
340    if actual != *claimed {
341        return CheckOutcome::Refuted {
342            reason: "witness disagrees with artifact re-derivation (forged or stale proof)"
343                .to_string(),
344        };
345    }
346
347    if actual.malformed {
348        return CheckOutcome::Refuted {
349            reason: format!(
350                "axonstore '{}' declares a malformed capability gate {:?} (not a valid §32.g scope: ^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$)",
351                actual.store_name, actual.capability
352            ),
353        };
354    }
355    CheckOutcome::Verified
356}
357
358/// §51.d — re-derive the resource-bound witness from the named subject
359/// (endpoint or socket) and verify it against the proof, then render
360/// the verdict. Independent of the producer (D51.2): the checker
361/// re-reads `retries` / `backpressure_credit` from the artifact and
362/// recomputes the bound; a forged witness is rejected because the
363/// recomputation disagrees.
364fn check_resource_bounds(
365    claimed: &ResourceBoundsWitness,
366    ir: &IRProgram,
367) -> CheckOutcome {
368    match claimed {
369        ResourceBoundsWitness::EndpointRetry { endpoint_name, .. } => {
370            let ep = match ir.endpoints.iter().find(|e| e.name == *endpoint_name) {
371                Some(e) => e,
372                None => {
373                    return CheckOutcome::Refuted {
374                        reason: format!(
375                            "endpoint '{}' not present in artifact",
376                            endpoint_name
377                        ),
378                    }
379                }
380            };
381            let actual = derive_endpoint_retry_witness(&ep.name, ep.retries);
382            if actual != *claimed {
383                return CheckOutcome::Refuted {
384                    reason: "witness disagrees with artifact re-derivation (forged or stale proof)"
385                        .to_string(),
386                };
387            }
388            if let ResourceBoundsWitness::EndpointRetry { retries, in_bounds, .. } = &actual {
389                if !in_bounds {
390                    return CheckOutcome::Refuted {
391                        reason: format!(
392                            "endpoint '{}' declares retries={} outside the bound [0, {}] (negative is nonsensical; above the ceiling is a retry storm)",
393                            ep.name,
394                            retries,
395                            super::proof_term::MAX_RETRIES
396                        ),
397                    };
398                }
399            }
400            CheckOutcome::Verified
401        }
402        ResourceBoundsWitness::SocketCredit { socket_name, .. } => {
403            let socket = match ir.sockets.iter().find(|s| s.name == *socket_name) {
404                Some(s) => s,
405                None => {
406                    return CheckOutcome::Refuted {
407                        reason: format!(
408                            "socket '{}' not present in artifact",
409                            socket_name
410                        ),
411                    }
412                }
413            };
414            // The witness claims a DECLARED credit. If the artifact's
415            // socket has no declared credit, the witness disagrees
416            // (forged / stale).
417            let credit = match socket.backpressure_credit {
418                Some(k) => k,
419                None => {
420                    return CheckOutcome::Refuted {
421                        reason: format!(
422                            "socket '{}' has no declared backpressure credit in the artifact, but the witness claims one (forged or stale proof)",
423                            socket_name
424                        ),
425                    }
426                }
427            };
428            let actual = derive_socket_credit_witness(&socket.name, credit);
429            if actual != *claimed {
430                return CheckOutcome::Refuted {
431                    reason: "witness disagrees with artifact re-derivation (forged or stale proof)"
432                        .to_string(),
433                };
434            }
435            if let ResourceBoundsWitness::SocketCredit { credit, positive, .. } = &actual {
436                if !positive {
437                    return CheckOutcome::Refuted {
438                        reason: format!(
439                            "socket '{}' declares backpressure credit({}) — a window < 1 deadlocks the §Fase 41.b typed-resource gate",
440                            socket.name, credit
441                        ),
442                    };
443                }
444            }
445            CheckOutcome::Verified
446        }
447    }
448}
449
450/// §51.e — re-derive the shield breach-policy witness from the named
451/// shield and verify it against the proof, then render the verdict.
452/// Independent of the producer (D51.2): the checker re-reads
453/// `on_breach` + `scan` from the artifact and recomputes both facts; a
454/// forged witness (claiming `vacuous_halt: false` for a halt shield
455/// that scans nothing) is rejected because the recomputation disagrees.
456fn check_shield_halt_guarantee(
457    claimed: &super::proof_term::ShieldHaltGuaranteeWitness,
458    ir: &IRProgram,
459) -> CheckOutcome {
460    let shield = match ir.shields.iter().find(|s| s.name == claimed.shield_name) {
461        Some(s) => s,
462        None => {
463            return CheckOutcome::Refuted {
464                reason: format!(
465                    "shield '{}' not present in artifact",
466                    claimed.shield_name
467                ),
468            }
469        }
470    };
471
472    // Re-derive independently — do NOT trust the witness.
473    let actual =
474        derive_shield_halt_witness(&shield.name, &shield.on_breach, &shield.scan);
475
476    if actual != *claimed {
477        return CheckOutcome::Refuted {
478            reason: "witness disagrees with artifact re-derivation (forged or stale proof)"
479                .to_string(),
480        };
481    }
482
483    if !actual.known_policy {
484        return CheckOutcome::Refuted {
485            reason: format!(
486                "shield '{}' declares an unknown on_breach policy {:?} (not in the closed breach-policy catalog)",
487                actual.shield_name, actual.on_breach
488            ),
489        };
490    }
491    if actual.vacuous_halt {
492        return CheckOutcome::Refuted {
493            reason: format!(
494                "shield '{}' declares `on_breach: halt` but an empty `scan: []` — the halt can never fire (no scan ⟹ no breach ⟹ no halt): a vacuous guarantee",
495                actual.shield_name
496            ),
497        };
498    }
499    CheckOutcome::Verified
500}
501
502/// §51.x — re-derive the capability-containment witness from the named
503/// endpoint and verify it against the proof, then render the verdict.
504/// Independent of the producer (D51.2): the checker re-resolves the
505/// `execute_flow`, re-walks its reachable store ops, re-resolves each
506/// gate, and recomputes the uncovered set; a forged witness (e.g.
507/// hiding an uncovered gate) is rejected because the recomputation
508/// disagrees.
509fn check_capability_containment(
510    claimed: &super::proof_term::CapabilityContainmentWitness,
511    ir: &IRProgram,
512) -> CheckOutcome {
513    let ep = match ir.endpoints.iter().find(|e| e.name == claimed.endpoint_name) {
514        Some(e) => e,
515        None => {
516            return CheckOutcome::Refuted {
517                reason: format!(
518                    "endpoint '{}' not present in artifact",
519                    claimed.endpoint_name
520                ),
521            }
522        }
523    };
524
525    // Re-derive independently — do NOT trust the witness.
526    let actual = derive_capability_containment_witness(
527        &ep.name,
528        &ep.execute_flow,
529        &ep.requires_capabilities,
530        ir,
531    );
532
533    if actual != *claimed {
534        return CheckOutcome::Refuted {
535            reason: "witness disagrees with artifact re-derivation (forged or stale proof)"
536                .to_string(),
537        };
538    }
539
540    // Verdict. An unresolvable execute_flow cannot be analyzed for
541    // store reachability — refuse to certify containment (sound: a
542    // missing flow could reach gated stores we cannot see).
543    if !actual.flow_resolved {
544        return CheckOutcome::Refuted {
545            reason: format!(
546                "endpoint '{}' executes flow '{}' which is not present in the artifact — cannot certify capability containment",
547                actual.endpoint_name, actual.execute_flow
548            ),
549        };
550    }
551    if !actual.uncovered_gates.is_empty() {
552        return CheckOutcome::Refuted {
553            reason: format!(
554                "endpoint '{}' reaches store(s) gated by capabilit(ies) {:?} that its declared `requires:` {:?} does not cover — a capability leak (a request satisfying the declared requires could reach a store it is not authorized for)",
555                actual.endpoint_name, actual.uncovered_gates, actual.declared_requires
556            ),
557        };
558    }
559    CheckOutcome::Verified
560}
561
562/// §58.i — re-derive the tool-call-soundness witness from the named flow
563/// + call site and verify it against the proof, then render the verdict.
564/// Independent of the producer (D51.2): the checker re-walks the SAME
565/// digest-bound IR, locates the call at `call_index`, re-reads the called
566/// tool's `parameters:` schema, and recomputes every fact; a forged
567/// witness (e.g. hiding an unknown argument or a type mismatch) is
568/// rejected because the recomputation disagrees.
569fn check_tool_call_soundness(
570    claimed: &super::proof_term::ToolCallSoundnessWitness,
571    ir: &IRProgram,
572) -> CheckOutcome {
573    // Re-derive independently — do NOT trust the witness. `None` ⟹ the
574    // flow or the call site named by the witness is absent from this
575    // artifact (forged / stale proof).
576    let actual = match derive_tool_call_soundness_witness(
577        &claimed.flow_name,
578        claimed.call_index,
579        ir,
580    ) {
581        Some(w) => w,
582        None => {
583            return CheckOutcome::Refuted {
584                reason: format!(
585                    "flow '{}' has no structured `use` call at index {} in this artifact",
586                    claimed.flow_name, claimed.call_index
587                ),
588            }
589        }
590    };
591
592    if actual != *claimed {
593        return CheckOutcome::Refuted {
594            reason: "witness disagrees with artifact re-derivation (forged or stale proof)"
595                .to_string(),
596        };
597    }
598
599    // Verdict on the re-derived (trusted) facts.
600    if !actual.schema_present {
601        // A schema-less / undeclared tool carries no arg contract — there
602        // is nothing to certify, so a proof for it is vacuously verified
603        // (generation never emits one; this stays total for a hand-built
604        // witness).
605        return CheckOutcome::Verified;
606    }
607    if !actual.unknown_args.is_empty() {
608        return CheckOutcome::Refuted {
609            reason: format!(
610                "call to tool '{}' in flow '{}' passes argument(s) {:?} the tool does not declare (declared parameters: {:?})",
611                actual.tool_name, actual.flow_name, actual.unknown_args, actual.declared_params
612            ),
613        };
614    }
615    if !actual.duplicate_args.is_empty() {
616        return CheckOutcome::Refuted {
617            reason: format!(
618                "call to tool '{}' in flow '{}' supplies duplicate argument(s) {:?}",
619                actual.tool_name, actual.flow_name, actual.duplicate_args
620            ),
621        };
622    }
623    if !actual.missing_required.is_empty() {
624        return CheckOutcome::Refuted {
625            reason: format!(
626                "call to tool '{}' in flow '{}' omits required argument(s) {:?}",
627                actual.tool_name, actual.flow_name, actual.missing_required
628            ),
629        };
630    }
631    if !actual.type_mismatches.is_empty() {
632        return CheckOutcome::Refuted {
633            reason: format!(
634                "call to tool '{}' in flow '{}' has literal-argument type mismatch(es) {:?} (each `arg:expected:got`)",
635                actual.tool_name, actual.flow_name, actual.type_mismatches
636            ),
637        };
638    }
639    CheckOutcome::Verified
640}