Skip to main content

pointlock_provider_kit/
conformance.rs

1//! Provider conformance suite (04 §8).
2//!
3//! Every provider implementation (FakeProvider included) must run all
4//! green before the CLI assembles it. This is the M0 minimal assertion
5//! set: four-way execute terminals, the reconcile fates (`completed` for
6//! archived succeeded *and* non-succeeded terminals — adopted verbatim,
7//! never demoted — plus `neverDispatched` / `logUnavailable`), fail-closed
8//! verdict caps plus a valid write, and cursor monotonicity — plus the
9//! callId-discipline and cancellation-discipline checks that need no
10//! fixture provisioning.
11//!
12//! # Fixture contract
13//!
14//! [`run_conformance`] drives a live [`Provider`], so the subject must be
15//! prepared (scripted fake, mock daemon, …) such that, in the session
16//! opened with [`ConformanceOptions::open`], its first four `execute`
17//! calls settle `succeeded`, `failed`, `cancelled`, `timedOut` — in that
18//! order; when [`ConformanceOptions::dispatch_count`] is provided, a
19//! FIFTH execute must settle `failed` with `retryable: true` (the
20//! no-autonomous-retry group). The `logUnavailable` fate and the legal
21//! observation omission need faults the suite cannot cause through the
22//! SPI; subjects provide them via the option hooks (each check is
23//! recorded as skipped when its hook is absent).
24
25use pointlock_ir::{ActionOutcome, ErrorClass, EventCursor, ReconcileResult, VerdictStatus};
26use serde_json::json;
27use uuid::Uuid;
28
29use crate::spi::{
30    BoundActionCall, CancellationToken, ObserveRequest, ObserveWant, OpenSessionOptions, Provider,
31    ProviderSession, VERDICT_EVIDENCE_MAX_ENTRIES, VERDICT_SUMMARY_MAX_CHARS, VerdictWrite,
32};
33
34/// Outcome status of one conformance check.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum CheckStatus {
37    /// The assertion held.
38    Passed,
39    /// The assertion failed (details in [`CheckResult::detail`]).
40    Failed,
41    /// The check could not be exercised (e.g. no fault-injection hook).
42    Skipped,
43}
44
45/// Result of one conformance check.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct CheckResult {
48    /// Stable check name.
49    pub name: &'static str,
50    /// Outcome status.
51    pub status: CheckStatus,
52    /// Failure/skip explanation.
53    pub detail: Option<String>,
54}
55
56/// Report of a full conformance run.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ConformanceReport {
59    /// Every executed (or skipped) check, in suite order.
60    pub checks: Vec<CheckResult>,
61}
62
63impl ConformanceReport {
64    /// `true` when no check failed (skips allowed).
65    pub fn passed(&self) -> bool {
66        self.checks
67            .iter()
68            .all(|check| check.status != CheckStatus::Failed)
69    }
70
71    /// `true` when every check passed — nothing failed *or* skipped.
72    pub fn all_green(&self) -> bool {
73        self.checks
74            .iter()
75            .all(|check| check.status == CheckStatus::Passed)
76    }
77
78    /// Panics with a readable summary unless [`all_green`](Self::all_green).
79    pub fn assert_all_green(&self) {
80        if self.all_green() {
81            return;
82        }
83        let mut lines = Vec::new();
84        for check in &self.checks {
85            let status = match check.status {
86                CheckStatus::Passed => "PASS",
87                CheckStatus::Failed => "FAIL",
88                CheckStatus::Skipped => "SKIP",
89            };
90            let detail = check.detail.as_deref().unwrap_or("");
91            lines.push(format!("  [{status}] {} {detail}", check.name));
92        }
93        panic!("provider conformance not all green:\n{}", lines.join("\n"));
94    }
95}
96
97/// Options for [`run_conformance`].
98pub struct ConformanceOptions {
99    /// Session options the suite opens the subject with.
100    pub open: OpenSessionOptions,
101    /// Fault hook making the issuing session's event log unreachable, so
102    /// the `logUnavailable` reconcile fate becomes observable. `None`
103    /// records that check as skipped.
104    pub inject_log_unavailable: Option<Box<dyn Fn() + Send + Sync>>,
105    /// Wire-request counter of the subject (how many dispatches actually
106    /// left the provider), for the 04 §8 no-autonomous-retry group: one
107    /// retryable failure must ride EXACTLY one wire request. When
108    /// present, the fixture must script a FIFTH execute settling
109    /// `failed` with `retryable: true`. `None` records the check as
110    /// skipped.
111    pub dispatch_count: Option<Box<dyn Fn() -> usize + Send + Sync>>,
112    /// Fault hook making the next observation legally omit its
113    /// screenshot, for the 04 §8 terminal-fidelity group: an omission is
114    /// typed data, never an error. `None` records the check as skipped.
115    pub inject_observation_omission: Option<Box<dyn Fn() + Send + Sync>>,
116}
117
118struct Recorder {
119    checks: Vec<CheckResult>,
120}
121
122impl Recorder {
123    fn record(&mut self, name: &'static str, result: Result<(), String>) {
124        self.checks.push(match result {
125            Ok(()) => CheckResult {
126                name,
127                status: CheckStatus::Passed,
128                detail: None,
129            },
130            Err(detail) => CheckResult {
131                name,
132                status: CheckStatus::Failed,
133                detail: Some(detail),
134            },
135        });
136    }
137
138    fn skip(&mut self, name: &'static str, reason: &str) {
139        self.checks.push(CheckResult {
140            name,
141            status: CheckStatus::Skipped,
142            detail: Some(reason.to_owned()),
143        });
144    }
145}
146
147fn fresh_call(session: &dyn ProviderSession) -> BoundActionCall {
148    let action_name = session
149        .attestation()
150        .actions
151        .keys()
152        .next()
153        .expect("an attested session declares at least one action")
154        .clone();
155    BoundActionCall {
156        call_id: Uuid::new_v4().to_string(),
157        action_name,
158        arguments: json!({}),
159        action_timeout_ms: None,
160        request_timeout_ms: None,
161    }
162}
163
164/// Runs the conformance suite against `provider` (see the module docs for
165/// the fixture contract) and returns the per-check report.
166pub async fn run_conformance(
167    provider: &dyn Provider,
168    options: ConformanceOptions,
169) -> ConformanceReport {
170    let mut recorder = Recorder { checks: Vec::new() };
171
172    // ── openSession is the prerequisite of everything else ────────────────
173    let session = match provider.open_session(options.open).await {
174        Ok(session) => {
175            recorder.record("openSession.opens", Ok(()));
176            session
177        }
178        Err(error) => {
179            recorder.record(
180                "openSession.opens",
181                Err(format!("open_session failed: {error}")),
182            );
183            return ConformanceReport {
184                checks: recorder.checks,
185            };
186        }
187    };
188    let session: &dyn ProviderSession = session.as_ref();
189
190    let cursor_before = session.current_cursor().await;
191    // The issuing credential of every dispatch the suite performs below:
192    // captured BEFORE those dispatches (04 §5 — the checkpoint binding
193    // cursor predates the intent; an after-the-fact cursor is not a
194    // credential). A cursor failure records failed checks downstream
195    // instead of panicking the harness.
196    let issuing_owned = cursor_before.clone().ok();
197    let issuing = match &issuing_owned {
198        Some(cursor) => cursor,
199        None => &EventCursor {
200            session_id: String::new(),
201            last_sequence: 0,
202        },
203    };
204
205    // ── execute: the four-way terminal set is reachable, unfolded ─────────
206    let mut terminal_call_ids = TerminalCallIds::default();
207    let result = execute_terminals(session, &mut terminal_call_ids).await;
208    recorder.record("execute.fourTerminalsReachable", result);
209    let succeeded_call_id = terminal_call_ids.succeeded;
210    let failed_call_id = terminal_call_ids.failed;
211
212    let cursor_after_executes = session.current_cursor().await;
213
214    // ── callId discipline: a repeated callId is rejected ───────────────────
215    if let Some(call_id) = &succeeded_call_id {
216        let mut replay = fresh_call(session);
217        replay.call_id = call_id.clone();
218        let result = match session.execute(replay, None).await {
219            Err(_) => Ok(()),
220            Ok(outcome) => Err(format!(
221                "a repeated callId must be rejected (a retry is a new callId + new WAL \
222                 intent); got terminal {}",
223                outcome.kind()
224            )),
225        };
226        recorder.record("execute.rejectsDuplicateCallId", result);
227    } else {
228        recorder.skip(
229            "execute.rejectsDuplicateCallId",
230            "no succeeded callId available (terminals check failed)",
231        );
232    }
233
234    // ── fail-closed: an unattested actionName never dispatches ────────────
235    {
236        let before = options.dispatch_count.as_ref().map(|counter| counter());
237        let mut rogue = fresh_call(session);
238        rogue.action_name = pointlock_ir::ActionName::new("conformanceRogueAction")
239            .expect("grammatical action name");
240        let result = match session.execute(rogue, None).await {
241            Err(_) => match (before, options.dispatch_count.as_ref()) {
242                (Some(before), Some(counter)) if counter() != before => Err(
243                    "the refusal must happen BEFORE any wire request left the provider".to_owned(),
244                ),
245                _ => Ok(()),
246            },
247            Ok(outcome) => Err(format!(
248                "an unattested actionName must be refused fail-closed (04 §8), got \
249                 terminal {}",
250                outcome.kind()
251            )),
252        };
253        recorder.record("execute.rejectsUnattestedAction", result);
254    }
255
256    // ── no autonomous retry: one retryable failure = one wire request ─────
257    match &options.dispatch_count {
258        Some(counter) => {
259            let before = counter();
260            let call = fresh_call(session);
261            let result = match session.execute(call, None).await {
262                Ok(ActionOutcome::Failed { error }) if error.retryable => {
263                    let sent = counter().saturating_sub(before);
264                    if sent == 1 {
265                        Ok(())
266                    } else {
267                        Err(format!(
268                            "a retryable failure must ride exactly one wire request — \
269                             retries are the runner's, never the provider's (04 §3); \
270                             counted {sent}"
271                        ))
272                    }
273                }
274                Ok(other) => Err(format!(
275                    "fixture contract: the fifth scripted execute must settle failed \
276                     with retryable: true, got {}",
277                    other.kind()
278                )),
279                Err(error) => Err(format!(
280                    "the retryable failure must arrive as a terminal, got \
281                     ProviderError: {error}"
282                )),
283            };
284            recorder.record("execute.noAutonomousRetry", result);
285        }
286        None => recorder.skip(
287            "execute.noAutonomousRetry",
288            "no dispatch_count hook provided",
289        ),
290    }
291
292    // ── terminal fidelity: a legal omission is data, never an error ───────
293    match &options.inject_observation_omission {
294        Some(inject) => {
295            inject();
296            let result = match session
297                .observe(
298                    ObserveRequest {
299                        wants: vec![ObserveWant::Screenshot],
300                    },
301                    None,
302                )
303                .await
304            {
305                Ok(observation) => {
306                    if observation.screenshot.is_none() && observation.screenshot_omission.is_some()
307                    {
308                        Ok(())
309                    } else {
310                        Err(format!(
311                            "the injected omission must surface as typed omission data \
312                             (screenshot absent + reason recorded); got screenshot={} \
313                             omission={:?}",
314                            observation.screenshot.is_some(),
315                            observation.screenshot_omission
316                        ))
317                    }
318                }
319                Err(error) => Err(format!(
320                    "a legal omission is typed data, never an error (04 §8): {error}"
321                )),
322            };
323            recorder.record("observe.omissionIsNotAnError", result);
324        }
325        None => recorder.skip(
326            "observe.omissionIsNotAnError",
327            "no inject_observation_omission hook provided",
328        ),
329    }
330
331    // ── reconcile: foreign issuing credential never fabricates ────────────
332    // `neverDispatched` (04 §5, 2026-07-18): with a credential naming a
333    // session that is NOT this one, for a callId this session's log DOES
334    // contain, the only sound answers are `completed` (the provider has
335    // cross-generation retrieval and genuinely read the history) or
336    // `logUnavailable` (it does not). `neverDispatched` would mean the
337    // provider scanned the wrong log and licensed an auto-replay.
338    if let Some(call_id) = &succeeded_call_id {
339        let foreign = EventCursor {
340            session_id: "conformance-foreign-generation".to_owned(),
341            last_sequence: 0,
342        };
343        let result = match session.reconcile(call_id, &foreign).await {
344            Ok(ReconcileResult::NeverDispatched) => Err(
345                "a foreign issuing credential answered neverDispatched for a call this \
346                 session dispatched — the provider scanned the wrong log"
347                    .to_owned(),
348            ),
349            Ok(_) => Ok(()),
350            Err(error) => Err(format!("reconcile failed: {error}")),
351        };
352        recorder.record("reconcile.foreignCredentialNeverFabricates", result);
353    } else {
354        recorder.skip(
355            "reconcile.foreignCredentialNeverFabricates",
356            "no succeeded callId available (terminals check failed)",
357        );
358    }
359
360    // ── cancellation discipline: pre-cancelled ⇒ no dispatch ──────────────
361    let result = pre_cancelled_execute(session, issuing).await;
362    recorder.record("execute.preCancelledSendsNothing", result);
363
364    // ── reconcile: completed (archived succeeded terminal) ────────────────
365    if let Some(call_id) = &succeeded_call_id {
366        let result = match session.reconcile(call_id, issuing).await {
367            Ok(ReconcileResult::Completed { outcome }) => match outcome.as_ref() {
368                ActionOutcome::Succeeded { result } if result.call_id == *call_id => Ok(()),
369                ActionOutcome::Succeeded { result } => Err(format!(
370                    "completed fate carries callId {} instead of {call_id}",
371                    result.call_id
372                )),
373                other => Err(format!(
374                    "the archived terminal was succeeded; completed fate must adopt it \
375                     verbatim, got {}",
376                    other.kind()
377                )),
378            },
379            Ok(other) => Err(format!("expected fate completed, got {other:?}")),
380            Err(error) => Err(format!("reconcile failed: {error}")),
381        };
382        recorder.record("reconcile.completed", result);
383    } else {
384        recorder.skip(
385            "reconcile.completed",
386            "no succeeded callId available (terminals check failed)",
387        );
388    }
389
390    // ── reconcile: completed (archived non-succeeded terminal) ────────────
391    // A recorded failed/cancelled/timedOut is a *certain* fate: it must
392    // come back as completed with the terminal verbatim, never demoted to
393    // an uncertain branch (logUnavailable/startedNoTerminal).
394    if let Some(call_id) = &failed_call_id {
395        let result = match session.reconcile(call_id, issuing).await {
396            Ok(ReconcileResult::Completed { outcome }) if outcome.kind() == "failed" => Ok(()),
397            Ok(ReconcileResult::Completed { outcome }) => Err(format!(
398                "the archived terminal was failed; completed fate must adopt it verbatim, \
399                 got {}",
400                outcome.kind()
401            )),
402            Ok(other) => Err(format!(
403                "an archived failed terminal is a certain fate and must reconcile as \
404                 completed, got {other:?}"
405            )),
406            Err(error) => Err(format!("reconcile failed: {error}")),
407        };
408        recorder.record("reconcile.completedNonSuccess", result);
409    } else {
410        recorder.skip(
411            "reconcile.completedNonSuccess",
412            "no failed callId available (terminals check failed)",
413        );
414    }
415
416    // ── reconcile: neverDispatched ─────────────────────────────────────────
417    let result = match session
418        .reconcile(
419            &Uuid::new_v4().to_string(),
420            &session.current_cursor().await.expect("cursor"),
421        )
422        .await
423    {
424        Ok(ReconcileResult::NeverDispatched) => Ok(()),
425        Ok(other) => Err(format!("expected fate neverDispatched, got {other:?}")),
426        Err(error) => Err(format!("reconcile failed: {error}")),
427    };
428    recorder.record("reconcile.neverDispatched", result);
429
430    // ── recordVerdict: fail-closed caps, then a valid write ───────────────
431    let oversized_summary = VerdictWrite {
432        status: VerdictStatus::Pass,
433        summary: "x".repeat(VERDICT_SUMMARY_MAX_CHARS + 1),
434        evidence: Vec::new(),
435    };
436    let result = expect_bind_arguments_invalid(
437        session.record_verdict(oversized_summary).await,
438        "an oversized summary",
439    );
440    recorder.record("recordVerdict.rejectsOversizedSummary", result);
441
442    let oversized_evidence = VerdictWrite {
443        status: VerdictStatus::Pass,
444        summary: "capped".to_owned(),
445        evidence: vec![sample_evidence(session).await; VERDICT_EVIDENCE_MAX_ENTRIES + 1],
446    };
447    let result = expect_bind_arguments_invalid(
448        session.record_verdict(oversized_evidence).await,
449        "an oversized evidence list",
450    );
451    recorder.record("recordVerdict.rejectsOversizedEvidence", result);
452
453    let valid = VerdictWrite {
454        status: VerdictStatus::Unknown,
455        summary: "conformance write".to_owned(),
456        evidence: Vec::new(),
457    };
458    let result = session
459        .record_verdict(valid)
460        .await
461        .map_err(|error| format!("a within-caps verdict must be accepted: {error}"));
462    recorder.record("recordVerdict.acceptsValid", result);
463
464    // ── cursor monotonicity ────────────────────────────────────────────────
465    let cursor_after_verdicts = session.current_cursor().await;
466    let result = cursor_monotonic(cursor_before, cursor_after_executes, cursor_after_verdicts);
467    recorder.record("currentCursor.monotonic", result);
468
469    // ── reconcile: logUnavailable (needs fault injection; last — it may
470    //    poison subsequent log reads) ────────────────────────────────────────
471    match &options.inject_log_unavailable {
472        Some(inject) => {
473            inject();
474            let probe = succeeded_call_id
475                .clone()
476                .unwrap_or_else(|| Uuid::new_v4().to_string());
477            let result = match session.reconcile(&probe, issuing).await {
478                Ok(ReconcileResult::LogUnavailable { .. }) => Ok(()),
479                Ok(other) => Err(format!(
480                    "an unreachable issuing-session log must always be logUnavailable, \
481                     got {other:?}"
482                )),
483                Err(error) => Err(format!("reconcile failed: {error}")),
484            };
485            recorder.record("reconcile.logUnavailable", result);
486        }
487        None => recorder.skip(
488            "reconcile.logUnavailable",
489            "no inject_log_unavailable hook provided",
490        ),
491    }
492
493    // ── teardown ───────────────────────────────────────────────────────────
494    let result = session
495        .end(crate::spi::SessionOutcome::Completed, None)
496        .await
497        .map_err(|error| format!("end must be best-effort and not fail teardown: {error}"));
498    recorder.record("end.completes", result);
499
500    ConformanceReport {
501        checks: recorder.checks,
502    }
503}
504
505/// The callIds of the scripted terminals the reconcile checks probe later.
506#[derive(Default)]
507struct TerminalCallIds {
508    succeeded: Option<String>,
509    failed: Option<String>,
510}
511
512async fn execute_terminals(
513    session: &dyn ProviderSession,
514    call_ids: &mut TerminalCallIds,
515) -> Result<(), String> {
516    let expected = ["succeeded", "failed", "cancelled", "timedOut"];
517    for expected_kind in expected {
518        let call = fresh_call(session);
519        let call_id = call.call_id.clone();
520        match session.execute(call, None).await {
521            Ok(outcome) => {
522                if outcome.kind() != expected_kind {
523                    return Err(format!(
524                        "fixture contract: expected terminal {expected_kind}, got {} \
525                         (terminals must arrive unfolded and untranslated)",
526                        outcome.kind()
527                    ));
528                }
529                match &outcome {
530                    ActionOutcome::Succeeded { result } => {
531                        if result.call_id != call_id {
532                            return Err(format!(
533                                "succeeded result must echo the dispatched callId verbatim: \
534                                 got {} instead of {call_id}",
535                                result.call_id
536                            ));
537                        }
538                        call_ids.succeeded = Some(call_id);
539                    }
540                    ActionOutcome::Failed { .. } => call_ids.failed = Some(call_id),
541                    _ => {}
542                }
543            }
544            Err(error) => {
545                return Err(format!(
546                    "expected terminal {expected_kind}, got ProviderError: {error}"
547                ));
548            }
549        }
550    }
551    Ok(())
552}
553
554async fn pre_cancelled_execute(
555    session: &dyn ProviderSession,
556    issuing: &EventCursor,
557) -> Result<(), String> {
558    let token = CancellationToken::new();
559    token.cancel();
560    let call = fresh_call(session);
561    let call_id = call.call_id.clone();
562    match session.execute(call, Some(token)).await {
563        Err(error) if error.error_class == ErrorClass::ActionCancelled => {}
564        Err(error) => {
565            return Err(format!(
566                "a pre-cancelled token must fail with class action_cancelled, got {}",
567                error.error_class as u8
568            ));
569        }
570        Ok(outcome) => {
571            return Err(format!(
572                "a pre-cancelled token must not dispatch; got terminal {}",
573                outcome.kind()
574            ));
575        }
576    }
577    match session.reconcile(&call_id, issuing).await {
578        Ok(ReconcileResult::NeverDispatched) => Ok(()),
579        Ok(other) => Err(format!(
580            "a pre-cancelled execute must leave no wire trace; reconcile found {other:?}"
581        )),
582        Err(error) => Err(format!("reconcile failed: {error}")),
583    }
584}
585
586fn expect_bind_arguments_invalid(
587    result: Result<(), crate::error::ProviderError>,
588    what: &str,
589) -> Result<(), String> {
590    match result {
591        Err(error) if error.error_class == ErrorClass::BindArgumentsInvalid => Ok(()),
592        Err(error) => Err(format!(
593            "{what} must be rejected with class bind_arguments_invalid, got: {error}"
594        )),
595        Ok(()) => Err(format!(
596            "{what} must be rejected fail-closed, but was accepted"
597        )),
598    }
599}
600
601async fn sample_evidence(session: &dyn ProviderSession) -> pointlock_ir::AssetRef {
602    // Prefer a genuine asset from an observation; fall back to a synthetic
603    // reference (the cap check must fire before any dereference).
604    if let Ok(observation) = session
605        .observe(
606            ObserveRequest {
607                wants: vec![ObserveWant::Screenshot],
608            },
609            None,
610        )
611        .await
612        && let Some(asset) = observation.screenshot
613    {
614        return asset;
615    }
616    pointlock_ir::AssetRef {
617        id: "conformance-synthetic-asset".to_owned(),
618        media_type: "image/png".to_owned(),
619        uri: "conformance://assets/synthetic".to_owned(),
620        sha256: None,
621    }
622}
623
624fn cursor_monotonic(
625    before: Result<EventCursor, crate::error::ProviderError>,
626    after_executes: Result<EventCursor, crate::error::ProviderError>,
627    after_verdicts: Result<EventCursor, crate::error::ProviderError>,
628) -> Result<(), String> {
629    let before = before.map_err(|error| format!("currentCursor failed: {error}"))?;
630    let after_executes =
631        after_executes.map_err(|error| format!("currentCursor failed: {error}"))?;
632    let after_verdicts =
633        after_verdicts.map_err(|error| format!("currentCursor failed: {error}"))?;
634
635    if after_executes.session_id != before.session_id
636        || after_verdicts.session_id != before.session_id
637    {
638        return Err(format!(
639            "cursor sessionId drifted within one session: {} / {} / {}",
640            before.session_id, after_executes.session_id, after_verdicts.session_id
641        ));
642    }
643    if after_executes.last_sequence <= before.last_sequence {
644        return Err(format!(
645            "cursor must strictly advance across executed actions: {} -> {}",
646            before.last_sequence, after_executes.last_sequence
647        ));
648    }
649    if after_verdicts.last_sequence < after_executes.last_sequence {
650        return Err(format!(
651            "cursor must never regress: {} -> {}",
652            after_executes.last_sequence, after_verdicts.last_sequence
653        ));
654    }
655    Ok(())
656}