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