Skip to main content

a3s_code_core/capability/
id.rs

1use std::fmt;
2use std::num::NonZeroU64;
3
4use serde::Serialize;
5
6use super::CapabilitySetError;
7
8pub const MAX_CAPABILITY_IDENTIFIER_BYTES: usize = 256;
9pub const USE_CAPABILITY_SNAPSHOT_CURSOR_SCHEMA: &str = "a3s.use.capability-snapshot-cursor.v1";
10
11/// Canonical lowercase SHA-256 value used by capability identity contracts.
12#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
13#[serde(transparent)]
14pub struct Sha256Digest(Box<str>);
15
16impl Sha256Digest {
17    pub fn new(value: impl Into<String>) -> Result<Self, CapabilitySetError> {
18        let value = value.into();
19        let valid = value.len() == 71
20            && value.starts_with("sha256:")
21            && value[7..]
22                .bytes()
23                .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'));
24        if !valid {
25            return Err(CapabilitySetError::InvalidDigest { field: "digest" });
26        }
27        Ok(Self(value.into_boxed_str()))
28    }
29
30    pub fn as_str(&self) -> &str {
31        &self.0
32    }
33}
34
35impl fmt::Display for Sha256Digest {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        formatter.write_str(self.as_str())
38    }
39}
40
41/// Closed product capability categories. Implementations inside a category
42/// remain open; arbitrary runtime categories do not enter the set.
43#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
44#[serde(rename_all = "kebab-case")]
45pub enum CapabilityKind {
46    Tool,
47    Skill,
48    Agent,
49    Command,
50    Hook,
51    Mcp,
52    Flow,
53    KnowledgeSurface,
54    Knowledge,
55    Ui,
56    Context,
57}
58
59impl CapabilityKind {
60    pub const fn as_str(self) -> &'static str {
61        match self {
62            Self::Tool => "tool",
63            Self::Skill => "skill",
64            Self::Agent => "agent",
65            Self::Command => "command",
66            Self::Hook => "hook",
67            Self::Mcp => "mcp",
68            Self::Flow => "flow",
69            Self::KnowledgeSurface => "knowledge-surface",
70            Self::Knowledge => "knowledge",
71            Self::Ui => "ui",
72            Self::Context => "context",
73        }
74    }
75}
76
77impl fmt::Display for CapabilityKind {
78    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79        formatter.write_str(self.as_str())
80    }
81}
82
83/// Stable identity of one trusted contribution source.
84#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
85#[serde(transparent)]
86pub struct CapabilitySourceId(Box<str>);
87
88impl CapabilitySourceId {
89    pub(super) fn scoped(
90        prefix: &'static str,
91        local: impl Into<String>,
92    ) -> Result<Self, CapabilitySetError> {
93        let local = local.into();
94        validate_identifier("source", &local)?;
95        let value = format!("{prefix}/{local}");
96        if value.len() > MAX_CAPABILITY_IDENTIFIER_BYTES {
97            return Err(CapabilitySetError::BoundExceeded {
98                field: "source",
99                max: MAX_CAPABILITY_IDENTIFIER_BYTES,
100            });
101        }
102        Ok(Self(value.into_boxed_str()))
103    }
104
105    pub fn as_str(&self) -> &str {
106        &self.0
107    }
108}
109
110impl fmt::Display for CapabilitySourceId {
111    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112        formatter.write_str(self.as_str())
113    }
114}
115
116/// Stable source, category, and local-surface identity.
117#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
118#[serde(rename_all = "camelCase")]
119pub struct CapabilityId {
120    source: CapabilitySourceId,
121    kind: CapabilityKind,
122    local_id: Box<str>,
123}
124
125impl CapabilityId {
126    pub fn new(
127        source: &super::CapabilitySource,
128        kind: CapabilityKind,
129        local_id: impl Into<String>,
130    ) -> Result<Self, CapabilitySetError> {
131        let local_id = local_id.into();
132        validate_identifier("local_id", &local_id)?;
133        Ok(Self {
134            source: source.id().clone(),
135            kind,
136            local_id: local_id.into_boxed_str(),
137        })
138    }
139
140    pub fn source(&self) -> &CapabilitySourceId {
141        &self.source
142    }
143
144    pub const fn kind(&self) -> CapabilityKind {
145        self.kind
146    }
147
148    pub fn local_id(&self) -> &str {
149        &self.local_id
150    }
151}
152
153impl fmt::Display for CapabilityId {
154    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
155        write!(formatter, "{}:{}:{}", self.source, self.kind, self.local_id)
156    }
157}
158
159/// Exact immutable A3S Use capability publication identity.
160#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
161#[serde(rename_all = "camelCase")]
162pub struct UseCapabilityGeneration {
163    schema: &'static str,
164    generation: u64,
165    revision: Sha256Digest,
166    registry_revision: Sha256Digest,
167}
168
169impl UseCapabilityGeneration {
170    pub fn new(generation: u64, revision: Sha256Digest, registry_revision: Sha256Digest) -> Self {
171        Self {
172            schema: USE_CAPABILITY_SNAPSHOT_CURSOR_SCHEMA,
173            generation,
174            revision,
175            registry_revision,
176        }
177    }
178
179    pub const fn schema(&self) -> &'static str {
180        self.schema
181    }
182
183    pub const fn generation(&self) -> u64 {
184        self.generation
185    }
186
187    pub fn revision(&self) -> &Sha256Digest {
188        &self.revision
189    }
190
191    pub fn registry_revision(&self) -> &Sha256Digest {
192        &self.registry_revision
193    }
194}
195
196/// Exact immutable A3S Use package lifecycle generation identity.
197#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
198#[serde(rename_all = "camelCase")]
199pub struct UsePackageGeneration {
200    package_id: Box<str>,
201    component_id: Box<str>,
202    route: Box<str>,
203    version: Box<str>,
204    lifecycle_generation: NonZeroU64,
205    package_digest: Sha256Digest,
206    manifest_digest: Sha256Digest,
207}
208
209impl UsePackageGeneration {
210    pub fn new(
211        package_id: impl Into<String>,
212        component_id: impl Into<String>,
213        route: impl Into<String>,
214        version: impl Into<String>,
215        lifecycle_generation: u64,
216        package_digest: Sha256Digest,
217        manifest_digest: Sha256Digest,
218    ) -> Result<Self, CapabilitySetError> {
219        let package_id = package_id.into();
220        let component_id = component_id.into();
221        let route = route.into();
222        let version = version.into();
223        validate_identifier("package_id", &package_id)?;
224        validate_identifier("component_id", &component_id)?;
225        validate_identifier("route", &route)?;
226        validate_bounded_text("version", &version, 128)?;
227        let lifecycle_generation =
228            NonZeroU64::new(lifecycle_generation).ok_or(CapabilitySetError::InvalidGeneration {
229                field: "lifecycle_generation",
230            })?;
231        Ok(Self {
232            package_id: package_id.into_boxed_str(),
233            component_id: component_id.into_boxed_str(),
234            route: route.into_boxed_str(),
235            version: version.into_boxed_str(),
236            lifecycle_generation,
237            package_digest,
238            manifest_digest,
239        })
240    }
241
242    pub fn package_id(&self) -> &str {
243        &self.package_id
244    }
245
246    pub fn component_id(&self) -> &str {
247        &self.component_id
248    }
249
250    pub fn route(&self) -> &str {
251        &self.route
252    }
253
254    pub fn version(&self) -> &str {
255        &self.version
256    }
257
258    pub const fn lifecycle_generation(&self) -> u64 {
259        self.lifecycle_generation.get()
260    }
261
262    pub fn package_digest(&self) -> &Sha256Digest {
263        &self.package_digest
264    }
265
266    pub fn manifest_digest(&self) -> &Sha256Digest {
267        &self.manifest_digest
268    }
269}
270
271/// Session-local immutable Code catalog generation.
272#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
273#[serde(transparent)]
274pub struct CodeCatalogGeneration(u64);
275
276impl CodeCatalogGeneration {
277    pub const INITIAL: Self = Self(0);
278
279    pub const fn new(value: u64) -> Self {
280        Self(value)
281    }
282
283    pub const fn get(self) -> u64 {
284        self.0
285    }
286
287    pub const fn checked_next(self) -> Option<Self> {
288        match self.0.checked_add(1) {
289            Some(value) => Some(Self(value)),
290            None => None,
291        }
292    }
293}
294
295pub(super) fn validate_identifier(
296    field: &'static str,
297    value: &str,
298) -> Result<(), CapabilitySetError> {
299    if value.is_empty() {
300        return Err(CapabilitySetError::InvalidIdentifier {
301            field,
302            reason: "it is empty",
303        });
304    }
305    if value.len() > MAX_CAPABILITY_IDENTIFIER_BYTES {
306        return Err(CapabilitySetError::BoundExceeded {
307            field,
308            max: MAX_CAPABILITY_IDENTIFIER_BYTES,
309        });
310    }
311    if !value.bytes().all(|byte| {
312        byte.is_ascii_lowercase()
313            || byte.is_ascii_digit()
314            || matches!(byte, b'-' | b'_' | b'.' | b'/')
315    }) {
316        return Err(CapabilitySetError::InvalidIdentifier {
317            field,
318            reason: "it contains non-canonical characters",
319        });
320    }
321    if !value
322        .as_bytes()
323        .first()
324        .is_some_and(u8::is_ascii_alphanumeric)
325        || !value
326            .as_bytes()
327            .last()
328            .is_some_and(u8::is_ascii_alphanumeric)
329        || value
330            .split('/')
331            .any(|segment| segment.is_empty() || matches!(segment, "." | ".."))
332    {
333        return Err(CapabilitySetError::InvalidIdentifier {
334            field,
335            reason: "it has an unsafe boundary or path segment",
336        });
337    }
338    Ok(())
339}
340
341fn validate_bounded_text(
342    field: &'static str,
343    value: &str,
344    max: usize,
345) -> Result<(), CapabilitySetError> {
346    if value.is_empty() || value.trim() != value || value.chars().any(char::is_control) {
347        return Err(CapabilitySetError::InvalidIdentifier {
348            field,
349            reason: "it is empty, padded, or contains control characters",
350        });
351    }
352    if value.len() > max {
353        return Err(CapabilitySetError::BoundExceeded { field, max });
354    }
355    Ok(())
356}