返回 CodeWhale
ledger.rs
根目录 / crates / tui / src / tools / subagent / coord / ledger.rs
1 //! Durable coordination records for delegated Work (#4647).
2 //!
3 //! Split out of `coord.rs` unchanged (#5462): the decision/claim/contention
4 //! ledger and its receipt types are the half of that file with consumers
5 //! outside the tool layer — `tui::coordination_detail`, `tui::work_surface`,
6 //! `tui::ui::tests`, and `core::engine::tests` all name these types — while
7 //! the tool wrappers around them are model-surface code. Keeping both in one
8 //! 3.8k-line file meant every read of either started by scrolling past the
9 //! other.
10 //!
11 //! This is a pure move with re-exports: `coord` re-publishes every public item
12 //! under its original path, so no consumer needed an edited import and no
13 //! behavior changed. New coordination *state* belongs here; new coordination
14 //! *tools* belong in `coord.rs`.
15
16 use std::collections::{BTreeSet, HashMap};
17
18 use serde::{Deserialize, Serialize};
19 use serde_json::Value;
20
21 use super::{
22 COORDINATION_PROJECTION_BYTE_LIMIT, COORDINATION_PROJECTION_DECISION_LIMIT,
23 COORDINATION_RECORD_LIMIT,
24 };
25 use crate::tools::subagent::normalize_claim_path;
26
27 /// Coordination records for delegated Work (#4647).
28 ///
29 /// Decision records, write-scope claims, and contention detection for parallel
30 /// agent work. Parallel work may proceed only when scopes and contracts do not
31 /// collide silently.
32 /// Status of a coordination decision.
33 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34 #[serde(rename_all = "snake_case")]
35 pub enum DecisionStatus {
36 Proposed,
37 Accepted,
38 Superseded,
39 }
40
41 /// Serialized coordination state schema. Increment only with an explicit
42 /// migration; restart/replay must never infer a newer contract from old data.
43 pub const COORDINATION_SCHEMA_VERSION: u32 = 1;
44
45 pub(super) const MAX_RECONCILIATION_RETRIES: u32 = 3;
46
47 const fn coordination_schema_version() -> u32 {
48 COORDINATION_SCHEMA_VERSION
49 }
50
51 /// A bounded coordination decision record (#4647).
52 ///
53 /// Persisted with stable subject, concise constraints, one active owner,
54 /// applicability scope, evidence handles, and sequence/version.
55 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56 pub struct DecisionRecord {
57 pub decision_id: String,
58 pub subject: String,
59 pub status: DecisionStatus,
60 pub owner: String,
61 pub scope: Vec<String>,
62 pub constraints: Vec<String>,
63 pub evidence_handles: Vec<String>,
64 pub version: u32,
65 pub sequence: u64,
66 }
67
68 /// A write-scope claim for a write-capable child (#4647).
69 ///
70 /// Declares expected repo-relative paths/trees and named contracts.
71 /// This is coordination metadata, not another approval system.
72 ///
73 /// An `exact_files` entry beneath a declared root binds that root to the
74 /// files listed under it (#6278): peers may then share the root with
75 /// disjoint file claims, and the claim's authorized surface is what
76 /// [`WriteScopeClaim::contains_path`] and [`WriteScopeClaim::overlaps`]
77 /// both see.
78 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
79 pub struct WriteScopeClaim {
80 pub owner: String,
81 pub roots: Vec<String>,
82 pub exact_files: Vec<String>,
83 pub contracts: Vec<String>,
84 }
85
86 impl WriteScopeClaim {
87 /// Roots that authorize their whole tree. A root with declared
88 /// `exact_files` beneath it is bound to those files instead: the files
89 /// are the real exclusion boundary, so peers may share one root while
90 /// claiming disjoint outputs (#6278). A root carrying no declared file
91 /// keeps tree-wide authority, and an exact file outside every root
92 /// stands alone.
93 fn open_roots(&self) -> impl Iterator<Item = &String> {
94 self.roots.iter().filter(|root| {
95 !self
96 .exact_files
97 .iter()
98 .any(|file| paths_overlap_by_containment(root, file))
99 })
100 }
101
102 /// Check whether this claim overlaps with another. A claim overlaps when
103 /// either normalized open tree contains the other or exact files collide.
104 #[must_use]
105 pub fn overlaps(&self, other: &WriteScopeClaim) -> bool {
106 for root_a in self.open_roots() {
107 for root_b in other.open_roots() {
108 if paths_overlap_by_containment(root_a, root_b)
109 || paths_overlap_by_containment(root_b, root_a)
110 {
111 return true;
112 }
113 }
114 }
115 for file_a in &self.exact_files {
116 if other
117 .exact_files
118 .iter()
119 .any(|file| paths_overlap_equal(file, file_a))
120 || other
121 .open_roots()
122 .any(|root| paths_overlap_by_containment(root, file_a))
123 {
124 return true;
125 }
126 }
127 for file_b in &other.exact_files {
128 if self
129 .open_roots()
130 .any(|root| paths_overlap_by_containment(root, file_b))
131 {
132 return true;
133 }
134 }
135 if self
136 .contracts
137 .iter()
138 .any(|contract| other.contracts.iter().any(|other| other == contract))
139 {
140 return true;
141 }
142 false
143 }
144
145 #[must_use]
146 pub fn contains_path(&self, path: &str) -> bool {
147 self.exact_files.iter().any(|file| file == path)
148 || self.open_roots().any(|root| path_contains(root, path))
149 }
150 }
151
152 fn path_contains(root: &str, candidate: &str) -> bool {
153 let root = root.trim_end_matches('/');
154 let candidate = candidate.trim_end_matches('/');
155 root == "."
156 || root == candidate
157 || candidate
158 .strip_prefix(root)
159 .is_some_and(|suffix| suffix.starts_with('/'))
160 }
161
162 fn paths_overlap_equal(left: &str, right: &str) -> bool {
163 left == right || left.to_lowercase() == right.to_lowercase()
164 }
165
166 fn paths_overlap_by_containment(root: &str, candidate: &str) -> bool {
167 path_contains(root, candidate) || path_contains(&root.to_lowercase(), &candidate.to_lowercase())
168 }
169
170 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171 pub struct PersistedWriteClaim {
172 pub claim: WriteScopeClaim,
173 pub sequence: u64,
174 #[serde(default)]
175 pub isolated_worktree: bool,
176 /// Which of this claim's roots/exact files existed on disk when it was
177 /// registered (#5906).
178 ///
179 /// Recorded because "the claimed path is gone" and "the claimed path does
180 /// not exist yet" look identical from a later `exists()` call and mean
181 /// opposite things. A claim on a worktree that has since been removed
182 /// describes work nobody can be doing and must stop refusing claimants; a
183 /// claim on a directory its owner is about to create is exactly the
184 /// forward-looking reservation the ledger exists to honor, and disarming
185 /// that would let two writers into the same new tree.
186 ///
187 /// Empty for claims persisted before this field existed and for claims
188 /// that named nothing on disk at the time — both mean "no evidence of
189 /// disappearance", so neither is ever treated as stale.
190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub present_at_claim: Vec<String>,
192 }
193
194 impl PersistedWriteClaim {
195 /// Whether every path this claim was recorded as actually covering has
196 /// since disappeared from disk (#5906).
197 ///
198 /// Isolated-worktree claims are excluded because they never contend at
199 /// all — the answer would be unused, and their paths are not resolved
200 /// against the coordination root.
201 pub fn names_only_vanished_paths<F>(&self, mut path_exists: F) -> bool
202 where
203 F: FnMut(&str) -> bool,
204 {
205 !self.isolated_worktree
206 && !self.present_at_claim.is_empty()
207 && !self
208 .present_at_claim
209 .iter()
210 .any(|path| path_exists(path.as_str()))
211 }
212 }
213
214 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
215 pub struct ReconciliationReceipt {
216 pub reconciliation_id: String,
217 pub subject: String,
218 pub owner: String,
219 pub input_decisions: Vec<String>,
220 pub outcome: String,
221 pub evidence_handles: Vec<String>,
222 /// Preserved candidate branches, patches, or artifact handles. A fan-in
223 /// receipt is not valid if either conflicting candidate was discarded.
224 #[serde(default)]
225 pub candidate_handles: Vec<String>,
226 #[serde(default)]
227 pub retry_count: u32,
228 #[serde(default)]
229 pub retry_limit: u32,
230 #[serde(default)]
231 pub reviewer_evidence_handles: Vec<String>,
232 #[serde(default)]
233 pub verifier_evidence_handles: Vec<String>,
234 #[serde(default)]
235 pub verification_outcome: String,
236 pub sequence: u64,
237 }
238
239 /// Durable receipt for the minimal accepted-decision context projected into a
240 /// child. It records counts and stable ids, never the child's transcript.
241 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
242 pub struct ContextProjectionReceipt {
243 pub child_id: String,
244 pub decision_ids: Vec<String>,
245 pub projected_bytes: usize,
246 /// Repeated constraint facts elided across otherwise distinct decisions.
247 /// Decision records themselves are never collapsed by this count.
248 pub deduplicated: usize,
249 /// Relevant unique decisions omitted solely because the hard count or
250 /// byte bound was reached. This must not be conflated with deduplication.
251 #[serde(default)]
252 pub omitted: usize,
253 pub sequence: u64,
254 }
255
256 /// Admission outcome persisted with a write-contention receipt.
257 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
258 #[serde(rename_all = "snake_case")]
259 pub enum WriteContentionDisposition {
260 BlockedPendingIsolationOrSerialization,
261 ResolvedBySuccessfulClaim,
262 }
263
264 impl WriteContentionDisposition {
265 #[must_use]
266 pub const fn as_str(self) -> &'static str {
267 match self {
268 Self::BlockedPendingIsolationOrSerialization => {
269 "blocked_pending_isolation_or_serialization"
270 }
271 Self::ResolvedBySuccessfulClaim => "resolved_by_successful_claim",
272 }
273 }
274
275 #[must_use]
276 pub const fn blocks_admission(self) -> bool {
277 matches!(self, Self::BlockedPendingIsolationOrSerialization)
278 }
279 }
280
281 /// Durable non-secret receipt emitted when two active shared-workspace claims
282 /// collide. Rejected scope expansion remains visible after restart.
283 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
284 pub struct WriteContentionReceipt {
285 pub claimant: String,
286 pub conflicting_owner: String,
287 pub roots: Vec<String>,
288 pub exact_files: Vec<String>,
289 pub contracts: Vec<String>,
290 pub disposition: WriteContentionDisposition,
291 /// Sequence of the later successful claim that resolved this receipt.
292 /// It intentionally references that claim's sequence instead of consuming
293 /// another ledger sequence.
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub resolution_sequence: Option<u64>,
296 pub sequence: u64,
297 }
298
299 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
300 pub struct CoordinationHotPath {
301 pub path: String,
302 pub active_claims: usize,
303 }
304
305 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
306 pub struct CoordinationDetailMetrics {
307 pub hottest_paths: Vec<CoordinationHotPath>,
308 pub package_or_module_growth: Option<Value>,
309 pub route_or_cost: Option<Value>,
310 pub note: String,
311 }
312
313 /// One bounded typed projection shared by headless inspection and the TUI.
314 /// It contains durable coordination facts only, never raw reasoning or a
315 /// delegated transcript.
316 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
317 pub struct CoordinationDetailProjection {
318 pub schema_version: u32,
319 pub sequence: u64,
320 pub decisions: Vec<DecisionRecord>,
321 pub write_claims: Vec<PersistedWriteClaim>,
322 pub reconciliations: Vec<ReconciliationReceipt>,
323 pub context_projections: Vec<ContextProjectionReceipt>,
324 pub contentions: Vec<WriteContentionReceipt>,
325 pub metrics: CoordinationDetailMetrics,
326 pub bounded: bool,
327 pub limit: usize,
328 /// Whether this process currently holds the workspace coordination flock.
329 /// When false, durable ledger writes are skipped and the UI must say so —
330 /// a counter must never tick on a turn the engine has already settled.
331 #[serde(default = "default_process_lock_held")]
332 pub process_lock_held: bool,
333 /// Human-readable reason when [`Self::process_lock_held`] is false.
334 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub process_lock_note: Option<String>,
336 }
337
338 fn default_process_lock_held() -> bool {
339 // Legacy projections (tests, older sessions) assume the lock is held so
340 // they do not spuriously light the unavailable banner.
341 true
342 }
343
344 /// Durable, bounded coordination state owned by `SubAgentManager`.
345 #[derive(Debug, Clone, Serialize, Deserialize)]
346 pub struct CoordinationLedger {
347 #[serde(default = "coordination_schema_version")]
348 pub schema_version: u32,
349 #[serde(default)]
350 pub sequence: u64,
351 #[serde(default)]
352 pub decisions: Vec<DecisionRecord>,
353 #[serde(default)]
354 pub write_claims: Vec<PersistedWriteClaim>,
355 #[serde(default)]
356 pub reconciliations: Vec<ReconciliationReceipt>,
357 #[serde(default)]
358 pub projections: Vec<ContextProjectionReceipt>,
359 #[serde(default)]
360 pub contentions: Vec<WriteContentionReceipt>,
361 /// Root-session provenance for records whose logical owner (`root`) is
362 /// otherwise shared by every conversation in a workspace. Agent-owned
363 /// records can also be stamped here, but may be resolved through their
364 /// immutable `SubAgent.owner_session_id`. Missing legacy provenance is
365 /// never guessed by active-session views.
366 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
367 pub record_sessions: HashMap<u64, String>,
368 }
369
370 impl Default for CoordinationLedger {
371 fn default() -> Self {
372 Self {
373 schema_version: COORDINATION_SCHEMA_VERSION,
374 sequence: 0,
375 decisions: Vec::new(),
376 write_claims: Vec::new(),
377 reconciliations: Vec::new(),
378 projections: Vec::new(),
379 contentions: Vec::new(),
380 record_sessions: HashMap::new(),
381 }
382 }
383 }
384
385 impl CoordinationLedger {
386 fn next_sequence(&mut self) -> u64 {
387 self.sequence = self.sequence.saturating_add(1);
388 self.sequence
389 }
390
391 pub fn record_decision(
392 &mut self,
393 mut decision: DecisionRecord,
394 ) -> Result<DecisionRecord, String> {
395 self.validate_schema()?;
396 decision.decision_id = decision.decision_id.trim().to_string();
397 if !decision.decision_id.is_empty() {
398 decision.decision_id = bounded_coordination_atom("decision id", &decision.decision_id)?;
399 }
400 decision.subject = bounded_coordination_atom("decision subject", &decision.subject)?;
401 decision.owner = bounded_coordination_atom("decision owner", &decision.owner)?;
402 decision.scope = normalize_coordination_values("decision scope", &decision.scope, 24)?;
403 decision.constraints =
404 normalize_coordination_values("decision constraints", &decision.constraints, 24)?;
405 decision.evidence_handles = normalize_coordination_values(
406 "decision evidence handles",
407 &decision.evidence_handles,
408 24,
409 )?;
410 reject_sensitive_coordination_values(&decision.constraints)?;
411 reject_sensitive_coordination_values(&decision.evidence_handles)?;
412 if decision.subject.trim().is_empty() || decision.owner.trim().is_empty() {
413 return Err("decision subject and owner are required".to_string());
414 }
415 if !decision.decision_id.trim().is_empty()
416 && self
417 .decisions
418 .iter()
419 .any(|existing| existing.decision_id == decision.decision_id)
420 {
421 return Err(format!(
422 "decision id '{}' already exists",
423 decision.decision_id
424 ));
425 }
426 if decision.status == DecisionStatus::Accepted
427 && let Some(existing) = self.decisions.iter().find(|existing| {
428 existing.subject == decision.subject
429 && existing.status == DecisionStatus::Accepted
430 && existing.decision_id != decision.decision_id
431 })
432 {
433 return Err(format!(
434 "subject '{}' already has accepted decision '{}' owned by '{}'; preserve both candidates and use neutral reconciliation",
435 decision.subject, existing.decision_id, existing.owner
436 ));
437 }
438 let next_version = self
439 .decisions
440 .iter()
441 .filter(|existing| existing.subject == decision.subject)
442 .map(|existing| existing.version)
443 .max()
444 .unwrap_or(0)
445 .saturating_add(1);
446 decision.version = decision.version.max(next_version);
447 decision.sequence = self.next_sequence();
448 if decision.decision_id.trim().is_empty() {
449 decision.decision_id = format!("decision_{}", decision.sequence);
450 }
451 self.decisions.push(decision.clone());
452 if self.decisions.len() > COORDINATION_RECORD_LIMIT {
453 let referenced = self
454 .reconciliations
455 .iter()
456 .flat_map(|receipt| receipt.input_decisions.iter())
457 .cloned()
458 .collect::<BTreeSet<_>>();
459 if let Some(index) = self.decisions.iter().position(|existing| {
460 existing.status != DecisionStatus::Accepted
461 && !referenced.contains(&existing.decision_id)
462 }) {
463 self.decisions.remove(index);
464 } else {
465 self.decisions.pop();
466 return Err(
467 "coordination decision capacity is occupied by accepted or reconciled records"
468 .to_string(),
469 );
470 }
471 }
472 Ok(decision)
473 }
474
475 pub fn update_decision_status(
476 &mut self,
477 decision_id: &str,
478 status: DecisionStatus,
479 owner: &str,
480 expected_version: u32,
481 ) -> Result<DecisionRecord, String> {
482 self.validate_schema()?;
483 let Some(index) = self
484 .decisions
485 .iter()
486 .position(|decision| decision.decision_id == decision_id)
487 else {
488 return Err(format!("decision '{decision_id}' not found"));
489 };
490 if self.decisions[index].owner != owner {
491 return Err(format!(
492 "decision '{decision_id}' is owned by '{}'; caller '{owner}' cannot change it",
493 self.decisions[index].owner
494 ));
495 }
496 if self.decisions[index].version != expected_version {
497 return Err(format!(
498 "decision '{decision_id}' version changed: expected {expected_version}, current {}",
499 self.decisions[index].version
500 ));
501 }
502 let subject = self.decisions[index].subject.clone();
503 if status == DecisionStatus::Accepted
504 && let Some(existing) =
505 self.decisions
506 .iter()
507 .enumerate()
508 .find_map(|(other_index, existing)| {
509 (other_index != index
510 && existing.subject == subject
511 && existing.status == DecisionStatus::Accepted)
512 .then_some(existing)
513 })
514 {
515 return Err(format!(
516 "subject '{subject}' already has accepted decision '{}' owned by '{}'; preserve both candidates and use neutral reconciliation",
517 existing.decision_id, existing.owner
518 ));
519 }
520 let sequence = self.next_sequence();
521 let decision = &mut self.decisions[index];
522 decision.status = status;
523 decision.version = decision.version.saturating_add(1);
524 decision.sequence = sequence;
525 Ok(decision.clone())
526 }
527
528 /// Register a bounded write claim.
529 ///
530 /// `owner_is_active` is the caller's whole answer to "may this existing
531 /// claim refuse a new claimant" — liveness and, since #5906, whether the
532 /// claim's recorded paths still exist. The ledger has no filesystem of its
533 /// own; [`super::SubAgentManager::admissible_coordination_owners`] resolves
534 /// both before calling, and stamps
535 /// [`PersistedWriteClaim::present_at_claim`] on the record afterwards.
536 pub fn register_claim<F>(
537 &mut self,
538 mut claim: WriteScopeClaim,
539 isolated_worktree: bool,
540 mut owner_is_active: F,
541 ) -> Result<PersistedWriteClaim, String>
542 where
543 F: FnMut(&str) -> bool,
544 {
545 self.validate_schema()?;
546 claim.owner = bounded_coordination_atom("write claim owner", &claim.owner)?;
547 claim.roots = normalize_claim_paths(&claim.roots)?;
548 claim.exact_files = normalize_claim_paths(&claim.exact_files)?;
549 claim.contracts = normalize_claim_strings(&claim.contracts, 16, 128, "contracts")?;
550 if claim.roots.is_empty() && claim.exact_files.is_empty() && claim.contracts.is_empty() {
551 return Err(
552 "write claim requires an owner and at least one root, file, or contract"
553 .to_string(),
554 );
555 }
556 let replacing_existing_owner = self
557 .write_claims
558 .iter()
559 .any(|existing| existing.claim.owner == claim.owner);
560 if !replacing_existing_owner && self.write_claims.len() >= COORDINATION_RECORD_LIMIT {
561 let mut inactive = Vec::new();
562 for existing in &self.write_claims {
563 if !owner_is_active(&existing.claim.owner) {
564 inactive.push((existing.sequence, existing.claim.owner.clone()));
565 }
566 }
567 inactive.sort_by_key(|(sequence, _)| *sequence);
568 for (_, owner) in inactive {
569 if self.write_claims.len() < COORDINATION_RECORD_LIMIT {
570 break;
571 }
572 self.write_claims
573 .retain(|existing| existing.claim.owner != owner);
574 }
575 if self.write_claims.len() >= COORDINATION_RECORD_LIMIT {
576 return Err(format!(
577 "write-claim capacity is {COORDINATION_RECORD_LIMIT} active owners; complete, serialize, or isolate existing work before admitting another writer"
578 ));
579 }
580 }
581 if !isolated_worktree
582 && let Some(existing) = self
583 .write_claims
584 .iter()
585 .find(|existing| {
586 !existing.isolated_worktree
587 && existing.claim.owner != claim.owner
588 && owner_is_active(&existing.claim.owner)
589 && existing.claim.overlaps(&claim)
590 })
591 .cloned()
592 {
593 let receipt = WriteContentionReceipt {
594 claimant: claim.owner.clone(),
595 conflicting_owner: existing.claim.owner.clone(),
596 roots: claim.roots.clone(),
597 exact_files: claim.exact_files.clone(),
598 contracts: claim.contracts.clone(),
599 disposition: WriteContentionDisposition::BlockedPendingIsolationOrSerialization,
600 resolution_sequence: None,
601 sequence: self.next_sequence(),
602 };
603 self.contentions.push(receipt);
604 trim_front(&mut self.contentions, COORDINATION_RECORD_LIMIT);
605 return Err(format!(
606 "write-scope contention with {}: requested roots {:?}, files {:?}, contracts {:?} overlap its writable roots {:?}, files {:?}, contracts {:?}. Claim disjoint sibling write_roots (for example tmp/scan/worker-a and tmp/scan/worker-b) or exact_files for each output. A read-only worker uses write_authority=read_only without a write claim. Otherwise serialize the writers (wait for that owner to settle, or cancel it) or use worktree isolation; a nested path inside an existing writable root still overlaps.",
607 existing.claim.owner,
608 claim.roots,
609 claim.exact_files,
610 claim.contracts,
611 existing.claim.roots,
612 existing.claim.exact_files,
613 existing.claim.contracts
614 ));
615 }
616 self.write_claims
617 .retain(|existing| existing.claim.owner != claim.owner);
618 let record = PersistedWriteClaim {
619 claim,
620 sequence: self.next_sequence(),
621 isolated_worktree,
622 // Stamped by the caller, which owns the filesystem.
623 present_at_claim: Vec::new(),
624 };
625 for contention in &mut self.contentions {
626 if contention.claimant == record.claim.owner
627 && contention.disposition.blocks_admission()
628 {
629 contention.disposition = WriteContentionDisposition::ResolvedBySuccessfulClaim;
630 contention.resolution_sequence = Some(record.sequence);
631 }
632 }
633 self.write_claims.push(record.clone());
634 Ok(record)
635 }
636
637 /// Release write claims whose owner is not a live claimant (#5562).
638 ///
639 /// Claims are durable records and legitimately outlive the agents that
640 /// registered them; that is fine for history, but a stale claim must never
641 /// keep blocking a later writer. An optional owner restricts the sweep to
642 /// one claim owner; live claimants are never removed. Returns the released
643 /// owners (one entry per claim, in ledger order).
644 pub fn release_stale_claims<F>(
645 &mut self,
646 owner: Option<&str>,
647 mut owner_is_active: F,
648 ) -> Result<Vec<String>, String>
649 where
650 F: FnMut(&str) -> bool,
651 {
652 self.validate_schema()?;
653 let owner_filter = match owner {
654 Some(value) => Some(bounded_coordination_atom("write claim owner", value)?),
655 None => None,
656 };
657 let mut released = Vec::new();
658 self.write_claims.retain(|record| {
659 if owner_filter
660 .as_deref()
661 .is_some_and(|wanted| wanted != record.claim.owner)
662 {
663 return true;
664 }
665 if owner_is_active(&record.claim.owner) {
666 return true;
667 }
668 released.push(record.claim.owner.clone());
669 false
670 });
671 // A release is a mutation: advance the ledger sequence so receipts
672 // stamped by the caller stay coherent (coordinator contract).
673 if !released.is_empty() {
674 self.next_sequence();
675 }
676 Ok(released)
677 }
678
679 #[allow(clippy::too_many_arguments)]
680 pub fn reconcile(
681 &mut self,
682 subject: String,
683 owner: String,
684 input_decisions: Vec<String>,
685 outcome: String,
686 evidence_handles: Vec<String>,
687 candidate_handles: Vec<String>,
688 retry_count: u32,
689 retry_limit: u32,
690 reviewer_evidence_handles: Vec<String>,
691 verifier_evidence_handles: Vec<String>,
692 verification_outcome: String,
693 ) -> Result<ReconciliationReceipt, String> {
694 self.validate_schema()?;
695 let subject = bounded_coordination_atom("reconciliation subject", &subject)?;
696 let owner = bounded_coordination_atom("reconciliation owner", &owner)?;
697 let outcome = bounded_coordination_atom("reconciliation outcome", &outcome)?;
698 let verification_outcome = bounded_coordination_atom(
699 "reconciliation verification outcome",
700 &verification_outcome,
701 )?;
702 if input_decisions.len() < 2 {
703 return Err("neutral fan-in requires at least two input decisions".to_string());
704 }
705 if input_decisions.iter().collect::<BTreeSet<_>>().len() != input_decisions.len() {
706 return Err("neutral fan-in decision ids must be distinct".to_string());
707 }
708 if candidate_handles.len() < 2
709 || candidate_handles
710 .iter()
711 .any(|handle| handle.trim().is_empty())
712 {
713 return Err(
714 "neutral fan-in must preserve at least two candidate branch, patch, or artifact handles"
715 .to_string(),
716 );
717 }
718 if candidate_handles.iter().collect::<BTreeSet<_>>().len() != candidate_handles.len() {
719 return Err("neutral fan-in candidate handles must be distinct".to_string());
720 }
721 let input_decisions =
722 normalize_coordination_values("input decision ids", &input_decisions, 24)?;
723 let evidence_handles = normalize_coordination_values(
724 "reconciliation evidence handles",
725 &evidence_handles,
726 24,
727 )?;
728 let candidate_handles =
729 normalize_coordination_values("candidate handles", &candidate_handles, 24)?;
730 if input_decisions.len() < 2 {
731 return Err(
732 "neutral fan-in requires at least two distinct normalized input decisions"
733 .to_string(),
734 );
735 }
736 if candidate_handles.len() < 2 {
737 return Err(
738 "neutral fan-in must preserve at least two distinct normalized candidate handles"
739 .to_string(),
740 );
741 }
742 let reviewer_evidence_handles = normalize_coordination_values(
743 "Reviewer evidence handles",
744 &reviewer_evidence_handles,
745 24,
746 )?;
747 let verifier_evidence_handles = normalize_coordination_values(
748 "Verifier evidence handles",
749 &verifier_evidence_handles,
750 24,
751 )?;
752 reject_sensitive_coordination_values(&evidence_handles)?;
753 reject_sensitive_coordination_values(&candidate_handles)?;
754 reject_sensitive_coordination_values(&reviewer_evidence_handles)?;
755 reject_sensitive_coordination_values(&verifier_evidence_handles)?;
756 if retry_limit == 0 || retry_limit > MAX_RECONCILIATION_RETRIES {
757 return Err(format!(
758 "reconciliation retry_limit must be between 1 and {MAX_RECONCILIATION_RETRIES}"
759 ));
760 }
761 if retry_count > retry_limit {
762 return Err("reconciliation retry_count exceeds retry_limit".to_string());
763 }
764 if reviewer_evidence_handles.is_empty() || verifier_evidence_handles.is_empty() {
765 return Err(
766 "neutral fan-in requires independent Reviewer and Verifier evidence handles"
767 .to_string(),
768 );
769 }
770 if reviewer_evidence_handles.iter().any(|review| {
771 verifier_evidence_handles
772 .iter()
773 .any(|verify| verify == review)
774 }) {
775 return Err("Reviewer and Verifier evidence handles must be independent".to_string());
776 }
777 if !matches!(
778 verification_outcome.as_str(),
779 "verified" | "failed" | "blocked"
780 ) {
781 return Err(
782 "neutral fan-in verification_outcome must be verified, failed, or blocked"
783 .to_string(),
784 );
785 }
786 if input_decisions.iter().any(|id| {
787 !self
788 .decisions
789 .iter()
790 .any(|decision| &decision.decision_id == id)
791 }) {
792 return Err("reconciliation references an unknown decision".to_string());
793 }
794 let inputs = input_decisions
795 .iter()
796 .filter_map(|id| {
797 self.decisions
798 .iter()
799 .find(|decision| &decision.decision_id == id)
800 })
801 .collect::<Vec<_>>();
802 if inputs.iter().any(|decision| decision.subject != subject) {
803 return Err("reconciliation inputs must share the requested subject".to_string());
804 }
805 if inputs.iter().any(|decision| decision.owner == owner) {
806 return Err(
807 "neutral fan-in owner must differ from every input decision owner".to_string(),
808 );
809 }
810 let sequence = self.next_sequence();
811 let receipt = ReconciliationReceipt {
812 reconciliation_id: format!("reconcile_{sequence}"),
813 subject,
814 owner,
815 input_decisions,
816 outcome,
817 evidence_handles,
818 candidate_handles,
819 retry_count,
820 retry_limit,
821 reviewer_evidence_handles,
822 verifier_evidence_handles,
823 verification_outcome,
824 sequence,
825 };
826 self.reconciliations.push(receipt.clone());
827 trim_front(&mut self.reconciliations, COORDINATION_RECORD_LIMIT);
828 Ok(receipt)
829 }
830
831 pub fn project_relevant_decisions(
832 &mut self,
833 child_id: &str,
834 claim: Option<&WriteScopeClaim>,
835 capabilities: &[String],
836 ) -> (String, ContextProjectionReceipt) {
837 const HEADER: &str = "Accepted coordination decisions relevant to this child (bounded):\n";
838 let mut seen_constraint_facts = BTreeSet::new();
839 let mut decision_ids = Vec::new();
840 let mut lines = Vec::new();
841 let mut projected_bytes = 0usize;
842 let mut deduplicated = 0usize;
843 let mut omitted = 0usize;
844 for decision in self
845 .decisions
846 .iter()
847 .rev()
848 .filter(|decision| decision.status == DecisionStatus::Accepted)
849 .filter(|decision| decision_is_relevant(decision, claim, capabilities))
850 {
851 if decision_ids.len() >= COORDINATION_PROJECTION_DECISION_LIMIT {
852 omitted = omitted.saturating_add(1);
853 continue;
854 }
855 let constraints = decision
856 .constraints
857 .iter()
858 .filter_map(|value| {
859 let value = bounded_utf8(value, 192);
860 if seen_constraint_facts.insert(value.clone()) {
861 Some(value)
862 } else {
863 deduplicated = deduplicated.saturating_add(1);
864 None
865 }
866 })
867 .take(8)
868 .collect::<Vec<_>>()
869 .join("; ");
870 let mut line = format!(
871 "- {} v{} [{}] owner={}",
872 decision.subject, decision.version, decision.decision_id, decision.owner,
873 );
874 if !constraints.is_empty() {
875 line.push_str(": ");
876 line.push_str(&constraints);
877 }
878 let line = bounded_utf8(&line, 512);
879 let added_bytes = line.len().saturating_add(1);
880 if HEADER
881 .len()
882 .saturating_add(projected_bytes)
883 .saturating_add(added_bytes)
884 > COORDINATION_PROJECTION_BYTE_LIMIT
885 {
886 omitted = omitted.saturating_add(1);
887 continue;
888 }
889 projected_bytes = projected_bytes.saturating_add(added_bytes);
890 decision_ids.push(decision.decision_id.clone());
891 lines.push(line);
892 }
893 let projection = if lines.is_empty() {
894 String::new()
895 } else {
896 format!("{HEADER}{}", lines.join("\n"))
897 };
898 let receipt = ContextProjectionReceipt {
899 child_id: child_id.to_string(),
900 decision_ids,
901 projected_bytes: projection.len(),
902 deduplicated,
903 omitted,
904 sequence: self.next_sequence(),
905 };
906 self.projections.push(receipt.clone());
907 trim_front(&mut self.projections, COORDINATION_RECORD_LIMIT);
908 (projection, receipt)
909 }
910
911 pub(in crate::tools::subagent) fn validate_replay(&mut self) -> Result<(), String> {
912 self.validate_schema()?;
913 if self.decisions.len() > COORDINATION_RECORD_LIMIT
914 || self.write_claims.len() > COORDINATION_RECORD_LIMIT
915 || self.reconciliations.len() > COORDINATION_RECORD_LIMIT
916 || self.projections.len() > COORDINATION_RECORD_LIMIT
917 || self.contentions.len() > COORDINATION_RECORD_LIMIT
918 {
919 return Err("coordination record count exceeds the durable bound".to_string());
920 }
921
922 let mut sequences = BTreeSet::new();
923 let mut max_sequence = 0_u64;
924 let mut decision_ids = BTreeSet::new();
925 let mut accepted_subjects = BTreeSet::new();
926 for decision in &self.decisions {
927 bounded_coordination_atom("decision id", &decision.decision_id)?;
928 bounded_coordination_atom("decision subject", &decision.subject)?;
929 bounded_coordination_atom("decision owner", &decision.owner)?;
930 if decision.version == 0 {
931 return Err(format!(
932 "decision '{}' has zero version",
933 decision.decision_id
934 ));
935 }
936 validate_sequence(
937 decision.sequence,
938 "decision",
939 &mut sequences,
940 &mut max_sequence,
941 )?;
942 if !decision_ids.insert(decision.decision_id.clone()) {
943 return Err(format!("duplicate decision id '{}'", decision.decision_id));
944 }
945 if decision.status == DecisionStatus::Accepted
946 && !accepted_subjects.insert(decision.subject.clone())
947 {
948 return Err(format!(
949 "multiple accepted decisions own subject '{}'",
950 decision.subject
951 ));
952 }
953 validate_normalized_coordination_values("decision scope", &decision.scope, 24)?;
954 validate_normalized_coordination_values(
955 "decision constraints",
956 &decision.constraints,
957 24,
958 )?;
959 validate_normalized_coordination_values(
960 "decision evidence handles",
961 &decision.evidence_handles,
962 24,
963 )?;
964 reject_sensitive_coordination_values(&decision.constraints)?;
965 reject_sensitive_coordination_values(&decision.evidence_handles)?;
966 }
967
968 let mut claim_owners = BTreeSet::new();
969 for claim in &self.write_claims {
970 validate_sequence(
971 claim.sequence,
972 "write claim",
973 &mut sequences,
974 &mut max_sequence,
975 )?;
976 bounded_coordination_atom("write claim owner", &claim.claim.owner)?;
977 if !claim_owners.insert(claim.claim.owner.clone()) {
978 return Err(format!(
979 "duplicate write claim owner '{}'",
980 claim.claim.owner
981 ));
982 }
983 let roots = normalize_claim_paths(&claim.claim.roots)?;
984 let exact_files = normalize_claim_paths(&claim.claim.exact_files)?;
985 let contracts = normalize_claim_strings(&claim.claim.contracts, 16, 128, "contracts")?;
986 if roots != claim.claim.roots
987 || exact_files != claim.claim.exact_files
988 || contracts != claim.claim.contracts
989 || (roots.is_empty() && exact_files.is_empty() && contracts.is_empty())
990 {
991 return Err(format!(
992 "write claim for '{}' is not normalized and bounded",
993 claim.claim.owner
994 ));
995 }
996 }
997
998 for receipt in &self.reconciliations {
999 validate_sequence(
1000 receipt.sequence,
1001 "reconciliation",
1002 &mut sequences,
1003 &mut max_sequence,
1004 )?;
1005 validate_reconciliation_receipt(receipt, &self.decisions)?;
1006 }
1007 for projection in &self.projections {
1008 validate_sequence(
1009 projection.sequence,
1010 "context projection",
1011 &mut sequences,
1012 &mut max_sequence,
1013 )?;
1014 bounded_coordination_atom("projection child", &projection.child_id)?;
1015 if projection.decision_ids.len() > COORDINATION_PROJECTION_DECISION_LIMIT
1016 || projection.projected_bytes > COORDINATION_PROJECTION_BYTE_LIMIT
1017 || projection
1018 .decision_ids
1019 .iter()
1020 .collect::<BTreeSet<_>>()
1021 .len()
1022 != projection.decision_ids.len()
1023 {
1024 return Err(format!(
1025 "context projection for '{}' exceeds its bounds or duplicates decisions",
1026 projection.child_id
1027 ));
1028 }
1029 }
1030 for contention in &self.contentions {
1031 validate_sequence(
1032 contention.sequence,
1033 "contention",
1034 &mut sequences,
1035 &mut max_sequence,
1036 )?;
1037 bounded_coordination_atom("contention claimant", &contention.claimant)?;
1038 bounded_coordination_atom(
1039 "contention conflicting owner",
1040 &contention.conflicting_owner,
1041 )?;
1042 match (contention.disposition, contention.resolution_sequence) {
1043 (WriteContentionDisposition::BlockedPendingIsolationOrSerialization, None) => {}
1044 (WriteContentionDisposition::ResolvedBySuccessfulClaim, Some(sequence))
1045 if sequence > contention.sequence && sequence <= self.sequence => {}
1046 (WriteContentionDisposition::BlockedPendingIsolationOrSerialization, Some(_)) => {
1047 return Err(
1048 "blocked contention receipt cannot carry a resolution sequence".to_string(),
1049 );
1050 }
1051 (WriteContentionDisposition::ResolvedBySuccessfulClaim, _) => {
1052 return Err(
1053 "resolved contention receipt requires a later valid resolution sequence"
1054 .to_string(),
1055 );
1056 }
1057 }
1058 if normalize_claim_paths(&contention.roots)? != contention.roots
1059 || normalize_claim_paths(&contention.exact_files)? != contention.exact_files
1060 || normalize_claim_strings(&contention.contracts, 16, 128, "contracts")?
1061 != contention.contracts
1062 {
1063 return Err("contention receipt paths/contracts are not normalized".to_string());
1064 }
1065 }
1066 if self.sequence < max_sequence {
1067 return Err(format!(
1068 "coordination sequence {} is behind record sequence {max_sequence}",
1069 self.sequence
1070 ));
1071 }
1072 Ok(())
1073 }
1074
1075 fn validate_schema(&self) -> Result<(), String> {
1076 if self.schema_version != COORDINATION_SCHEMA_VERSION {
1077 return Err(format!(
1078 "unsupported coordination schema {}; expected {}",
1079 self.schema_version, COORDINATION_SCHEMA_VERSION
1080 ));
1081 }
1082 Ok(())
1083 }
1084 }
1085
1086 fn normalize_claim_paths(paths: &[String]) -> Result<Vec<String>, String> {
1087 if paths.len() > 32 {
1088 return Err("write claim paths accept at most 32 entries".to_string());
1089 }
1090 let mut normalized = Vec::new();
1091 for path in paths {
1092 let path = normalize_claim_path(path)?;
1093 if !normalized.contains(&path) {
1094 normalized.push(path);
1095 }
1096 }
1097 Ok(normalized)
1098 }
1099
1100 fn normalize_claim_strings(
1101 values: &[String],
1102 count_limit: usize,
1103 char_limit: usize,
1104 field: &str,
1105 ) -> Result<Vec<String>, String> {
1106 if values.len() > count_limit {
1107 return Err(format!(
1108 "write claim {field} accepts at most {count_limit} entries"
1109 ));
1110 }
1111 let mut normalized = Vec::new();
1112 for value in values {
1113 let value = value.trim();
1114 if value.is_empty()
1115 || value.chars().count() > char_limit
1116 || value.chars().any(char::is_control)
1117 {
1118 return Err(format!(
1119 "write claim {field} entries must be 1..={char_limit} characters"
1120 ));
1121 }
1122 if !normalized.iter().any(|existing| existing == value) {
1123 normalized.push(value.to_string());
1124 }
1125 }
1126 Ok(normalized)
1127 }
1128
1129 fn bounded_coordination_atom(field: &str, value: &str) -> Result<String, String> {
1130 let value = value.trim();
1131 if value.is_empty()
1132 || value.chars().count() > 512
1133 || value.chars().any(|ch| matches!(ch, '\r' | '\n'))
1134 {
1135 return Err(format!(
1136 "{field} must be one non-empty line of at most 512 characters"
1137 ));
1138 }
1139 Ok(value.to_string())
1140 }
1141
1142 fn normalize_coordination_values(
1143 field: &str,
1144 values: &[String],
1145 limit: usize,
1146 ) -> Result<Vec<String>, String> {
1147 if values.len() > limit {
1148 return Err(format!("{field} accepts at most {limit} entries"));
1149 }
1150 let mut normalized = Vec::new();
1151 for value in values {
1152 let value = bounded_coordination_atom(field, value)?;
1153 if !normalized.contains(&value) {
1154 normalized.push(value);
1155 }
1156 }
1157 Ok(normalized)
1158 }
1159
1160 fn validate_normalized_coordination_values(
1161 field: &str,
1162 values: &[String],
1163 limit: usize,
1164 ) -> Result<(), String> {
1165 if normalize_coordination_values(field, values, limit)? != values {
1166 return Err(format!("{field} is not trimmed and deduplicated"));
1167 }
1168 Ok(())
1169 }
1170
1171 fn reject_sensitive_coordination_values(values: &[String]) -> Result<(), String> {
1172 const SENSITIVE_MARKERS: &[&str] = &[
1173 "secret",
1174 "password",
1175 "api_key",
1176 "api-key",
1177 "authorization:",
1178 "bearer ",
1179 "token=",
1180 "sk-",
1181 "ghp_",
1182 "xoxb-",
1183 "<thinking",
1184 "chain of thought",
1185 "raw reasoning",
1186 ];
1187 for value in values {
1188 let lower = value.to_ascii_lowercase();
1189 if let Some(marker) = SENSITIVE_MARKERS
1190 .iter()
1191 .find(|marker| lower.contains(**marker))
1192 {
1193 return Err(format!(
1194 "coordination metadata rejected sensitive or raw-reasoning marker '{marker}'"
1195 ));
1196 }
1197 }
1198 Ok(())
1199 }
1200
1201 fn validate_sequence(
1202 sequence: u64,
1203 kind: &str,
1204 sequences: &mut BTreeSet<u64>,
1205 max_sequence: &mut u64,
1206 ) -> Result<(), String> {
1207 if sequence == 0 || !sequences.insert(sequence) {
1208 return Err(format!(
1209 "{kind} has a zero or duplicate sequence {sequence}"
1210 ));
1211 }
1212 *max_sequence = (*max_sequence).max(sequence);
1213 Ok(())
1214 }
1215
1216 fn validate_reconciliation_receipt(
1217 receipt: &ReconciliationReceipt,
1218 decisions: &[DecisionRecord],
1219 ) -> Result<(), String> {
1220 bounded_coordination_atom("reconciliation id", &receipt.reconciliation_id)?;
1221 bounded_coordination_atom("reconciliation subject", &receipt.subject)?;
1222 bounded_coordination_atom("reconciliation owner", &receipt.owner)?;
1223 bounded_coordination_atom("reconciliation outcome", &receipt.outcome)?;
1224 if receipt.input_decisions.len() < 2
1225 || receipt
1226 .input_decisions
1227 .iter()
1228 .collect::<BTreeSet<_>>()
1229 .len()
1230 != receipt.input_decisions.len()
1231 {
1232 return Err("reconciliation requires at least two distinct decision ids".to_string());
1233 }
1234 let inputs = receipt
1235 .input_decisions
1236 .iter()
1237 .map(|id| {
1238 decisions
1239 .iter()
1240 .find(|decision| &decision.decision_id == id)
1241 .ok_or_else(|| format!("reconciliation references unknown decision '{id}'"))
1242 })
1243 .collect::<Result<Vec<_>, _>>()?;
1244 if inputs
1245 .iter()
1246 .any(|decision| decision.subject != receipt.subject)
1247 {
1248 return Err("reconciliation inputs must share the requested subject".to_string());
1249 }
1250 if inputs
1251 .iter()
1252 .any(|decision| decision.owner == receipt.owner)
1253 {
1254 return Err("neutral fan-in owner must differ from every candidate owner".to_string());
1255 }
1256 if receipt.candidate_handles.len() < 2
1257 || receipt
1258 .candidate_handles
1259 .iter()
1260 .collect::<BTreeSet<_>>()
1261 .len()
1262 != receipt.candidate_handles.len()
1263 {
1264 return Err("reconciliation requires at least two distinct candidate handles".to_string());
1265 }
1266 validate_normalized_coordination_values("candidate handles", &receipt.candidate_handles, 24)?;
1267 validate_normalized_coordination_values(
1268 "reconciliation evidence handles",
1269 &receipt.evidence_handles,
1270 24,
1271 )?;
1272 validate_normalized_coordination_values(
1273 "Reviewer evidence handles",
1274 &receipt.reviewer_evidence_handles,
1275 24,
1276 )?;
1277 validate_normalized_coordination_values(
1278 "Verifier evidence handles",
1279 &receipt.verifier_evidence_handles,
1280 24,
1281 )?;
1282 reject_sensitive_coordination_values(&receipt.candidate_handles)?;
1283 reject_sensitive_coordination_values(&receipt.evidence_handles)?;
1284 reject_sensitive_coordination_values(&receipt.reviewer_evidence_handles)?;
1285 reject_sensitive_coordination_values(&receipt.verifier_evidence_handles)?;
1286 if receipt.retry_limit == 0
1287 || receipt.retry_limit > MAX_RECONCILIATION_RETRIES
1288 || receipt.retry_count > receipt.retry_limit
1289 {
1290 return Err("reconciliation retry count/limit is invalid".to_string());
1291 }
1292 if receipt.reviewer_evidence_handles.is_empty()
1293 || receipt.verifier_evidence_handles.is_empty()
1294 || receipt.reviewer_evidence_handles.iter().any(|review| {
1295 receipt
1296 .verifier_evidence_handles
1297 .iter()
1298 .any(|verify| verify == review)
1299 })
1300 {
1301 return Err("Reviewer and Verifier evidence must be present and independent".to_string());
1302 }
1303 if !matches!(
1304 receipt.verification_outcome.as_str(),
1305 "verified" | "failed" | "blocked"
1306 ) {
1307 return Err("reconciliation verification outcome is invalid".to_string());
1308 }
1309 Ok(())
1310 }
1311
1312 fn decision_is_relevant(
1313 decision: &DecisionRecord,
1314 claim: Option<&WriteScopeClaim>,
1315 capabilities: &[String],
1316 ) -> bool {
1317 if decision.scope.is_empty() {
1318 return true;
1319 }
1320 decision.scope.iter().any(|raw| {
1321 let value = raw.trim();
1322 let (kind, value) = value
1323 .split_once(':')
1324 .map_or(("", value), |(kind, value)| (kind.trim(), value.trim()));
1325 match kind {
1326 "capability" => capabilities.iter().any(|capability| capability == value),
1327 "contract" => {
1328 claim.is_some_and(|claim| claim.contracts.iter().any(|contract| contract == value))
1329 }
1330 "path" => claim.is_some_and(|claim| claim_reaches_path(claim, value)),
1331 _ => {
1332 capabilities.iter().any(|capability| capability == value)
1333 || claim.is_some_and(|claim| {
1334 claim.contracts.iter().any(|contract| contract == value)
1335 || claim_reaches_path(claim, value)
1336 })
1337 }
1338 }
1339 })
1340 }
1341
1342 fn claim_reaches_path(claim: &WriteScopeClaim, path: &str) -> bool {
1343 let Ok(path) = normalize_claim_path(path) else {
1344 return false;
1345 };
1346 claim.contains_path(&path)
1347 || claim.roots.iter().any(|root| path_contains(&path, root))
1348 || claim
1349 .exact_files
1350 .iter()
1351 .any(|file| path_contains(&path, file))
1352 }
1353
1354 fn bounded_utf8(value: &str, byte_limit: usize) -> String {
1355 if value.len() <= byte_limit {
1356 return value.to_string();
1357 }
1358 let mut end = byte_limit;
1359 while !value.is_char_boundary(end) {
1360 end = end.saturating_sub(1);
1361 }
1362 value[..end].to_string()
1363 }
1364
1365 fn trim_front<T>(records: &mut Vec<T>, limit: usize) {
1366 if records.len() > limit {
1367 records.drain(..records.len() - limit);
1368 }
1369 }
1370
1371 #[cfg(test)]
1372 mod records_tests {
1373 use super::*;
1374 use serde_json::json;
1375
1376 #[test]
1377 fn overlapping_roots_detected() {
1378 let a = WriteScopeClaim {
1379 owner: "agent-a".into(),
1380 roots: vec!["src/tui/".into()],
1381 exact_files: vec![],
1382 contracts: vec![],
1383 };
1384 let b = WriteScopeClaim {
1385 owner: "agent-b".into(),
1386 roots: vec!["src/tui/widgets/".into()],
1387 exact_files: vec![],
1388 contracts: vec![],
1389 };
1390 assert!(a.overlaps(&b));
1391 }
1392
1393 #[test]
1394 fn disjoint_roots_no_overlap() {
1395 let a = WriteScopeClaim {
1396 owner: "agent-a".into(),
1397 roots: vec!["src/tui/".into()],
1398 exact_files: vec![],
1399 contracts: vec![],
1400 };
1401 let b = WriteScopeClaim {
1402 owner: "agent-b".into(),
1403 roots: vec!["src/core/".into()],
1404 exact_files: vec![],
1405 contracts: vec![],
1406 };
1407 assert!(!a.overlaps(&b));
1408 }
1409
1410 #[test]
1411 fn exact_file_collision_detected() {
1412 let a = WriteScopeClaim {
1413 owner: "agent-a".into(),
1414 roots: vec![],
1415 exact_files: vec!["src/main.rs".into()],
1416 contracts: vec![],
1417 };
1418 let b = WriteScopeClaim {
1419 owner: "agent-b".into(),
1420 roots: vec![],
1421 exact_files: vec!["src/main.rs".into()],
1422 contracts: vec![],
1423 };
1424 assert!(a.overlaps(&b));
1425 }
1426
1427 #[test]
1428 fn release_stale_claims_releases_only_inactive_owners_and_honours_an_owner_filter() {
1429 let mut ledger = CoordinationLedger::default();
1430 for (owner, root) in [
1431 ("live-builder", "src/live"),
1432 ("zombie-builder", "src/zombie"),
1433 ("prior-session-builder", "src/prior"),
1434 ] {
1435 ledger
1436 .register_claim(
1437 WriteScopeClaim {
1438 owner: owner.into(),
1439 roots: vec![root.into()],
1440 exact_files: Vec::new(),
1441 contracts: Vec::new(),
1442 },
1443 false,
1444 |candidate| candidate == "live-builder",
1445 )
1446 .expect("non-overlapping claim registers");
1447 }
1448 assert_eq!(ledger.write_claims.len(), 3);
1449
1450 let released = ledger
1451 .release_stale_claims(None, |candidate| candidate == "live-builder")
1452 .expect("release sweeps stale claims");
1453 assert_eq!(released, vec!["zombie-builder", "prior-session-builder"]);
1454 assert_eq!(ledger.write_claims.len(), 1);
1455 assert_eq!(ledger.write_claims[0].claim.owner, "live-builder");
1456
1457 // A second sweep is idempotent and never touches a live claimant.
1458 let released = ledger
1459 .release_stale_claims(None, |candidate| candidate == "live-builder")
1460 .expect("idempotent sweep");
1461 assert!(released.is_empty());
1462 }
1463
1464 #[test]
1465 fn release_stale_claims_refuses_to_remove_an_owner_filter_for_a_live_claimant() {
1466 let mut ledger = CoordinationLedger::default();
1467 ledger
1468 .register_claim(
1469 WriteScopeClaim {
1470 owner: "live-builder".into(),
1471 roots: vec!["src/live".into()],
1472 exact_files: Vec::new(),
1473 contracts: Vec::new(),
1474 },
1475 false,
1476 |candidate| candidate == "live-builder",
1477 )
1478 .expect("claim registers");
1479 let released = ledger
1480 .release_stale_claims(Some("live-builder"), |candidate| {
1481 candidate == "live-builder"
1482 })
1483 .expect("a live claimant is never released");
1484 assert!(released.is_empty());
1485
1486 // A stale owner named in the filter IS released; other stale owners are not touched.
1487 ledger
1488 .register_claim(
1489 WriteScopeClaim {
1490 owner: "zombie-builder".into(),
1491 roots: vec!["src/zombie".into()],
1492 exact_files: Vec::new(),
1493 contracts: Vec::new(),
1494 },
1495 false,
1496 |candidate| candidate == "live-builder",
1497 )
1498 .expect("claim registers");
1499 let released = ledger
1500 .release_stale_claims(Some("zombie-builder"), |candidate| {
1501 candidate == "live-builder"
1502 })
1503 .expect("filtered release");
1504 assert_eq!(released, vec!["zombie-builder"]);
1505 assert_eq!(ledger.write_claims.len(), 1);
1506 }
1507
1508 #[test]
1509 fn path_overlap_respects_component_boundaries_and_root_coverage() {
1510 let root = WriteScopeClaim {
1511 owner: "agent-a".into(),
1512 roots: vec!["src".into()],
1513 exact_files: vec![],
1514 contracts: vec![],
1515 };
1516 let sibling = WriteScopeClaim {
1517 owner: "agent-b".into(),
1518 roots: vec!["src2".into()],
1519 exact_files: vec![],
1520 contracts: vec![],
1521 };
1522 let child_file = WriteScopeClaim {
1523 owner: "agent-c".into(),
1524 roots: vec![],
1525 exact_files: vec!["src/lib.rs".into()],
1526 contracts: vec![],
1527 };
1528 assert!(!root.overlaps(&sibling));
1529 assert!(root.overlaps(&child_file));
1530 }
1531
1532 #[test]
1533 fn disjoint_exact_files_share_a_root() {
1534 // #6278: N workers, one results directory, one disjoint file each.
1535 let a = WriteScopeClaim {
1536 owner: "agent-a".into(),
1537 roots: vec!["tmp/scan".into()],
1538 exact_files: vec!["tmp/scan/a.txt".into()],
1539 contracts: vec![],
1540 };
1541 let b = WriteScopeClaim {
1542 owner: "agent-b".into(),
1543 roots: vec!["tmp/scan".into()],
1544 exact_files: vec!["tmp/scan/b.txt".into()],
1545 contracts: vec![],
1546 };
1547 assert!(!a.overlaps(&b));
1548 assert!(!b.overlaps(&a));
1549 // The bound root is the file boundary: the shared tree and the
1550 // peer's file are outside this claim's authorized surface.
1551 assert!(a.contains_path("tmp/scan/a.txt"));
1552 assert!(!a.contains_path("tmp/scan/b.txt"));
1553 assert!(!a.contains_path("tmp/scan/scratch.txt"));
1554 }
1555
1556 #[test]
1557 fn file_bound_roots_still_refuse_real_overlap() {
1558 let a = WriteScopeClaim {
1559 owner: "agent-a".into(),
1560 roots: vec!["tmp/scan".into()],
1561 exact_files: vec!["tmp/scan/a.txt".into()],
1562 contracts: vec![],
1563 };
1564 // The same file under the shared root still contends.
1565 let same_file = WriteScopeClaim {
1566 owner: "agent-b".into(),
1567 roots: vec!["tmp/scan".into()],
1568 exact_files: vec!["tmp/scan/a.txt".into()],
1569 contracts: vec![],
1570 };
1571 assert!(a.overlaps(&same_file));
1572 // An open root — no files declared beneath it — keeps tree-wide
1573 // authority and still contends with a file beneath it.
1574 let open_root = WriteScopeClaim {
1575 owner: "agent-c".into(),
1576 roots: vec!["tmp/scan".into()],
1577 exact_files: vec![],
1578 contracts: vec![],
1579 };
1580 assert!(a.overlaps(&open_root));
1581 // A root whose files sit elsewhere stays open: union claims are
1582 // unchanged for files outside the claimed trees.
1583 let mixed = WriteScopeClaim {
1584 owner: "agent-d".into(),
1585 roots: vec!["src".into()],
1586 exact_files: vec!["README.md".into()],
1587 contracts: vec![],
1588 };
1589 assert!(mixed.contains_path("src/lib.rs"));
1590 assert!(mixed.contains_path("README.md"));
1591 }
1592
1593 #[test]
1594 fn disjoint_file_claims_under_one_root_both_register() {
1595 let mut ledger = CoordinationLedger::default();
1596 for (owner, file) in [("agent-a", "tmp/scan/a.txt"), ("agent-b", "tmp/scan/b.txt")] {
1597 ledger
1598 .register_claim(
1599 WriteScopeClaim {
1600 owner: owner.into(),
1601 roots: vec!["tmp/scan".into()],
1602 exact_files: vec![file.into()],
1603 contracts: vec![],
1604 },
1605 false,
1606 |_| true,
1607 )
1608 .expect("disjoint files under a shared root register");
1609 }
1610 assert_eq!(ledger.write_claims.len(), 2);
1611 assert!(ledger.contentions.is_empty());
1612 }
1613
1614 #[test]
1615 fn legacy_blocked_contention_wire_defaults_to_unresolved() {
1616 let receipt: WriteContentionReceipt = serde_json::from_value(json!({
1617 "claimant": "agent-b",
1618 "conflicting_owner": "agent-a",
1619 "roots": ["src"],
1620 "exact_files": [],
1621 "contracts": ["public-api"],
1622 "disposition": "blocked_pending_isolation_or_serialization",
1623 "sequence": 3
1624 }))
1625 .expect("existing blocked wire receipt remains readable");
1626
1627 assert_eq!(
1628 receipt.disposition,
1629 WriteContentionDisposition::BlockedPendingIsolationOrSerialization
1630 );
1631 assert_eq!(receipt.resolution_sequence, None);
1632 assert!(receipt.disposition.blocks_admission());
1633 }
1634
1635 #[test]
1636 fn active_shared_claims_contend_but_isolated_claims_do_not() {
1637 let mut ledger = CoordinationLedger::default();
1638 let first = WriteScopeClaim {
1639 owner: "agent-a".into(),
1640 roots: vec!["src".into()],
1641 exact_files: vec![],
1642 contracts: vec!["public-api".into()],
1643 };
1644 ledger.register_claim(first, false, |_| false).unwrap();
1645 let second = WriteScopeClaim {
1646 owner: "agent-b".into(),
1647 roots: vec!["docs".into()],
1648 exact_files: vec![],
1649 contracts: vec!["public-api".into()],
1650 };
1651 let err = ledger
1652 .register_claim(second.clone(), false, |owner| owner == "agent-a")
1653 .unwrap_err();
1654 assert!(
1655 err.contains("contention") && err.contains("agent-a"),
1656 "{err}"
1657 );
1658 assert_eq!(ledger.contentions.len(), 1);
1659 assert_eq!(ledger.contentions[0].claimant, "agent-b");
1660 assert_eq!(ledger.contentions[0].conflicting_owner, "agent-a");
1661 assert_eq!(
1662 ledger.contentions[0].disposition,
1663 WriteContentionDisposition::BlockedPendingIsolationOrSerialization
1664 );
1665 assert_eq!(
1666 serde_json::to_value(&ledger.contentions[0]).unwrap()["disposition"],
1667 json!("blocked_pending_isolation_or_serialization")
1668 );
1669 let resolving_claim = ledger
1670 .register_claim(second, true, |owner| owner == "agent-a")
1671 .expect("isolated claim resolves the blocked admission");
1672 assert_eq!(
1673 ledger.contentions[0].disposition,
1674 WriteContentionDisposition::ResolvedBySuccessfulClaim
1675 );
1676 assert_eq!(
1677 ledger.contentions[0].resolution_sequence,
1678 Some(resolving_claim.sequence)
1679 );
1680 }
1681
1682 #[test]
1683 fn active_write_claims_are_never_evicted_by_receipt_retention() {
1684 let mut ledger = CoordinationLedger::default();
1685 for index in 0..COORDINATION_RECORD_LIMIT {
1686 ledger
1687 .register_claim(
1688 WriteScopeClaim {
1689 owner: format!("agent-{index:03}"),
1690 roots: vec![format!("pkg-{index:03}")],
1691 exact_files: vec![],
1692 contracts: vec![],
1693 },
1694 false,
1695 |_| true,
1696 )
1697 .unwrap();
1698 }
1699 let error = ledger
1700 .register_claim(
1701 WriteScopeClaim {
1702 owner: "agent-over-cap".into(),
1703 roots: vec!["new-package".into()],
1704 exact_files: vec![],
1705 contracts: vec![],
1706 },
1707 false,
1708 |_| true,
1709 )
1710 .expect_err("all-active capacity must fail before evicting ownership");
1711 assert!(error.contains("active owners"), "{error}");
1712 assert_eq!(ledger.write_claims.len(), COORDINATION_RECORD_LIMIT);
1713 assert!(
1714 ledger
1715 .write_claims
1716 .iter()
1717 .any(|record| record.claim.owner == "agent-000")
1718 );
1719 }
1720
1721 #[test]
1722 fn accepted_decisions_require_owner_and_explicit_neutral_reconciliation() {
1723 let mut ledger = CoordinationLedger::default();
1724 let make = |id: &str, owner: &str, status| DecisionRecord {
1725 decision_id: id.into(),
1726 subject: "storage".into(),
1727 status,
1728 owner: owner.into(),
1729 scope: vec!["router".into()],
1730 constraints: vec![],
1731 evidence_handles: vec![format!("receipt:{id}")],
1732 version: 1,
1733 sequence: 0,
1734 };
1735 ledger
1736 .record_decision(make("a", "agent-a", DecisionStatus::Accepted))
1737 .unwrap();
1738 ledger
1739 .record_decision(make("b", "agent-b", DecisionStatus::Proposed))
1740 .unwrap();
1741 let owner_error = ledger
1742 .update_decision_status("b", DecisionStatus::Accepted, "root", 2)
1743 .unwrap_err();
1744 assert!(owner_error.contains("owned by 'agent-b'"), "{owner_error}");
1745 let stale = ledger
1746 .update_decision_status("b", DecisionStatus::Accepted, "agent-b", 1)
1747 .unwrap_err();
1748 assert!(stale.contains("expected 1, current 2"), "{stale}");
1749 let conflict = ledger
1750 .update_decision_status("b", DecisionStatus::Accepted, "agent-b", 2)
1751 .unwrap_err();
1752 assert!(conflict.contains("neutral reconciliation"), "{conflict}");
1753 ledger
1754 .update_decision_status("a", DecisionStatus::Superseded, "agent-a", 1)
1755 .unwrap();
1756 ledger
1757 .update_decision_status("b", DecisionStatus::Accepted, "agent-b", 2)
1758 .unwrap();
1759 let receipt = ledger
1760 .reconcile(
1761 "storage".into(),
1762 "root".into(),
1763 vec!["a".into(), "b".into()],
1764 "use bounded origin-session artifacts".into(),
1765 vec!["test:coord".into()],
1766 vec!["branch:agent-a".into(), "branch:agent-b".into()],
1767 1,
1768 3,
1769 vec!["review:independent".into()],
1770 vec!["verify:locked".into()],
1771 "verified".into(),
1772 )
1773 .unwrap();
1774 assert_eq!(receipt.input_decisions.len(), 2);
1775 assert!(receipt.sequence > ledger.decisions[1].sequence);
1776 }
1777
1778 #[test]
1779 fn relevant_decision_projection_is_deduplicated_bounded_and_receipted() {
1780 let mut ledger = CoordinationLedger::default();
1781 for (id, subject, scope) in [
1782 ("file", "file-contract", "path:src"),
1783 ("docs", "docs-contract", "path:docs"),
1784 ("api", "api-contract", "contract:public-api"),
1785 ] {
1786 ledger
1787 .record_decision(DecisionRecord {
1788 decision_id: id.into(),
1789 subject: subject.into(),
1790 status: DecisionStatus::Accepted,
1791 owner: "planner".into(),
1792 scope: vec![scope.into()],
1793 constraints: vec!["bounded".into(), "bounded".into()],
1794 evidence_handles: vec![format!("receipt:{id}")],
1795 version: 1,
1796 sequence: 0,
1797 })
1798 .unwrap();
1799 }
1800 let claim = WriteScopeClaim {
1801 owner: "worker".into(),
1802 roots: vec!["src/tui".into()],
1803 exact_files: vec![],
1804 contracts: vec!["public-api".into()],
1805 };
1806 let (projection, receipt) =
1807 ledger.project_relevant_decisions("worker", Some(&claim), &["File".into()]);
1808 assert!(projection.contains("file-contract"), "{projection}");
1809 assert!(projection.contains("api-contract"), "{projection}");
1810 assert!(!projection.contains("docs-contract"), "{projection}");
1811 assert!(projection.len() <= COORDINATION_PROJECTION_BYTE_LIMIT);
1812 assert_eq!(receipt.decision_ids, vec!["api", "file"]);
1813 assert_eq!(receipt.deduplicated, 1);
1814 assert_eq!(ledger.projections.last(), Some(&receipt));
1815 }
1816
1817 #[test]
1818 fn projection_receipt_distinguishes_unique_omissions_from_deduplication() {
1819 let mut ledger = CoordinationLedger::default();
1820 for index in 0..(COORDINATION_PROJECTION_DECISION_LIMIT + 2) {
1821 ledger
1822 .record_decision(DecisionRecord {
1823 decision_id: format!("decision-{index}"),
1824 subject: format!("subject-{index}"),
1825 status: DecisionStatus::Accepted,
1826 owner: "planner".into(),
1827 scope: vec!["path:src".into()],
1828 constraints: vec![format!("constraint-{index}")],
1829 evidence_handles: vec![format!("receipt:{index}")],
1830 version: 1,
1831 sequence: 0,
1832 })
1833 .unwrap();
1834 }
1835 let claim = WriteScopeClaim {
1836 owner: "worker".into(),
1837 roots: vec!["src".into()],
1838 exact_files: vec![],
1839 contracts: vec![],
1840 };
1841
1842 let (projection, receipt) = ledger.project_relevant_decisions("worker", Some(&claim), &[]);
1843
1844 assert!(projection.len() <= COORDINATION_PROJECTION_BYTE_LIMIT);
1845 assert_eq!(
1846 receipt.decision_ids.len(),
1847 COORDINATION_PROJECTION_DECISION_LIMIT
1848 );
1849 assert_eq!(receipt.deduplicated, 0);
1850 assert_eq!(receipt.omitted, 2);
1851 }
1852
1853 #[test]
1854 fn coordination_schema_drift_fails_closed_before_mutation() {
1855 let mut ledger = CoordinationLedger {
1856 schema_version: COORDINATION_SCHEMA_VERSION + 1,
1857 ..CoordinationLedger::default()
1858 };
1859 let error = ledger
1860 .register_claim(
1861 WriteScopeClaim {
1862 owner: "worker".into(),
1863 roots: vec!["src".into()],
1864 exact_files: vec![],
1865 contracts: vec![],
1866 },
1867 false,
1868 |_| false,
1869 )
1870 .unwrap_err();
1871 assert!(error.contains("unsupported coordination schema"), "{error}");
1872 assert!(ledger.write_claims.is_empty());
1873 }
1874 }
1875
1875 lines RUST