1use 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 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)] 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 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}