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