1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum CheckStatus {
37 Passed,
39 Failed,
41 Skipped,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct CheckResult {
48 pub name: &'static str,
50 pub status: CheckStatus,
52 pub detail: Option<String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct ConformanceReport {
59 pub checks: Vec<CheckResult>,
61}
62
63impl ConformanceReport {
64 pub fn passed(&self) -> bool {
66 self.checks
67 .iter()
68 .all(|check| check.status != CheckStatus::Failed)
69 }
70
71 pub fn all_green(&self) -> bool {
73 self.checks
74 .iter()
75 .all(|check| check.status == CheckStatus::Passed)
76 }
77
78 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
97pub struct ConformanceOptions {
99 pub open: OpenSessionOptions,
101 pub inject_log_unavailable: Option<Box<dyn Fn() + Send + Sync>>,
105 pub dispatch_count: Option<Box<dyn Fn() -> usize + Send + Sync>>,
112 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
164pub async fn run_conformance(
167 provider: &dyn Provider,
168 options: ConformanceOptions,
169) -> ConformanceReport {
170 let mut recorder = Recorder { checks: Vec::new() };
171
172 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 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 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 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 {
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 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 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 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 let result = pre_cancelled_execute(session, issuing).await;
362 recorder.record("execute.preCancelledSendsNothing", result);
363
364 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 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 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 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 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 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 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#[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 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}