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}