Skip to main content

driven/strategy/
identity.rs

1use crate::{DrivenError, Result};
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5use super::{LANE_CLAIM_SCHEMA, MAX_LANES};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8pub struct LaneId(u8);
9
10impl LaneId {
11    pub fn new(value: u8) -> Result<Self> {
12        if (1..=MAX_LANES).contains(&value) {
13            Ok(Self(value))
14        } else {
15            Err(DrivenError::Validation(format!(
16                "lane must be between 1 and {}, got {}",
17                MAX_LANES, value
18            )))
19        }
20    }
21
22    pub fn value(self) -> u8 {
23        self.0
24    }
25}
26
27impl fmt::Display for LaneId {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        write!(f, "{}", self.0)
30    }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34pub struct PassNumber(u32);
35
36impl PassNumber {
37    pub fn first() -> Self {
38        Self(1)
39    }
40
41    pub fn new(value: u32) -> Result<Self> {
42        if value == 0 {
43            Err(DrivenError::Validation(
44                "pass must be at least 1".to_string(),
45            ))
46        } else {
47            Ok(Self(value))
48        }
49    }
50
51    pub fn next(self) -> Result<Self> {
52        self.0
53            .checked_add(1)
54            .ok_or_else(|| DrivenError::Validation("pass overflow".to_string()))
55            .and_then(Self::new)
56    }
57
58    pub fn value(self) -> u32 {
59        self.0
60    }
61}
62
63impl fmt::Display for PassNumber {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        write!(f, "{}", self.0)
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
70pub struct WorkerId(String);
71
72impl WorkerId {
73    pub fn new(raw: impl AsRef<str>) -> Result<Self> {
74        let normalized = raw.as_ref().trim().to_ascii_lowercase();
75        if normalized.is_empty() {
76            return Err(DrivenError::Validation(
77                "worker id cannot be empty".to_string(),
78            ));
79        }
80
81        let valid = normalized.bytes().all(|b| {
82            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.')
83        });
84        if !valid {
85            return Err(DrivenError::Validation(format!(
86                "worker id contains unsupported characters: {}",
87                raw.as_ref()
88            )));
89        }
90
91        Ok(Self(normalized))
92    }
93
94    pub fn as_str(&self) -> &str {
95        &self.0
96    }
97
98    fn validate_canonical(&self, label: &str) -> Result<()> {
99        let canonical = Self::new(&self.0)?;
100        if &canonical != self {
101            return Err(DrivenError::Validation(format!(
102                "{} worker id must be normalized",
103                label
104            )));
105        }
106        Ok(())
107    }
108}
109
110impl fmt::Display for WorkerId {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str(&self.0)
113    }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct ClaimToken(String);
118
119impl ClaimToken {
120    pub fn as_str(&self) -> &str {
121        &self.0
122    }
123}
124
125impl fmt::Display for ClaimToken {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        f.write_str(&self.0)
128    }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
132pub struct ClaimId(String);
133
134impl ClaimId {
135    pub fn new(raw: impl AsRef<str>) -> Result<Self> {
136        let normalized = raw.as_ref().trim().to_ascii_lowercase();
137        if normalized.is_empty() {
138            return Err(DrivenError::Validation(
139                "claim id cannot be empty".to_string(),
140            ));
141        }
142        let valid = normalized.bytes().all(|b| {
143            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.' | b':')
144        });
145        if !valid {
146            return Err(DrivenError::Validation(format!(
147                "claim id contains unsupported characters: {}",
148                raw.as_ref()
149            )));
150        }
151        Ok(Self(normalized))
152    }
153
154    pub fn legacy() -> Self {
155        Self("legacy".to_string())
156    }
157
158    pub fn from_sequence(sequence: u64) -> Self {
159        Self(format!("claim-{sequence:016}"))
160    }
161
162    pub fn as_str(&self) -> &str {
163        &self.0
164    }
165
166    pub fn is_legacy(&self) -> bool {
167        self.0 == "legacy"
168    }
169
170    pub(crate) fn sequence(&self) -> Option<u64> {
171        self.0.strip_prefix("claim-")?.parse().ok()
172    }
173}
174
175impl Default for ClaimId {
176    fn default() -> Self {
177        Self::legacy()
178    }
179}
180
181impl fmt::Display for ClaimId {
182    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183        f.write_str(&self.0)
184    }
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
188pub struct StateSessionId(String);
189
190impl StateSessionId {
191    pub fn new(raw: impl AsRef<str>) -> Result<Self> {
192        let normalized = raw.as_ref().trim().to_ascii_lowercase();
193        if normalized.is_empty() {
194            return Err(DrivenError::Validation(
195                "state session id cannot be empty".to_string(),
196            ));
197        }
198        let valid = normalized.bytes().all(|b| {
199            b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'-' | b'_' | b'.' | b':')
200        });
201        if !valid {
202            return Err(DrivenError::Validation(format!(
203                "state session id contains unsupported characters: {}",
204                raw.as_ref()
205            )));
206        }
207        Ok(Self(normalized))
208    }
209
210    pub fn generate() -> Self {
211        Self(format!("state-{}", uuid::Uuid::new_v4().simple()))
212    }
213
214    pub fn as_str(&self) -> &str {
215        &self.0
216    }
217}
218
219impl fmt::Display for StateSessionId {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        f.write_str(&self.0)
222    }
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum WorkerKind {
228    Human,
229    CodexSubagent,
230    Automation,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234pub struct WorkerIdentity {
235    pub id: WorkerId,
236    pub display_name: String,
237    pub kind: WorkerKind,
238}
239
240impl WorkerIdentity {
241    pub fn new(
242        id: impl AsRef<str>,
243        display_name: impl Into<String>,
244        kind: WorkerKind,
245    ) -> Result<Self> {
246        let display_name = display_name.into();
247        if display_name.trim().is_empty() {
248            return Err(DrivenError::Validation(
249                "worker display name cannot be empty".to_string(),
250            ));
251        }
252
253        Ok(Self {
254            id: WorkerId::new(id)?,
255            display_name,
256            kind,
257        })
258    }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum ClaimStatus {
264    Claimed,
265    Released,
266    Completed,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub struct SubagentDelegation {
271    pub worker_id: WorkerId,
272    pub lane: LaneId,
273    pub pass: PassNumber,
274    pub task: String,
275}
276
277impl SubagentDelegation {
278    pub fn new(
279        worker_id: WorkerId,
280        lane: LaneId,
281        pass: PassNumber,
282        task: impl Into<String>,
283    ) -> Self {
284        Self {
285            worker_id,
286            lane,
287            pass,
288            task: task.into(),
289        }
290    }
291
292    pub fn validate(&self) -> Result<()> {
293        self.worker_id.validate_canonical("subagent")?;
294        if self.task.trim().is_empty() {
295            return Err(DrivenError::Validation(
296                "subagent task cannot be empty".to_string(),
297            ));
298        }
299        Ok(())
300    }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304pub struct LaneClaim {
305    pub schema: String,
306    pub lane: LaneId,
307    pub pass: PassNumber,
308    pub worker_id: WorkerId,
309    pub worker: WorkerIdentity,
310    pub scope: String,
311    #[serde(default, skip_serializing_if = "ClaimId::is_legacy")]
312    pub claim_id: ClaimId,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub state_session_id: Option<StateSessionId>,
315    pub status: ClaimStatus,
316    pub token: ClaimToken,
317    pub subagents: Vec<SubagentDelegation>,
318}
319
320impl LaneClaim {
321    pub fn new(
322        lane: LaneId,
323        pass: PassNumber,
324        worker_id: WorkerId,
325        scope: impl Into<String>,
326    ) -> Self {
327        let scope = scope.into();
328        let worker = WorkerIdentity {
329            display_name: worker_id.as_str().to_string(),
330            id: worker_id.clone(),
331            kind: WorkerKind::Automation,
332        };
333
334        Self {
335            schema: LANE_CLAIM_SCHEMA.to_string(),
336            token: derive_claim_token(lane, pass, &worker_id, &scope),
337            lane,
338            pass,
339            worker_id,
340            worker,
341            scope,
342            claim_id: ClaimId::legacy(),
343            state_session_id: None,
344            status: ClaimStatus::Claimed,
345            subagents: Vec::new(),
346        }
347    }
348
349    pub fn with_worker_identity(mut self, worker: WorkerIdentity) -> Self {
350        self.worker_id = worker.id.clone();
351        self.worker = worker;
352        self.refresh_token();
353        self
354    }
355
356    pub fn with_claim_id(mut self, claim_id: ClaimId) -> Self {
357        self.claim_id = claim_id;
358        self.refresh_token();
359        self
360    }
361
362    pub fn with_state_session_id(mut self, state_session_id: StateSessionId) -> Self {
363        self.state_session_id = Some(state_session_id);
364        self.refresh_token();
365        self
366    }
367
368    pub fn with_subagent(mut self, subagent: SubagentDelegation) -> Self {
369        self.subagents.push(subagent);
370        self
371    }
372
373    pub fn is_owner(&self, worker_id: &WorkerId) -> bool {
374        &self.worker_id == worker_id
375    }
376
377    pub(crate) fn refresh_token(&mut self) {
378        self.token = derive_claim_token_for_id(
379            self.lane,
380            self.pass,
381            &self.worker_id,
382            &self.scope,
383            &self.claim_id,
384            self.state_session_id.as_ref(),
385        );
386    }
387
388    pub fn validate(&self) -> Result<()> {
389        LaneId::new(self.lane.value())?;
390        PassNumber::new(self.pass.value())?;
391        self.worker_id.validate_canonical("claim")?;
392        self.worker.id.validate_canonical("worker identity")?;
393        if ClaimId::new(self.claim_id.as_str())? != self.claim_id {
394            return Err(DrivenError::Validation(
395                "claim id must be normalized".to_string(),
396            ));
397        }
398        if let Some(state_session_id) = &self.state_session_id
399            && StateSessionId::new(state_session_id.as_str())? != *state_session_id
400        {
401            return Err(DrivenError::Validation(
402                "state session id must be normalized".to_string(),
403            ));
404        }
405        if self.schema != LANE_CLAIM_SCHEMA {
406            return Err(DrivenError::Validation(format!(
407                "lane claim schema must be {}",
408                LANE_CLAIM_SCHEMA
409            )));
410        }
411        if self.scope.trim().is_empty() {
412            return Err(DrivenError::Validation(
413                "lane scope cannot be empty".to_string(),
414            ));
415        }
416        if self.worker_id != self.worker.id {
417            return Err(DrivenError::Validation(
418                "claim worker id and worker identity must match".to_string(),
419            ));
420        }
421        if self.worker.display_name.trim().is_empty() {
422            return Err(DrivenError::Validation(
423                "worker display name cannot be empty".to_string(),
424            ));
425        }
426        let expected_token = derive_claim_token_for_id(
427            self.lane,
428            self.pass,
429            &self.worker_id,
430            &self.scope,
431            &self.claim_id,
432            self.state_session_id.as_ref(),
433        );
434        if self.token != expected_token {
435            return Err(DrivenError::Validation(
436                "claim token does not match lane/pass/worker/scope/claim id/state session"
437                    .to_string(),
438            ));
439        }
440        for subagent in &self.subagents {
441            subagent.validate()?;
442            if subagent.lane != self.lane || subagent.pass != self.pass {
443                return Err(DrivenError::Validation(
444                    "subagent lane/pass must match parent claim lane/pass".to_string(),
445                ));
446            }
447        }
448        Ok(())
449    }
450}
451
452pub fn derive_claim_token(
453    lane: LaneId,
454    pass: PassNumber,
455    worker_id: &WorkerId,
456    scope: &str,
457) -> ClaimToken {
458    derive_claim_token_for_id(lane, pass, worker_id, scope, &ClaimId::legacy(), None)
459}
460
461fn derive_claim_token_for_id(
462    lane: LaneId,
463    pass: PassNumber,
464    worker_id: &WorkerId,
465    scope: &str,
466    claim_id: &ClaimId,
467    state_session_id: Option<&StateSessionId>,
468) -> ClaimToken {
469    let canonical = if let Some(state_session_id) = state_session_id {
470        format!(
471            "driven.lane_claim.v3\nlane={}\npass={}\nworker={}\nscope={}\nclaim_id={}\nstate_session_id={}\n",
472            lane.value(),
473            pass.value(),
474            worker_id.as_str(),
475            scope.trim(),
476            claim_id.as_str(),
477            state_session_id.as_str()
478        )
479    } else if claim_id.is_legacy() {
480        format!(
481            "driven.lane_claim.v1\nlane={}\npass={}\nworker={}\nscope={}\n",
482            lane.value(),
483            pass.value(),
484            worker_id.as_str(),
485            scope.trim()
486        )
487    } else {
488        format!(
489            "driven.lane_claim.v2\nlane={}\npass={}\nworker={}\nscope={}\nclaim_id={}\n",
490            lane.value(),
491            pass.value(),
492            worker_id.as_str(),
493            scope.trim(),
494            claim_id.as_str()
495        )
496    };
497    ClaimToken(blake3::hash(canonical.as_bytes()).to_hex().to_string())
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    #[test]
505    fn lane_id_accepts_only_one_through_thirty() {
506        assert!(LaneId::new(1).is_ok());
507        assert!(LaneId::new(30).is_ok());
508        assert!(LaneId::new(0).is_err());
509        assert!(LaneId::new(31).is_err());
510    }
511
512    #[test]
513    fn worker_id_normalizes_and_rejects_invalid_input() {
514        assert_eq!(
515            WorkerId::new(" Worker.One ").unwrap().as_str(),
516            "worker.one"
517        );
518        assert!(WorkerId::new("worker one").is_err());
519    }
520
521    #[test]
522    fn claim_token_is_deterministic() {
523        let lane = LaneId::new(2).unwrap();
524        let pass = PassNumber::new(3).unwrap();
525        let worker = WorkerId::new("worker-a").unwrap();
526        assert_eq!(
527            derive_claim_token(lane, pass, &worker, "scope"),
528            derive_claim_token(lane, pass, &worker, "scope")
529        );
530    }
531
532    #[test]
533    fn claim_validation_rejects_tampered_schema_and_token() {
534        let mut claim = LaneClaim::new(
535            LaneId::new(1).unwrap(),
536            PassNumber::first(),
537            WorkerId::new("worker-a").unwrap(),
538            "scope",
539        );
540
541        claim.schema = "wrong".to_string();
542        assert!(claim.validate().is_err());
543
544        claim.schema = LANE_CLAIM_SCHEMA.to_string();
545        claim.scope = "other-scope".to_string();
546        assert!(claim.validate().is_err());
547    }
548}