Skip to main content

a3s_code_core/evaluation/
dispatch_ledger.rs

1//! Durable, provider-neutral dispatch claims for auxiliary evaluations.
2//!
3//! The ledger records only an evaluator dispatch identity, its lease, and an
4//! optional digest-only terminal result receipt. It does not record prompts,
5//! rubric decisions, tenant data, or business audit state. A host may use the
6//! in-memory implementation for a process-scoped supervisor or the file
7//! implementation when replay protection must survive a restart.
8
9use super::identity::validate_digest;
10use crate::execution_identity::{ExecutionIdentityV1, ExecutionResultReceiptV1};
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use thiserror::Error;
17use tokio::io::AsyncWriteExt;
18use tokio::sync::Mutex;
19
20pub const EVALUATION_DISPATCH_LEDGER_SCHEMA_V1: &str = "a3s.code.evaluation-dispatch-ledger.v1";
21pub const EVALUATION_DISPATCH_LEDGER_DEFAULT_MAX_RECORDS: usize = 4096;
22pub const EVALUATION_DISPATCH_LEDGER_MAX_BYTES: usize = 16 * 1024 * 1024;
23pub const EVALUATION_DISPATCH_MIN_LEASE_MS: u64 = 60 * 1000;
24pub const EVALUATION_DISPATCH_LEASE_GRACE_MS: u64 = 30 * 1000;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum EvaluationDispatchClaimOutcome {
28    Claimed { attempt: u32 },
29    Completed,
30    Busy { lease_expires_at_ms: u64 },
31    Conflict,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Error)]
35pub enum EvaluationDispatchLedgerError {
36    #[error("evaluation dispatch ledger field `{0}` is invalid")]
37    InvalidField(&'static str),
38    #[error("evaluation dispatch ledger schema is unsupported")]
39    UnsupportedSchema,
40    #[error("evaluation dispatch ledger claim conflicts with an existing identity")]
41    Conflict,
42    #[error("evaluation dispatch ledger storage failed: {0}")]
43    Storage(String),
44    #[error("evaluation dispatch ledger is corrupt: {0}")]
45    Corrupt(String),
46    #[error("evaluation dispatch ledger exceeds its configured size limit")]
47    SizeLimit,
48}
49
50#[async_trait]
51pub trait EvaluationDispatchLedger: Send + Sync {
52    async fn claim(
53        &self,
54        dispatch_id: &str,
55        request_digest: &str,
56        owner_id: &str,
57        now_ms: u64,
58        lease_ms: u64,
59    ) -> Result<EvaluationDispatchClaimOutcome, EvaluationDispatchLedgerError>;
60
61    /// Claim a dispatch while binding its canonical execution identity. The
62    /// default keeps third-party ledgers source-compatible; built-in ledgers
63    /// persist and fence on the identity.
64    async fn claim_with_identity(
65        &self,
66        dispatch_id: &str,
67        request_digest: &str,
68        identity: &ExecutionIdentityV1,
69        owner_id: &str,
70        now_ms: u64,
71        lease_ms: u64,
72    ) -> Result<EvaluationDispatchClaimOutcome, EvaluationDispatchLedgerError> {
73        identity
74            .validate()
75            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
76        self.claim(dispatch_id, request_digest, owner_id, now_ms, lease_ms)
77            .await
78    }
79
80    async fn renew(
81        &self,
82        dispatch_id: &str,
83        request_digest: &str,
84        owner_id: &str,
85        now_ms: u64,
86        lease_ms: u64,
87    ) -> Result<bool, EvaluationDispatchLedgerError>;
88
89    /// Renew a claim only when both its legacy ledger key and canonical
90    /// identity still match the admitted worker.
91    async fn renew_with_identity(
92        &self,
93        dispatch_id: &str,
94        request_digest: &str,
95        identity: &ExecutionIdentityV1,
96        owner_id: &str,
97        now_ms: u64,
98        lease_ms: u64,
99    ) -> Result<bool, EvaluationDispatchLedgerError> {
100        identity
101            .validate()
102            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
103        self.renew(dispatch_id, request_digest, owner_id, now_ms, lease_ms)
104            .await
105    }
106
107    async fn complete(
108        &self,
109        dispatch_id: &str,
110        request_digest: &str,
111        owner_id: &str,
112        completed_at_ms: u64,
113    ) -> Result<(), EvaluationDispatchLedgerError>;
114
115    /// Complete a claim with a bounded digest-only result receipt.
116    async fn complete_with_receipt(
117        &self,
118        dispatch_id: &str,
119        request_digest: &str,
120        identity: &ExecutionIdentityV1,
121        owner_id: &str,
122        receipt: &ExecutionResultReceiptV1,
123        completed_at_ms: u64,
124    ) -> Result<(), EvaluationDispatchLedgerError> {
125        identity
126            .validate()
127            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
128        receipt
129            .validate()
130            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("result_receipt"))?;
131        if &receipt.identity != identity {
132            return Err(EvaluationDispatchLedgerError::Conflict);
133        }
134        self.complete(dispatch_id, request_digest, owner_id, completed_at_ms)
135            .await
136    }
137
138    async fn release(
139        &self,
140        dispatch_id: &str,
141        request_digest: &str,
142        owner_id: &str,
143    ) -> Result<(), EvaluationDispatchLedgerError>;
144
145    /// Release a claim only when its canonical identity also matches. Legacy
146    /// implementations fall back to the existing request/owner fence.
147    async fn release_with_identity(
148        &self,
149        dispatch_id: &str,
150        request_digest: &str,
151        identity: &ExecutionIdentityV1,
152        owner_id: &str,
153    ) -> Result<(), EvaluationDispatchLedgerError> {
154        identity
155            .validate()
156            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
157        self.release(dispatch_id, request_digest, owner_id).await
158    }
159
160    /// Read the terminal receipt when the ledger supports result persistence.
161    async fn completed_receipt(
162        &self,
163        _dispatch_id: &str,
164    ) -> Result<Option<ExecutionResultReceiptV1>, EvaluationDispatchLedgerError> {
165        Ok(None)
166    }
167
168    async fn prune_completed(&self, before_ms: u64)
169        -> Result<usize, EvaluationDispatchLedgerError>;
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "snake_case")]
174enum DispatchClaimStatus {
175    Pending,
176    Completed,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(deny_unknown_fields)]
181struct DispatchClaimRecord {
182    request_digest: String,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    execution_identity: Option<ExecutionIdentityV1>,
185    status: DispatchClaimStatus,
186    owner_id: String,
187    lease_expires_at_ms: u64,
188    attempts: u32,
189    created_at_ms: u64,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    completed_at_ms: Option<u64>,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    result_receipt: Option<ExecutionResultReceiptV1>,
194}
195
196#[derive(Debug, Default, Serialize, Deserialize)]
197#[serde(deny_unknown_fields)]
198struct DispatchLedgerFileV1 {
199    schema: String,
200    records: BTreeMap<String, DispatchClaimRecord>,
201}
202
203#[derive(Debug, Default)]
204pub struct MemoryEvaluationDispatchLedger {
205    records: Mutex<BTreeMap<String, DispatchClaimRecord>>,
206}
207
208impl MemoryEvaluationDispatchLedger {
209    pub fn new() -> Self {
210        Self::default()
211    }
212}
213
214#[async_trait]
215impl EvaluationDispatchLedger for MemoryEvaluationDispatchLedger {
216    async fn claim(
217        &self,
218        dispatch_id: &str,
219        request_digest: &str,
220        owner_id: &str,
221        now_ms: u64,
222        lease_ms: u64,
223    ) -> Result<EvaluationDispatchClaimOutcome, EvaluationDispatchLedgerError> {
224        validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
225        let mut records = self.records.lock().await;
226        claim_record(
227            &mut records,
228            dispatch_id,
229            request_digest,
230            None,
231            owner_id,
232            now_ms,
233            lease_ms,
234        )
235    }
236
237    async fn claim_with_identity(
238        &self,
239        dispatch_id: &str,
240        request_digest: &str,
241        identity: &ExecutionIdentityV1,
242        owner_id: &str,
243        now_ms: u64,
244        lease_ms: u64,
245    ) -> Result<EvaluationDispatchClaimOutcome, EvaluationDispatchLedgerError> {
246        validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
247        identity
248            .validate()
249            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
250        let mut records = self.records.lock().await;
251        claim_record(
252            &mut records,
253            dispatch_id,
254            request_digest,
255            Some(identity),
256            owner_id,
257            now_ms,
258            lease_ms,
259        )
260    }
261
262    async fn renew(
263        &self,
264        dispatch_id: &str,
265        request_digest: &str,
266        owner_id: &str,
267        now_ms: u64,
268        lease_ms: u64,
269    ) -> Result<bool, EvaluationDispatchLedgerError> {
270        validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
271        let mut records = self.records.lock().await;
272        Ok(renew_record(
273            &mut records,
274            dispatch_id,
275            request_digest,
276            None,
277            owner_id,
278            now_ms,
279            lease_ms,
280        ))
281    }
282
283    async fn renew_with_identity(
284        &self,
285        dispatch_id: &str,
286        request_digest: &str,
287        identity: &ExecutionIdentityV1,
288        owner_id: &str,
289        now_ms: u64,
290        lease_ms: u64,
291    ) -> Result<bool, EvaluationDispatchLedgerError> {
292        validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
293        identity
294            .validate()
295            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
296        let mut records = self.records.lock().await;
297        Ok(renew_record(
298            &mut records,
299            dispatch_id,
300            request_digest,
301            Some(identity),
302            owner_id,
303            now_ms,
304            lease_ms,
305        ))
306    }
307
308    async fn complete(
309        &self,
310        dispatch_id: &str,
311        request_digest: &str,
312        owner_id: &str,
313        completed_at_ms: u64,
314    ) -> Result<(), EvaluationDispatchLedgerError> {
315        validate_identity_args(dispatch_id, request_digest, owner_id)?;
316        let mut records = self.records.lock().await;
317        complete_record(
318            &mut records,
319            dispatch_id,
320            request_digest,
321            None,
322            owner_id,
323            None,
324            completed_at_ms,
325        )
326    }
327
328    async fn complete_with_receipt(
329        &self,
330        dispatch_id: &str,
331        request_digest: &str,
332        identity: &ExecutionIdentityV1,
333        owner_id: &str,
334        receipt: &ExecutionResultReceiptV1,
335        completed_at_ms: u64,
336    ) -> Result<(), EvaluationDispatchLedgerError> {
337        validate_identity_args(dispatch_id, request_digest, owner_id)?;
338        identity
339            .validate()
340            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
341        receipt
342            .validate()
343            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("result_receipt"))?;
344        if &receipt.identity != identity {
345            return Err(EvaluationDispatchLedgerError::Conflict);
346        }
347        let mut records = self.records.lock().await;
348        complete_record(
349            &mut records,
350            dispatch_id,
351            request_digest,
352            Some(identity),
353            owner_id,
354            Some(receipt),
355            completed_at_ms,
356        )
357    }
358
359    async fn release(
360        &self,
361        dispatch_id: &str,
362        request_digest: &str,
363        owner_id: &str,
364    ) -> Result<(), EvaluationDispatchLedgerError> {
365        validate_identity_args(dispatch_id, request_digest, owner_id)?;
366        let mut records = self.records.lock().await;
367        release_record(&mut records, dispatch_id, request_digest, None, owner_id)
368    }
369
370    async fn release_with_identity(
371        &self,
372        dispatch_id: &str,
373        request_digest: &str,
374        identity: &ExecutionIdentityV1,
375        owner_id: &str,
376    ) -> Result<(), EvaluationDispatchLedgerError> {
377        validate_identity_args(dispatch_id, request_digest, owner_id)?;
378        identity
379            .validate()
380            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
381        let mut records = self.records.lock().await;
382        release_record(
383            &mut records,
384            dispatch_id,
385            request_digest,
386            Some(identity),
387            owner_id,
388        )
389    }
390
391    async fn completed_receipt(
392        &self,
393        dispatch_id: &str,
394    ) -> Result<Option<ExecutionResultReceiptV1>, EvaluationDispatchLedgerError> {
395        let records = self.records.lock().await;
396        Ok(records
397            .get(dispatch_id)
398            .and_then(|record| record.result_receipt.clone()))
399    }
400
401    async fn prune_completed(
402        &self,
403        before_ms: u64,
404    ) -> Result<usize, EvaluationDispatchLedgerError> {
405        let mut records = self.records.lock().await;
406        Ok(prune_records(&mut records, before_ms))
407    }
408}
409
410#[derive(Debug, Clone)]
411pub struct FileEvaluationDispatchLedger {
412    root: PathBuf,
413    max_records: usize,
414    process_lock: Arc<Mutex<()>>,
415}
416
417impl FileEvaluationDispatchLedger {
418    pub fn new(root: impl Into<PathBuf>) -> Self {
419        Self {
420            root: root.into(),
421            max_records: EVALUATION_DISPATCH_LEDGER_DEFAULT_MAX_RECORDS,
422            process_lock: Arc::new(Mutex::new(())),
423        }
424    }
425
426    pub fn with_max_records(
427        root: impl Into<PathBuf>,
428        max_records: usize,
429    ) -> Result<Self, EvaluationDispatchLedgerError> {
430        if max_records == 0 {
431            return Err(EvaluationDispatchLedgerError::InvalidField("max_records"));
432        }
433        Ok(Self {
434            root: root.into(),
435            max_records,
436            process_lock: Arc::new(Mutex::new(())),
437        })
438    }
439
440    /// Open and validate the persisted claim generation before use.
441    pub async fn open(root: impl Into<PathBuf>) -> Result<Self, EvaluationDispatchLedgerError> {
442        let ledger = Self::new(root);
443        ledger.validate_store().await?;
444        Ok(ledger)
445    }
446
447    pub async fn open_with_max_records(
448        root: impl Into<PathBuf>,
449        max_records: usize,
450    ) -> Result<Self, EvaluationDispatchLedgerError> {
451        let ledger = Self::with_max_records(root, max_records)?;
452        ledger.validate_store().await?;
453        Ok(ledger)
454    }
455
456    pub fn root(&self) -> &Path {
457        &self.root
458    }
459
460    pub fn max_records(&self) -> usize {
461        self.max_records
462    }
463
464    pub async fn validate_store(&self) -> Result<usize, EvaluationDispatchLedgerError> {
465        Ok(self.read_records().await?.len())
466    }
467
468    fn data_path(&self) -> PathBuf {
469        self.root.join("evaluation-dispatch-ledger.json")
470    }
471
472    fn lock_path(&self) -> PathBuf {
473        self.root.join(".evaluation-dispatch-ledger.lock")
474    }
475
476    async fn acquire_file_lock(&self) -> Result<std::fs::File, EvaluationDispatchLedgerError> {
477        tokio::fs::create_dir_all(&self.root)
478            .await
479            .map_err(|error| storage_error("create dispatch ledger directory", error))?;
480        let path = self.lock_path();
481        tokio::task::spawn_blocking(move || {
482            use fs2::FileExt;
483            let file = std::fs::OpenOptions::new()
484                .create(true)
485                .truncate(false)
486                .read(true)
487                .write(true)
488                .open(&path)
489                .map_err(|error| storage_error("open dispatch ledger lock", error))?;
490            file.lock_exclusive()
491                .map_err(|error| storage_error("lock dispatch ledger", error))?;
492            Ok(file)
493        })
494        .await
495        .map_err(|error| {
496            EvaluationDispatchLedgerError::Storage(format!("lock task failed: {error}"))
497        })?
498    }
499
500    async fn read_records(
501        &self,
502    ) -> Result<BTreeMap<String, DispatchClaimRecord>, EvaluationDispatchLedgerError> {
503        read_records_from_path(&self.data_path(), self.max_records).await
504    }
505
506    async fn mutate<T>(
507        &self,
508        mutation: impl FnOnce(
509            &mut BTreeMap<String, DispatchClaimRecord>,
510        ) -> Result<T, EvaluationDispatchLedgerError>,
511    ) -> Result<T, EvaluationDispatchLedgerError> {
512        let _process_guard = self.process_lock.lock().await;
513        let _file_guard = self.acquire_file_lock().await?;
514        let mut records = self.read_records().await?;
515        let value = mutation(&mut records)?;
516        enforce_retention(&mut records, self.max_records)?;
517        self.publish_records(&records).await?;
518        Ok(value)
519    }
520
521    async fn publish_records(
522        &self,
523        records: &BTreeMap<String, DispatchClaimRecord>,
524    ) -> Result<(), EvaluationDispatchLedgerError> {
525        let bytes = serde_json::to_vec(&DispatchLedgerFileV1 {
526            schema: EVALUATION_DISPATCH_LEDGER_SCHEMA_V1.to_string(),
527            records: records.clone(),
528        })
529        .map_err(|error| {
530            EvaluationDispatchLedgerError::Storage(format!("encode ledger: {error}"))
531        })?;
532        if bytes.len() > EVALUATION_DISPATCH_LEDGER_MAX_BYTES {
533            return Err(EvaluationDispatchLedgerError::SizeLimit);
534        }
535        tokio::fs::create_dir_all(&self.root)
536            .await
537            .map_err(|error| storage_error("create dispatch ledger directory", error))?;
538        let path = self.data_path();
539        let temp = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
540        let result = async {
541            let mut file = tokio::fs::File::create(&temp)
542                .await
543                .map_err(|error| storage_error("create dispatch ledger generation", error))?;
544            file.write_all(&bytes)
545                .await
546                .map_err(|error| storage_error("write dispatch ledger generation", error))?;
547            file.sync_all()
548                .await
549                .map_err(|error| storage_error("sync dispatch ledger generation", error))?;
550            drop(file);
551            let temp_copy = temp.clone();
552            let path_copy = path.clone();
553            tokio::task::spawn_blocking(move || {
554                tempfile::TempPath::try_from_path(temp_copy)
555                    .map_err(|error| storage_error("prepare dispatch ledger replacement", error))?
556                    .persist(path_copy)
557                    .map_err(|error| {
558                        storage_error("publish dispatch ledger generation", error.error)
559                    })
560            })
561            .await
562            .map_err(|error| {
563                EvaluationDispatchLedgerError::Storage(format!("publish task failed: {error}"))
564            })??;
565            Ok::<(), EvaluationDispatchLedgerError>(())
566        }
567        .await;
568        if result.is_err() {
569            let _ = tokio::fs::remove_file(&temp).await;
570        }
571        result
572    }
573}
574
575#[async_trait]
576impl EvaluationDispatchLedger for FileEvaluationDispatchLedger {
577    async fn claim(
578        &self,
579        dispatch_id: &str,
580        request_digest: &str,
581        owner_id: &str,
582        now_ms: u64,
583        lease_ms: u64,
584    ) -> Result<EvaluationDispatchClaimOutcome, EvaluationDispatchLedgerError> {
585        validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
586        self.mutate(|records| {
587            claim_record(
588                records,
589                dispatch_id,
590                request_digest,
591                None,
592                owner_id,
593                now_ms,
594                lease_ms,
595            )
596        })
597        .await
598    }
599
600    async fn renew(
601        &self,
602        dispatch_id: &str,
603        request_digest: &str,
604        owner_id: &str,
605        now_ms: u64,
606        lease_ms: u64,
607    ) -> Result<bool, EvaluationDispatchLedgerError> {
608        validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
609        self.mutate(|records| {
610            Ok(renew_record(
611                records,
612                dispatch_id,
613                request_digest,
614                None,
615                owner_id,
616                now_ms,
617                lease_ms,
618            ))
619        })
620        .await
621    }
622
623    async fn complete(
624        &self,
625        dispatch_id: &str,
626        request_digest: &str,
627        owner_id: &str,
628        completed_at_ms: u64,
629    ) -> Result<(), EvaluationDispatchLedgerError> {
630        validate_identity_args(dispatch_id, request_digest, owner_id)?;
631        self.mutate(|records| {
632            complete_record(
633                records,
634                dispatch_id,
635                request_digest,
636                None,
637                owner_id,
638                None,
639                completed_at_ms,
640            )
641        })
642        .await
643    }
644
645    async fn release(
646        &self,
647        dispatch_id: &str,
648        request_digest: &str,
649        owner_id: &str,
650    ) -> Result<(), EvaluationDispatchLedgerError> {
651        validate_identity_args(dispatch_id, request_digest, owner_id)?;
652        self.mutate(|records| release_record(records, dispatch_id, request_digest, None, owner_id))
653            .await
654    }
655
656    async fn claim_with_identity(
657        &self,
658        dispatch_id: &str,
659        request_digest: &str,
660        identity: &ExecutionIdentityV1,
661        owner_id: &str,
662        now_ms: u64,
663        lease_ms: u64,
664    ) -> Result<EvaluationDispatchClaimOutcome, EvaluationDispatchLedgerError> {
665        validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
666        identity
667            .validate()
668            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
669        self.mutate(|records| {
670            claim_record(
671                records,
672                dispatch_id,
673                request_digest,
674                Some(identity),
675                owner_id,
676                now_ms,
677                lease_ms,
678            )
679        })
680        .await
681    }
682
683    async fn renew_with_identity(
684        &self,
685        dispatch_id: &str,
686        request_digest: &str,
687        identity: &ExecutionIdentityV1,
688        owner_id: &str,
689        now_ms: u64,
690        lease_ms: u64,
691    ) -> Result<bool, EvaluationDispatchLedgerError> {
692        validate_claim_args(dispatch_id, request_digest, owner_id, now_ms, lease_ms)?;
693        identity
694            .validate()
695            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
696        self.mutate(|records| {
697            Ok(renew_record(
698                records,
699                dispatch_id,
700                request_digest,
701                Some(identity),
702                owner_id,
703                now_ms,
704                lease_ms,
705            ))
706        })
707        .await
708    }
709
710    async fn complete_with_receipt(
711        &self,
712        dispatch_id: &str,
713        request_digest: &str,
714        identity: &ExecutionIdentityV1,
715        owner_id: &str,
716        receipt: &ExecutionResultReceiptV1,
717        completed_at_ms: u64,
718    ) -> Result<(), EvaluationDispatchLedgerError> {
719        validate_identity_args(dispatch_id, request_digest, owner_id)?;
720        identity
721            .validate()
722            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
723        receipt
724            .validate()
725            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("result_receipt"))?;
726        if &receipt.identity != identity {
727            return Err(EvaluationDispatchLedgerError::Conflict);
728        }
729        self.mutate(|records| {
730            complete_record(
731                records,
732                dispatch_id,
733                request_digest,
734                Some(identity),
735                owner_id,
736                Some(receipt),
737                completed_at_ms,
738            )
739        })
740        .await
741    }
742
743    async fn release_with_identity(
744        &self,
745        dispatch_id: &str,
746        request_digest: &str,
747        identity: &ExecutionIdentityV1,
748        owner_id: &str,
749    ) -> Result<(), EvaluationDispatchLedgerError> {
750        validate_identity_args(dispatch_id, request_digest, owner_id)?;
751        identity
752            .validate()
753            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("execution_identity"))?;
754        self.mutate(|records| {
755            release_record(
756                records,
757                dispatch_id,
758                request_digest,
759                Some(identity),
760                owner_id,
761            )
762        })
763        .await
764    }
765
766    async fn completed_receipt(
767        &self,
768        dispatch_id: &str,
769    ) -> Result<Option<ExecutionResultReceiptV1>, EvaluationDispatchLedgerError> {
770        Ok(self
771            .read_records()
772            .await?
773            .get(dispatch_id)
774            .and_then(|record| record.result_receipt.clone()))
775    }
776
777    async fn prune_completed(
778        &self,
779        before_ms: u64,
780    ) -> Result<usize, EvaluationDispatchLedgerError> {
781        self.mutate(|records| Ok(prune_records(records, before_ms)))
782            .await
783    }
784}
785
786fn validate_claim_args(
787    dispatch_id: &str,
788    request_digest: &str,
789    owner_id: &str,
790    now_ms: u64,
791    lease_ms: u64,
792) -> Result<(), EvaluationDispatchLedgerError> {
793    validate_identity_args(dispatch_id, request_digest, owner_id)?;
794    if now_ms == 0 {
795        return Err(EvaluationDispatchLedgerError::InvalidField("now_ms"));
796    }
797    if lease_ms == 0 {
798        return Err(EvaluationDispatchLedgerError::InvalidField("lease_ms"));
799    }
800    Ok(())
801}
802
803fn validate_identity_args(
804    dispatch_id: &str,
805    request_digest: &str,
806    owner_id: &str,
807) -> Result<(), EvaluationDispatchLedgerError> {
808    validate_text("dispatch_id", dispatch_id)?;
809    validate_text("owner_id", owner_id)?;
810    validate_digest(request_digest)
811        .map_err(|_| EvaluationDispatchLedgerError::InvalidField("request_digest"))
812}
813
814fn validate_text(field: &'static str, value: &str) -> Result<(), EvaluationDispatchLedgerError> {
815    if value.is_empty() || value.len() > 512 || value.contains('\0') || value.lines().count() != 1 {
816        return Err(EvaluationDispatchLedgerError::InvalidField(field));
817    }
818    Ok(())
819}
820
821fn claim_record(
822    records: &mut BTreeMap<String, DispatchClaimRecord>,
823    dispatch_id: &str,
824    request_digest: &str,
825    identity: Option<&ExecutionIdentityV1>,
826    owner_id: &str,
827    now_ms: u64,
828    lease_ms: u64,
829) -> Result<EvaluationDispatchClaimOutcome, EvaluationDispatchLedgerError> {
830    let lease_expires_at_ms = now_ms.saturating_add(lease_ms.max(1));
831    match records.get_mut(dispatch_id) {
832        None => {
833            records.insert(
834                dispatch_id.to_string(),
835                DispatchClaimRecord {
836                    request_digest: request_digest.to_string(),
837                    execution_identity: identity.cloned(),
838                    status: DispatchClaimStatus::Pending,
839                    owner_id: owner_id.to_string(),
840                    lease_expires_at_ms,
841                    attempts: 1,
842                    created_at_ms: now_ms,
843                    completed_at_ms: None,
844                    result_receipt: None,
845                },
846            );
847            Ok(EvaluationDispatchClaimOutcome::Claimed { attempt: 1 })
848        }
849        Some(record) if record.request_digest != request_digest => {
850            Ok(EvaluationDispatchClaimOutcome::Conflict)
851        }
852        Some(record)
853            if identity.is_some_and(|identity| {
854                record
855                    .execution_identity
856                    .as_ref()
857                    .is_some_and(|record_identity| record_identity != identity)
858            }) =>
859        {
860            Ok(EvaluationDispatchClaimOutcome::Conflict)
861        }
862        Some(record) if record.status == DispatchClaimStatus::Completed => {
863            Ok(EvaluationDispatchClaimOutcome::Completed)
864        }
865        Some(record) if record.lease_expires_at_ms > now_ms && record.owner_id != owner_id => {
866            Ok(EvaluationDispatchClaimOutcome::Busy {
867                lease_expires_at_ms: record.lease_expires_at_ms,
868            })
869        }
870        Some(record) => {
871            if record.execution_identity.is_none() {
872                record.execution_identity = identity.cloned();
873            }
874            record.owner_id = owner_id.to_string();
875            record.lease_expires_at_ms = lease_expires_at_ms;
876            record.attempts = record.attempts.saturating_add(1);
877            Ok(EvaluationDispatchClaimOutcome::Claimed {
878                attempt: record.attempts,
879            })
880        }
881    }
882}
883
884fn renew_record(
885    records: &mut BTreeMap<String, DispatchClaimRecord>,
886    dispatch_id: &str,
887    request_digest: &str,
888    identity: Option<&ExecutionIdentityV1>,
889    owner_id: &str,
890    now_ms: u64,
891    lease_ms: u64,
892) -> bool {
893    let Some(record) = records.get_mut(dispatch_id) else {
894        return false;
895    };
896    if record.request_digest != request_digest
897        || identity.is_some_and(|identity| {
898            record
899                .execution_identity
900                .as_ref()
901                .is_some_and(|record_identity| record_identity != identity)
902        })
903        || record.status != DispatchClaimStatus::Pending
904        || record.owner_id != owner_id
905        || record.lease_expires_at_ms <= now_ms
906    {
907        return false;
908    }
909    record.lease_expires_at_ms = now_ms.saturating_add(lease_ms.max(1));
910    true
911}
912
913fn complete_record(
914    records: &mut BTreeMap<String, DispatchClaimRecord>,
915    dispatch_id: &str,
916    request_digest: &str,
917    identity: Option<&ExecutionIdentityV1>,
918    owner_id: &str,
919    result_receipt: Option<&ExecutionResultReceiptV1>,
920    completed_at_ms: u64,
921) -> Result<(), EvaluationDispatchLedgerError> {
922    let record = records
923        .get_mut(dispatch_id)
924        .ok_or(EvaluationDispatchLedgerError::Conflict)?;
925    if record.request_digest != request_digest {
926        return Err(EvaluationDispatchLedgerError::Conflict);
927    }
928    if identity.is_some_and(|identity| {
929        record
930            .execution_identity
931            .as_ref()
932            .is_some_and(|record_identity| record_identity != identity)
933    }) {
934        return Err(EvaluationDispatchLedgerError::Conflict);
935    }
936    if record.status == DispatchClaimStatus::Completed {
937        if let (Some(expected), Some(actual)) = (record.result_receipt.as_ref(), result_receipt) {
938            if expected != actual {
939                return Err(EvaluationDispatchLedgerError::Conflict);
940            }
941        }
942        return Ok(());
943    }
944    if record.owner_id != owner_id {
945        return Err(EvaluationDispatchLedgerError::Conflict);
946    }
947    if completed_at_ms < record.created_at_ms {
948        return Err(EvaluationDispatchLedgerError::InvalidField(
949            "completed_at_ms",
950        ));
951    }
952    // A completion after lease expiry is stale. Without this fence, a
953    // suspended worker could publish a terminal receipt after another worker
954    // is entitled to take over the dispatch.
955    if record.lease_expires_at_ms <= completed_at_ms {
956        return Err(EvaluationDispatchLedgerError::Conflict);
957    }
958    if let Some(identity) = identity {
959        record.execution_identity = Some(identity.clone());
960    }
961    record.result_receipt = result_receipt.cloned();
962    record.status = DispatchClaimStatus::Completed;
963    record.lease_expires_at_ms = 0;
964    record.completed_at_ms = Some(completed_at_ms);
965    Ok(())
966}
967
968fn release_record(
969    records: &mut BTreeMap<String, DispatchClaimRecord>,
970    dispatch_id: &str,
971    request_digest: &str,
972    identity: Option<&ExecutionIdentityV1>,
973    owner_id: &str,
974) -> Result<(), EvaluationDispatchLedgerError> {
975    let Some(record) = records.get_mut(dispatch_id) else {
976        return Ok(());
977    };
978    if record.request_digest != request_digest
979        || identity.is_some_and(|identity| {
980            record
981                .execution_identity
982                .as_ref()
983                .is_some_and(|record_identity| record_identity != identity)
984        })
985        || record.status == DispatchClaimStatus::Completed
986    {
987        return Ok(());
988    }
989    if record.owner_id == owner_id {
990        record.owner_id.clear();
991        record.lease_expires_at_ms = 0;
992    }
993    Ok(())
994}
995
996fn prune_records(records: &mut BTreeMap<String, DispatchClaimRecord>, before_ms: u64) -> usize {
997    let before = records.len();
998    records.retain(|_, record| {
999        record.status != DispatchClaimStatus::Completed
1000            || record.completed_at_ms.unwrap_or(u64::MAX) >= before_ms
1001    });
1002    before - records.len()
1003}
1004
1005fn enforce_retention(
1006    records: &mut BTreeMap<String, DispatchClaimRecord>,
1007    max_records: usize,
1008) -> Result<(), EvaluationDispatchLedgerError> {
1009    while records.len() > max_records {
1010        let candidate = records
1011            .iter()
1012            .filter(|(_, record)| record.status == DispatchClaimStatus::Completed)
1013            .min_by_key(|(id, record)| (record.completed_at_ms.unwrap_or(u64::MAX), id.as_str()))
1014            .map(|(id, _)| id.clone());
1015        let Some(candidate) = candidate else {
1016            return Err(EvaluationDispatchLedgerError::SizeLimit);
1017        };
1018        records.remove(&candidate);
1019    }
1020    Ok(())
1021}
1022
1023async fn read_records_from_path(
1024    path: &Path,
1025    max_records: usize,
1026) -> Result<BTreeMap<String, DispatchClaimRecord>, EvaluationDispatchLedgerError> {
1027    let bytes = match crate::bounded_io::read_file_bounded_async(
1028        path,
1029        EVALUATION_DISPATCH_LEDGER_MAX_BYTES,
1030    )
1031    .await
1032    {
1033        Ok(bytes) => bytes,
1034        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
1035        Err(error) if error.kind() == std::io::ErrorKind::InvalidData => {
1036            return Err(EvaluationDispatchLedgerError::SizeLimit)
1037        }
1038        Err(error) => return Err(storage_error("read dispatch ledger", error)),
1039    };
1040    let file: DispatchLedgerFileV1 = serde_json::from_slice(&bytes).map_err(|error| {
1041        EvaluationDispatchLedgerError::Corrupt(format!("decode ledger: {error}"))
1042    })?;
1043    if file.schema != EVALUATION_DISPATCH_LEDGER_SCHEMA_V1 {
1044        return Err(EvaluationDispatchLedgerError::UnsupportedSchema);
1045    }
1046    if file.records.len() > max_records {
1047        return Err(EvaluationDispatchLedgerError::Corrupt(format!(
1048            "{} records exceed configured retention limit {}",
1049            file.records.len(),
1050            max_records
1051        )));
1052    }
1053    for (dispatch_id, record) in &file.records {
1054        validate_text("dispatch_id", dispatch_id)?;
1055        validate_digest(&record.request_digest)
1056            .map_err(|_| EvaluationDispatchLedgerError::InvalidField("request_digest"))?;
1057        if let Some(identity) = &record.execution_identity {
1058            identity.validate().map_err(|_| {
1059                EvaluationDispatchLedgerError::Corrupt("execution identity is invalid".to_string())
1060            })?;
1061        }
1062        if let Some(receipt) = &record.result_receipt {
1063            receipt.validate().map_err(|_| {
1064                EvaluationDispatchLedgerError::Corrupt("result receipt is invalid".to_string())
1065            })?;
1066            if record
1067                .execution_identity
1068                .as_ref()
1069                .is_some_and(|identity| identity != &receipt.identity)
1070            {
1071                return Err(EvaluationDispatchLedgerError::Corrupt(
1072                    "result receipt identity does not match claim".to_string(),
1073                ));
1074            }
1075        }
1076        if !record.owner_id.is_empty() {
1077            validate_text("owner_id", &record.owner_id)?;
1078        }
1079        if record.created_at_ms == 0
1080            || record.attempts == 0
1081            || record
1082                .completed_at_ms
1083                .is_some_and(|completed| completed < record.created_at_ms)
1084        {
1085            return Err(EvaluationDispatchLedgerError::Corrupt(
1086                "claim record timestamps or attempts are invalid".to_string(),
1087            ));
1088        }
1089        match record.status {
1090            DispatchClaimStatus::Pending
1091                if record.completed_at_ms.is_some()
1092                    || record.result_receipt.is_some()
1093                    || (record.owner_id.is_empty() && record.lease_expires_at_ms != 0)
1094                    || (!record.owner_id.is_empty() && record.lease_expires_at_ms == 0) =>
1095            {
1096                return Err(EvaluationDispatchLedgerError::Corrupt(
1097                    "pending claim fields are inconsistent".to_string(),
1098                ));
1099            }
1100            DispatchClaimStatus::Completed
1101                if record.completed_at_ms.is_none()
1102                    || record.lease_expires_at_ms != 0
1103                    || record.owner_id.is_empty() =>
1104            {
1105                return Err(EvaluationDispatchLedgerError::Corrupt(
1106                    "completed claim fields are inconsistent".to_string(),
1107                ));
1108            }
1109            _ => {}
1110        }
1111    }
1112    Ok(file.records)
1113}
1114
1115fn storage_error(operation: &str, error: impl std::fmt::Display) -> EvaluationDispatchLedgerError {
1116    EvaluationDispatchLedgerError::Storage(format!("{operation}: {error}"))
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121    use super::*;
1122    use crate::execution_identity::{ExecutionResultOutcomeV1, EXECUTION_RESULT_RECEIPT_SCHEMA_V1};
1123
1124    fn identity(domain: &str) -> ExecutionIdentityV1 {
1125        ExecutionIdentityV1::derive(domain, &serde_json::json!({"request": "bounded"})).unwrap()
1126    }
1127
1128    fn receipt(identity: ExecutionIdentityV1) -> ExecutionResultReceiptV1 {
1129        ExecutionResultReceiptV1 {
1130            schema: EXECUTION_RESULT_RECEIPT_SCHEMA_V1.to_string(),
1131            identity,
1132            evidence_digest:
1133                "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1134                    .to_string(),
1135            outcome: ExecutionResultOutcomeV1::Succeeded,
1136            result_digest: Some(
1137                "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
1138                    .to_string(),
1139            ),
1140            result_bytes: 1,
1141        }
1142    }
1143
1144    #[test]
1145    fn legacy_claim_record_deserializes_without_identity_or_receipt() {
1146        let value = serde_json::json!({
1147            "request_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
1148            "status": "pending",
1149            "owner_id": "owner-1",
1150            "lease_expires_at_ms": 101,
1151            "attempts": 1,
1152            "created_at_ms": 1
1153        });
1154        let record: DispatchClaimRecord = serde_json::from_value(value).unwrap();
1155        assert!(record.execution_identity.is_none());
1156        assert!(record.result_receipt.is_none());
1157    }
1158
1159    #[tokio::test]
1160    async fn identity_fences_claim_renewal_and_terminal_receipt() {
1161        let ledger = MemoryEvaluationDispatchLedger::new();
1162        let canonical = identity("a3s.test.identity");
1163        let wrong = identity("a3s.test.other");
1164        let request_digest =
1165            "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
1166
1167        assert!(matches!(
1168            ledger
1169                .claim_with_identity("dispatch-1", request_digest, &canonical, "owner-1", 1, 100)
1170                .await
1171                .unwrap(),
1172            EvaluationDispatchClaimOutcome::Claimed { attempt: 1 }
1173        ));
1174        assert!(!ledger
1175            .renew_with_identity("dispatch-1", request_digest, &wrong, "owner-1", 2, 100)
1176            .await
1177            .unwrap());
1178        assert!(ledger
1179            .renew_with_identity("dispatch-1", request_digest, &canonical, "owner-1", 2, 100)
1180            .await
1181            .unwrap());
1182
1183        let result = receipt(canonical.clone());
1184        ledger
1185            .complete_with_receipt(
1186                "dispatch-1",
1187                request_digest,
1188                &canonical,
1189                "owner-1",
1190                &result,
1191                3,
1192            )
1193            .await
1194            .unwrap();
1195        assert_eq!(
1196            ledger.completed_receipt("dispatch-1").await.unwrap(),
1197            Some(result)
1198        );
1199        assert!(matches!(
1200            ledger
1201                .complete_with_receipt(
1202                    "dispatch-1",
1203                    request_digest,
1204                    &wrong,
1205                    "owner-1",
1206                    &receipt(wrong.clone()),
1207                    4,
1208                )
1209                .await,
1210            Err(EvaluationDispatchLedgerError::Conflict)
1211        ));
1212    }
1213}