Skip to main content

pointlock_provider_kit/
fake.rs

1//! FakeProvider — the deterministic, programmable reference implementation
2//! of the SPI (04 §8): an in-memory world model with scripted outcomes and
3//! fault injection. It is both the conformance suite's reference subject
4//! and the runner's E2E test double.
5//!
6//! Determinism: timestamps come from a logical millisecond clock,
7//! observation/asset ids from counters, and event sequences from a
8//! monotonic counter — no wall clock, no randomness.
9
10use std::collections::{BTreeMap, VecDeque};
11use std::sync::{Arc, Mutex, MutexGuard};
12
13use async_trait::async_trait;
14use pointlock_ir::{
15    ActionName, ActionOutcome, ActionResult, AssetRef, ErrorClass, ErrorInfo, EventCursor,
16    FeatureId, Hash, JsonSchemaDocument, Observation, ReconcileResult, ScreenshotOmissionReason,
17    UiSnapshotOmissionReason, UiSnapshotRef, Viewport,
18};
19use serde_json::json;
20use sha2::{Digest, Sha256};
21
22use crate::error::{ProviderError, RetryableSource};
23use crate::lockfile::{
24    CapabilityAttestation, CapabilityLockfile, LockfileDevice, LockfileHello, LockfileProvider,
25    PeerInfo, ProtocolVersion,
26};
27use crate::manifest::{
28    ActionDefinitionStatic, ActionProtection, ChannelRole, ChannelSupport, FeatureDeclarations,
29    PlatformKind, ProtocolRange, ProviderManifest, VerbBinding,
30};
31use crate::spi::{
32    BoundActionCall, CancellationToken, EvidenceStream, ObserveRequest, ObserveWant, Provider,
33    ProviderSession, SessionHealth, SessionOutcome, UiSnapshotOutcome,
34    VERDICT_EVIDENCE_MAX_ENTRIES, VERDICT_SUMMARY_MAX_CHARS, VerdictWrite,
35};
36
37/// One scripted execute behavior ([`crate::spi::ProviderSession::execute`]),
38/// consumed front-to-back
39/// from the script queue.
40#[derive(Debug, Clone, PartialEq)]
41pub enum ScriptedOutcome {
42    /// Return this terminal outcome. The journal records the dispatch and
43    /// archives the terminal. For `succeeded`, the fake stamps
44    /// `result.callId` and the timestamps from its logical clock so the
45    /// post-condition `result.callId == call.callId` holds regardless of
46    /// what the script author wrote.
47    Terminal(ActionOutcome),
48    /// Simulate transport rupture after dispatch reached the daemon but
49    /// before any terminal arrived: the journal records a dispatch with no
50    /// terminal (reconcile → `startedNoTerminal`) and `execute` fails with
51    /// a `transport_lost`-class [`ProviderError`].
52    TransportLostAfterDispatch,
53    /// Simulate transport rupture before dispatch reached the daemon: no
54    /// journal trace (reconcile → `neverDispatched`) and `execute` fails
55    /// with a `transport_lost`-class [`ProviderError`].
56    TransportLostBeforeDispatch,
57}
58
59impl ScriptedOutcome {
60    /// A minimal deterministic `succeeded` terminal (callId and timestamps
61    /// are stamped by the fake at execute time).
62    pub fn succeeded() -> Self {
63        ScriptedOutcome::Terminal(ActionOutcome::Succeeded {
64            result: Box::new(ActionResult {
65                call_id: String::new(),
66                started_at_ms: 0,
67                finished_at_ms: 0,
68                output: json!({}),
69                before: None,
70                after: None,
71                evidence: Vec::new(),
72                execution: None,
73            }),
74        })
75    }
76
77    /// A `failed` terminal with the given wire code and retryable flag.
78    pub fn failed(code: &str, retryable: bool) -> Self {
79        ScriptedOutcome::Terminal(ActionOutcome::Failed {
80            error: ErrorInfo {
81                code: code.to_owned(),
82                message: format!("scripted failure: {code}"),
83                retryable,
84                details: None,
85            },
86        })
87    }
88
89    /// A `cancelled` terminal (wire code `action_cancelled`).
90    pub fn cancelled() -> Self {
91        ScriptedOutcome::Terminal(ActionOutcome::Cancelled {
92            error: ErrorInfo {
93                code: "action_cancelled".to_owned(),
94                message: "scripted cancellation".to_owned(),
95                retryable: false,
96                details: None,
97            },
98        })
99    }
100
101    /// A `timedOut` terminal (wire code `action_timeout`).
102    pub fn timed_out() -> Self {
103        ScriptedOutcome::Terminal(ActionOutcome::TimedOut {
104            error: ErrorInfo {
105                code: "action_timeout".to_owned(),
106                message: "scripted action budget expiry".to_owned(),
107                retryable: true,
108                details: None,
109            },
110        })
111    }
112}
113
114/// One dispatch-journal entry: the fake's stand-in for the daemon's
115/// append-only session event log (`actionStarted` / `actionCompleted`).
116#[derive(Debug, Clone, PartialEq)]
117struct JournalEntry {
118    call_id: String,
119    /// The archived terminal; `None` models `actionStarted` without
120    /// `actionCompleted`.
121    terminal: Option<ActionOutcome>,
122}
123
124#[derive(Debug)]
125struct FakeWorld {
126    script: VecDeque<ScriptedOutcome>,
127    journal: Vec<JournalEntry>,
128    verdicts: Vec<VerdictWrite>,
129    /// Monotonic event sequence (the daemon-log watermark).
130    sequence: u64,
131    /// Logical millisecond clock.
132    clock_ms: u64,
133    observation_counter: u64,
134    session_counter: u64,
135    screenshot_omission: Option<ScreenshotOmissionReason>,
136    ui_snapshot_omission: Option<UiSnapshotOmissionReason>,
137    /// When set, `reconcile` reports the issuing session's log as
138    /// unreachable with this reason.
139    log_unavailable: Option<String>,
140    /// When set, `fetch_evidence` fails with a typed unsupported error
141    /// (the control-plane-without-byte-channel scenario): the runner must
142    /// degrade localization to a typed gap, never abort the run.
143    fetch_unsupported: Option<String>,
144    /// When set, `record_verdict` fails with a typed transport error:
145    /// the runner must annotate "remote archival failed" and keep the
146    /// local verdict, never abort the run (04 §5).
147    record_verdict_error: Option<String>,
148    /// Evidence bytes addressable by `AssetRef.id`.
149    evidence_store: BTreeMap<String, Vec<u8>>,
150    /// Injected default UI snapshot served by `observe` (evidence bytes)
151    /// and registered for `ui_snapshot` per observation.
152    injected_ui_snapshot: Option<serde_json::Value>,
153    /// observation id → the synthetic UiSnapshot `ui_snapshot` serves.
154    ui_snapshots: BTreeMap<String, serde_json::Value>,
155    ended: Option<(SessionOutcome, Option<String>)>,
156}
157
158impl FakeWorld {
159    fn new(script: VecDeque<ScriptedOutcome>) -> Self {
160        FakeWorld {
161            script,
162            journal: Vec::new(),
163            verdicts: Vec::new(),
164            sequence: 0,
165            clock_ms: 0,
166            observation_counter: 0,
167            session_counter: 0,
168            screenshot_omission: None,
169            ui_snapshot_omission: None,
170            log_unavailable: None,
171            fetch_unsupported: None,
172            record_verdict_error: None,
173            evidence_store: BTreeMap::new(),
174            injected_ui_snapshot: None,
175            ui_snapshots: BTreeMap::new(),
176            ended: None,
177        }
178    }
179
180    fn tick_ms(&mut self) -> u64 {
181        self.clock_ms += 1;
182        self.clock_ms
183    }
184
185    fn bump_sequence(&mut self) {
186        self.sequence += 1;
187    }
188
189    /// Builds a deterministic asset, registering its bytes for
190    /// `fetch_evidence`.
191    fn make_asset(&mut self, id: String, media_type: &str, bytes: Vec<u8>) -> AssetRef {
192        let sha256 = hex_sha256(&bytes);
193        self.evidence_store.insert(id.clone(), bytes);
194        AssetRef {
195            uri: format!("fake://assets/{id}"),
196            id,
197            media_type: media_type.to_owned(),
198            sha256: Some(sha256),
199        }
200    }
201
202    /// Synthesizes one observation: a fresh id, a registered screenshot
203    /// asset, and — when a snapshot is given — a `UiSnapshotRef` whose
204    /// evidence bytes are the snapshot's canonical JSON, with the snapshot
205    /// registered for `ui_snapshot(observationId)` (both dereference
206    /// routes serve the same injected tree).
207    fn synthesize_observation(
208        &mut self,
209        device_id: &str,
210        ui_snapshot: Option<serde_json::Value>,
211    ) -> Observation {
212        self.observation_counter += 1;
213        let observation_id = format!("obs-{}", self.observation_counter);
214        let captured_at_ms = self.tick_ms();
215        let screenshot = {
216            let id = format!("{observation_id}-screenshot");
217            let bytes = format!("fake-screenshot:{observation_id}").into_bytes();
218            self.make_asset(id, "image/png", bytes)
219        };
220        let ui_snapshot = ui_snapshot.map(|snapshot| {
221            let bytes =
222                serde_json::to_vec(&snapshot).expect("an injected UiSnapshot value serializes");
223            let id = format!("{observation_id}-uitree");
224            let evidence = self.make_asset(id, "application/json", bytes);
225            self.ui_snapshots.insert(observation_id.clone(), snapshot);
226            UiSnapshotRef { evidence }
227        });
228        Observation {
229            id: observation_id,
230            device_id: device_id.to_owned(),
231            captured_at_ms,
232            viewport: Viewport {
233                width: 1080,
234                height: 2400,
235                scale_factor: 2.0,
236            },
237            screenshot: Some(screenshot),
238            screenshot_omission: None,
239            ui_snapshot,
240            ui_snapshot_omission: None,
241            metadata: BTreeMap::new(),
242        }
243    }
244}
245
246/// A cheap, clonable handle into the fake's world for programming and
247/// inspection from tests (fault injection, recorded-state assertions).
248#[derive(Debug, Clone)]
249pub struct FakeHandle {
250    world: Arc<Mutex<FakeWorld>>,
251}
252
253impl FakeHandle {
254    fn world(&self) -> MutexGuard<'_, FakeWorld> {
255        self.world.lock().expect("fake world lock poisoned")
256    }
257
258    /// Appends a scripted outcome to the back of the script queue.
259    pub fn push_script(&self, outcome: ScriptedOutcome) {
260        self.world().script.push_back(outcome);
261    }
262
263    /// Makes `reconcile` report the issuing session's log as unreachable.
264    pub fn set_log_unavailable(&self, reason: impl Into<String>) {
265        self.world().log_unavailable = Some(reason.into());
266    }
267
268    /// Restores log availability.
269    pub fn clear_log_unavailable(&self) {
270        self.world().log_unavailable = None;
271    }
272
273    /// Makes `fetch_evidence` fail with a typed unsupported error (`None`
274    /// restores it). Models a control plane without an asset byte channel
275    /// — the runner degrades localization to a typed gap (M2).
276    pub fn set_fetch_evidence_unsupported(&self, reason: Option<String>) {
277        self.world().fetch_unsupported = reason;
278    }
279
280    /// Makes `record_verdict` fail with a typed transport error (`None`
281    /// restores it). Models a wire failure of remote verdict archival —
282    /// the runner annotates it and never aborts the run (04 §5).
283    pub fn set_record_verdict_error(&self, reason: Option<String>) {
284        self.world().record_verdict_error = reason;
285    }
286
287    /// Injects a screenshot omission for subsequent observations.
288    pub fn set_screenshot_omission(&self, reason: Option<ScreenshotOmissionReason>) {
289        self.world().screenshot_omission = reason;
290    }
291
292    /// Injects a UI-snapshot omission for subsequent observations and
293    /// `ui_snapshot` calls.
294    pub fn set_ui_snapshot_omission(&self, reason: Option<UiSnapshotOmissionReason>) {
295        self.world().ui_snapshot_omission = reason;
296    }
297
298    /// Registers evidence bytes addressable via `AssetRef.id`.
299    pub fn insert_evidence(&self, asset_id: impl Into<String>, bytes: Vec<u8>) {
300        self.world().evidence_store.insert(asset_id.into(), bytes);
301    }
302
303    /// Injects the synthetic UiSnapshot that subsequent `observe` calls
304    /// serve: the produced `UiSnapshotRef` evidence bytes are the
305    /// snapshot's canonical JSON (fetchable via `fetch_evidence`) and
306    /// `ui_snapshot(observationId)` returns the injected content. `None`
307    /// restores the placeholder bytes.
308    pub fn inject_ui_snapshot(&self, snapshot: Option<serde_json::Value>) {
309        self.world().injected_ui_snapshot = snapshot;
310    }
311
312    /// Synthesizes an [`Observation`] carrying a registered screenshot
313    /// asset and — when `ui_snapshot` is given — a `UiSnapshotRef` whose
314    /// evidence bytes are the snapshot's canonical JSON, with
315    /// `ui_snapshot(observationId)` serving the same injected tree. Embed
316    /// it into a scripted `ActionResult` (`before`/`after`) to drive the
317    /// runner's uiTree/vision verify channels programmatically.
318    pub fn make_observation(&self, ui_snapshot: Option<serde_json::Value>) -> Observation {
319        self.make_observation_for("fake-device-1", ui_snapshot)
320    }
321
322    /// [`Self::make_observation`] with an explicit device id — the
323    /// observation record lands in the ledger, so a harness binding a
324    /// non-default device must not journal the wrong identity.
325    pub fn make_observation_for(
326        &self,
327        device_id: &str,
328        ui_snapshot: Option<serde_json::Value>,
329    ) -> Observation {
330        self.world().synthesize_observation(device_id, ui_snapshot)
331    }
332
333    /// The verdicts accepted by `record_verdict` so far.
334    pub fn recorded_verdicts(&self) -> Vec<VerdictWrite> {
335        self.world().verdicts.clone()
336    }
337
338    /// The callIds journaled as dispatched, in dispatch order.
339    pub fn dispatched_call_ids(&self) -> Vec<String> {
340        self.world()
341            .journal
342            .iter()
343            .map(|entry| entry.call_id.clone())
344            .collect()
345    }
346
347    /// The outcome recorded by the first effective `end` call, if any.
348    pub fn ended_outcome(&self) -> Option<(SessionOutcome, Option<String>)> {
349        self.world().ended.clone()
350    }
351}
352
353/// Deterministic, programmable SPI reference implementation (04 §8).
354///
355/// Construction injects the execute script; all other behavior is
356/// programmable through [`FakeProvider::handle`]. Sessions share the
357/// provider's world, so a journal written through one session generation
358/// stays readable by `reconcile` from a later one (resume tests).
359#[derive(Debug)]
360pub struct FakeProvider {
361    manifest: ProviderManifest,
362    lockfile: CapabilityLockfile,
363    world: Arc<Mutex<FakeWorld>>,
364}
365
366impl FakeProvider {
367    /// Creates a fake provider that will serve `script` from its
368    /// `execute` implementation, front to back.
369    pub fn new(script: VecDeque<ScriptedOutcome>) -> Self {
370        let manifest = Self::default_manifest();
371        let lockfile = Self::default_lockfile(&manifest);
372        FakeProvider {
373            manifest,
374            lockfile,
375            world: Arc::new(Mutex::new(FakeWorld::new(script))),
376        }
377    }
378
379    /// A programming/inspection handle sharing this provider's world.
380    pub fn handle(&self) -> FakeHandle {
381        FakeHandle {
382            world: Arc::clone(&self.world),
383        }
384    }
385
386    /// The lockfile this fake attests against (its digest goes into
387    /// [`crate::spi::OpenSessionOptions::lockfile_digest`]).
388    pub fn lockfile(&self) -> &CapabilityLockfile {
389        &self.lockfile
390    }
391
392    /// Session options that open successfully against this fake.
393    pub fn default_open_options(&self) -> crate::spi::OpenSessionOptions {
394        crate::spi::OpenSessionOptions {
395            endpoint: json!({ "fake": true }),
396            device_id: "fake-device-1".to_owned(),
397            required_features: self.lockfile.hello.features_enabled.clone(),
398            lockfile_digest: self.lockfile.digest.clone(),
399        }
400    }
401
402    fn feature(id: &str) -> FeatureId {
403        FeatureId::new(id).expect("built-in feature ids are grammatical")
404    }
405
406    fn action_name(name: &str) -> ActionName {
407        ActionName::new(name).expect("built-in action names are grammatical")
408    }
409
410    fn semantic_action(name: &str) -> ActionDefinitionStatic {
411        ActionDefinitionStatic {
412            name: Self::action_name(name),
413            input_schema: JsonSchemaDocument::new(json!({ "type": "object" }))
414                .expect("an object schema is a valid schema document"),
415            output_schema: None,
416            protection: ActionProtection::Standard,
417            synthetic: false,
418        }
419    }
420
421    /// A provider-synthetic readonly action (04 §9.4.3): the fake serves
422    /// `observe`/`screenshot` from its own `observe()`, so no driver
423    /// declares them and they must not appear in a lockfile.
424    fn synthetic_action(name: &str) -> ActionDefinitionStatic {
425        // Unlike the fake's driver actions this carries a real schema: the
426        // `wants` vocabulary is closed, and compiling a flow that asks for
427        // a part nobody can capture should fail at compile time.
428        let input_schema = if name == "observe" {
429            json!({
430                "type": "object",
431                "additionalProperties": false,
432                "required": ["wants"],
433                "properties": {
434                    "wants": {
435                        "type": "array",
436                        "minItems": 1,
437                        "uniqueItems": true,
438                        "items": { "enum": ["screenshot", "uiSnapshot"] }
439                    }
440                }
441            })
442        } else {
443            json!({ "type": "object", "additionalProperties": false, "properties": {} })
444        };
445        ActionDefinitionStatic {
446            name: Self::action_name(name),
447            input_schema: JsonSchemaDocument::new(input_schema)
448                .expect("an object schema is a valid schema document"),
449            output_schema: None,
450            protection: ActionProtection::Standard,
451            synthetic: true,
452        }
453    }
454
455    fn default_manifest() -> ProviderManifest {
456        use pointlock_ir::CanonicalVerb;
457        let semantic = Self::feature("device.semanticActions.v1");
458        let verb_binding = |verb: CanonicalVerb, action: &str, arg: &str| VerbBinding {
459            verb,
460            action_name: Self::action_name(action),
461            requires_feature: Some(semantic.clone()),
462            arg_map: BTreeMap::from([(arg.to_owned(), arg.to_owned())]),
463        };
464        ProviderManifest {
465            name: "fake".to_owned(),
466            version: "0.1.0".to_owned(),
467            protocol: ProtocolRange {
468                major: 1,
469                min_minor: 5,
470                max_minor: 5,
471            },
472            features: FeatureDeclarations {
473                guaranteed: vec![
474                    Self::feature("device.semanticActions.v1"),
475                    Self::feature("observation.uiSnapshot.v1"),
476                    Self::feature("verdict.record.v1"),
477                    Self::feature("events.snapshot.v1"),
478                    Self::feature("request.control.v1"),
479                ],
480                conditional: Vec::new(),
481            },
482            verb_bindings: vec![
483                verb_binding(CanonicalVerb::Tap, "tapElement", "element"),
484                verb_binding(CanonicalVerb::SetValue, "setElementValue", "element"),
485                verb_binding(CanonicalVerb::Clear, "clearElement", "element"),
486                verb_binding(CanonicalVerb::WaitFor, "waitForElement", "element"),
487                verb_binding(CanonicalVerb::Find, "findElement", "element"),
488                verb_binding(CanonicalVerb::Observe, "observe", "wants"),
489                // The shortcut form fixes `wants` inside the action and
490                // takes no arguments (its schema forbids any), so its
491                // binding maps none — same shape as the devicerail
492                // manifest.
493                VerbBinding {
494                    verb: CanonicalVerb::Screenshot,
495                    action_name: Self::action_name("screenshot"),
496                    requires_feature: None,
497                    arg_map: BTreeMap::new(),
498                },
499            ],
500            channels: vec![
501                ChannelSupport {
502                    channel: pointlock_ir::Channel::UiTree,
503                    role: ChannelRole::Both,
504                    requires_feature: Some(Self::feature("observation.uiSnapshot.v1")),
505                    requires_platform: None,
506                },
507                // vision: verify-only (principle 7) — the fake synthesizes
508                // a screenshot on every observation, so declaring the
509                // channel is honest; the verification itself is
510                // Pointlock-side (pointlock-vision).
511                ChannelSupport {
512                    channel: pointlock_ir::Channel::Vision,
513                    role: ChannelRole::Verify,
514                    requires_feature: None,
515                    requires_platform: None,
516                },
517            ],
518            known_actions: vec![
519                Self::semantic_action("findElement"),
520                Self::semantic_action("tapElement"),
521                Self::semantic_action("clearElement"),
522                Self::semantic_action("setElementValue"),
523                Self::semantic_action("waitForElement"),
524                Self::synthetic_action("observe"),
525                Self::synthetic_action("screenshot"),
526            ],
527        }
528    }
529
530    fn default_lockfile(manifest: &ProviderManifest) -> CapabilityLockfile {
531        let mut lockfile = CapabilityLockfile {
532            provider: LockfileProvider {
533                name: manifest.name.clone(),
534                version: manifest.version.clone(),
535            },
536            attested_at: "1970-01-01T00:00:00Z".to_owned(),
537            hello: LockfileHello {
538                protocol_selected: ProtocolVersion { major: 1, minor: 5 },
539                features_enabled: manifest.features.guaranteed.clone(),
540                server: PeerInfo {
541                    name: "fake-daemon".to_owned(),
542                    version: manifest.version.clone(),
543                },
544            },
545            device: LockfileDevice {
546                platform: PlatformKind::Android,
547                // A lockfile lists DRIVER actions; the synthetic ones are
548                // the provider's own and are overlaid at bind time.
549                actions: manifest
550                    .known_actions
551                    .iter()
552                    .filter(|action| !action.synthetic)
553                    .cloned()
554                    .collect(),
555            },
556            digest: Hash::new(format!("sha256:{}", "0".repeat(64)))
557                .expect("the placeholder digest is grammatical"),
558        };
559        lockfile.seal();
560        lockfile
561    }
562}
563
564#[async_trait]
565impl Provider for FakeProvider {
566    fn manifest(&self) -> &ProviderManifest {
567        &self.manifest
568    }
569
570    async fn open_session(
571        &self,
572        opts: crate::spi::OpenSessionOptions,
573    ) -> Result<Box<dyn ProviderSession>, ProviderError> {
574        if opts.lockfile_digest != self.lockfile.digest {
575            return Err(ProviderError::new(
576                ErrorClass::CapabilityDrift,
577                format!(
578                    "attestation mismatch: live world digest {} != expected lockfileDigest {}",
579                    self.lockfile.digest, opts.lockfile_digest
580                ),
581                RetryableSource::Classifier,
582            ));
583        }
584        if let Some(missing) = opts
585            .required_features
586            .iter()
587            .find(|feature| !self.lockfile.hello.features_enabled.contains(feature))
588        {
589            return Err(ProviderError::new(
590                ErrorClass::CapabilityDrift,
591                format!("required feature not negotiated: {missing}"),
592                RetryableSource::Classifier,
593            ));
594        }
595        let session_id = {
596            let mut world = self.world.lock().expect("fake world lock poisoned");
597            world.session_counter += 1;
598            // A new session generation reopens the world.
599            world.ended = None;
600            format!("fake-session-{}", world.session_counter)
601        };
602        let attestation = CapabilityAttestation::from_lockfile(
603            &self.lockfile,
604            // Deterministic "live" attestation timestamp.
605            "1970-01-01T00:00:01Z",
606        );
607        Ok(Box::new(FakeProviderSession {
608            session_id,
609            device_id: opts.device_id,
610            attestation,
611            world: Arc::clone(&self.world),
612        }))
613    }
614}
615
616/// A session over the fake world. See [`FakeProvider`].
617#[derive(Debug)]
618pub struct FakeProviderSession {
619    session_id: String,
620    device_id: String,
621    attestation: CapabilityAttestation,
622    world: Arc<Mutex<FakeWorld>>,
623}
624
625impl FakeProviderSession {
626    fn world(&self) -> MutexGuard<'_, FakeWorld> {
627        self.world.lock().expect("fake world lock poisoned")
628    }
629
630    fn transport_lost(context: &str) -> ProviderError {
631        ProviderError::new(
632            ErrorClass::TransportLost,
633            format!("fake transport lost: {context}"),
634            RetryableSource::Classifier,
635        )
636        .with_client_code("transport_closed")
637    }
638
639    fn ensure_active(world: &FakeWorld, method: &str) -> Result<(), ProviderError> {
640        match world.ended {
641            Some(_) => Err(Self::transport_lost(&format!(
642                "session already ended; {method} unavailable"
643            ))),
644            None => Ok(()),
645        }
646    }
647
648    fn pre_cancelled(cancel: &Option<CancellationToken>) -> bool {
649        cancel.as_ref().is_some_and(CancellationToken::is_cancelled)
650    }
651
652    fn cancelled_before_dispatch() -> ProviderError {
653        ProviderError::new(
654            ErrorClass::ActionCancelled,
655            "cancellation token was already cancelled; no wire request was sent",
656            RetryableSource::Classifier,
657        )
658    }
659}
660
661fn hex_sha256(bytes: &[u8]) -> String {
662    let digest = Sha256::digest(bytes);
663    digest.iter().map(|byte| format!("{byte:02x}")).collect()
664}
665
666#[async_trait]
667impl ProviderSession for FakeProviderSession {
668    fn attestation(&self) -> &CapabilityAttestation {
669        &self.attestation
670    }
671
672    async fn execute(
673        &self,
674        call: BoundActionCall,
675        cancel: Option<CancellationToken>,
676    ) -> Result<ActionOutcome, ProviderError> {
677        // Provider-synthetic observation actions (04 §9.4.3) are served
678        // from `observe()` rather than the journal. Routed before the world
679        // lock is taken, since `observe` takes it too. Absence from the
680        // attestation is the shadowing rule at run time: a driver action of
681        // the same name is attested and takes the normal path.
682        if !self.attestation.actions.contains_key(&call.action_name)
683            && let Some(wants) = crate::synthetic_observation_wants(&call)?
684        {
685            {
686                let world = self.world();
687                Self::ensure_active(&world, "execute")?;
688            }
689            if Self::pre_cancelled(&cancel) {
690                return Err(Self::cancelled_before_dispatch());
691            }
692            let started_at_ms = crate::now_ms();
693            let observation = self
694                .observe(
695                    ObserveRequest {
696                        wants: wants.clone(),
697                    },
698                    cancel,
699                )
700                .await?;
701            return Ok(ActionOutcome::Succeeded {
702                result: Box::new(pointlock_ir::ActionResult {
703                    call_id: call.call_id,
704                    started_at_ms,
705                    finished_at_ms: crate::now_ms(),
706                    output: crate::observation_projection(&observation, &wants),
707                    before: None,
708                    after: Some(observation),
709                    evidence: Vec::new(),
710                    execution: None,
711                }),
712            });
713        }
714        let mut world = self.world();
715        Self::ensure_active(&world, "execute")?;
716        if Self::pre_cancelled(&cancel) {
717            return Err(Self::cancelled_before_dispatch());
718        }
719        if !self.attestation.actions.contains_key(&call.action_name) {
720            return Err(ProviderError::new(
721                ErrorClass::CapabilityDrift,
722                format!(
723                    "actionName {} is not attested; refusing to dispatch",
724                    call.action_name
725                ),
726                RetryableSource::Classifier,
727            ));
728        }
729        if world
730            .journal
731            .iter()
732            .any(|entry| entry.call_id == call.call_id)
733        {
734            return Err(ProviderError::new(
735                ErrorClass::BindArgumentsInvalid,
736                format!(
737                    "duplicate callId {}: a retry must be a new callId with a new WAL intent",
738                    call.call_id
739                ),
740                RetryableSource::Classifier,
741            ));
742        }
743
744        let scripted = world.script.pop_front().unwrap_or_else(|| {
745            // Script exhaustion is a harness misconfiguration; surface it as
746            // a deterministic failed terminal rather than a transport error.
747            ScriptedOutcome::Terminal(ActionOutcome::Failed {
748                error: ErrorInfo {
749                    code: "fake_script_exhausted".to_owned(),
750                    message: "FakeProvider script exhausted; push more ScriptedOutcomes".to_owned(),
751                    retryable: false,
752                    details: None,
753                },
754            })
755        });
756
757        match scripted {
758            ScriptedOutcome::Terminal(mut outcome) => {
759                let started_at_ms = world.tick_ms();
760                let finished_at_ms = world.tick_ms();
761                if let ActionOutcome::Succeeded { result } = &mut outcome {
762                    result.call_id = call.call_id.clone();
763                    result.started_at_ms = started_at_ms;
764                    result.finished_at_ms = finished_at_ms;
765                }
766                // actionStarted + actionCompleted.
767                world.bump_sequence();
768                world.bump_sequence();
769                world.journal.push(JournalEntry {
770                    call_id: call.call_id,
771                    terminal: Some(outcome.clone()),
772                });
773                Ok(outcome)
774            }
775            ScriptedOutcome::TransportLostAfterDispatch => {
776                // actionStarted only.
777                world.bump_sequence();
778                world.journal.push(JournalEntry {
779                    call_id: call.call_id,
780                    terminal: None,
781                });
782                Err(Self::transport_lost("connection dropped after dispatch"))
783            }
784            ScriptedOutcome::TransportLostBeforeDispatch => {
785                Err(Self::transport_lost("connection dropped before dispatch"))
786            }
787        }
788    }
789
790    async fn observe(
791        &self,
792        req: ObserveRequest,
793        cancel: Option<CancellationToken>,
794    ) -> Result<Observation, ProviderError> {
795        let mut world = self.world();
796        Self::ensure_active(&world, "observe")?;
797        if Self::pre_cancelled(&cancel) {
798            return Err(Self::cancelled_before_dispatch());
799        }
800        world.observation_counter += 1;
801        let observation_id = format!("obs-{}", world.observation_counter);
802        let captured_at_ms = world.tick_ms();
803
804        let wants_screenshot = req.wants.contains(&ObserveWant::Screenshot);
805        let wants_ui_snapshot = req.wants.contains(&ObserveWant::UiSnapshot);
806
807        let screenshot_omission = world.screenshot_omission;
808        let screenshot = (wants_screenshot && screenshot_omission.is_none()).then(|| {
809            let id = format!("{observation_id}-screenshot");
810            let bytes = format!("fake-screenshot:{observation_id}").into_bytes();
811            world.make_asset(id, "image/png", bytes)
812        });
813
814        let ui_snapshot_omission = world.ui_snapshot_omission;
815        let ui_snapshot = (wants_ui_snapshot && ui_snapshot_omission.is_none()).then(|| {
816            let id = format!("{observation_id}-uitree");
817            // An injected snapshot is served on both dereference routes:
818            // its canonical JSON becomes the evidence bytes and the value
819            // is registered for `ui_snapshot(observationId)`.
820            let bytes = match world.injected_ui_snapshot.clone() {
821                Some(snapshot) => {
822                    let bytes = serde_json::to_vec(&snapshot)
823                        .expect("an injected UiSnapshot value serializes");
824                    world.ui_snapshots.insert(observation_id.clone(), snapshot);
825                    bytes
826                }
827                None => format!("fake-uitree:{observation_id}").into_bytes(),
828            };
829            UiSnapshotRef {
830                evidence: world.make_asset(id, "application/json", bytes),
831            }
832        });
833
834        // observationCaptured.
835        world.bump_sequence();
836        Ok(Observation {
837            id: observation_id,
838            device_id: self.device_id.clone(),
839            captured_at_ms,
840            viewport: Viewport {
841                width: 1080,
842                height: 2400,
843                scale_factor: 2.0,
844            },
845            screenshot,
846            screenshot_omission: wants_screenshot.then_some(screenshot_omission).flatten(),
847            ui_snapshot,
848            ui_snapshot_omission: wants_ui_snapshot.then_some(ui_snapshot_omission).flatten(),
849            metadata: BTreeMap::new(),
850        })
851    }
852
853    async fn ui_snapshot(&self, observation_id: &str) -> Result<UiSnapshotOutcome, ProviderError> {
854        let world = self.world();
855        Self::ensure_active(&world, "ui_snapshot")?;
856        if let Some(reason) = world.ui_snapshot_omission {
857            return Ok(UiSnapshotOutcome::Unavailable { reason });
858        }
859        if let Some(snapshot) = world.ui_snapshots.get(observation_id) {
860            return Ok(UiSnapshotOutcome::Available {
861                snapshot: snapshot.clone(),
862            });
863        }
864        Ok(UiSnapshotOutcome::Available {
865            snapshot: json!({
866                "observationId": observation_id,
867                "contexts": [],
868                "nodes": [],
869            }),
870        })
871    }
872
873    async fn reconcile(
874        &self,
875        call_id: &str,
876        issuing: &EventCursor,
877    ) -> Result<ReconcileResult, ProviderError> {
878        // The fake's journal is WORLD-scoped: it survives session
879        // generations, so it genuinely contains the issuing session's
880        // events whatever the credential — the fake trivially has the
881        // cross-generation log retrieval a real provider only gains in
882        // v0.2 (04 §5). Answering `neverDispatched` from it is therefore
883        // sound for any credential; `_issuing` documents the contract.
884        let _ = issuing;
885        let world = self.world();
886        if let Some(reason) = &world.log_unavailable {
887            return Ok(ReconcileResult::LogUnavailable {
888                reason: reason.clone(),
889            });
890        }
891        let Some(entry) = world.journal.iter().find(|entry| entry.call_id == call_id) else {
892            // The complete event range of the issuing session shows no
893            // trace: safe to replay.
894            return Ok(ReconcileResult::NeverDispatched);
895        };
896        match &entry.terminal {
897            // An archived terminal is a certain fate whatever its four-way
898            // discriminant: adopt it verbatim through the completed fate.
899            Some(outcome) => Ok(ReconcileResult::Completed {
900                outcome: Box::new(outcome.clone()),
901            }),
902            None => Ok(ReconcileResult::StartedNoTerminal),
903        }
904    }
905
906    async fn fetch_evidence(&self, asset: &AssetRef) -> Result<EvidenceStream, ProviderError> {
907        let world = self.world();
908        Self::ensure_active(&world, "fetch_evidence")?;
909        if let Some(reason) = &world.fetch_unsupported {
910            return Err(ProviderError::new(
911                ErrorClass::ActionFailedFinal,
912                format!("fetch_evidence unsupported: {reason}"),
913                RetryableSource::Classifier,
914            )
915            .with_client_code("asset_fetch_unsupported"));
916        }
917        let Some(bytes) = world.evidence_store.get(&asset.id).cloned() else {
918            return Err(ProviderError::new(
919                ErrorClass::ActionFailedFinal,
920                format!("unknown evidence asset: {}", asset.id),
921                RetryableSource::Classifier,
922            ));
923        };
924        if let Some(expected) = &asset.sha256 {
925            let actual = hex_sha256(&bytes);
926            if &actual != expected {
927                return Err(ProviderError::new(
928                    ErrorClass::ActionFailedFinal,
929                    format!(
930                        "evidence integrity failure for {}: sha256 {actual} != declared {expected}",
931                        asset.id
932                    ),
933                    RetryableSource::Classifier,
934                ));
935            }
936        }
937        Ok(Box::pin(futures_util::stream::iter([Ok(bytes)])))
938    }
939
940    async fn record_verdict(&self, verdict: VerdictWrite) -> Result<(), ProviderError> {
941        let mut world = self.world();
942        Self::ensure_active(&world, "record_verdict")?;
943        if let Some(reason) = &world.record_verdict_error {
944            return Err(ProviderError::new(
945                ErrorClass::TransportLost,
946                format!("scripted verdict.record failure: {reason}"),
947                RetryableSource::Classifier,
948            ));
949        }
950        let summary_chars = verdict.summary.chars().count();
951        if summary_chars > VERDICT_SUMMARY_MAX_CHARS {
952            return Err(ProviderError::new(
953                ErrorClass::BindArgumentsInvalid,
954                format!(
955                    "verdict summary is {summary_chars} chars; wire cap is \
956                     {VERDICT_SUMMARY_MAX_CHARS} (fail-closed; compaction is the runner's job)"
957                ),
958                RetryableSource::Classifier,
959            ));
960        }
961        if verdict.evidence.len() > VERDICT_EVIDENCE_MAX_ENTRIES {
962            return Err(ProviderError::new(
963                ErrorClass::BindArgumentsInvalid,
964                format!(
965                    "verdict cites {} evidence entries; wire cap is {VERDICT_EVIDENCE_MAX_ENTRIES}",
966                    verdict.evidence.len()
967                ),
968                RetryableSource::Classifier,
969            ));
970        }
971        world.verdicts.push(verdict);
972        // verdictRecorded.
973        world.bump_sequence();
974        Ok(())
975    }
976
977    async fn current_cursor(&self) -> Result<EventCursor, ProviderError> {
978        let world = self.world();
979        Ok(EventCursor {
980            session_id: self.session_id.clone(),
981            last_sequence: world.sequence,
982        })
983    }
984
985    async fn health(&self) -> Result<SessionHealth, ProviderError> {
986        let world = self.world();
987        Ok(SessionHealth {
988            ok: world.ended.is_none(),
989            degraded: None,
990        })
991    }
992
993    async fn end(
994        &self,
995        outcome: SessionOutcome,
996        reason: Option<String>,
997    ) -> Result<(), ProviderError> {
998        let mut world = self.world();
999        if world.ended.is_some() {
1000            // Idempotent: ending an ended session is a no-op (04 §2.1).
1001            return Ok(());
1002        }
1003        world.ended = Some((outcome, reason));
1004        // sessionEnded.
1005        world.bump_sequence();
1006        Ok(())
1007    }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013    use futures_util::StreamExt;
1014    use pointlock_ir::VerdictStatus;
1015
1016    fn call(call_id: &str) -> BoundActionCall {
1017        BoundActionCall {
1018            call_id: call_id.to_owned(),
1019            action_name: ActionName::new("tapElement").unwrap(),
1020            arguments: json!({}),
1021            action_timeout_ms: None,
1022            request_timeout_ms: None,
1023        }
1024    }
1025
1026    async fn open(provider: &FakeProvider) -> Box<dyn ProviderSession> {
1027        provider
1028            .open_session(provider.default_open_options())
1029            .await
1030            .expect("open_session")
1031    }
1032
1033    #[tokio::test]
1034    async fn execute_stamps_call_id_and_journals_terminal() {
1035        let provider = FakeProvider::new(VecDeque::from([ScriptedOutcome::succeeded()]));
1036        let session = open(&provider).await;
1037        let outcome = session
1038            .execute(call("call-1"), None)
1039            .await
1040            .expect("execute");
1041        let ActionOutcome::Succeeded { result } = outcome else {
1042            panic!("expected succeeded, got {outcome:?}");
1043        };
1044        assert_eq!(result.call_id, "call-1");
1045        assert!(result.finished_at_ms > result.started_at_ms);
1046        assert_eq!(provider.handle().dispatched_call_ids(), vec!["call-1"]);
1047
1048        let fate = session
1049            .reconcile("call-1", &session.current_cursor().await.expect("cursor"))
1050            .await
1051            .expect("reconcile");
1052        let ReconcileResult::Completed { outcome } = fate else {
1053            panic!("expected completed, got {fate:?}");
1054        };
1055        let ActionOutcome::Succeeded { result } = *outcome else {
1056            panic!("expected a succeeded terminal, got {outcome:?}");
1057        };
1058        assert_eq!(result.call_id, "call-1");
1059    }
1060
1061    #[tokio::test]
1062    async fn a_foreign_issuing_credential_reads_the_world_journal() {
1063        // The fake's world journal is the union of every generation's
1064        // log (in-process cross-generation retrieval): a foreign
1065        // credential still reads the TRUE complete history, so
1066        // `neverDispatched` for an unseen callId is sound — unlike a
1067        // per-session log, which must answer logUnavailable (the
1068        // devicerail guard).
1069        let provider = FakeProvider::new(VecDeque::new());
1070        let session = open(&provider).await;
1071        let foreign = EventCursor {
1072            session_id: "some-earlier-generation".to_owned(),
1073            last_sequence: 3,
1074        };
1075        let fate = session
1076            .reconcile("never-seen", &foreign)
1077            .await
1078            .expect("reconcile");
1079        assert!(
1080            matches!(fate, ReconcileResult::NeverDispatched),
1081            "got {fate:?}"
1082        );
1083    }
1084
1085    #[tokio::test]
1086    async fn reconcile_adopts_archived_non_succeeded_terminals_verbatim() {
1087        let provider = FakeProvider::new(VecDeque::from([
1088            ScriptedOutcome::failed("device_unavailable", true),
1089            ScriptedOutcome::timed_out(),
1090        ]));
1091        let session = open(&provider).await;
1092        // Non-succeeded terminals are Ok values, not errors (04 §3).
1093        session
1094            .execute(call("call-f"), None)
1095            .await
1096            .expect("failed terminal");
1097        session
1098            .execute(call("call-t"), None)
1099            .await
1100            .expect("timedOut terminal");
1101
1102        for (call_id, kind) in [("call-f", "failed"), ("call-t", "timedOut")] {
1103            let fate = session
1104                .reconcile(call_id, &session.current_cursor().await.expect("cursor"))
1105                .await
1106                .expect("reconcile");
1107            let ReconcileResult::Completed { outcome } = fate else {
1108                panic!("expected completed for {call_id}, got {fate:?}");
1109            };
1110            assert_eq!(outcome.kind(), kind);
1111        }
1112    }
1113
1114    #[tokio::test]
1115    async fn transport_loss_variants_map_to_reconcile_fates() {
1116        let provider = FakeProvider::new(VecDeque::from([
1117            ScriptedOutcome::TransportLostAfterDispatch,
1118            ScriptedOutcome::TransportLostBeforeDispatch,
1119        ]));
1120        let session = open(&provider).await;
1121
1122        let error = session.execute(call("hung"), None).await.unwrap_err();
1123        assert_eq!(error.error_class, ErrorClass::TransportLost);
1124        assert_eq!(
1125            session
1126                .reconcile("hung", &session.current_cursor().await.expect("cursor"))
1127                .await
1128                .expect("reconcile"),
1129            ReconcileResult::StartedNoTerminal
1130        );
1131
1132        let error = session.execute(call("lost"), None).await.unwrap_err();
1133        assert_eq!(error.error_class, ErrorClass::TransportLost);
1134        assert_eq!(
1135            session
1136                .reconcile("lost", &session.current_cursor().await.expect("cursor"))
1137                .await
1138                .expect("reconcile"),
1139            ReconcileResult::NeverDispatched
1140        );
1141    }
1142
1143    #[tokio::test]
1144    async fn duplicate_call_id_is_rejected_without_consuming_script() {
1145        let provider = FakeProvider::new(VecDeque::from([
1146            ScriptedOutcome::succeeded(),
1147            ScriptedOutcome::failed("device_unavailable", true),
1148        ]));
1149        let session = open(&provider).await;
1150        session.execute(call("dup"), None).await.expect("first");
1151        let error = session.execute(call("dup"), None).await.unwrap_err();
1152        assert_eq!(error.error_class, ErrorClass::BindArgumentsInvalid);
1153        // The scripted `failed` terminal is still available for a fresh id.
1154        let outcome = session.execute(call("fresh"), None).await.expect("second");
1155        assert_eq!(outcome.kind(), "failed");
1156    }
1157
1158    #[tokio::test]
1159    async fn pre_cancelled_token_prevents_dispatch() {
1160        let provider = FakeProvider::new(VecDeque::from([ScriptedOutcome::succeeded()]));
1161        let session = open(&provider).await;
1162        let token = CancellationToken::new();
1163        token.cancel();
1164        let error = session
1165            .execute(call("never-sent"), Some(token))
1166            .await
1167            .unwrap_err();
1168        assert_eq!(error.error_class, ErrorClass::ActionCancelled);
1169        assert_eq!(
1170            session
1171                .reconcile(
1172                    "never-sent",
1173                    &session.current_cursor().await.expect("cursor")
1174                )
1175                .await
1176                .expect("reconcile"),
1177            ReconcileResult::NeverDispatched
1178        );
1179    }
1180
1181    #[tokio::test]
1182    async fn unattested_action_is_refused_fail_closed() {
1183        let provider = FakeProvider::new(VecDeque::from([ScriptedOutcome::succeeded()]));
1184        let session = open(&provider).await;
1185        let mut bogus = call("bogus");
1186        bogus.action_name = ActionName::new("launchMissiles").unwrap();
1187        let error = session.execute(bogus, None).await.unwrap_err();
1188        assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
1189        assert!(provider.handle().dispatched_call_ids().is_empty());
1190    }
1191
1192    #[tokio::test]
1193    async fn observe_honors_wants_and_injected_omissions() {
1194        let provider = FakeProvider::new(VecDeque::new());
1195        let session = open(&provider).await;
1196        let observation = session
1197            .observe(
1198                ObserveRequest {
1199                    wants: vec![ObserveWant::Screenshot, ObserveWant::UiSnapshot],
1200                },
1201                None,
1202            )
1203            .await
1204            .expect("observe");
1205        assert!(observation.screenshot.is_some());
1206        assert!(observation.ui_snapshot.is_some());
1207        assert!(observation.screenshot_omission.is_none());
1208
1209        provider
1210            .handle()
1211            .set_screenshot_omission(Some(ScreenshotOmissionReason::ProtectedAction));
1212        let observation = session
1213            .observe(
1214                ObserveRequest {
1215                    wants: vec![ObserveWant::Screenshot],
1216                },
1217                None,
1218            )
1219            .await
1220            .expect("observe");
1221        assert!(observation.screenshot.is_none());
1222        assert_eq!(
1223            observation.screenshot_omission,
1224            Some(ScreenshotOmissionReason::ProtectedAction)
1225        );
1226        // An unwanted part is simply absent, with no omission claim.
1227        assert!(observation.ui_snapshot.is_none());
1228        assert!(observation.ui_snapshot_omission.is_none());
1229    }
1230
1231    #[tokio::test]
1232    async fn ui_snapshot_omission_yields_typed_unavailable() {
1233        let provider = FakeProvider::new(VecDeque::new());
1234        let session = open(&provider).await;
1235        provider
1236            .handle()
1237            .set_ui_snapshot_omission(Some(UiSnapshotOmissionReason::DriverUnsupported));
1238        let outcome = session.ui_snapshot("obs-1").await.expect("ui_snapshot");
1239        assert_eq!(
1240            outcome,
1241            UiSnapshotOutcome::Unavailable {
1242                reason: UiSnapshotOmissionReason::DriverUnsupported
1243            }
1244        );
1245    }
1246
1247    #[tokio::test]
1248    async fn injected_ui_snapshot_is_served_on_both_dereference_routes() {
1249        let provider = FakeProvider::new(VecDeque::new());
1250        let session = open(&provider).await;
1251        let tree = json!({
1252            "formatVersion": 1,
1253            "observationId": "ignored",
1254            "context": { "contextKind": "native", "contextId": "ctx-1", "documentEpoch": "e1" },
1255            "rootStableNodeIds": ["n1"],
1256            "nodes": [ { "stableNodeId": "n1", "role": "switch", "identifier": "wifi" } ],
1257        });
1258
1259        // Route 1: observe() serves the injected tree.
1260        provider.handle().inject_ui_snapshot(Some(tree.clone()));
1261        let observation = session
1262            .observe(
1263                ObserveRequest {
1264                    wants: vec![ObserveWant::UiSnapshot],
1265                },
1266                None,
1267            )
1268            .await
1269            .expect("observe");
1270        let snapshot_ref = observation.ui_snapshot.expect("ui snapshot ref");
1271        let outcome = session
1272            .ui_snapshot(&observation.id)
1273            .await
1274            .expect("ui_snapshot");
1275        assert_eq!(
1276            outcome,
1277            UiSnapshotOutcome::Available {
1278                snapshot: tree.clone()
1279            }
1280        );
1281        let mut stream = session
1282            .fetch_evidence(&snapshot_ref.evidence)
1283            .await
1284            .expect("stream");
1285        let mut bytes = Vec::new();
1286        while let Some(chunk) = stream.next().await {
1287            bytes.extend(chunk.expect("chunk"));
1288        }
1289        assert_eq!(bytes, serde_json::to_vec(&tree).expect("serialize"));
1290
1291        // Route 2: a handle-synthesized observation carries the same
1292        // contract for embedding into scripted ActionResults.
1293        let synthesized = provider.handle().make_observation(Some(tree.clone()));
1294        assert!(synthesized.screenshot.is_some());
1295        let outcome = session
1296            .ui_snapshot(&synthesized.id)
1297            .await
1298            .expect("ui_snapshot");
1299        assert_eq!(outcome, UiSnapshotOutcome::Available { snapshot: tree });
1300    }
1301
1302    #[tokio::test]
1303    async fn fetch_evidence_streams_bytes_and_verifies_sha256() {
1304        let provider = FakeProvider::new(VecDeque::new());
1305        let session = open(&provider).await;
1306        let observation = session
1307            .observe(
1308                ObserveRequest {
1309                    wants: vec![ObserveWant::Screenshot],
1310                },
1311                None,
1312            )
1313            .await
1314            .expect("observe");
1315        let asset = observation.screenshot.expect("screenshot asset");
1316
1317        let mut stream = session.fetch_evidence(&asset).await.expect("stream");
1318        let mut bytes = Vec::new();
1319        while let Some(chunk) = stream.next().await {
1320            bytes.extend(chunk.expect("chunk"));
1321        }
1322        assert_eq!(Some(hex_sha256(&bytes)), asset.sha256);
1323
1324        let mut tampered = asset.clone();
1325        tampered.sha256 = Some("0".repeat(64));
1326        let error = session
1327            .fetch_evidence(&tampered)
1328            .await
1329            .err()
1330            .expect("integrity failure");
1331        assert_eq!(error.error_class, ErrorClass::ActionFailedFinal);
1332    }
1333
1334    #[tokio::test]
1335    async fn record_verdict_caps_fail_closed() {
1336        let provider = FakeProvider::new(VecDeque::new());
1337        let session = open(&provider).await;
1338
1339        let oversized = VerdictWrite {
1340            status: VerdictStatus::Pass,
1341            summary: "x".repeat(VERDICT_SUMMARY_MAX_CHARS + 1),
1342            evidence: Vec::new(),
1343        };
1344        let error = session.record_verdict(oversized).await.unwrap_err();
1345        assert_eq!(error.error_class, ErrorClass::BindArgumentsInvalid);
1346
1347        let valid = VerdictWrite {
1348            status: VerdictStatus::Unknown,
1349            summary: "verify chain degraded".to_owned(),
1350            evidence: Vec::new(),
1351        };
1352        session.record_verdict(valid.clone()).await.expect("write");
1353        assert_eq!(provider.handle().recorded_verdicts(), vec![valid]);
1354    }
1355
1356    #[tokio::test]
1357    async fn end_is_idempotent_and_breaks_other_methods() {
1358        let provider = FakeProvider::new(VecDeque::from([ScriptedOutcome::succeeded()]));
1359        let session = open(&provider).await;
1360        session
1361            .end(SessionOutcome::Completed, Some("done".to_owned()))
1362            .await
1363            .expect("end");
1364        session
1365            .end(SessionOutcome::Failed, None)
1366            .await
1367            .expect("idempotent end");
1368        assert_eq!(
1369            provider.handle().ended_outcome(),
1370            Some((SessionOutcome::Completed, Some("done".to_owned())))
1371        );
1372        let health = session.health().await.expect("health");
1373        assert!(!health.ok);
1374        let error = session.execute(call("late"), None).await.unwrap_err();
1375        assert_eq!(error.error_class, ErrorClass::TransportLost);
1376    }
1377
1378    #[tokio::test]
1379    async fn open_session_fails_closed_on_digest_or_feature_drift() {
1380        let provider = FakeProvider::new(VecDeque::new());
1381
1382        let mut drifted = provider.default_open_options();
1383        drifted.lockfile_digest = Hash::new(format!("sha256:{}", "f".repeat(64))).unwrap();
1384        let error = provider.open_session(drifted).await.err().expect("drift");
1385        assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
1386
1387        let mut unsatisfiable = provider.default_open_options();
1388        unsatisfiable
1389            .required_features
1390            .push(FeatureId::new("media.stream.v1").unwrap());
1391        let error = provider
1392            .open_session(unsatisfiable)
1393            .await
1394            .err()
1395            .expect("missing feature");
1396        assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
1397    }
1398}