Skip to main content

bobby_browser_client/
skills.rs

1//! Skill command and outcome wire types (Ghost, ZigZagZig, …).
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize, Serializer};
7
8use crate::{AttemptId, CheckpointId, CommandClass, CommandId, PageId, SessionId, WorkflowId};
9
10pub const SKILL_SCHEMA_VERSION: u16 = 1;
11const MAX_CAPABILITIES: usize = 32;
12const MAX_PREFERRED_ENGINES: usize = 8;
13const MAX_PROFILE_VALUES: usize = 64;
14const MAX_TACTICS: usize = 32;
15const MAX_EVIDENCE_REFS: usize = 128;
16const MAX_NAME_BYTES: usize = 128;
17const MAX_POSTCONDITION_BYTES: usize = 1024;
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21#[serde(tag = "skill", content = "action", rename_all = "camelCase")]
22pub enum SkillCommand {
23    Ghost(SkillGhostCommand),
24    ZigZagZig(SkillZigZagZigCommand),
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29#[serde(rename_all = "camelCase")]
30pub enum SkillGhostCommand {
31    On,
32    Off,
33    Status,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
38#[serde(rename_all = "camelCase")]
39pub enum SkillZigZagZigCommand {
40    Run,
41    Status,
42    Stop,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
46#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
47#[serde(rename_all = "camelCase")]
48pub enum SkillCapability {
49    EngineSelection,
50    ProfilePersistence,
51    Locale,
52    Timezone,
53    Viewport,
54    UserAgentConsistency,
55    InteractionCadence,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[serde(rename_all = "camelCase")]
61pub enum SkillFailure {
62    UnsupportedCapability,
63    ConfigurationConflict,
64    DeadlineExceeded,
65    TargetDrift,
66    PostconditionFailed,
67    EffectUncertain,
68    CheckpointMismatch,
69    StrategyExhausted,
70    EngineUnavailable,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
75#[serde(rename_all = "camelCase")]
76pub enum SkillTactic {
77    ObserveAgain,
78    ResolveSemanticTarget,
79    ChangeInteractionMethod,
80    /// Solve a human-verification challenge blocking the page, in place,
81    /// through the vision loop. Cheap and non-destructive: no checkpoint
82    /// authority, no session replacement. Fails closed when no challenge
83    /// is present, so a challenge-free page costs one bounded attempt.
84    SolveChallenge,
85    ReconcileCheckpoint,
86    FreshGhostSession,
87    SelectCompatibleEngine,
88    RestartDurableBoundary,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93#[serde(rename_all = "camelCase")]
94pub enum SkillBrowserEngine {
95    Firefox,
96    Chromium,
97    WebKit,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
101#[serde(
102    rename_all = "camelCase",
103    deny_unknown_fields,
104    try_from = "SkillProfileRequestWire"
105)]
106pub struct SkillProfileRequest {
107    pub schema_version: u16,
108    pub required: BTreeSet<SkillCapability>,
109    pub optional: BTreeSet<SkillCapability>,
110    pub preferred_engines: Vec<SkillBrowserEngine>,
111    pub values: BTreeMap<String, String>,
112}
113
114#[derive(Deserialize, Serialize)]
115#[serde(rename_all = "camelCase", deny_unknown_fields)]
116struct SkillProfileRequestWire {
117    schema_version: u16,
118    required: BTreeSet<SkillCapability>,
119    optional: BTreeSet<SkillCapability>,
120    preferred_engines: Vec<SkillBrowserEngine>,
121    values: BTreeMap<String, String>,
122}
123
124impl SkillProfileRequest {
125    pub const SCHEMA_VERSION: u16 = SKILL_SCHEMA_VERSION;
126
127    pub fn new(
128        required: impl IntoIterator<Item = SkillCapability>,
129        optional: impl IntoIterator<Item = SkillCapability>,
130        preferred_engines: impl IntoIterator<Item = SkillBrowserEngine>,
131        values: BTreeMap<String, String>,
132    ) -> Result<Self, String> {
133        let profile = Self {
134            schema_version: Self::SCHEMA_VERSION,
135            required: required.into_iter().collect(),
136            optional: optional.into_iter().collect(),
137            preferred_engines: preferred_engines.into_iter().collect(),
138            values,
139        };
140        profile.validate()?;
141        Ok(profile)
142    }
143
144    fn validate(&self) -> Result<(), String> {
145        validate_schema_version(self.schema_version)?;
146        validate_capabilities(&self.required, "required")?;
147        validate_capabilities(&self.optional, "optional")?;
148        if !self.required.is_disjoint(&self.optional) {
149            return Err("required and optional capabilities must not overlap".into());
150        }
151        if self.required.union(&self.optional).count() > MAX_CAPABILITIES {
152            return Err("required and optional capabilities exceed 32 entries".into());
153        }
154        if self.preferred_engines.len() > MAX_PREFERRED_ENGINES {
155            return Err("preferred engines exceed 8 entries".into());
156        }
157        validate_profile_values(&self.values)?;
158        Ok(())
159    }
160}
161
162impl TryFrom<SkillProfileRequestWire> for SkillProfileRequest {
163    type Error = String;
164
165    fn try_from(value: SkillProfileRequestWire) -> Result<Self, Self::Error> {
166        let profile = Self {
167            schema_version: value.schema_version,
168            required: value.required,
169            optional: value.optional,
170            preferred_engines: value.preferred_engines,
171            values: value.values,
172        };
173        profile.validate()?;
174        Ok(profile)
175    }
176}
177
178impl Serialize for SkillProfileRequest {
179    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
180    where
181        S: Serializer,
182    {
183        self.validate().map_err(serde::ser::Error::custom)?;
184        SkillProfileRequestWire {
185            schema_version: self.schema_version,
186            required: self.required.clone(),
187            optional: self.optional.clone(),
188            preferred_engines: self.preferred_engines.clone(),
189            values: self.values.clone(),
190        }
191        .serialize(serializer)
192    }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
196#[serde(
197    rename_all = "camelCase",
198    deny_unknown_fields,
199    try_from = "SkillProfileWire"
200)]
201pub struct SkillProfile {
202    pub schema_version: u16,
203    pub version: String,
204    pub engine: SkillBrowserEngine,
205    pub effective_capabilities: BTreeSet<SkillCapability>,
206    pub observable_digest: String,
207}
208
209#[derive(Deserialize, Serialize)]
210#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212struct SkillProfileWire {
213    schema_version: u16,
214    version: String,
215    engine: SkillBrowserEngine,
216    effective_capabilities: BTreeSet<SkillCapability>,
217    observable_digest: String,
218}
219
220impl SkillProfile {
221    pub const SCHEMA_VERSION: u16 = SKILL_SCHEMA_VERSION;
222
223    pub fn new(
224        version: impl Into<String>,
225        engine: SkillBrowserEngine,
226        effective_capabilities: impl IntoIterator<Item = SkillCapability>,
227        observable_digest: impl Into<String>,
228    ) -> Result<Self, String> {
229        let profile = Self {
230            schema_version: Self::SCHEMA_VERSION,
231            version: version.into(),
232            engine,
233            effective_capabilities: effective_capabilities.into_iter().collect(),
234            observable_digest: observable_digest.into(),
235        };
236        profile.validate()?;
237        Ok(profile)
238    }
239
240    fn validate(&self) -> Result<(), String> {
241        validate_schema_version(self.schema_version)?;
242        validate_version(&self.version, "profile version")?;
243        validate_capabilities(&self.effective_capabilities, "effective capabilities")?;
244        validate_observable_digest(&self.observable_digest)
245    }
246}
247
248impl TryFrom<SkillProfileWire> for SkillProfile {
249    type Error = String;
250
251    fn try_from(value: SkillProfileWire) -> Result<Self, Self::Error> {
252        let profile = Self {
253            schema_version: value.schema_version,
254            version: value.version,
255            engine: value.engine,
256            effective_capabilities: value.effective_capabilities,
257            observable_digest: value.observable_digest,
258        };
259        profile.validate()?;
260        Ok(profile)
261    }
262}
263
264impl Serialize for SkillProfile {
265    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
266    where
267        S: Serializer,
268    {
269        self.validate().map_err(serde::ser::Error::custom)?;
270        SkillProfileWire {
271            schema_version: self.schema_version,
272            version: self.version.clone(),
273            engine: self.engine,
274            effective_capabilities: self.effective_capabilities.clone(),
275            observable_digest: self.observable_digest.clone(),
276        }
277        .serialize(serializer)
278    }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
282#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
283#[serde(
284    rename_all = "camelCase",
285    deny_unknown_fields,
286    try_from = "SkillDecisionWire"
287)]
288pub struct SkillDecision {
289    pub tactic: SkillTactic,
290    pub trigger: SkillFailure,
291    pub expected_postcondition: String,
292    pub remaining_deadline_ms: u64,
293    pub tactic_budget_ms: u64,
294    pub checkpoint_id: Option<CheckpointId>,
295    pub selected_engine: Option<SkillBrowserEngine>,
296}
297
298#[derive(Deserialize, Serialize)]
299#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
300#[serde(rename_all = "camelCase", deny_unknown_fields)]
301struct SkillDecisionWire {
302    tactic: SkillTactic,
303    trigger: SkillFailure,
304    expected_postcondition: String,
305    remaining_deadline_ms: u64,
306    tactic_budget_ms: u64,
307    checkpoint_id: Option<CheckpointId>,
308    selected_engine: Option<SkillBrowserEngine>,
309}
310
311impl SkillDecision {
312    pub fn new(
313        tactic: SkillTactic,
314        trigger: SkillFailure,
315        expected_postcondition: impl Into<String>,
316        remaining_deadline_ms: u64,
317        tactic_budget_ms: u64,
318        checkpoint_id: Option<CheckpointId>,
319        selected_engine: Option<SkillBrowserEngine>,
320    ) -> Result<Self, String> {
321        let decision = Self {
322            tactic,
323            trigger,
324            expected_postcondition: expected_postcondition.into(),
325            remaining_deadline_ms,
326            tactic_budget_ms,
327            checkpoint_id,
328            selected_engine,
329        };
330        decision.validate()?;
331        Ok(decision)
332    }
333
334    fn validate(&self) -> Result<(), String> {
335        validate_postcondition(&self.expected_postcondition)?;
336        if self.tactic_budget_ms > self.remaining_deadline_ms {
337            return Err("tactic budget must not exceed the remaining deadline".into());
338        }
339        match (self.tactic, self.selected_engine) {
340            (SkillTactic::SelectCompatibleEngine, None) => {
341                return Err("engine selection requires a selected engine".into());
342            }
343            (SkillTactic::SelectCompatibleEngine, Some(_)) | (_, None) => {}
344            (_, Some(_)) => return Err("only engine selection may select an engine".into()),
345        }
346        Ok(())
347    }
348}
349
350impl TryFrom<SkillDecisionWire> for SkillDecision {
351    type Error = String;
352
353    fn try_from(value: SkillDecisionWire) -> Result<Self, Self::Error> {
354        Self::new(
355            value.tactic,
356            value.trigger,
357            value.expected_postcondition,
358            value.remaining_deadline_ms,
359            value.tactic_budget_ms,
360            value.checkpoint_id,
361            value.selected_engine,
362        )
363    }
364}
365
366impl Serialize for SkillDecision {
367    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
368    where
369        S: Serializer,
370    {
371        self.validate().map_err(serde::ser::Error::custom)?;
372        SkillDecisionWire {
373            tactic: self.tactic,
374            trigger: self.trigger,
375            expected_postcondition: self.expected_postcondition.clone(),
376            remaining_deadline_ms: self.remaining_deadline_ms,
377            tactic_budget_ms: self.tactic_budget_ms,
378            checkpoint_id: self.checkpoint_id.clone(),
379            selected_engine: self.selected_engine,
380        }
381        .serialize(serializer)
382    }
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
386#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
387#[serde(
388    tag = "status",
389    rename_all = "camelCase",
390    deny_unknown_fields,
391    try_from = "SkillOutcomeWire"
392)]
393pub enum SkillOutcome {
394    Applied {
395        evidence: Vec<SkillEvidenceRef>,
396    },
397    Adapted {
398        tactic: SkillTactic,
399        evidence: Vec<SkillEvidenceRef>,
400    },
401    Degraded {
402        unsupported: BTreeSet<SkillCapability>,
403        evidence: Vec<SkillEvidenceRef>,
404    },
405    Stopped {
406        evidence: Vec<SkillEvidenceRef>,
407    },
408    Failed {
409        failure: SkillFailure,
410        evidence: Vec<SkillEvidenceRef>,
411    },
412}
413
414#[derive(Deserialize, Serialize)]
415#[serde(tag = "status", rename_all = "camelCase", deny_unknown_fields)]
416#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
417enum SkillOutcomeWire {
418    Applied {
419        evidence: Vec<SkillEvidenceRef>,
420    },
421    Adapted {
422        tactic: SkillTactic,
423        evidence: Vec<SkillEvidenceRef>,
424    },
425    Degraded {
426        unsupported: BTreeSet<SkillCapability>,
427        evidence: Vec<SkillEvidenceRef>,
428    },
429    Stopped {
430        evidence: Vec<SkillEvidenceRef>,
431    },
432    Failed {
433        failure: SkillFailure,
434        evidence: Vec<SkillEvidenceRef>,
435    },
436}
437
438impl SkillOutcome {
439    pub fn applied(evidence: Vec<SkillEvidenceRef>) -> Result<Self, String> {
440        Self::validate_evidence(&evidence)?;
441        Ok(Self::Applied { evidence })
442    }
443
444    pub fn adapted(tactic: SkillTactic, evidence: Vec<SkillEvidenceRef>) -> Result<Self, String> {
445        Self::validate_evidence(&evidence)?;
446        Ok(Self::Adapted { tactic, evidence })
447    }
448
449    pub fn degraded(
450        unsupported: BTreeSet<SkillCapability>,
451        evidence: Vec<SkillEvidenceRef>,
452    ) -> Result<Self, String> {
453        validate_capabilities(&unsupported, "unsupported")?;
454        Self::validate_evidence(&evidence)?;
455        Ok(Self::Degraded {
456            unsupported,
457            evidence,
458        })
459    }
460
461    pub fn stopped(evidence: Vec<SkillEvidenceRef>) -> Result<Self, String> {
462        Self::validate_evidence(&evidence)?;
463        Ok(Self::Stopped { evidence })
464    }
465
466    pub fn failed(failure: SkillFailure, evidence: Vec<SkillEvidenceRef>) -> Result<Self, String> {
467        Self::validate_evidence(&evidence)?;
468        Ok(Self::Failed { failure, evidence })
469    }
470
471    fn validate_evidence(evidence: &[SkillEvidenceRef]) -> Result<(), String> {
472        if evidence.len() > MAX_EVIDENCE_REFS {
473            return Err("evidence references exceed 128 entries".into());
474        }
475        for evidence_ref in evidence {
476            evidence_ref.validate()?;
477        }
478        Ok(())
479    }
480
481    fn validate(&self) -> Result<(), String> {
482        match self {
483            Self::Applied { evidence }
484            | Self::Adapted { evidence, .. }
485            | Self::Stopped { evidence }
486            | Self::Failed { evidence, .. } => Self::validate_evidence(evidence),
487            Self::Degraded {
488                unsupported,
489                evidence,
490            } => {
491                validate_capabilities(unsupported, "unsupported")?;
492                Self::validate_evidence(evidence)
493            }
494        }
495    }
496}
497
498impl Serialize for SkillOutcome {
499    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
500    where
501        S: Serializer,
502    {
503        self.validate().map_err(serde::ser::Error::custom)?;
504        match self {
505            Self::Applied { evidence } => SkillOutcomeWire::Applied {
506                evidence: evidence.clone(),
507            },
508            Self::Adapted { tactic, evidence } => SkillOutcomeWire::Adapted {
509                tactic: *tactic,
510                evidence: evidence.clone(),
511            },
512            Self::Degraded {
513                unsupported,
514                evidence,
515            } => SkillOutcomeWire::Degraded {
516                unsupported: unsupported.clone(),
517                evidence: evidence.clone(),
518            },
519            Self::Stopped { evidence } => SkillOutcomeWire::Stopped {
520                evidence: evidence.clone(),
521            },
522            Self::Failed { failure, evidence } => SkillOutcomeWire::Failed {
523                failure: *failure,
524                evidence: evidence.clone(),
525            },
526        }
527        .serialize(serializer)
528    }
529}
530
531impl TryFrom<SkillOutcomeWire> for SkillOutcome {
532    type Error = String;
533
534    fn try_from(value: SkillOutcomeWire) -> Result<Self, Self::Error> {
535        match value {
536            SkillOutcomeWire::Applied { evidence } => Self::applied(evidence),
537            SkillOutcomeWire::Adapted { tactic, evidence } => Self::adapted(tactic, evidence),
538            SkillOutcomeWire::Degraded {
539                unsupported,
540                evidence,
541            } => Self::degraded(unsupported, evidence),
542            SkillOutcomeWire::Stopped { evidence } => Self::stopped(evidence),
543            SkillOutcomeWire::Failed { failure, evidence } => Self::failed(failure, evidence),
544        }
545    }
546}
547
548#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
549#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
550#[serde(
551    rename_all = "camelCase",
552    deny_unknown_fields,
553    try_from = "SkillEvidenceRefWire"
554)]
555pub struct SkillEvidenceRef {
556    pub artifact_id: String,
557    pub sha256: String,
558}
559
560#[derive(Deserialize, Serialize)]
561#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
562#[serde(rename_all = "camelCase", deny_unknown_fields)]
563struct SkillEvidenceRefWire {
564    artifact_id: String,
565    sha256: String,
566}
567
568impl SkillEvidenceRef {
569    pub fn new(artifact_id: impl Into<String>, sha256: impl Into<String>) -> Result<Self, String> {
570        let evidence = Self {
571            artifact_id: artifact_id.into(),
572            sha256: sha256.into(),
573        };
574        evidence.validate()?;
575        Ok(evidence)
576    }
577
578    fn validate(&self) -> Result<(), String> {
579        validate_artifact_id(&self.artifact_id)?;
580        if self.sha256.len() != 64
581            || !self.sha256.bytes().all(|byte| {
582                byte.is_ascii_digit() || (byte.is_ascii_lowercase() && byte.is_ascii_hexdigit())
583            })
584        {
585            return Err("sha256 must be exactly 64 lowercase hexadecimal characters".into());
586        }
587        Ok(())
588    }
589}
590
591impl TryFrom<SkillEvidenceRefWire> for SkillEvidenceRef {
592    type Error = String;
593
594    fn try_from(value: SkillEvidenceRefWire) -> Result<Self, Self::Error> {
595        Self::new(value.artifact_id, value.sha256)
596    }
597}
598
599impl Serialize for SkillEvidenceRef {
600    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
601    where
602        S: Serializer,
603    {
604        self.validate().map_err(serde::ser::Error::custom)?;
605        SkillEvidenceRefWire {
606            artifact_id: self.artifact_id.clone(),
607            sha256: self.sha256.clone(),
608        }
609        .serialize(serializer)
610    }
611}
612
613#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
614#[serde(
615    rename_all = "camelCase",
616    deny_unknown_fields,
617    try_from = "SkillCheckpointProofWire"
618)]
619pub struct SkillCheckpointProof {
620    pub checkpoint_id: CheckpointId,
621    pub session_id: SessionId,
622    pub verified_at: DateTime<Utc>,
623    pub attestation: SkillEvidenceRef,
624}
625
626#[derive(Deserialize, Serialize)]
627#[serde(rename_all = "camelCase", deny_unknown_fields)]
628struct SkillCheckpointProofWire {
629    checkpoint_id: CheckpointId,
630    session_id: SessionId,
631    verified_at: DateTime<Utc>,
632    attestation: SkillEvidenceRef,
633}
634
635impl SkillCheckpointProof {
636    const MAX_AGE_MINUTES: i64 = 15;
637
638    pub fn new(
639        checkpoint_id: CheckpointId,
640        session_id: SessionId,
641        verified_at: DateTime<Utc>,
642        attestation: SkillEvidenceRef,
643    ) -> Result<Self, String> {
644        let proof = Self {
645            checkpoint_id,
646            session_id,
647            verified_at,
648            attestation,
649        };
650        proof.validate()?;
651        Ok(proof)
652    }
653
654    pub fn is_fresh_at(&self, now: DateTime<Utc>) -> bool {
655        now >= self.verified_at
656            && now.signed_duration_since(self.verified_at)
657                <= chrono::Duration::minutes(Self::MAX_AGE_MINUTES)
658    }
659
660    fn validate(&self) -> Result<(), String> {
661        self.attestation.validate()?;
662        if !self.is_fresh_at(Utc::now()) {
663            return Err("checkpoint proof is stale or from the future".into());
664        }
665        Ok(())
666    }
667}
668
669impl TryFrom<SkillCheckpointProofWire> for SkillCheckpointProof {
670    type Error = String;
671
672    fn try_from(value: SkillCheckpointProofWire) -> Result<Self, Self::Error> {
673        Self::new(
674            value.checkpoint_id,
675            value.session_id,
676            value.verified_at,
677            value.attestation,
678        )
679    }
680}
681
682impl Serialize for SkillCheckpointProof {
683    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
684    where
685        S: Serializer,
686    {
687        self.validate().map_err(serde::ser::Error::custom)?;
688        SkillCheckpointProofWire {
689            checkpoint_id: self.checkpoint_id.clone(),
690            session_id: self.session_id.clone(),
691            verified_at: self.verified_at,
692            attestation: self.attestation.clone(),
693        }
694        .serialize(serializer)
695    }
696}
697
698#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
699#[serde(
700    rename_all = "camelCase",
701    deny_unknown_fields,
702    try_from = "SkillIssuedDecisionWire"
703)]
704pub struct SkillIssuedDecision {
705    pub reservation_id: CommandId,
706    pub session_id: SessionId,
707    #[serde(default)]
708    pub command_identity: Option<SkillCommandIdentity>,
709    pub decision: SkillDecision,
710    pub checkpoint_proof: Option<SkillCheckpointProof>,
711    pub issued_at: DateTime<Utc>,
712    pub deadline: DateTime<Utc>,
713}
714
715#[derive(Deserialize, Serialize)]
716#[serde(rename_all = "camelCase", deny_unknown_fields)]
717struct SkillIssuedDecisionWire {
718    reservation_id: CommandId,
719    session_id: SessionId,
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    command_identity: Option<SkillCommandIdentity>,
722    decision: SkillDecision,
723    checkpoint_proof: Option<SkillCheckpointProof>,
724    issued_at: DateTime<Utc>,
725    deadline: DateTime<Utc>,
726}
727
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
729#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
730#[serde(rename_all = "camelCase", deny_unknown_fields)]
731pub struct SkillCommandIdentity {
732    pub command_id: CommandId,
733    pub workflow_id: WorkflowId,
734    pub attempt_id: AttemptId,
735    pub session_id: SessionId,
736    pub page_id: Option<PageId>,
737    pub command_class: CommandClass,
738    pub command_sha256: String,
739}
740
741impl SkillCommandIdentity {
742    pub fn new(
743        command_id: CommandId,
744        workflow_id: WorkflowId,
745        attempt_id: AttemptId,
746        session_id: SessionId,
747        page_id: Option<PageId>,
748        command_class: CommandClass,
749        command_sha256: impl Into<String>,
750    ) -> Result<Self, String> {
751        let identity = Self {
752            command_id,
753            workflow_id,
754            attempt_id,
755            session_id,
756            page_id,
757            command_class,
758            command_sha256: command_sha256.into(),
759        };
760        identity.validate()?;
761        Ok(identity)
762    }
763
764    fn validate(&self) -> Result<(), String> {
765        if self.command_sha256.len() != 64
766            || !self
767                .command_sha256
768                .bytes()
769                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
770        {
771            return Err("skill command identity requires a lowercase SHA-256 digest".into());
772        }
773        Ok(())
774    }
775}
776
777impl SkillIssuedDecision {
778    pub fn new(
779        reservation_id: CommandId,
780        session_id: SessionId,
781        decision: SkillDecision,
782        checkpoint_proof: Option<SkillCheckpointProof>,
783        issued_at: DateTime<Utc>,
784        deadline: DateTime<Utc>,
785    ) -> Result<Self, String> {
786        Self::new_inner(
787            reservation_id,
788            session_id,
789            None,
790            decision,
791            checkpoint_proof,
792            issued_at,
793            deadline,
794        )
795    }
796
797    pub fn new_for_command(
798        reservation_id: CommandId,
799        session_id: SessionId,
800        command_identity: SkillCommandIdentity,
801        decision: SkillDecision,
802        checkpoint_proof: Option<SkillCheckpointProof>,
803        issued_at: DateTime<Utc>,
804        deadline: DateTime<Utc>,
805    ) -> Result<Self, String> {
806        if command_identity.session_id != session_id {
807            return Err("issued decision command identity belongs to another session".into());
808        }
809        Self::new_inner(
810            reservation_id,
811            session_id,
812            Some(command_identity),
813            decision,
814            checkpoint_proof,
815            issued_at,
816            deadline,
817        )
818    }
819
820    fn new_inner(
821        reservation_id: CommandId,
822        session_id: SessionId,
823        command_identity: Option<SkillCommandIdentity>,
824        decision: SkillDecision,
825        checkpoint_proof: Option<SkillCheckpointProof>,
826        issued_at: DateTime<Utc>,
827        deadline: DateTime<Utc>,
828    ) -> Result<Self, String> {
829        let issued = Self {
830            reservation_id,
831            session_id,
832            command_identity,
833            decision,
834            checkpoint_proof,
835            issued_at,
836            deadline,
837        };
838        issued.validate()?;
839        Ok(issued)
840    }
841
842    pub fn is_active_at(&self, now: DateTime<Utc>) -> bool {
843        now >= self.issued_at && now <= self.deadline
844    }
845
846    fn validate(&self) -> Result<(), String> {
847        self.decision.validate()?;
848        if let Some(identity) = &self.command_identity {
849            identity.validate()?;
850            if identity.session_id != self.session_id {
851                return Err("issued decision command identity belongs to another session".into());
852            }
853        }
854        let interval_ms = issued_interval_ms(self.issued_at, self.deadline)?;
855        if self.decision.remaining_deadline_ms != interval_ms {
856            return Err(
857                "issued decision remaining deadline does not match its issuance interval".into(),
858            );
859        }
860        if self.decision.tactic_budget_ms > interval_ms {
861            return Err("issued decision tactic budget exceeds its issuance interval".into());
862        }
863        match (
864            tactic_requires_checkpoint(self.decision.tactic),
865            &self.checkpoint_proof,
866        ) {
867            (true, Some(proof))
868                if proof.session_id == self.session_id
869                    && self.decision.checkpoint_id.as_ref() == Some(&proof.checkpoint_id) =>
870            {
871                proof.validate()?;
872            }
873            (true, _) => {
874                return Err("recovery decision requires its verified checkpoint proof".into())
875            }
876            (false, None) if self.decision.checkpoint_id.is_none() => {}
877            (false, _) => return Err("non-recovery decision cannot carry checkpoint proof".into()),
878        }
879        Ok(())
880    }
881}
882
883impl TryFrom<SkillIssuedDecisionWire> for SkillIssuedDecision {
884    type Error = String;
885
886    fn try_from(value: SkillIssuedDecisionWire) -> Result<Self, Self::Error> {
887        match value.command_identity {
888            Some(identity) => Self::new_for_command(
889                value.reservation_id,
890                value.session_id,
891                identity,
892                value.decision,
893                value.checkpoint_proof,
894                value.issued_at,
895                value.deadline,
896            ),
897            None => Self::new(
898                value.reservation_id,
899                value.session_id,
900                value.decision,
901                value.checkpoint_proof,
902                value.issued_at,
903                value.deadline,
904            ),
905        }
906    }
907}
908
909impl Serialize for SkillIssuedDecision {
910    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
911    where
912        S: Serializer,
913    {
914        self.validate().map_err(serde::ser::Error::custom)?;
915        SkillIssuedDecisionWire {
916            reservation_id: self.reservation_id.clone(),
917            session_id: self.session_id.clone(),
918            command_identity: self.command_identity.clone(),
919            decision: self.decision.clone(),
920            checkpoint_proof: self.checkpoint_proof.clone(),
921            issued_at: self.issued_at,
922            deadline: self.deadline,
923        }
924        .serialize(serializer)
925    }
926}
927
928#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
929#[serde(
930    rename_all = "camelCase",
931    deny_unknown_fields,
932    try_from = "SkillSessionStateWire"
933)]
934pub struct SkillSessionState {
935    pub schema_version: u16,
936    pub session_id: SessionId,
937    pub active_versions: BTreeMap<String, String>,
938    pub effective_profile: Option<SkillProfile>,
939    pub last_checkpoint_id: Option<CheckpointId>,
940    pub verified_checkpoint: Option<SkillCheckpointProof>,
941    pub reserved_tactic: Option<SkillTactic>,
942    pub pending_issuance: Option<SkillIssuedDecision>,
943    pub attempted_tactics: Vec<SkillTactic>,
944    pub evidence: Vec<SkillEvidenceRef>,
945    pub deadline: DateTime<Utc>,
946}
947
948#[derive(Deserialize, Serialize)]
949#[serde(rename_all = "camelCase", deny_unknown_fields)]
950struct SkillSessionStateWire {
951    schema_version: u16,
952    session_id: SessionId,
953    active_versions: BTreeMap<String, String>,
954    effective_profile: Option<SkillProfile>,
955    last_checkpoint_id: Option<CheckpointId>,
956    verified_checkpoint: Option<SkillCheckpointProof>,
957    reserved_tactic: Option<SkillTactic>,
958    pending_issuance: Option<SkillIssuedDecision>,
959    attempted_tactics: Vec<SkillTactic>,
960    evidence: Vec<SkillEvidenceRef>,
961    deadline: DateTime<Utc>,
962}
963
964impl SkillSessionState {
965    pub const SCHEMA_VERSION: u16 = SKILL_SCHEMA_VERSION;
966
967    #[allow(clippy::too_many_arguments)] // The versioned wire contract maps one-to-one here.
968    pub fn new(
969        session_id: SessionId,
970        active_versions: BTreeMap<String, String>,
971        effective_profile: Option<SkillProfile>,
972        last_checkpoint_id: Option<CheckpointId>,
973        verified_checkpoint: Option<SkillCheckpointProof>,
974        reserved_tactic: Option<SkillTactic>,
975        pending_issuance: Option<SkillIssuedDecision>,
976        attempted_tactics: Vec<SkillTactic>,
977        evidence: Vec<SkillEvidenceRef>,
978        deadline: DateTime<Utc>,
979    ) -> Result<Self, String> {
980        let state = Self {
981            schema_version: Self::SCHEMA_VERSION,
982            session_id,
983            active_versions,
984            effective_profile,
985            last_checkpoint_id,
986            verified_checkpoint,
987            reserved_tactic,
988            pending_issuance,
989            attempted_tactics,
990            evidence,
991            deadline,
992        };
993        state.validate()?;
994        Ok(state)
995    }
996
997    fn validate(&self) -> Result<(), String> {
998        validate_schema_version(self.schema_version)?;
999        validate_active_versions(&self.active_versions)?;
1000        if self.attempted_tactics.len() > MAX_TACTICS {
1001            return Err("attempted tactics exceed 32 entries".into());
1002        }
1003        if let Some(profile) = &self.effective_profile {
1004            profile.validate()?;
1005        }
1006        match (&self.last_checkpoint_id, &self.verified_checkpoint) {
1007            (None, None) => {}
1008            (Some(checkpoint_id), Some(proof))
1009                if proof.checkpoint_id == *checkpoint_id && proof.session_id == self.session_id =>
1010            {
1011                proof.validate()?;
1012            }
1013            (Some(_), None) => return Err("checkpoint requires a verified proof".into()),
1014            (None, Some(_)) => return Err("verified proof requires a checkpoint".into()),
1015            (Some(_), Some(_)) => {
1016                return Err("verified proof does not match session checkpoint".into())
1017            }
1018        }
1019        match (&self.reserved_tactic, &self.pending_issuance) {
1020            (None, None) => {}
1021            (Some(tactic), Some(issued))
1022                if issued.session_id == self.session_id
1023                    && issued.decision.tactic == *tactic
1024                    && self
1025                        .attempted_tactics
1026                        .iter()
1027                        .filter(|attempted| **attempted == *tactic)
1028                        .count()
1029                        == 1 =>
1030            {
1031                issued.validate()?;
1032                if issued.deadline != self.deadline {
1033                    return Err("issued decision deadline does not match session deadline".into());
1034                }
1035                if tactic_requires_checkpoint(*tactic)
1036                    && issued.checkpoint_proof != self.verified_checkpoint
1037                {
1038                    return Err("issued decision proof does not match session proof".into());
1039                }
1040            }
1041            (Some(_), None) => return Err("tactic reservation requires an issued decision".into()),
1042            (None, Some(_)) => return Err("issued decision requires a tactic reservation".into()),
1043            (Some(_), Some(_)) => {
1044                return Err("issued decision does not match its reservation".into())
1045            }
1046        }
1047        SkillOutcome::validate_evidence(&self.evidence)
1048    }
1049}
1050
1051impl TryFrom<SkillSessionStateWire> for SkillSessionState {
1052    type Error = String;
1053
1054    fn try_from(value: SkillSessionStateWire) -> Result<Self, Self::Error> {
1055        let state = Self {
1056            schema_version: value.schema_version,
1057            session_id: value.session_id,
1058            active_versions: value.active_versions,
1059            effective_profile: value.effective_profile,
1060            last_checkpoint_id: value.last_checkpoint_id,
1061            verified_checkpoint: value.verified_checkpoint,
1062            reserved_tactic: value.reserved_tactic,
1063            pending_issuance: value.pending_issuance,
1064            attempted_tactics: value.attempted_tactics,
1065            evidence: value.evidence,
1066            deadline: value.deadline,
1067        };
1068        state.validate()?;
1069        Ok(state)
1070    }
1071}
1072
1073impl Serialize for SkillSessionState {
1074    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1075    where
1076        S: Serializer,
1077    {
1078        self.validate().map_err(serde::ser::Error::custom)?;
1079        SkillSessionStateWire {
1080            schema_version: self.schema_version,
1081            session_id: self.session_id.clone(),
1082            active_versions: self.active_versions.clone(),
1083            effective_profile: self.effective_profile.clone(),
1084            last_checkpoint_id: self.last_checkpoint_id.clone(),
1085            verified_checkpoint: self.verified_checkpoint.clone(),
1086            reserved_tactic: self.reserved_tactic,
1087            pending_issuance: self.pending_issuance.clone(),
1088            attempted_tactics: self.attempted_tactics.clone(),
1089            evidence: self.evidence.clone(),
1090            deadline: self.deadline,
1091        }
1092        .serialize(serializer)
1093    }
1094}
1095
1096fn tactic_requires_checkpoint(tactic: SkillTactic) -> bool {
1097    matches!(
1098        tactic,
1099        SkillTactic::ReconcileCheckpoint
1100            | SkillTactic::FreshGhostSession
1101            | SkillTactic::SelectCompatibleEngine
1102            | SkillTactic::RestartDurableBoundary
1103    )
1104}
1105
1106fn issued_interval_ms(issued_at: DateTime<Utc>, deadline: DateTime<Utc>) -> Result<u64, String> {
1107    let interval = deadline.signed_duration_since(issued_at);
1108    if interval < chrono::Duration::zero() {
1109        return Err("issued decision is after the workflow deadline".into());
1110    }
1111    // Wire durations are whole milliseconds; sub-millisecond remainder is consistently floored.
1112    u64::try_from(interval.num_milliseconds())
1113        .map_err(|_| "issued decision interval is not representable in milliseconds".into())
1114}
1115
1116fn validate_schema_version(schema_version: u16) -> Result<(), String> {
1117    if schema_version != SKILL_SCHEMA_VERSION {
1118        return Err("skill schema version must be 1".into());
1119    }
1120    Ok(())
1121}
1122
1123fn validate_capabilities(
1124    capabilities: &BTreeSet<SkillCapability>,
1125    field: &str,
1126) -> Result<(), String> {
1127    if capabilities.len() > MAX_CAPABILITIES {
1128        return Err(format!("{field} capabilities exceed 32 entries"));
1129    }
1130    Ok(())
1131}
1132
1133fn validate_profile_values(values: &BTreeMap<String, String>) -> Result<(), String> {
1134    if values.len() > MAX_PROFILE_VALUES {
1135        return Err("profile values exceed 64 entries".into());
1136    }
1137    for (name, value) in values {
1138        match name.as_str() {
1139            "locale" => validate_locale(value)?,
1140            "timezone" => validate_timezone(value)?,
1141            "userAgentConsistency" => validate_user_agent(value)?,
1142            "engineSelection" | "profilePersistence" | "viewport" | "interactionCadence" => {
1143                validate_version(value, name)?
1144            }
1145            _ => return Err(format!("unsupported profile setting: {name}")),
1146        }
1147    }
1148    Ok(())
1149}
1150
1151fn validate_active_versions(values: &BTreeMap<String, String>) -> Result<(), String> {
1152    if values.len() > MAX_PROFILE_VALUES {
1153        return Err("active versions exceed 64 entries".into());
1154    }
1155    for (name, version) in values {
1156        if name.len() > MAX_NAME_BYTES
1157            || name.strip_prefix("Skill").is_none_or(|suffix| {
1158                suffix.is_empty() || !suffix.bytes().all(|byte| byte.is_ascii_alphanumeric())
1159            })
1160        {
1161            return Err("active version name must be a Skill identifier".into());
1162        }
1163        validate_version(version, "active version")?;
1164    }
1165    Ok(())
1166}
1167
1168fn validate_artifact_id(value: &str) -> Result<(), String> {
1169    if value.is_empty()
1170        || value.len() > MAX_NAME_BYTES
1171        || !value
1172            .bytes()
1173            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
1174        || value.contains("..")
1175        || credential_or_token_prefix(value)
1176    {
1177        return Err("artifact id must be an opaque identifier".into());
1178    }
1179    Ok(())
1180}
1181
1182fn validate_version(value: &str, field: &str) -> Result<(), String> {
1183    if value.is_empty()
1184        || value.len() > MAX_NAME_BYTES
1185        || !value
1186            .bytes()
1187            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'+' | b'-'))
1188        || credential_or_token_prefix(value)
1189    {
1190        return Err(format!("{field} must use the conservative version grammar"));
1191    }
1192    Ok(())
1193}
1194
1195fn validate_locale(value: &str) -> Result<(), String> {
1196    if value.is_empty()
1197        || value.len() > MAX_NAME_BYTES
1198        || !value
1199            .bytes()
1200            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
1201    {
1202        return Err("locale is invalid".into());
1203    }
1204    Ok(())
1205}
1206
1207fn validate_timezone(value: &str) -> Result<(), String> {
1208    if value.is_empty()
1209        || value.len() > MAX_NAME_BYTES
1210        || value.starts_with('/')
1211        || value.contains("..")
1212        || !value
1213            .bytes()
1214            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'_' | b'+' | b'-'))
1215    {
1216        return Err("timezone is invalid".into());
1217    }
1218    Ok(())
1219}
1220
1221fn validate_user_agent(value: &str) -> Result<(), String> {
1222    if value.is_empty()
1223        || value.len() > MAX_NAME_BYTES
1224        || !value
1225            .bytes()
1226            .all(|byte| byte.is_ascii_graphic() || byte == b' ')
1227    {
1228        return Err("user agent is invalid".into());
1229    }
1230    validate_display_text_safety(value, "user agent")
1231}
1232
1233fn validate_postcondition(value: &str) -> Result<(), String> {
1234    if value.is_empty() || value.len() > MAX_POSTCONDITION_BYTES {
1235        return Err("expected postcondition must be between 1 and 1024 bytes".into());
1236    }
1237    validate_display_text_safety(value, "expected postcondition")
1238}
1239
1240fn validate_observable_digest(value: &str) -> Result<(), String> {
1241    validate_display_text_safety(value, "observable digest")
1242}
1243
1244fn validate_display_text_safety(value: &str, field: &str) -> Result<(), String> {
1245    if value.chars().any(char::is_control)
1246        || value.contains("..")
1247        || value.contains("~/")
1248        || value.contains("~\\")
1249        || value.starts_with('/')
1250        || value.starts_with('\\')
1251        || value.contains(" /")
1252        || value.contains("\\\\")
1253        || contains_forbidden_secret_term(value)
1254        || contains_windows_drive_path(value)
1255        || value.contains("://")
1256        || credential_or_token_prefix(value)
1257        || credential_assignment(value)
1258    {
1259        return Err(format!("{field} contains unsafe wire metadata"));
1260    }
1261    Ok(())
1262}
1263
1264fn contains_forbidden_secret_term(value: &str) -> bool {
1265    value
1266        .to_ascii_lowercase()
1267        .split(|character: char| !character.is_ascii_alphanumeric())
1268        .any(|token| {
1269            matches!(
1270                token,
1271                "cookie" | "password" | "authorization" | "bearer" | "token"
1272            )
1273        })
1274}
1275
1276fn contains_windows_drive_path(value: &str) -> bool {
1277    value.as_bytes().windows(3).any(|window| {
1278        window[0].is_ascii_alphabetic() && window[1] == b':' && matches!(window[2], b'/' | b'\\')
1279    })
1280}
1281
1282fn credential_or_token_prefix(value: &str) -> bool {
1283    let lower = value.to_ascii_lowercase();
1284    [
1285        "basic ",
1286        "bearer ",
1287        "authorization:",
1288        "authorization ",
1289        "ghp_",
1290        "gho_",
1291        "ghs_",
1292        "github_pat_",
1293        "sk-",
1294        "token",
1295    ]
1296    .iter()
1297    .any(|prefix| lower.contains(prefix))
1298}
1299
1300fn credential_assignment(value: &str) -> bool {
1301    let lower = value.to_ascii_lowercase();
1302    [
1303        "authorization=",
1304        "cookie=",
1305        "password=",
1306        "token=",
1307        "api_key=",
1308        "apikey=",
1309    ]
1310    .iter()
1311    .any(|prefix| lower.contains(prefix))
1312}