1#![forbid(unsafe_code)]
2
3use std::{
4 borrow::Cow,
5 collections::{HashMap, HashSet},
6 fs::{self, OpenOptions},
7 io::Write,
8 path::{Path, PathBuf},
9 thread,
10 time::{Duration, UNIX_EPOCH},
11};
12
13use serde::Serialize;
14use serde_json::Value;
15use sha2::{Digest, Sha256};
16use time::{OffsetDateTime, format_description::well_known::Rfc3339};
17
18pub mod assets;
19#[cfg(feature = "cli")]
20pub mod cli;
21mod gate;
22mod host;
23mod status_reports;
24
25use gate::{EventKind, EvidenceEvent, GateClock, GateTarget, Outcome, ValidatedStream};
26pub use host::{Host, Recovery};
27pub use status_reports::skill_evolution_status;
28pub use status_reports::{MethodGapResearchInventory, method_gap_research_inventory};
29
30pub const SESSION_UNAVAILABLE: &str = "unavailable";
31const SYMPTOM_KEYS: &[&str] = &[
32 "triggering",
33 "execution",
34 "output",
35 "state",
36 "tool-compatibility",
37 "coordination",
38 "cost",
39 "unknown",
40];
41const DISPOSITIONS: &[&str] = &[
42 "resolved_by_change",
43 "closed_no_skill_defect",
44 "outside_target",
45 "insufficient_independence",
46 "monitor_for_recurrence",
47 "superseded_by_target_version",
48 "candidate_rejected_validation",
49 "blocked_no_valid_test",
50];
51const EVOLUTION_ADJUDICATING_DISPOSITIONS: &[&str] = &[
52 "resolved_by_change",
53 "closed_no_skill_defect",
54 "outside_target",
55 "insufficient_independence",
56 "monitor_for_recurrence",
57 "candidate_rejected_validation",
58];
59const EVOLUTION_INSTRUMENT_LIMITED_DISPOSITIONS: &[&str] = &["blocked_no_valid_test"];
67const EXTERNAL_OWNER_KINDS: &[&str] = &[
68 "skill",
69 "contract",
70 "tool",
71 "environment",
72 "model_limitation",
73 "user_instruction",
74];
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ErrorClass {
78 Refusal,
79 UnsafeFailure,
80}
81
82#[derive(Debug)]
83pub struct Error {
84 class: ErrorClass,
85 message: String,
86 recovery: Option<Recovery>,
87}
88
89impl Error {
90 #[must_use]
91 pub fn class(&self) -> ErrorClass {
92 self.class
93 }
94
95 #[must_use]
102 pub fn recovery(&self) -> Option<Recovery> {
103 self.recovery
104 }
105}
106
107impl std::fmt::Display for Error {
108 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 formatter.write_str(&self.message)
110 }
111}
112
113impl std::error::Error for Error {}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
122struct DirectoryHash {
123 content_hash: String,
124 file_count: usize,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
128pub struct HashReport {
129 pub schema: String,
130 pub content_hash: String,
131 pub file_count: usize,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
135pub struct ValidatedEventStream {
136 pub events: Vec<Value>,
137 pub integrity_errors: Vec<String>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct DerivationInputs {
142 pub generated_at: String,
143 pub now_epoch_milliseconds: i64,
144 pub session_id: String,
145 pub lock_owner: String,
146}
147
148impl DerivationInputs {
149 fn clock(&self) -> GateClock<'_> {
155 GateClock {
156 generated_at: &self.generated_at,
157 now_epoch_milliseconds: self.now_epoch_milliseconds,
158 session_id: &self.session_id,
159 }
160 }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct LifecycleInputs {
165 pub recorded_at: String,
166 pub now_epoch_milliseconds: i64,
167 pub session_id: String,
168 pub lock_owner: String,
169 pub operator_skill: PathBuf,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct LifecycleEventInputs {
174 pub event_id: String,
175 pub recorded_at: String,
176 pub now_epoch_milliseconds: i64,
177 pub repository_head: String,
178 pub session_id: String,
179 pub lock_owner: String,
180 pub operator_skill: PathBuf,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct EvolutionClaimRequest {
185 pub review_id: String,
186 pub risk_tier: String,
187 pub record_operating_skill_hash: bool,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct EvolutionValidationRequest {
196 pub review_id: String,
197 pub decision: String,
198 pub risk_tier: String,
199 pub candidate: PathBuf,
200 pub trials: String,
201 pub artifacts: String,
202 pub summary: Option<String>,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct EvolutionLandRequest {
207 pub review_id: String,
208 pub candidate: PathBuf,
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
212#[serde(rename_all = "snake_case")]
213pub enum ExternalOwnerKind {
214 Skill,
215 Contract,
216 Tool,
217 Environment,
218 ModelLimitation,
219 UserInstruction,
220}
221
222impl ExternalOwnerKind {
223 #[must_use]
224 pub fn parse(value: &str) -> Option<Self> {
225 match value {
226 "skill" => Some(Self::Skill),
227 "contract" => Some(Self::Contract),
228 "tool" => Some(Self::Tool),
229 "environment" => Some(Self::Environment),
230 "model_limitation" => Some(Self::ModelLimitation),
231 "user_instruction" => Some(Self::UserInstruction),
232 _ => None,
233 }
234 }
235
236 #[must_use]
237 pub fn roster() -> &'static [&'static str] {
238 EXTERNAL_OWNER_KINDS
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
243pub struct ExternalOwner {
244 pub event_id: String,
245 pub kind: ExternalOwnerKind,
246 pub reference: String,
247}
248
249const CONSTRAINT_PROVENANCE_FIELDS: &[&str] = &[
250 "run_condition",
251 "observed",
252 "consequence",
253 "workaround_taken",
254];
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
257#[serde(rename_all = "snake_case")]
258pub enum ConstraintProvenanceField {
259 RunCondition,
260 Observed,
261 Consequence,
262 WorkaroundTaken,
263}
264
265impl ConstraintProvenanceField {
266 #[must_use]
267 pub fn parse(value: &str) -> Option<Self> {
268 match value {
269 "run_condition" => Some(Self::RunCondition),
270 "observed" => Some(Self::Observed),
271 "consequence" => Some(Self::Consequence),
272 "workaround_taken" => Some(Self::WorkaroundTaken),
273 _ => None,
274 }
275 }
276
277 #[must_use]
278 pub fn roster() -> &'static [&'static str] {
279 CONSTRAINT_PROVENANCE_FIELDS
280 }
281
282 fn as_str(self) -> &'static str {
283 match self {
284 Self::RunCondition => "run_condition",
285 Self::Observed => "observed",
286 Self::Consequence => "consequence",
287 Self::WorkaroundTaken => "workaround_taken",
288 }
289 }
290}
291
292#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
293pub struct ConstraintProvenance {
294 pub constraint_label: String,
295 pub event_id: String,
296 pub field: ConstraintProvenanceField,
297}
298
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct EvolutionCloseRequest {
301 pub review_id: String,
302 pub disposition: String,
303 pub note: String,
304 pub adjudicate: Vec<String>,
305 pub concluded: Vec<String>,
309 pub external_owners: Vec<ExternalOwner>,
313 pub instrument_limited: Vec<String>,
320 pub constraint_provenance: Vec<ConstraintProvenance>,
324 pub trials: Option<String>,
325 pub artifacts: Option<String>,
326}
327
328#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
329pub struct DirectoryDiff {
330 pub added: Vec<String>,
331 pub removed: Vec<String>,
332 pub modified: Vec<String>,
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub struct LandingMechanicsReceipt {
337 pub after_hash: String,
338 pub changed_files: DirectoryDiff,
339 pub mirror_status: &'static str,
340}
341
342#[derive(Debug, Clone, PartialEq, Eq)]
343struct ValidatedLandingPreparation {
344 candidate_hash: String,
345 backup_directory: PathBuf,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq)]
349struct ValidatedLandingPolicy<'a> {
350 owner_id: &'a str,
351 backup_directory: PathBuf,
352 event_id: &'a str,
353}
354
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct RecordUseRequest {
357 pub outcome: String,
358 pub task_label: String,
359 pub symptom_key: Option<String>,
360 pub expected: Option<String>,
361 pub observed: Option<String>,
362 pub consequence: Option<String>,
363 pub workaround: Option<String>,
364 pub run_condition: Option<String>,
365 pub retrospective: bool,
366 pub evidence_refs: Vec<String>,
367 pub same_run_group: Option<String>,
368 pub further_incident: bool,
376}
377
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct RecordInputs {
380 pub event_id: String,
381 pub recorded_at: String,
382 pub now_epoch_milliseconds: i64,
383 pub repository_head: String,
384 pub session_id: String,
385 pub lock_owner: String,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
389pub struct RecordReceipt {
390 pub schema: String,
391 pub event_id: String,
392 pub gate_status: GateStatus,
393 pub terminal_reply: String,
394}
395
396#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
397pub struct CandidateCluster {
398 pub symptom_key: String,
399 pub open_event_ids: Vec<String>,
400 pub independent_incidents: usize,
401 pub max_severity: String,
402}
403
404#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
405pub struct GateStatus {
406 pub schema_version: u8,
407 pub generated_at: String,
408 pub target_content_hash: String,
409 pub qualifying_uses_on_current_hash: usize,
410 pub open_incident_ids: Vec<String>,
411 pub candidate_clusters: Vec<CandidateCluster>,
412 pub state: String,
413 pub authorized_workflow: Option<String>,
414 pub authorization_reason: Option<String>,
415 pub trigger_event_ids: Vec<String>,
416 pub threshold_session_id: Option<String>,
417 pub not_before: Option<String>,
418 pub active_review_id: Option<String>,
419 pub last_completed_review_id: Option<String>,
420 pub review_reentry_basis: Option<String>,
421 pub target_name: String,
422 pub target_repo_relative_path: String,
423 pub derivation_session_id: Option<String>,
424 #[serde(skip_serializing_if = "Vec::is_empty")]
433 pub instrument_limited_incident_ids: Vec<String>,
434 #[serde(skip_serializing_if = "Vec::is_empty")]
435 pub integrity_errors: Vec<String>,
436}
437
438pub fn resolve_top_level_session_id(
439 explicit: Option<&str>,
440 claude_code_session_id: Option<&str>,
441 codex_thread_id: Option<&str>,
442) -> Result<String, Error> {
443 if let Some(explicit) = explicit {
444 return Ok(if explicit.is_empty() {
445 SESSION_UNAVAILABLE.to_owned()
446 } else {
447 explicit.to_owned()
448 });
449 }
450 let present = [
451 ("CLAUDE_CODE_SESSION_ID", claude_code_session_id),
452 ("CODEX_THREAD_ID", codex_thread_id),
453 ]
454 .into_iter()
455 .filter_map(|(name, value)| {
456 value
457 .filter(|value| !value.is_empty())
458 .map(|value| (name, value))
459 })
460 .collect::<Vec<_>>();
461 if present.is_empty() {
462 return Ok(SESSION_UNAVAILABLE.to_owned());
463 }
464 if present.iter().any(|(_, value)| *value != present[0].1) {
465 let identities = present
466 .iter()
467 .map(|(name, value)| format!("{name}={value}"))
468 .collect::<Vec<_>>()
469 .join(", ");
470 return Err(refusal(format!(
471 "Conflicting top-level-session identities: {identities}. Refusing to guess which host owns this top-level session; nothing was recorded or modified. Unset all but one supported session variable (or pass an explicit --session-id) and retry."
472 )));
473 }
474 Ok(present[0].1.to_owned())
475}
476
477pub fn parse_method_gap_family_selector(selector: &str) -> Result<String, Error> {
478 let stem = selector.strip_suffix("-*");
479 let valid = stem.is_some_and(|stem| {
480 !stem.is_empty()
481 && stem
482 .bytes()
483 .next()
484 .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
485 && stem
486 .bytes()
487 .next_back()
488 .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit())
489 && stem
490 .bytes()
491 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
492 });
493 if !valid {
494 return Err(refusal(format!(
495 "Invalid family selector {selector:?}. Use one literal skill-name prefix followed by a terminal \"*\", for example \"game-*\"."
496 )));
497 }
498 Ok(format!("{}-", stem.expect("validated family stem")))
499}
500
501pub fn hash_skill(root: &Path, target: &Path, host: &Host) -> Result<HashReport, Error> {
502 let target = target_context(root, target)?;
503 let hash = hash_target_directory(&target.target_real)?;
504 Ok(HashReport {
505 schema: host.hash_schema(),
506 content_hash: hash.content_hash,
507 file_count: hash.file_count,
508 })
509}
510
511pub fn skill_key_for_target(root: &Path, target: &Path) -> Result<String, Error> {
512 let rooted_target = if target.is_absolute() {
513 target.to_owned()
514 } else {
515 root.join(target)
516 };
517 let target = target_context(root, &rooted_target)?;
518 target
519 .evidence_directory
520 .file_name()
521 .and_then(|name| name.to_str())
522 .map(str::to_owned)
523 .ok_or_else(|| unsafe_failure("Derived evidence-store key is not valid UTF-8.".to_owned()))
524}
525
526fn read_stream(path: &Path) -> Result<ValidatedStream, Error> {
531 match fs::read(path) {
532 Ok(bytes) => Ok(ValidatedStream::parse(&bytes, &path.display().to_string())),
533 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(ValidatedStream::empty()),
534 Err(error) => Err(unsafe_failure(format!(
535 "Could not read event stream {}: {error}",
536 path.display()
537 ))),
538 }
539}
540
541pub fn read_validated_event_stream(path: &Path) -> Result<ValidatedEventStream, Error> {
542 let stream = read_stream(path)?;
543 Ok(ValidatedEventStream {
544 integrity_errors: stream.integrity_errors().to_vec(),
545 events: stream
546 .events()
547 .iter()
548 .map(|event| event.raw.clone())
549 .collect(),
550 })
551}
552
553pub fn derive_store(
554 root: &Path,
555 target: &Path,
556 inputs: &DerivationInputs,
557) -> Result<GateStatus, Error> {
558 validate_derivation_inputs(inputs)?;
559 let target = target_context(root, target)?;
560 let events_path = target.evidence_directory.join("events.jsonl");
561 if !target.evidence_directory.exists() {
562 return Err(refusal(format!(
563 "Cannot derive a gate projection because the event stream does not exist: {}. Nothing modified.",
564 events_path.display()
565 )));
566 }
567 let _lock = EvidenceLock::acquire(&target.evidence_directory, &inputs.lock_owner)?;
568 if !events_path.is_file() {
569 return Err(refusal(format!(
570 "Cannot derive a gate projection because the event stream does not exist: {}. Nothing modified.",
571 events_path.display()
572 )));
573 }
574 let hash = hash_target_directory(&target.target_real)?;
575 let stream = read_stream(&events_path)?;
576 let status = gate::derive(&stream, target.labels(), &hash.content_hash, inputs.clock());
577 write_gate_status(&target.evidence_directory, &status)?;
578 Ok(status)
579}
580
581pub fn evolution_preflight(
582 root: &Path,
583 target: &Path,
584 inputs: &LifecycleInputs,
585 host: &Host,
586) -> Result<Value, Error> {
587 let derivation_inputs = lifecycle_derivation_inputs(inputs)?;
588 let target = lifecycle_target_context(root, target, &inputs.operator_skill)?;
589 let operating_package =
590 assets::compare_installed_skill_evolution_package(&inputs.operator_skill, host)?;
591 fs::create_dir_all(&target.evidence_directory).map_err(|error| {
592 unsafe_failure(format!(
593 "Could not create evidence directory {}: {error}",
594 target.evidence_directory.display()
595 ))
596 })?;
597 let _lock = EvidenceLock::acquire(&target.evidence_directory, &inputs.lock_owner)?;
598 let (stream, hash, status) = authorize_evolution(&target, &derivation_inputs)?;
599 let mut receipt = serde_json::json!({
600 "authorized": true,
601 "target": {
602 "name": target.target_name,
603 "repo_relative_path": target.repo_relative_path,
604 "content_hash": hash.content_hash
605 },
606 "evidence_dir": target
607 .evidence_directory
608 .strip_prefix(&target.repository_root)
609 .unwrap_or(&target.evidence_directory)
610 .to_string_lossy()
611 .replace(std::path::MAIN_SEPARATOR, "/"),
612 "gate": {
613 "state": status.state,
614 "authorization_reason": status.authorization_reason,
615 "trigger_event_ids": status.trigger_event_ids,
616 "threshold_session_id": status.threshold_session_id
617 },
618 "evidence_packet": evolution_evidence_packet(&target, stream.events(), &status)
619 });
620 if !operating_package.matches_shipped {
621 receipt["operating_package_matches_shipped"] = serde_json::json!(false);
622 receipt["operating_package_differing_files"] =
623 serde_json::json!(operating_package.differing_files);
624 }
625 Ok(receipt)
626}
627
628struct OperatingPackageProvenance {
629 skill_hash: String,
630 matches_shipped: bool,
631 differing_files: Vec<String>,
632}
633
634fn operating_package_provenance(
635 inputs: &LifecycleEventInputs,
636 host: &Host,
637) -> Result<OperatingPackageProvenance, Error> {
638 let comparison =
639 assets::compare_installed_skill_evolution_package(&inputs.operator_skill, host)?;
640 Ok(OperatingPackageProvenance {
641 skill_hash: hash_target_directory(&inputs.operator_skill)?.content_hash,
642 matches_shipped: comparison.matches_shipped,
643 differing_files: comparison.differing_files,
644 })
645}
646
647pub fn evolution_claim(
648 root: &Path,
649 target: &Path,
650 request: &EvolutionClaimRequest,
651 inputs: &LifecycleEventInputs,
652 host: &Host,
653) -> Result<Value, Error> {
654 if !matches!(
655 request.risk_tier.as_str(),
656 "provisional" | "ordinary" | "high"
657 ) {
658 return Err(refusal(
659 "--risk-tier must be one of provisional|ordinary|high".to_owned(),
660 ));
661 }
662 if request.review_id.is_empty() {
663 return Err(refusal("Missing required --review-id.".to_owned()));
664 }
665 let derivation_inputs = lifecycle_event_derivation_inputs(inputs)?;
666 let target = lifecycle_target_context(root, target, &inputs.operator_skill)?;
667 let operating_package = operating_package_provenance(inputs, host)?;
668 if !operating_package.matches_shipped {
669 return Err(refusal(format!(
670 "{} installed Skill Evolution file(s) differ from the ones this crate ships: {}. Run `{} skills evidence install --root <repository> --force` before claiming. Nothing modified.",
671 operating_package.differing_files.len(),
672 operating_package.differing_files.join(", "),
673 host.command
674 )));
675 }
676 fs::create_dir_all(&target.evidence_directory).map_err(|error| {
677 unsafe_failure(format!(
678 "Could not create evidence directory {}: {error}",
679 target.evidence_directory.display()
680 ))
681 })?;
682 let _lock = EvidenceLock::acquire(&target.evidence_directory, &inputs.lock_owner)?;
683 let (stream, hash, status) = authorize_evolution(&target, &derivation_inputs)?;
684 let proof = status.threshold_session_id.as_ref().map_or_else(
685 || {
686 serde_json::json!({
687 "type": "cooldown_elapsed",
688 "not_before": status.not_before,
689 "claimed_at": inputs.recorded_at
690 })
691 },
692 |threshold_session_id| {
693 serde_json::json!({
694 "type": "different_session",
695 "threshold_session_id": threshold_session_id,
696 "review_session_id": inputs.session_id
697 })
698 },
699 );
700 let mut payload = serde_json::json!({
701 "review_id": request.review_id,
702 "target_hash": hash.content_hash,
703 "trigger_event_ids": status.trigger_event_ids,
704 "authorizing_rule": status.authorization_reason,
705 "risk_tier": request.risk_tier,
706 "session_or_cooldown_proof": proof
707 });
708 payload["operating_skill_hash"] = serde_json::json!(operating_package.skill_hash);
709 payload["operating_package_matches_shipped"] =
710 serde_json::json!(operating_package.matches_shipped);
711 let event = lifecycle_event(
712 &target,
713 &hash.content_hash,
714 "skill-evolution",
715 "review_started",
716 payload,
717 inputs,
718 );
719 let after = append_lifecycle_event(
720 &target,
721 &hash.content_hash,
722 &stream,
723 event,
724 &derivation_inputs,
725 )?;
726 if after.active_review_id.as_deref() != Some(request.review_id.as_str()) {
727 return Err(unsafe_failure(format!(
728 "Claim appended but another review owns the target ({}). Stop without semantic analysis.",
729 after.active_review_id.as_deref().unwrap_or("none")
730 )));
731 }
732 Ok(serde_json::json!({
733 "review_id": request.review_id,
734 "state": after.state,
735 "target_hash": hash.content_hash,
736 "authorizing_rule": status.authorization_reason,
737 "trigger_event_ids": status.trigger_event_ids,
738 "risk_tier": request.risk_tier,
739 "evidence_dir": evidence_relative_path(&target)
740 }))
741}
742
743pub fn evolution_record_validation(
744 root: &Path,
745 target: &Path,
746 request: &EvolutionValidationRequest,
747 inputs: &LifecycleEventInputs,
748 host: &Host,
749) -> Result<Value, Error> {
750 if request.review_id.is_empty() {
751 return Err(refusal("Missing required --review-id.".to_owned()));
752 }
753 if !matches!(request.decision.as_str(), "accepted" | "rejected") {
754 return Err(refusal("--decision must be accepted|rejected.".to_owned()));
755 }
756 if !matches!(request.risk_tier.as_str(), "ordinary" | "high") {
757 return Err(refusal(
758 "--risk-tier must be final: ordinary|high".to_owned(),
759 ));
760 }
761 let trial_count = request
762 .trials
763 .parse::<usize>()
764 .ok()
765 .filter(|count| *count >= 1)
766 .ok_or_else(|| refusal("--trials must be a positive integer.".to_owned()))?;
767 let minimum = if request.risk_tier == "high" { 5 } else { 3 };
768 if request.decision == "accepted" && trial_count < minimum {
769 return Err(refusal(format!(
770 "An accepted {} change requires at least {minimum} paired trials; got {trial_count}.",
771 request.risk_tier
772 )));
773 }
774 if request.artifacts.is_empty() {
775 return Err(refusal(
776 "Missing required --artifacts <path to retained trial outputs>.".to_owned(),
777 ));
778 }
779 let derivation_inputs = lifecycle_event_derivation_inputs(inputs)?;
780 let target = lifecycle_target_context(root, target, &inputs.operator_skill)?;
781 let candidate_real = resolve_target(&target.repository_root, &request.candidate)?;
782 let _lock = EvidenceLock::acquire(&target.evidence_directory, &inputs.lock_owner)?;
783 let stream = read_valid_lifecycle_stream(&target)?;
784 find_review_start(stream.events(), &request.review_id)?;
785 let hash = hash_target_directory(&target.target_real)?;
786 require_active_ownership(
787 &target,
788 &stream,
789 &hash.content_hash,
790 &request.review_id,
791 &derivation_inputs,
792 "Review",
793 )?;
794 let candidate_hash = hash_target_directory(&candidate_real)?;
795 let candidate_path = candidate_real
796 .strip_prefix(&target.repository_root)
797 .unwrap_or(&candidate_real)
798 .to_string_lossy()
799 .replace(std::path::MAIN_SEPARATOR, "/");
800 let operating_package = operating_package_provenance(inputs, host)?;
801 let event = lifecycle_event(
802 &target,
803 &hash.content_hash,
804 "skill-evolution",
805 "validation_completed",
806 serde_json::json!({
807 "review_id": request.review_id,
808 "decision": request.decision,
809 "risk_tier": request.risk_tier,
810 "candidate_hash": candidate_hash.content_hash,
811 "candidate_path": candidate_path,
812 "trial_count": trial_count,
813 "artifacts_path": request.artifacts,
814 "summary": request.summary,
815 "operating_skill_hash": operating_package.skill_hash,
816 "operating_package_matches_shipped": operating_package.matches_shipped
817 }),
818 inputs,
819 );
820 append_lifecycle_event(
821 &target,
822 &hash.content_hash,
823 &stream,
824 event,
825 &derivation_inputs,
826 )?;
827 Ok(serde_json::json!({
828 "recorded": inputs.event_id,
829 "decision": request.decision,
830 "risk_tier": request.risk_tier,
831 "candidate_hash": candidate_hash.content_hash,
832 "trial_count": trial_count
833 }))
834}
835
836pub fn evolution_land(
837 root: &Path,
838 target: &Path,
839 request: &EvolutionLandRequest,
840 inputs: &LifecycleEventInputs,
841 host: &Host,
842) -> Result<Value, Error> {
843 if request.review_id.is_empty() {
844 return Err(refusal("Missing required --review-id.".to_owned()));
845 }
846 let derivation_inputs = lifecycle_event_derivation_inputs(inputs)?;
847 let target = lifecycle_target_context(root, target, &inputs.operator_skill)?;
848 let candidate_real = resolve_target(&target.repository_root, &request.candidate)?;
849 let operating_package = operating_package_provenance(inputs, host)?;
852 let _lock = EvidenceLock::acquire(&target.evidence_directory, &inputs.lock_owner)?;
853 let stream = read_valid_lifecycle_stream(&target)?;
854 let review = find_review_start(stream.events(), &request.review_id)?;
855 let live_hash = hash_target_directory(&target.target_real)?.content_hash;
856 require_active_ownership(
857 &target,
858 &stream,
859 &live_hash,
860 &request.review_id,
861 &derivation_inputs,
862 "Review",
863 )?;
864 let baseline = review
865 .raw
866 .pointer("/payload/target_hash")
867 .and_then(Value::as_str)
868 .expect("validated review_started target hash")
869 .to_owned();
870 if live_hash != baseline {
871 return Err(refusal(format!(
872 "Live target hash {}… no longer equals the review baseline {}…; the target changed since the claim. Authorization expired — stop without landing.",
873 &live_hash[..12],
874 &baseline[..baseline.len().min(12)]
875 )));
876 }
877 let preparation = prepare_validated_landing(
878 &target,
879 stream.events(),
880 &live_hash,
881 &candidate_real,
882 ValidatedLandingPolicy {
883 owner_id: &request.review_id,
884 backup_directory: target
885 .evidence_directory
886 .join("reviews")
887 .join(&request.review_id)
888 .join("pre-land-backup"),
889 event_id: &inputs.event_id,
890 },
891 )?;
892 let mirror = expected_mirror_path(&target);
893 let landing = land_validated_candidate(
894 &target.target_real,
895 &candidate_real,
896 &preparation.backup_directory,
897 &baseline,
898 &preparation.candidate_hash,
899 mirror.as_deref(),
900 )?;
901 let event = lifecycle_event(
902 &target,
903 &landing.after_hash,
904 "skill-evolution",
905 "change_landed",
906 serde_json::json!({
907 "review_id": request.review_id,
908 "before_hash": baseline,
909 "after_hash": landing.after_hash,
910 "changed_files": landing.changed_files,
911 "mirror_status": landing.mirror_status,
912 "operating_skill_hash": operating_package.skill_hash,
913 "operating_package_matches_shipped": operating_package.matches_shipped
914 }),
915 inputs,
916 );
917 append_lifecycle_event(
918 &target,
919 &landing.after_hash,
920 &stream,
921 event,
922 &derivation_inputs,
923 )?;
924 Ok(serde_json::json!({
925 "landed": true,
926 "before_hash": baseline,
927 "after_hash": landing.after_hash,
928 "changed_files": landing.changed_files,
929 "mirror_status": landing.mirror_status,
930 "backup": preparation
931 .backup_directory
932 .strip_prefix(&target.repository_root)
933 .unwrap_or(&preparation.backup_directory)
934 .to_string_lossy()
935 .replace(std::path::MAIN_SEPARATOR, "/")
936 }))
937}
938
939fn extend_without_duplicates(destination: &mut Vec<String>, additions: &[String]) {
944 for identity in additions {
945 if !destination.contains(identity) {
946 destination.push(identity.clone());
947 }
948 }
949}
950
951pub fn evolution_close(
952 root: &Path,
953 target: &Path,
954 request: &EvolutionCloseRequest,
955 inputs: &LifecycleEventInputs,
956 host: &Host,
957) -> Result<Value, Error> {
958 if request.review_id.is_empty() {
959 return Err(refusal("Missing required --review-id.".to_owned()));
960 }
961 if !DISPOSITIONS.contains(&request.disposition.as_str()) {
962 return Err(refusal(format!(
963 "--disposition must be one of {}",
964 DISPOSITIONS.join("|")
965 )));
966 }
967 if request.note.is_empty() {
968 return Err(refusal(
969 "Missing required --note: record the adjudication rationale in the immutable event."
970 .to_owned(),
971 ));
972 }
973 let trial_count = request
974 .trials
975 .as_ref()
976 .map(|trials| {
977 trials
978 .parse::<usize>()
979 .ok()
980 .filter(|count| *count >= 1)
981 .ok_or_else(|| refusal("--trials must be a positive integer.".to_owned()))
982 })
983 .transpose()?;
984 if request.artifacts.as_ref().is_some_and(String::is_empty) {
985 return Err(refusal(
986 "--artifacts must name the retained validation outputs when supplied.".to_owned(),
987 ));
988 }
989 if !EVOLUTION_ADJUDICATING_DISPOSITIONS.contains(&request.disposition.as_str())
990 && !request.adjudicate.is_empty()
991 {
992 return Err(refusal(format!(
993 "--adjudicate is not allowed with non-adjudicating disposition {}. Nothing done.",
994 request.disposition
995 )));
996 }
997 if !EVOLUTION_ADJUDICATING_DISPOSITIONS.contains(&request.disposition.as_str())
998 && !request.concluded.is_empty()
999 {
1000 return Err(refusal(format!(
1001 "--concluded is not allowed with non-adjudicating disposition {}. Nothing done.",
1002 request.disposition
1003 )));
1004 }
1005 if !EVOLUTION_ADJUDICATING_DISPOSITIONS.contains(&request.disposition.as_str())
1009 && !request.instrument_limited.is_empty()
1010 {
1011 return Err(refusal(format!(
1012 "--instrument-limited is not allowed with non-adjudicating disposition {}, which already reaches no conclusion about anything it covers. Nothing done.",
1013 request.disposition
1014 )));
1015 }
1016 if request.disposition != "blocked_no_valid_test"
1017 && request.instrument_limited.is_empty()
1018 && !request.constraint_provenance.is_empty()
1019 {
1020 return Err(refusal(
1021 "--constraint-provenance is allowed only for blocked_no_valid_test or an event named --instrument-limited. Nothing done."
1022 .to_owned(),
1023 ));
1024 }
1025 let derivation_inputs = lifecycle_event_derivation_inputs(inputs)?;
1026 let target = lifecycle_target_context(root, target, &inputs.operator_skill)?;
1027 let _lock = EvidenceLock::acquire(&target.evidence_directory, &inputs.lock_owner)?;
1028 let stream = read_valid_lifecycle_stream(&target)?;
1029 let review = find_review_start(stream.events(), &request.review_id)?;
1030 let trigger_event_ids = review
1031 .raw
1032 .pointer("/payload/trigger_event_ids")
1033 .and_then(Value::as_array)
1034 .expect("validated review trigger identities")
1035 .iter()
1036 .map(|identity| {
1037 identity
1038 .as_str()
1039 .expect("validated review trigger identity")
1040 .to_owned()
1041 })
1042 .collect::<Vec<_>>();
1043 if stream.events().iter().any(|event| {
1044 matches!(event.kind, EventKind::ReviewDisposition { .. })
1045 && event.review_id() == Some(request.review_id.as_str())
1046 }) {
1047 return Err(refusal(format!(
1048 "Review {} already has a review_disposition. Nothing done.",
1049 request.review_id
1050 )));
1051 }
1052 let report = target
1053 .evidence_directory
1054 .join("reviews")
1055 .join(format!("{}.md", request.review_id));
1056 if !report.is_file() {
1057 let expected = report
1058 .strip_prefix(&target.repository_root)
1059 .unwrap_or(&report)
1060 .to_string_lossy()
1061 .replace(std::path::MAIN_SEPARATOR, "/");
1062 return Err(refusal(format!(
1063 "Review report must exist before close: {expected}. Nothing done."
1064 )));
1065 }
1066 let hash = hash_target_directory(&target.target_real)?.content_hash;
1067 let landed = stream.events().iter().any(|event| {
1068 matches!(event.kind, EventKind::ChangeLanded { .. })
1069 && event.review_id() == Some(request.review_id.as_str())
1070 });
1071 let before = if landed {
1072 if request.disposition != "resolved_by_change" {
1073 return Err(refusal(
1074 "A change already landed for this review; the only valid disposition is resolved_by_change."
1075 .to_owned(),
1076 ));
1077 }
1078 None
1079 } else {
1080 let before = require_active_ownership(
1081 &target,
1082 &stream,
1083 &hash,
1084 &request.review_id,
1085 &derivation_inputs,
1086 "Review",
1087 )?;
1088 if request.disposition == "resolved_by_change" {
1089 return Err(refusal(
1090 "resolved_by_change requires a change_landed event for this review; land first or pick a no-change disposition."
1091 .to_owned(),
1092 ));
1093 }
1094 Some(before)
1095 };
1096 if request.disposition == "candidate_rejected_validation" {
1097 let latest = stream.events().iter().rfind(|event| {
1098 matches!(event.kind, EventKind::ValidationCompleted { .. })
1099 && event.review_id() == Some(request.review_id.as_str())
1100 });
1101 if latest.and_then(|event| {
1102 event
1103 .raw
1104 .pointer("/payload/decision")
1105 .and_then(Value::as_str)
1106 }) != Some("rejected")
1107 {
1108 return Err(refusal(
1109 "candidate_rejected_validation requires the latest validation_completed for this review to record decision=rejected."
1110 .to_owned(),
1111 ));
1112 }
1113 }
1114 let known = stream
1115 .events()
1116 .iter()
1117 .map(|event| event.event_id.as_str())
1118 .collect::<HashSet<_>>();
1119 for identity in &request.adjudicate {
1120 if !known.contains(identity.as_str()) {
1121 return Err(refusal(format!(
1122 "--adjudicate references unknown event_id {identity}. Nothing done."
1123 )));
1124 }
1125 }
1126 let mut coverage = trigger_event_ids;
1127 extend_without_duplicates(&mut coverage, &request.adjudicate);
1128 for (flag, routes) in [
1129 ("--concluded", request.concluded.as_slice()),
1130 (
1131 "--instrument-limited",
1132 request.instrument_limited.as_slice(),
1133 ),
1134 ] {
1135 let mut seen = HashSet::new();
1136 if let Some(duplicate) = routes.iter().find(|identity| !seen.insert(*identity)) {
1137 return Err(refusal(format!(
1138 "{flag} names event {duplicate} more than once. Nothing done."
1139 )));
1140 }
1141 }
1142 for identity in &request.concluded {
1143 if !coverage.contains(identity) {
1144 return Err(refusal(format!(
1145 "--concluded {identity} is not in this review's coverage list. Nothing done."
1146 )));
1147 }
1148 }
1149 for identity in &request.instrument_limited {
1154 if !coverage.contains(identity) {
1155 return Err(refusal(format!(
1156 "--instrument-limited {identity} is not in this review's coverage list; it can only narrow what the close concluded, never widen what it covered. Nothing done."
1157 )));
1158 }
1159 }
1160 let mut resolved_constraint_provenance =
1161 Vec::with_capacity(request.constraint_provenance.len());
1162 for citation in &request.constraint_provenance {
1163 if !coverage.contains(&citation.event_id) {
1164 return Err(refusal(format!(
1165 "--constraint-provenance event {} is not in this review's coverage list. Nothing done.",
1166 citation.event_id
1167 )));
1168 }
1169 if citation.constraint_label.is_empty() {
1170 return Err(refusal(
1171 "--constraint-provenance CONSTRAINT_LABEL must be non-empty. Nothing done."
1172 .to_owned(),
1173 ));
1174 }
1175 let field = citation.field.as_str();
1176 let field_value = stream
1177 .events()
1178 .iter()
1179 .find(|event| event.event_id == citation.event_id)
1180 .and_then(|event| event.raw.pointer(&format!("/payload/{field}")))
1181 .and_then(Value::as_str)
1182 .filter(|value| !value.is_empty());
1183 let Some(field_value) = field_value else {
1184 return Err(refusal(format!(
1185 "--constraint-provenance {} {} {field} names an absent, null, or empty field. Nothing done.",
1186 citation.constraint_label, citation.event_id
1187 )));
1188 };
1189 resolved_constraint_provenance.push(serde_json::json!({
1190 "constraint_label": citation.constraint_label,
1191 "event_id": citation.event_id,
1192 "field": field,
1193 "field_value": field_value
1194 }));
1195 }
1196 let provenance_required = if request.disposition == "blocked_no_valid_test" {
1197 coverage.as_slice()
1198 } else {
1199 request.instrument_limited.as_slice()
1200 };
1201 for identity in provenance_required {
1202 if !request
1203 .constraint_provenance
1204 .iter()
1205 .any(|citation| citation.event_id == *identity)
1206 {
1207 return Err(refusal(format!(
1208 "Instrument-limited event {identity} requires at least one --constraint-provenance <CONSTRAINT_LABEL> {identity} <FIELD>. Nothing done."
1209 )));
1210 }
1211 }
1212 if EVOLUTION_ADJUDICATING_DISPOSITIONS.contains(&request.disposition.as_str()) {
1213 for identity in &coverage {
1214 match (
1215 request.concluded.contains(identity),
1216 request.instrument_limited.contains(identity),
1217 ) {
1218 (true, false) | (false, true) => {}
1219 (false, false) => {
1220 return Err(refusal(format!(
1221 "Covered event {identity} has no explicit route; pass --concluded {identity} or --instrument-limited {identity}. Nothing done."
1222 )));
1223 }
1224 (true, true) => {
1225 return Err(refusal(format!(
1226 "Covered event {identity} has conflicting --concluded and --instrument-limited routes. Nothing done."
1227 )));
1228 }
1229 }
1230 }
1231 }
1232 if request.disposition != "outside_target" && !request.external_owners.is_empty() {
1233 return Err(refusal(format!(
1234 "--external-owner is allowed only with disposition outside_target, not {}. Nothing done.",
1235 request.disposition
1236 )));
1237 }
1238 if request.disposition == "outside_target" {
1239 for owner in &request.external_owners {
1240 if owner.reference.is_empty() {
1241 return Err(refusal(format!(
1242 "--external-owner REFERENCE must be non-empty for event {}. Nothing done.",
1243 owner.event_id
1244 )));
1245 }
1246 if !request.concluded.contains(&owner.event_id) {
1247 return Err(refusal(format!(
1248 "--external-owner event {} is not in this close's concluded route. Nothing done.",
1249 owner.event_id
1250 )));
1251 }
1252 }
1253 for identity in &request.concluded {
1254 let owner_count = request
1255 .external_owners
1256 .iter()
1257 .filter(|owner| owner.event_id == *identity)
1258 .count();
1259 if owner_count != 1 {
1260 return Err(refusal(format!(
1261 "Concluded outside_target event {identity} requires exactly one --external-owner {identity} <KIND> <REFERENCE>. Nothing done."
1262 )));
1263 }
1264 }
1265 }
1266 let operating_package = operating_package_provenance(inputs, host)?;
1275 let mut payload = serde_json::json!({
1276 "review_id": request.review_id,
1277 "disposition": request.disposition,
1278 "adjudicated_event_ids": coverage,
1279 "note": request.note,
1280 "operating_skill_hash": operating_package.skill_hash,
1281 "operating_package_matches_shipped": operating_package.matches_shipped
1282 });
1283 if !request.instrument_limited.is_empty() {
1284 payload["instrument_limited_event_ids"] = serde_json::json!(request.instrument_limited);
1288 }
1289 if !resolved_constraint_provenance.is_empty() {
1290 payload["constraint_provenance"] = serde_json::json!(resolved_constraint_provenance);
1291 }
1292 if !request.external_owners.is_empty() {
1293 payload["external_owners"] = serde_json::to_value(&request.external_owners)
1294 .expect("external owners always serialize");
1295 }
1296 if let Some(trial_count) = trial_count {
1297 payload["trial_count"] = serde_json::json!(trial_count);
1298 }
1299 if let Some(artifacts) = &request.artifacts {
1300 payload["artifacts_path"] = serde_json::json!(artifacts);
1301 }
1302 let event = lifecycle_event(
1303 &target,
1304 &hash,
1305 "skill-evolution",
1306 "review_disposition",
1307 payload,
1308 inputs,
1309 );
1310 let prepared = prepare_lifecycle_event(&target, &hash, &stream, event, &derivation_inputs)?;
1311 let after = &prepared.status;
1312 let mut receipt = serde_json::json!({
1313 "closed": request.review_id,
1314 "disposition": request.disposition,
1315 "adjudicated_event_ids": coverage,
1316 "state": after.state
1317 });
1318 if !request.external_owners.is_empty() {
1319 receipt["external_owners"] = serde_json::to_value(&request.external_owners)
1320 .expect("external owners always serialize");
1321 }
1322 if !resolved_constraint_provenance.is_empty() {
1323 receipt["constraint_provenance"] = serde_json::json!(resolved_constraint_provenance);
1324 }
1325 let names_untestable_coverage = !request.instrument_limited.is_empty();
1333 if EVOLUTION_INSTRUMENT_LIMITED_DISPOSITIONS.contains(&request.disposition.as_str())
1334 || names_untestable_coverage
1335 {
1336 let previously_retired = before
1337 .as_ref()
1338 .map(|status| {
1339 status
1340 .instrument_limited_incident_ids
1341 .iter()
1342 .map(String::as_str)
1343 .collect::<HashSet<_>>()
1344 })
1345 .unwrap_or_default();
1346 let retirement_reach = after
1347 .instrument_limited_incident_ids
1348 .iter()
1349 .filter(|identity| !previously_retired.contains(identity.as_str()))
1350 .filter(|identity| {
1351 !names_untestable_coverage || request.instrument_limited.contains(identity)
1352 })
1353 .cloned()
1354 .collect::<Vec<_>>();
1355 receipt["retired_from_gate_event_ids"] = serde_json::json!(retirement_reach);
1356 }
1357 append_prepared_lifecycle_event(&target, &prepared)?;
1358 Ok(receipt)
1359}
1360
1361fn authorize_evolution(
1362 target: &TargetContext,
1363 inputs: &DerivationInputs,
1364) -> Result<(ValidatedStream, DirectoryHash, GateStatus), Error> {
1365 let hash = hash_target_directory(&target.target_real)?;
1366 let stream = read_stream(&target.evidence_directory.join("events.jsonl"))?;
1367 let status = gate::derive(&stream, target.labels(), &hash.content_hash, inputs.clock());
1368 write_gate_status(&target.evidence_directory, &status)?;
1369 if status.state == "blocked" {
1370 return Err(evolution_refusal(
1371 "blocked",
1372 "event_stream_integrity_valid",
1373 "refused_closed_gate",
1374 status.instrument_limited_incident_ids.len(),
1375 ));
1376 }
1377 if status.state == "review_in_progress" {
1378 return Err(evolution_refusal(
1379 "review_in_progress",
1380 &format!(
1381 "gate_status.active_review_id IS null (active: {})",
1382 status.active_review_id.as_deref().unwrap_or("none")
1383 ),
1384 "refused_closed_gate",
1385 status.instrument_limited_incident_ids.len(),
1386 ));
1387 }
1388 if matches!(
1389 status.state.as_str(),
1390 "eligible_pending_cooldown" | "quarantined_pending_cooldown"
1391 ) {
1392 return Err(evolution_refusal(
1393 &status.state,
1394 "cooldown_or_different_session_condition_passed",
1395 "refused_cooldown_or_same_session",
1396 status.instrument_limited_incident_ids.len(),
1397 ));
1398 }
1399 if status.authorized_workflow.as_deref() != Some("skill-evolution")
1400 || !matches!(status.state.as_str(), "eligible" | "quarantined_eligible")
1401 {
1402 return Err(evolution_refusal(
1403 &status.state,
1404 "authorized_workflow == \"skill-evolution\" AND state IN {eligible, quarantined_eligible}",
1405 "refused_closed_gate",
1406 status.instrument_limited_incident_ids.len(),
1407 ));
1408 }
1409 if status.target_content_hash != hash.content_hash {
1410 return Err(evolution_refusal(
1411 &status.state,
1412 "current_target_content_hash == gate_status.target_content_hash",
1413 "refused_closed_gate",
1414 status.instrument_limited_incident_ids.len(),
1415 ));
1416 }
1417 Ok((stream, hash, status))
1418}
1419
1420fn read_valid_lifecycle_stream(target: &TargetContext) -> Result<ValidatedStream, Error> {
1421 let stream = read_stream(&target.evidence_directory.join("events.jsonl"))?;
1422 if !stream.is_intact() {
1423 return Err(unsafe_failure(format!(
1424 "Event stream integrity failure — nothing done:\n {}",
1425 stream.integrity_errors().join("\n ")
1426 )));
1427 }
1428 Ok(stream)
1429}
1430
1431fn find_review_start<'a>(
1432 events: &'a [EvidenceEvent],
1433 review_id: &str,
1434) -> Result<&'a EvidenceEvent, Error> {
1435 events
1436 .iter()
1437 .find(|event| {
1438 matches!(event.kind, EventKind::ReviewStarted { .. })
1439 && event.review_id() == Some(review_id)
1440 })
1441 .ok_or_else(|| {
1442 refusal(format!(
1443 "No review_started event found for review {review_id}. Nothing done."
1444 ))
1445 })
1446}
1447
1448fn require_active_ownership(
1449 target: &TargetContext,
1450 stream: &ValidatedStream,
1451 current_hash: &str,
1452 expected_id: &str,
1453 inputs: &DerivationInputs,
1454 label: &str,
1455) -> Result<GateStatus, Error> {
1456 let status = gate::derive(stream, target.labels(), current_hash, inputs.clock());
1457 if status.active_review_id.as_deref() != Some(expected_id) {
1458 return Err(refusal(format!(
1459 "{label} {expected_id} does not own the target (active review: {}). Nothing done.",
1460 status.active_review_id.as_deref().unwrap_or("none")
1461 )));
1462 }
1463 Ok(status)
1464}
1465
1466fn evolution_evidence_packet(
1467 target: &TargetContext,
1468 events: &[EvidenceEvent],
1469 status: &GateStatus,
1470) -> Value {
1471 let by_id = events
1472 .iter()
1473 .map(|event| (event.event_id.as_str(), event))
1474 .collect::<HashMap<_, _>>();
1475 let triggers = status
1476 .trigger_event_ids
1477 .iter()
1478 .filter_map(|identity| by_id.get(identity.as_str()).copied())
1479 .collect::<Vec<_>>();
1480 let trigger_keys = triggers
1481 .iter()
1482 .filter_map(|event| {
1483 event
1484 .raw
1485 .pointer("/payload/symptom_key")
1486 .and_then(Value::as_str)
1487 })
1488 .collect::<HashSet<_>>();
1489 let related_prior_dispositions = events
1490 .iter()
1491 .filter(|event| {
1492 matches!(event.kind, EventKind::ReviewDisposition { .. })
1493 && event
1494 .raw
1495 .pointer("/payload/adjudicated_event_ids")
1496 .and_then(Value::as_array)
1497 .is_some_and(|identities| {
1498 identities.iter().any(|identity| {
1499 identity
1500 .as_str()
1501 .and_then(|identity| by_id.get(identity).copied())
1502 .and_then(|adjudicated| {
1503 adjudicated
1504 .raw
1505 .pointer("/payload/symptom_key")
1506 .and_then(Value::as_str)
1507 })
1508 .is_some_and(|key| trigger_keys.contains(key))
1509 })
1510 })
1511 })
1512 .map(|event| event.raw.clone())
1513 .collect::<Vec<_>>();
1514 let review_starts = events
1515 .iter()
1516 .filter_map(|event| match &event.kind {
1517 EventKind::ReviewStarted { review_id } => Some((
1518 review_id.as_str(),
1519 (
1520 event
1521 .raw
1522 .pointer("/payload/target_hash")
1523 .and_then(Value::as_str)
1524 .unwrap_or(event.target_content_hash.as_str()),
1525 event
1526 .raw
1527 .pointer("/payload/operating_skill_hash")
1528 .and_then(Value::as_str),
1529 ),
1530 )),
1531 _ => None,
1532 })
1533 .collect::<HashMap<_, _>>();
1534 let prior_reviews = events
1535 .iter()
1536 .filter_map(|event| match &event.kind {
1537 EventKind::ReviewDisposition {
1538 review_id,
1539 adjudicated_event_ids,
1540 ..
1541 } => {
1542 let mut prior = serde_json::json!({
1543 "review_id": review_id,
1544 "disposition": event.raw.pointer("/payload/disposition").and_then(Value::as_str),
1545 "same_target_hash": review_starts.get(review_id.as_str())
1546 .map(|(target_hash, _)| *target_hash)
1547 == Some(status.target_content_hash.as_str()),
1548 "note": event.raw.pointer("/payload/note").and_then(Value::as_str),
1549 "adjudicated_event_ids": adjudicated_event_ids
1550 });
1551 if let Some(operating_skill_hash) = review_starts
1552 .get(review_id.as_str())
1553 .and_then(|(_, operating_skill_hash)| *operating_skill_hash)
1554 {
1555 prior["operating_skill_hash"] =
1556 Value::String(operating_skill_hash.to_owned());
1557 }
1558 let report = format!("reviews/{review_id}.md");
1559 if target.evidence_directory.join(&report).is_file() {
1560 prior["report"] = serde_json::json!(report);
1561 }
1562 for field in ["trial_count", "artifacts_path", "external_owners"] {
1563 if let Some(value) = event.raw.pointer(&format!("/payload/{field}")) {
1564 prior[field] = value.clone();
1565 }
1566 }
1567 Some(prior)
1568 }
1569 _ => None,
1570 })
1571 .collect::<Vec<_>>();
1572 let mut cited_evidence_refs = Vec::new();
1573 for reference in triggers.iter().flat_map(|event| {
1574 event
1575 .raw
1576 .pointer("/payload/evidence_refs")
1577 .and_then(Value::as_array)
1578 .into_iter()
1579 .flatten()
1580 .filter_map(Value::as_str)
1581 }) {
1582 if !cited_evidence_refs.contains(&reference) {
1583 cited_evidence_refs.push(reference);
1584 }
1585 }
1586 serde_json::json!({
1587 "trigger_events": triggers.iter().map(|event| &event.raw).collect::<Vec<_>>(),
1588 "qualifying_uses_on_current_hash": status.qualifying_uses_on_current_hash,
1589 "open_incident_ids": status.open_incident_ids,
1590 "instrument_limited_incident_ids": status.instrument_limited_incident_ids,
1594 "candidate_clusters": status.candidate_clusters,
1595 "prior_reviews": prior_reviews,
1596 "related_prior_dispositions": related_prior_dispositions,
1597 "cited_evidence_refs": cited_evidence_refs
1598 })
1599}
1600
1601fn validate_derivation_inputs(inputs: &DerivationInputs) -> Result<(), Error> {
1602 if OffsetDateTime::parse(&inputs.generated_at, &Rfc3339).is_err() {
1603 return Err(refusal(
1604 "The explicit derivation clock is not an RFC 3339 timestamp.".to_owned(),
1605 ));
1606 }
1607 for (label, value) in [
1608 ("session identity", inputs.session_id.as_str()),
1609 ("lock owner identity", inputs.lock_owner.as_str()),
1610 ] {
1611 if value.is_empty() {
1612 return Err(refusal(format!("Missing explicit {label}.")));
1613 }
1614 }
1615 Ok(())
1616}
1617
1618fn lifecycle_derivation_inputs(inputs: &LifecycleInputs) -> Result<DerivationInputs, Error> {
1619 let derivation_inputs = DerivationInputs {
1620 generated_at: inputs.recorded_at.clone(),
1621 now_epoch_milliseconds: inputs.now_epoch_milliseconds,
1622 session_id: inputs.session_id.clone(),
1623 lock_owner: inputs.lock_owner.clone(),
1624 };
1625 validate_derivation_inputs(&derivation_inputs)?;
1626 Ok(derivation_inputs)
1627}
1628
1629fn lifecycle_event_derivation_inputs(
1630 inputs: &LifecycleEventInputs,
1631) -> Result<DerivationInputs, Error> {
1632 let derivation_inputs = DerivationInputs {
1633 generated_at: inputs.recorded_at.clone(),
1634 now_epoch_milliseconds: inputs.now_epoch_milliseconds,
1635 session_id: inputs.session_id.clone(),
1636 lock_owner: inputs.lock_owner.clone(),
1637 };
1638 validate_derivation_inputs(&derivation_inputs)?;
1639 for (label, value) in [
1640 ("event identity", inputs.event_id.as_str()),
1641 ("repository identity", inputs.repository_head.as_str()),
1642 ] {
1643 if value.is_empty() {
1644 return Err(refusal(format!("Missing explicit {label}.")));
1645 }
1646 }
1647 Ok(derivation_inputs)
1648}
1649
1650fn lifecycle_event(
1651 target: &TargetContext,
1652 target_hash: &str,
1653 operator_workflow: &str,
1654 event_type: &str,
1655 payload: Value,
1656 inputs: &LifecycleEventInputs,
1657) -> Value {
1658 serde_json::json!({
1659 "schema_version": 1,
1660 "event_id": inputs.event_id,
1661 "event_type": event_type,
1662 "recorded_at": inputs.recorded_at,
1663 "operator_workflow": operator_workflow,
1664 "target": {
1665 "name": target.target_name,
1666 "repo_relative_path": target.repo_relative_path,
1667 "content_hash": target_hash,
1668 "repo_head": inputs.repository_head
1669 },
1670 "top_level_session_id": inputs.session_id,
1671 "payload": payload
1672 })
1673}
1674
1675struct PreparedLifecycleEvent {
1676 event: Value,
1677 status: GateStatus,
1678 projection_temporary: PathBuf,
1679}
1680
1681fn prepare_lifecycle_event(
1682 target: &TargetContext,
1683 current_hash: &str,
1684 stream: &ValidatedStream,
1685 event: Value,
1686 inputs: &DerivationInputs,
1687) -> Result<PreparedLifecycleEvent, Error> {
1688 let extended = stream.accept_candidate(&event).map_err(|errors| {
1689 unsafe_failure(format!(
1690 "Constructed event failed validation — nothing appended:\n {}",
1691 errors.join("\n ")
1692 ))
1693 })?;
1694 let status = gate::derive(&extended, target.labels(), current_hash, inputs.clock());
1695 let projection_temporary = target.evidence_directory.join(".gate-status.json.tmp");
1696 prepare_gate_status(&projection_temporary, &status)?;
1697 Ok(PreparedLifecycleEvent {
1698 event,
1699 status,
1700 projection_temporary,
1701 })
1702}
1703
1704fn append_prepared_lifecycle_event(
1705 target: &TargetContext,
1706 prepared: &PreparedLifecycleEvent,
1707) -> Result<(), Error> {
1708 let events_path = target.evidence_directory.join("events.jsonl");
1709 if let Err(error) = append_event(&events_path, &prepared.event) {
1710 let _ = fs::remove_file(&prepared.projection_temporary);
1711 return Err(error);
1712 }
1713 let projection = target.evidence_directory.join("gate-status.json");
1714 fs::rename(&prepared.projection_temporary, &projection).map_err(|error| {
1715 unsafe_failure_with_recovery(
1716 format!(
1717 "Evidence event appended, but the gate projection could not be atomically replaced ({}).",
1718 error
1719 ),
1720 Recovery::RederiveGate,
1721 )
1722 })?;
1723 Ok(())
1724}
1725
1726fn append_lifecycle_event(
1727 target: &TargetContext,
1728 current_hash: &str,
1729 stream: &ValidatedStream,
1730 event: Value,
1731 inputs: &DerivationInputs,
1732) -> Result<GateStatus, Error> {
1733 let prepared = prepare_lifecycle_event(target, current_hash, stream, event, inputs)?;
1734 let status = prepared.status.clone();
1735 append_prepared_lifecycle_event(target, &prepared)?;
1736 Ok(status)
1737}
1738
1739fn evidence_relative_path(target: &TargetContext) -> String {
1740 target
1741 .evidence_directory
1742 .strip_prefix(&target.repository_root)
1743 .unwrap_or(&target.evidence_directory)
1744 .to_string_lossy()
1745 .replace(std::path::MAIN_SEPARATOR, "/")
1746}
1747
1748fn evolution_refusal(
1749 state: &str,
1750 condition: &str,
1751 outcome: &str,
1752 instrument_limited_incidents: usize,
1753) -> Error {
1754 let retired = match instrument_limited_incidents {
1758 0 => String::new(),
1759 count => format!(
1760 "\nRetired as untestable: {count} (an earlier close could not decide that evidence and removed it from this gate)."
1761 ),
1762 };
1763 refusal(format!(
1764 "Skill Evolution not authorized.\nGate: {state}.{retired}\nFailed condition: {condition}.\nNo target analysis or modification performed.\nTerminal outcome: {outcome}."
1765 ))
1766}
1767
1768pub fn record_use(
1769 root: &Path,
1770 target: &Path,
1771 request: &RecordUseRequest,
1772 inputs: &RecordInputs,
1773 host: &Host,
1774) -> Result<RecordReceipt, Error> {
1775 validate_record_request(request)?;
1776 if OffsetDateTime::parse(&inputs.recorded_at, &Rfc3339).is_err() {
1777 return Err(refusal(
1778 "The explicit record clock is not an RFC 3339 timestamp.".to_owned(),
1779 ));
1780 }
1781 for (label, value) in [
1782 ("event identity", inputs.event_id.as_str()),
1783 ("repository identity", inputs.repository_head.as_str()),
1784 ("session identity", inputs.session_id.as_str()),
1785 ("lock owner identity", inputs.lock_owner.as_str()),
1786 ] {
1787 if value.is_empty() {
1788 return Err(refusal(format!("Missing explicit {label}.")));
1789 }
1790 }
1791
1792 let target = target_context(root, target)?;
1793 let capture_skill_directory = target
1794 .repository_root
1795 .join(".claude/skills/skill-evidence-capture");
1796 let is_capture_skill = if capture_skill_directory.exists() {
1797 capture_skill_directory.canonicalize().map_err(|error| {
1798 unsafe_failure(format!(
1799 "Could not resolve the Skill Evidence Capture directory {}: {error}",
1800 capture_skill_directory.display()
1801 ))
1802 })? == target.target_real
1803 } else {
1804 false
1805 };
1806 if is_capture_skill {
1807 if !matches!(
1808 Outcome::parse(&request.outcome),
1809 Some(Outcome::MaterialFailure | Outcome::SevereIncident)
1810 ) {
1811 return Err(refusal(
1812 "Self-receipts are incident-only: Skill Evidence Capture never records its own clean or friction uses. Nothing recorded."
1813 .to_owned(),
1814 ));
1815 }
1816 if request.evidence_refs.is_empty() {
1817 return Err(refusal(
1818 "A self-targeted incident must cite concrete evidence of the failed capture attempt (--evidence-ref). Nothing recorded."
1819 .to_owned(),
1820 ));
1821 }
1822 }
1823
1824 fs::create_dir_all(&target.evidence_directory).map_err(|error| {
1825 unsafe_failure(format!(
1826 "Could not create evidence directory {}: {error}",
1827 target.evidence_directory.display()
1828 ))
1829 })?;
1830 let _lock = EvidenceLock::acquire(&target.evidence_directory, &inputs.lock_owner)?;
1831 let hash = hash_target_directory(&target.target_real)?;
1832 let events_path = target.evidence_directory.join("events.jsonl");
1833 let stream = read_stream(&events_path)?;
1834 if !stream.is_intact() {
1835 return Err(unsafe_failure(format!(
1836 "Event stream integrity failure — nothing recorded:\n {}",
1837 stream.integrity_errors().join("\n ")
1838 )));
1839 }
1840
1841 let normalized_label = request
1842 .task_label
1843 .split_whitespace()
1844 .collect::<Vec<_>>()
1845 .join(" ")
1846 .to_lowercase();
1847 let task_fingerprint = short_digest(normalized_label.as_bytes(), 16);
1848 let legacy_run_group = short_digest(
1849 format!("{}::{normalized_label}", target.target_name).as_bytes(),
1850 12,
1851 );
1852 let requested_run_group = request.same_run_group.clone().unwrap_or_else(|| {
1853 short_digest(
1854 format!(
1855 "{}::{normalized_label}::{}",
1856 target.target_name, inputs.session_id
1857 )
1858 .as_bytes(),
1859 12,
1860 )
1861 });
1862 let recorded_uses_on_this_hash = || {
1863 stream
1864 .events()
1865 .iter()
1866 .filter(|event| event.target_content_hash == hash.content_hash)
1867 .filter_map(|event| event.use_recorded().map(|recorded| (event, recorded)))
1868 };
1869 let sibling_run_group = request
1880 .further_incident
1881 .then(|| {
1882 recorded_uses_on_this_hash()
1883 .find(|(event, recorded)| {
1884 event.top_level_session_id == inputs.session_id
1885 && recorded.task_fingerprint == task_fingerprint
1886 })
1887 .map(|(_, recorded)| recorded.same_run_group.clone())
1888 })
1889 .flatten();
1890 let sibling_in_run_group = sibling_run_group.is_some();
1891 let same_run_group = sibling_run_group.unwrap_or(requested_run_group);
1892 let duplicate = recorded_uses_on_this_hash()
1893 .find(|(event, recorded)| {
1894 recorded.same_run_group == same_run_group
1895 || (recorded.same_run_group == legacy_run_group
1896 && event.top_level_session_id == inputs.session_id)
1897 })
1898 .map(|(event, _)| event);
1899 if let Some(duplicate) = duplicate.filter(|_| !sibling_in_run_group) {
1901 let message = if duplicate.top_level_session_id == inputs.session_id {
1902 format!(
1903 "Duplicate receipt refused: run group {same_run_group} already recorded on the unchanged target ({}). A retry or continuation of the same task is the same qualifying use; a genuinely distinct use needs a distinct --task-label.",
1904 duplicate.event_id
1905 )
1906 } else {
1907 format!(
1908 "Duplicate receipt refused: run group {same_run_group} already recorded on the unchanged target by event {} from different top-level session {} at {}. A run recorded in an earlier top-level session cannot receive another receipt while the target is unchanged; do not record the same run again as a new use. Nothing recorded.",
1909 duplicate.event_id, duplicate.top_level_session_id, duplicate.recorded_at
1910 )
1911 };
1912 return Err(refusal(message));
1913 }
1914 if request.further_incident && !sibling_in_run_group {
1915 let elsewhere = recorded_uses_on_this_hash()
1923 .find(|(event, recorded)| {
1924 event.top_level_session_id != inputs.session_id
1925 && recorded.task_fingerprint == task_fingerprint
1926 })
1927 .map(|(event, _)| event);
1928 return Err(refusal(match elsewhere {
1929 Some(event) => format!(
1930 "--further-incident reaches only a run recorded in this top-level session, and this task's records were written elsewhere (event {} in session {} at {}). A further incident is matched to its run by that run's own session and task label, so this deviation cannot join a run recorded in another one; recording it without the flag would count one run twice. Nothing recorded.",
1931 event.event_id, event.top_level_session_id, event.recorded_at
1932 ),
1933 None =>
1934 "--further-incident declares another incident from a run this store has not recorded: no use record in this top-level session carries this task's label against the target as it stands. A run's first incident is an ordinary use — record it without the flag. Nothing recorded."
1935 .to_owned(),
1936 }));
1937 }
1938
1939 let clean = Outcome::parse(&request.outcome) == Some(Outcome::Clean);
1940 let event = serde_json::json!({
1941 "schema_version": 1,
1942 "event_id": inputs.event_id,
1943 "event_type": "use_recorded",
1944 "recorded_at": inputs.recorded_at,
1945 "operator_workflow": "skill-evidence-capture",
1946 "target": {
1947 "name": target.target_name,
1948 "repo_relative_path": target.repo_relative_path,
1949 "content_hash": hash.content_hash,
1950 "repo_head": inputs.repository_head
1951 },
1952 "top_level_session_id": inputs.session_id,
1953 "payload": {
1954 "qualifying_use": true,
1955 "retrospective": request.retrospective,
1956 "task_label": request.task_label,
1957 "task_fingerprint": task_fingerprint,
1958 "outcome": request.outcome,
1959 "symptom_key": if clean { None::<&str> } else { request.symptom_key.as_deref() },
1960 "expected": if clean { None::<&str> } else { request.expected.as_deref() },
1961 "observed": if clean { None::<&str> } else { request.observed.as_deref() },
1962 "consequence": if clean { None::<&str> } else { request.consequence.as_deref() },
1963 "workaround_taken": if clean { None::<&str> } else { request.workaround.as_deref() },
1964 "run_condition": if clean { None::<&str> } else { request.run_condition.as_deref() },
1965 "evidence_refs": request.evidence_refs,
1966 "same_run_group": same_run_group
1967 }
1968 });
1969 let recorded = stream.accept_candidate(&event).map_err(|errors| {
1970 refusal(format!(
1971 "Constructed event is invalid: {}",
1972 errors.join("; ")
1973 ))
1974 })?;
1975 let derivation_inputs = DerivationInputs {
1976 generated_at: inputs.recorded_at.clone(),
1977 now_epoch_milliseconds: inputs.now_epoch_milliseconds,
1978 session_id: inputs.session_id.clone(),
1979 lock_owner: inputs.lock_owner.clone(),
1980 };
1981 let status = gate::derive(
1982 &recorded,
1983 target.labels(),
1984 &hash.content_hash,
1985 derivation_inputs.clock(),
1986 );
1987 let projection_temporary = target.evidence_directory.join(".gate-status.json.tmp");
1988 prepare_gate_status(&projection_temporary, &status)?;
1989 if let Err(error) = append_event(&events_path, &event) {
1990 let _ = fs::remove_file(&projection_temporary);
1991 return Err(error);
1992 }
1993 let projection = target.evidence_directory.join("gate-status.json");
1994 fs::rename(&projection_temporary, &projection).map_err(|error| {
1995 unsafe_failure_with_recovery(
1996 format!(
1997 "Evidence recorded: {}, but the gate projection could not be atomically replaced ({}).",
1998 inputs.event_id, error
1999 ),
2000 Recovery::RederiveGate,
2001 )
2002 })?;
2003 let terminal_reply = build_reply(
2004 &inputs.event_id,
2005 &status,
2006 some_run_holds_several_open_incidents(recorded.events(), &status.open_incident_ids),
2007 );
2008 Ok(RecordReceipt {
2009 schema: host.record_schema(),
2010 event_id: inputs.event_id.clone(),
2011 gate_status: status,
2012 terminal_reply,
2013 })
2014}
2015
2016fn validate_record_request(request: &RecordUseRequest) -> Result<(), Error> {
2017 if Outcome::parse(&request.outcome).is_none() {
2018 return Err(refusal(format!(
2019 "--outcome must be one of {}",
2020 Outcome::ALLOWED
2021 )));
2022 }
2023 if request.task_label.is_empty() {
2024 return Err(refusal("Missing required --task-label.".to_owned()));
2025 }
2026 if request.outcome == "clean" {
2027 for (flag, value) in [
2028 ("symptom-key", request.symptom_key.as_ref()),
2029 ("expected", request.expected.as_ref()),
2030 ("observed", request.observed.as_ref()),
2031 ("consequence", request.consequence.as_ref()),
2032 ("workaround", request.workaround.as_ref()),
2033 ("run-condition", request.run_condition.as_ref()),
2034 ] {
2035 if value.is_some() {
2036 return Err(refusal(format!(
2037 "--{flag} is not allowed for a clean outcome."
2038 )));
2039 }
2040 }
2041 } else {
2042 if request
2043 .symptom_key
2044 .as_deref()
2045 .is_none_or(|key| !SYMPTOM_KEYS.contains(&key))
2046 {
2047 return Err(refusal(format!(
2048 "--symptom-key must be one of {}",
2049 SYMPTOM_KEYS.join("|")
2050 )));
2051 }
2052 for (flag, value) in [
2053 ("expected", request.expected.as_deref()),
2054 ("observed", request.observed.as_deref()),
2055 ("consequence", request.consequence.as_deref()),
2056 ] {
2057 if value.is_none_or(str::is_empty) {
2058 return Err(refusal(format!(
2059 "--{flag} is required for a non-clean outcome."
2060 )));
2061 }
2062 }
2063 if request.run_condition.as_deref().is_none_or(str::is_empty) {
2064 return Err(refusal(
2065 "--run-condition is required for a non-clean outcome: state the observable condition the use ran under (volume, elapsed length, how far from the start the failure surfaced, which gate caught it). A later review needs it to judge whether any fresh trial can reproduce this incident."
2066 .to_owned(),
2067 ));
2068 }
2069 }
2070 if request.further_incident && request.outcome == "clean" {
2071 return Err(refusal(
2072 "--further-incident records another incident from a run, and a clean outcome is not one: a run this store already recorded as deviating cannot also be recorded as clean, because nothing withdraws the record that says otherwise. The reverse is allowed and is why the flag is asymmetric — a run first recorded clean may still gain an incident, since noticing a deviation later adds what the earlier receipt could not see rather than contradicting it. Nothing recorded."
2073 .to_owned(),
2074 ));
2075 }
2076 if request.further_incident && request.same_run_group.is_some() {
2077 return Err(refusal(
2078 "--further-incident cannot be combined with --same-run-group: a further incident comes from the same run, whose group is already derived from the target, the task label, and this top-level session. The two flags declare different things, and the use denominator counts run groups, so it stays on computed identity rather than an asserted one. Nothing recorded."
2079 .to_owned(),
2080 ));
2081 }
2082 if request.retrospective && request.evidence_refs.is_empty() {
2083 return Err(refusal(
2084 "Retrospective evidence requires a concrete recoverable reference (artifact, diff, log, transcript); memory alone is inadmissible. Nothing recorded."
2085 .to_owned(),
2086 ));
2087 }
2088 if request
2089 .evidence_refs
2090 .iter()
2091 .any(|reference| reference.is_empty())
2092 {
2093 return Err(refusal(
2094 "--evidence-ref values must be non-empty.".to_owned(),
2095 ));
2096 }
2097 Ok(())
2098}
2099
2100fn some_run_holds_several_open_incidents(
2108 events: &[EvidenceEvent],
2109 open_incident_ids: &[String],
2110) -> bool {
2111 let open = open_incident_ids
2112 .iter()
2113 .map(String::as_str)
2114 .collect::<HashSet<_>>();
2115 let mut open_incidents_per_run_group = HashMap::new();
2116 for event in events {
2117 if !open.contains(event.event_id.as_str()) {
2118 continue;
2119 }
2120 let Some(recorded) = event.use_recorded() else {
2121 continue;
2122 };
2123 let seen = open_incidents_per_run_group
2124 .entry(recorded.same_run_group.as_str())
2125 .or_insert(0_usize);
2126 *seen += 1;
2127 if *seen > 1 {
2128 return true;
2129 }
2130 }
2131 false
2132}
2133
2134fn short_digest(bytes: &[u8], length: usize) -> String {
2135 format!("{:x}", Sha256::digest(bytes))[..length].to_owned()
2136}
2137
2138struct EvidenceLock {
2139 directory: PathBuf,
2140 owner: String,
2141}
2142
2143impl EvidenceLock {
2144 fn acquire(evidence_directory: &Path, owner: &str) -> Result<Self, Error> {
2145 let directory = evidence_directory.join(".lock");
2146 let mut acquired = false;
2147 for _ in 0..40 {
2148 match fs::create_dir(&directory) {
2149 Ok(()) => {
2150 acquired = true;
2151 break;
2152 }
2153 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
2154 thread::sleep(Duration::from_millis(50));
2155 }
2156 Err(error) => {
2157 return Err(unsafe_failure(format!(
2158 "Could not acquire evidence lock at {}: {error}; nothing recorded.",
2159 directory.display()
2160 )));
2161 }
2162 }
2163 }
2164 if !acquired {
2165 return Err(unsafe_failure_with_recovery(
2166 format!(
2167 "Could not acquire evidence lock at {}: {}; nothing recorded.",
2168 directory.display(),
2169 held_lock_description(&directory)
2170 ),
2171 Recovery::ReleaseEvidenceLock,
2172 ));
2173 }
2174 let owner_path = directory.join("owner");
2175 if let Err(error) = fs::write(&owner_path, owner) {
2176 let _ = fs::remove_dir(&directory);
2177 return Err(unsafe_failure(format!(
2178 "Could not establish evidence lock ownership at {}: {error}; nothing recorded.",
2179 directory.display()
2180 )));
2181 }
2182 Ok(Self {
2183 directory,
2184 owner: owner.to_owned(),
2185 })
2186 }
2187}
2188
2189impl Drop for EvidenceLock {
2190 fn drop(&mut self) {
2191 let owner_path = self.directory.join("owner");
2192 let owns_lock = fs::read_to_string(&owner_path).is_ok_and(|owner| owner == self.owner);
2193 if owns_lock {
2194 let _ = fs::remove_file(owner_path);
2195 let _ = fs::remove_dir(&self.directory);
2196 }
2197 }
2198}
2199
2200fn held_lock_description(directory: &Path) -> String {
2211 let holder = fs::read_to_string(directory.join("owner"))
2212 .ok()
2213 .map(|owner| owner.trim().to_owned())
2214 .filter(|owner| !owner.is_empty())
2215 .map_or_else(
2216 || "held by an unnamed owner".to_owned(),
2217 |owner| format!("held by \"{owner}\""),
2218 );
2219 match lock_start_milliseconds(directory) {
2220 Some(milliseconds) => format!(
2221 "{holder} since {}",
2222 gate::format_timestamp_milliseconds(milliseconds)
2223 ),
2224 None => holder,
2225 }
2226}
2227
2228fn lock_start_milliseconds(directory: &Path) -> Option<i64> {
2240 const BEYOND_EXPRESSIBLE_MILLISECONDS: i64 = 253_402_300_800_000;
2242
2243 let milliseconds = i64::try_from(
2244 fs::metadata(directory)
2245 .ok()?
2246 .modified()
2247 .ok()?
2248 .duration_since(UNIX_EPOCH)
2249 .ok()?
2250 .as_millis(),
2251 )
2252 .ok()?;
2253 (milliseconds < BEYOND_EXPRESSIBLE_MILLISECONDS).then_some(milliseconds)
2254}
2255
2256fn event_line_bytes(event: &Value) -> Result<Vec<u8>, Error> {
2267 match event["event_type"].as_str() {
2268 Some("use_recorded") => {
2269 Ok(serde_json::to_vec(event)
2270 .expect("serializing a validated evidence event cannot fail"))
2271 }
2272 _ => serialize_legacy_ordered_lifecycle_event(event),
2273 }
2274}
2275
2276fn append_event(path: &Path, event: &Value) -> Result<(), Error> {
2277 append_serialized_line(path, event_line_bytes(event)?)
2278}
2279
2280fn append_serialized_line(path: &Path, mut line: Vec<u8>) -> Result<(), Error> {
2281 line.push(b'\n');
2282 let mut file = OpenOptions::new()
2283 .create(true)
2284 .append(true)
2285 .open(path)
2286 .map_err(|error| {
2287 unsafe_failure(format!(
2288 "Could not open event stream {} for append: {error}",
2289 path.display()
2290 ))
2291 })?;
2292 file.write_all(&line).map_err(|error| {
2293 unsafe_failure(format!(
2294 "Could not append a complete event to {}: {error}",
2295 path.display()
2296 ))
2297 })?;
2298 file.sync_all().map_err(|error| {
2299 unsafe_failure(format!(
2300 "Could not synchronize appended event in {}: {error}",
2301 path.display()
2302 ))
2303 })
2304}
2305
2306fn encoded_json(value: &Value) -> String {
2307 serde_json::to_string(value).expect("serializing a validated event field cannot fail")
2308}
2309
2310fn ordered_json_object(parts: &[(&str, String)]) -> String {
2311 format!(
2312 "{{{}}}",
2313 parts
2314 .iter()
2315 .map(|(key, value)| {
2316 format!(
2317 "{}:{value}",
2318 encoded_json(&Value::String((*key).to_owned()))
2319 )
2320 })
2321 .collect::<Vec<_>>()
2322 .join(",")
2323 )
2324}
2325
2326fn serialize_legacy_ordered_lifecycle_event(event: &Value) -> Result<Vec<u8>, Error> {
2327 let payload = &event["payload"];
2328 let payload = match event["event_type"].as_str() {
2329 Some("review_started") => {
2330 let proof = &payload["session_or_cooldown_proof"];
2331 let proof = if proof["type"] == "different_session" {
2332 ordered_json_object(&[
2333 ("type", encoded_json(&proof["type"])),
2334 (
2335 "threshold_session_id",
2336 encoded_json(&proof["threshold_session_id"]),
2337 ),
2338 (
2339 "review_session_id",
2340 encoded_json(&proof["review_session_id"]),
2341 ),
2342 ])
2343 } else {
2344 ordered_json_object(&[
2345 ("type", encoded_json(&proof["type"])),
2346 ("not_before", encoded_json(&proof["not_before"])),
2347 ("claimed_at", encoded_json(&proof["claimed_at"])),
2348 ])
2349 };
2350 let mut parts = vec![
2351 ("review_id", encoded_json(&payload["review_id"])),
2352 ("target_hash", encoded_json(&payload["target_hash"])),
2353 (
2354 "trigger_event_ids",
2355 encoded_json(&payload["trigger_event_ids"]),
2356 ),
2357 (
2358 "authorizing_rule",
2359 encoded_json(&payload["authorizing_rule"]),
2360 ),
2361 ("risk_tier", encoded_json(&payload["risk_tier"])),
2362 ("session_or_cooldown_proof", proof),
2363 ];
2364 if let Some(operating_skill_hash) = payload.get("operating_skill_hash") {
2365 parts.push(("operating_skill_hash", encoded_json(operating_skill_hash)));
2366 }
2367 if let Some(matches_shipped) = payload.get("operating_package_matches_shipped") {
2368 parts.push((
2369 "operating_package_matches_shipped",
2370 encoded_json(matches_shipped),
2371 ));
2372 }
2373 ordered_json_object(&parts)
2374 }
2375 Some("validation_completed") => ordered_json_object(&[
2376 ("review_id", encoded_json(&payload["review_id"])),
2377 ("decision", encoded_json(&payload["decision"])),
2378 ("risk_tier", encoded_json(&payload["risk_tier"])),
2379 ("candidate_hash", encoded_json(&payload["candidate_hash"])),
2380 ("candidate_path", encoded_json(&payload["candidate_path"])),
2381 ("trial_count", encoded_json(&payload["trial_count"])),
2382 ("artifacts_path", encoded_json(&payload["artifacts_path"])),
2383 ("summary", encoded_json(&payload["summary"])),
2384 (
2385 "operating_skill_hash",
2386 encoded_json(&payload["operating_skill_hash"]),
2387 ),
2388 (
2389 "operating_package_matches_shipped",
2390 encoded_json(&payload["operating_package_matches_shipped"]),
2391 ),
2392 ]),
2393 Some("change_landed") => {
2394 let changed = &payload["changed_files"];
2395 let changed = ordered_json_object(&[
2396 ("added", encoded_json(&changed["added"])),
2397 ("removed", encoded_json(&changed["removed"])),
2398 ("modified", encoded_json(&changed["modified"])),
2399 ]);
2400 ordered_json_object(&[
2401 ("review_id", encoded_json(&payload["review_id"])),
2402 ("before_hash", encoded_json(&payload["before_hash"])),
2403 ("after_hash", encoded_json(&payload["after_hash"])),
2404 ("changed_files", changed),
2405 ("mirror_status", encoded_json(&payload["mirror_status"])),
2406 (
2407 "operating_skill_hash",
2408 encoded_json(&payload["operating_skill_hash"]),
2409 ),
2410 (
2411 "operating_package_matches_shipped",
2412 encoded_json(&payload["operating_package_matches_shipped"]),
2413 ),
2414 ])
2415 }
2416 Some("review_disposition") => {
2417 let mut parts = vec![
2418 ("review_id", encoded_json(&payload["review_id"])),
2419 ("disposition", encoded_json(&payload["disposition"])),
2420 (
2421 "adjudicated_event_ids",
2422 encoded_json(&payload["adjudicated_event_ids"]),
2423 ),
2424 ];
2425 if let Some(instrument_limited) = payload.get("instrument_limited_event_ids") {
2428 parts.push((
2429 "instrument_limited_event_ids",
2430 encoded_json(instrument_limited),
2431 ));
2432 }
2433 if let Some(constraint_provenance) = payload.get("constraint_provenance") {
2434 parts.push(("constraint_provenance", encoded_json(constraint_provenance)));
2435 }
2436 if let Some(external_owners) = payload.get("external_owners") {
2437 parts.push(("external_owners", encoded_json(external_owners)));
2438 }
2439 parts.push(("note", encoded_json(&payload["note"])));
2440 if let Some(trial_count) = payload.get("trial_count") {
2441 parts.push(("trial_count", encoded_json(trial_count)));
2442 }
2443 if let Some(artifacts_path) = payload.get("artifacts_path") {
2444 parts.push(("artifacts_path", encoded_json(artifacts_path)));
2445 }
2446 parts.push((
2447 "operating_skill_hash",
2448 encoded_json(&payload["operating_skill_hash"]),
2449 ));
2450 parts.push((
2451 "operating_package_matches_shipped",
2452 encoded_json(&payload["operating_package_matches_shipped"]),
2453 ));
2454 ordered_json_object(&parts)
2455 }
2456 Some(event_type) => {
2457 return Err(unsafe_failure(format!(
2458 "Cannot serialize unsupported lifecycle event type {event_type}."
2459 )));
2460 }
2461 None => {
2462 return Err(unsafe_failure(
2463 "Cannot serialize lifecycle event without event_type.".to_owned(),
2464 ));
2465 }
2466 };
2467 let target = &event["target"];
2468 let target = ordered_json_object(&[
2469 ("name", encoded_json(&target["name"])),
2470 (
2471 "repo_relative_path",
2472 encoded_json(&target["repo_relative_path"]),
2473 ),
2474 ("content_hash", encoded_json(&target["content_hash"])),
2475 ("repo_head", encoded_json(&target["repo_head"])),
2476 ]);
2477 Ok(ordered_json_object(&[
2478 ("schema_version", encoded_json(&event["schema_version"])),
2479 ("event_id", encoded_json(&event["event_id"])),
2480 ("event_type", encoded_json(&event["event_type"])),
2481 ("recorded_at", encoded_json(&event["recorded_at"])),
2482 (
2483 "operator_workflow",
2484 encoded_json(&event["operator_workflow"]),
2485 ),
2486 ("target", target),
2487 (
2488 "top_level_session_id",
2489 encoded_json(&event["top_level_session_id"]),
2490 ),
2491 ("payload", payload),
2492 ])
2493 .into_bytes())
2494}
2495
2496fn build_reply(event_id: &str, status: &GateStatus, multi_incident_run: bool) -> String {
2505 let head = format!("Evidence recorded: {event_id}.");
2506 match status.state.as_str() {
2507 "closed" => format!("{head}\nGate: closed.\nNo action authorized."),
2508 "collecting" => {
2509 let clusters = status
2510 .candidate_clusters
2511 .iter()
2512 .map(|cluster| format!("{}={}", cluster.symptom_key, cluster.independent_incidents))
2513 .collect::<Vec<_>>()
2514 .join(", ");
2515 let clusters = if clusters.is_empty() {
2521 "none".to_owned()
2522 } else {
2523 clusters
2524 };
2525 let retired = match status.instrument_limited_incident_ids.len() {
2526 0 => String::new(),
2527 count => format!(" retired as untestable: {count};"),
2528 };
2529 let siblings = if multi_incident_run {
2533 " (a run that recorded several incidents counts once)"
2534 } else {
2535 ""
2536 };
2537 format!(
2538 "{head}\nGate: collecting — open incidents: {} (independent by symptom: {clusters});{retired} qualifying uses on current target hash: {}{siblings}.\nNo action authorized.",
2539 status.open_incident_ids.len(),
2540 status.qualifying_uses_on_current_hash
2541 )
2542 }
2543 "eligible_pending_cooldown" => format!(
2544 "{head}\nGate: eligible pending fresh-session/cooldown requirement.\nSkill Evolution is not authorized in this session. No target action authorized."
2545 ),
2546 "eligible" => format!(
2547 "{head}\nGate: eligible for Skill Evolution after a fresh derivation in a permitted session.\nNo target action performed by Evidence Capture."
2548 ),
2549 "quarantined_pending_cooldown" | "quarantined_eligible" => format!(
2550 "{head}\nGate: target quarantined pending fresh Skill Evolution eligibility.\nStop using the target. Immediate operational containment is allowed; permanent skill edits are not."
2551 ),
2552 "review_in_progress" => format!(
2553 "{head}\nGate: review_in_progress — active review {} owns the target.\nNo action authorized.",
2554 status.active_review_id.as_deref().unwrap_or("unavailable")
2555 ),
2556 state => format!("{head}\nGate: {state}.\nNo action authorized."),
2557 }
2558}
2559
2560#[derive(Debug)]
2561struct TargetContext {
2562 repository_root: PathBuf,
2563 target_real: PathBuf,
2564 target_name: String,
2565 repo_relative_path: String,
2566 evidence_directory: PathBuf,
2567}
2568
2569impl TargetContext {
2570 fn labels(&self) -> GateTarget<'_> {
2575 GateTarget {
2576 name: &self.target_name,
2577 repo_relative_path: &self.repo_relative_path,
2578 }
2579 }
2580}
2581
2582fn lifecycle_target_context(
2589 root: &Path,
2590 target: &Path,
2591 operator_skill: &Path,
2592) -> Result<TargetContext, Error> {
2593 let target = target_context(root, target)?;
2594 if fs::canonicalize(operator_skill)
2595 .ok()
2596 .is_some_and(|operator_real| operator_real == target.target_real)
2597 {
2598 return Err(evolution_refusal(
2599 "not derived (self-target)",
2600 "operator_skill_path != target_skill_path",
2601 "refused_self_target",
2602 0,
2603 ));
2604 }
2605 Ok(target)
2606}
2607
2608fn target_context(root: &Path, target: &Path) -> Result<TargetContext, Error> {
2609 let root = canonical_directory(root, "repository root")?;
2610 let target_real = resolve_target(&root, target)?;
2611 let target_name = target_real
2612 .file_name()
2613 .and_then(|name| name.to_str())
2614 .ok_or_else(|| refusal("Target skill name is not valid UTF-8.".to_owned()))?
2615 .to_owned();
2616 let relative = target_real
2617 .strip_prefix(&root)
2618 .ok()
2619 .map_or_else(|| target_real.clone(), Path::to_path_buf);
2620 let repo_relative_path = relative
2621 .to_string_lossy()
2622 .replace(std::path::MAIN_SEPARATOR, "/");
2623 let key = skill_key(&root, &target_real, &target_name);
2624 let evidence_directory = root.join("reports/skill-evidence").join(key);
2625 Ok(TargetContext {
2626 repository_root: root,
2627 target_real,
2628 target_name,
2629 repo_relative_path,
2630 evidence_directory,
2631 })
2632}
2633
2634fn skill_key(root: &Path, target_real: &Path, target_name: &str) -> String {
2635 for skill_root in [".claude/skills", ".agents/skills"] {
2636 let candidate = root.join(skill_root).join(target_name);
2637 if candidate.exists()
2638 && candidate
2639 .canonicalize()
2640 .is_ok_and(|canonical| canonical != target_real)
2641 {
2642 let relative = target_real
2643 .strip_prefix(root)
2644 .unwrap_or(target_real)
2645 .to_string_lossy()
2646 .replace(std::path::MAIN_SEPARATOR, "/");
2647 let digest = Sha256::digest(relative.as_bytes());
2648 return format!("{target_name}-{:x}", digest)[..target_name.len() + 9].to_owned();
2649 }
2650 }
2651 target_name.to_owned()
2652}
2653
2654fn hash_target_directory(target: &Path) -> Result<DirectoryHash, Error> {
2655 let files = list_files(target)?;
2656 let mut hasher = Sha256::new();
2657 for relative_path in &files {
2658 hasher.update(path_bytes(relative_path));
2659 hasher.update([0]);
2660 let bytes = fs::read(target.join(relative_path)).map_err(|error| {
2661 unsafe_failure(format!(
2662 "Could not read target file {}: {error}",
2663 relative_path.display()
2664 ))
2665 })?;
2666 hasher.update(bytes);
2667 hasher.update([0]);
2668 }
2669 Ok(DirectoryHash {
2670 content_hash: format!("{:x}", hasher.finalize()),
2671 file_count: files.len(),
2672 })
2673}
2674
2675fn write_gate_status(directory: &Path, status: &GateStatus) -> Result<(), Error> {
2676 let destination = directory.join("gate-status.json");
2677 let temporary = directory.join(".gate-status.json.tmp");
2678 prepare_gate_status(&temporary, status)?;
2679 fs::rename(&temporary, &destination).map_err(|error| {
2680 unsafe_failure(format!(
2681 "Could not atomically replace gate projection {}: {error}",
2682 destination.display()
2683 ))
2684 })
2685}
2686
2687fn prepare_gate_status(temporary: &Path, status: &GateStatus) -> Result<(), Error> {
2688 let bytes =
2689 serde_json::to_vec_pretty(status).expect("serializing a validated gate status cannot fail");
2690 let mut file = fs::File::create(temporary).map_err(|error| {
2691 unsafe_failure(format!(
2692 "Could not create temporary gate projection {}: {error}",
2693 temporary.display()
2694 ))
2695 })?;
2696 file.write_all(&bytes).map_err(|error| {
2697 unsafe_failure(format!(
2698 "Could not write temporary gate projection {}: {error}",
2699 temporary.display()
2700 ))
2701 })?;
2702 file.write_all(b"\n").map_err(|error| {
2703 unsafe_failure(format!(
2704 "Could not finish temporary gate projection {}: {error}",
2705 temporary.display()
2706 ))
2707 })?;
2708 file.sync_all().map_err(|error| {
2709 unsafe_failure(format!(
2710 "Could not synchronize temporary gate projection {}: {error}",
2711 temporary.display()
2712 ))
2713 })
2714}
2715
2716fn canonical_directory(path: &Path, label: &str) -> Result<PathBuf, Error> {
2717 let canonical = path
2718 .canonicalize()
2719 .map_err(|_| refusal(format!("{label} does not exist: {}", path.display())))?;
2720 if !canonical.is_dir() {
2721 return Err(refusal(format!(
2722 "{label} is not a directory: {}",
2723 path.display()
2724 )));
2725 }
2726 Ok(canonical)
2727}
2728
2729fn resolve_target(root: &Path, target: &Path) -> Result<PathBuf, Error> {
2752 let mut candidates = Vec::new();
2753 if target.is_absolute() {
2754 candidates.push(target.to_owned());
2755 } else {
2756 let current = std::env::current_dir().map_err(|error| {
2757 unsafe_failure(format!("Could not resolve current directory: {error}"))
2758 })?;
2759 candidates.push(root.join(target));
2760 candidates.push(current.join(target));
2761 }
2762 for candidate in candidates {
2763 if !candidate.is_dir() {
2764 continue;
2765 }
2766 let canonical = candidate.canonicalize().map_err(|error| {
2767 unsafe_failure(format!(
2768 "Could not resolve target skill directory {}: {error}",
2769 candidate.display()
2770 ))
2771 })?;
2772 if !canonical.join("SKILL.md").is_file() {
2773 return Err(refusal(format!(
2774 "Target is not a skill directory (no SKILL.md): {}",
2775 target.display()
2776 )));
2777 }
2778 return Ok(canonical);
2779 }
2780 Err(refusal(format!(
2781 "Target skill directory not found: {}",
2782 target.display()
2783 )))
2784}
2785
2786pub fn snapshot_baseline(
2787 source: &Path,
2788 destination: &Path,
2789 expected_hash: &str,
2790) -> Result<(), Error> {
2791 copy_directory_contents(source, destination)?;
2792 let copied_hash = hash_target_directory(destination)?.content_hash;
2793 if copied_hash != expected_hash {
2794 let _ = fs::remove_dir_all(destination);
2795 return Err(unsafe_failure(
2796 "Baseline snapshot did not reproduce the live target hash; nothing recorded."
2797 .to_owned(),
2798 ));
2799 }
2800 Ok(())
2801}
2802
2803pub fn diff_directories(before: &Path, after: &Path) -> Result<DirectoryDiff, Error> {
2804 let before_files = list_files(before)?;
2805 let after_files = list_files(after)?;
2806 let before_set = before_files.iter().cloned().collect::<HashSet<_>>();
2807 let after_set = after_files.iter().cloned().collect::<HashSet<_>>();
2808 let mut added = after_set
2809 .difference(&before_set)
2810 .map(|path| normalized_path(path))
2811 .collect::<Vec<_>>();
2812 let mut removed = before_set
2813 .difference(&after_set)
2814 .map(|path| normalized_path(path))
2815 .collect::<Vec<_>>();
2816 let mut modified = Vec::new();
2817 for path in before_set.intersection(&after_set) {
2818 let before_bytes = fs::read(before.join(path)).map_err(|error| {
2819 unsafe_failure(format!(
2820 "Could not read baseline file {} while deriving the directory diff: {error}",
2821 path.display()
2822 ))
2823 })?;
2824 let after_bytes = fs::read(after.join(path)).map_err(|error| {
2825 unsafe_failure(format!(
2826 "Could not read landed file {} while deriving the directory diff: {error}",
2827 path.display()
2828 ))
2829 })?;
2830 if before_bytes != after_bytes {
2831 modified.push(normalized_path(path));
2832 }
2833 }
2834 added.sort();
2835 removed.sort();
2836 modified.sort();
2837 Ok(DirectoryDiff {
2838 added,
2839 removed,
2840 modified,
2841 })
2842}
2843
2844fn prepare_validated_landing(
2845 target: &TargetContext,
2846 events: &[EvidenceEvent],
2847 live_hash: &str,
2848 candidate: &Path,
2849 policy: ValidatedLandingPolicy<'_>,
2850) -> Result<ValidatedLandingPreparation, Error> {
2851 if candidate == target.target_real {
2852 return Err(refusal(
2853 "--candidate must be the isolated copy, not the live target.".to_owned(),
2854 ));
2855 }
2856 let candidate_hash = hash_target_directory(candidate)?.content_hash;
2857 if candidate_hash == live_hash {
2858 return Err(refusal(
2859 "Candidate is byte-identical to the live target; nothing to land.".to_owned(),
2860 ));
2861 }
2862 let latest = events
2863 .iter()
2864 .rfind(|event| {
2865 matches!(event.kind, EventKind::ValidationCompleted { .. })
2866 && event.review_id() == Some(policy.owner_id)
2867 && event
2868 .raw
2869 .pointer("/payload/decision")
2870 .and_then(Value::as_str)
2871 == Some("accepted")
2872 })
2873 .ok_or_else(|| {
2874 refusal(format!(
2875 "No accepted validation_completed event exists for review {}. Landing refused.",
2876 policy.owner_id
2877 ))
2878 })?;
2879 let validated_hash = latest
2880 .raw
2881 .pointer("/payload/candidate_hash")
2882 .and_then(Value::as_str)
2883 .expect("validated candidate hash");
2884 if validated_hash != candidate_hash {
2885 return Err(refusal(format!(
2886 "Candidate bytes are not exactly those validated (validated {}…, supplied {}…). Landing refused.",
2887 &validated_hash[..validated_hash.len().min(12)],
2888 &candidate_hash[..12]
2889 )));
2890 }
2891 if events.iter().any(|event| event.event_id == policy.event_id) {
2892 return Err(unsafe_failure(format!(
2893 "Constructed event failed validation — nothing appended:\n duplicate event_id {}",
2894 policy.event_id
2895 )));
2896 }
2897 if policy.backup_directory.exists() {
2898 return Err(refusal(format!(
2899 "Backup already exists at {}; a prior land attempt ran for this review. Inspect before retrying.",
2900 policy.backup_directory.display()
2901 )));
2902 }
2903 Ok(ValidatedLandingPreparation {
2904 candidate_hash,
2905 backup_directory: policy.backup_directory,
2906 })
2907}
2908
2909pub fn land_validated_candidate(
2910 live_target: &Path,
2911 candidate: &Path,
2912 backup: &Path,
2913 baseline_hash: &str,
2914 candidate_hash: &str,
2915 mirror: Option<&Path>,
2916) -> Result<LandingMechanicsReceipt, Error> {
2917 snapshot_baseline(live_target, backup, baseline_hash).map_err(|error| {
2918 unsafe_failure(format!(
2919 "Backup copy did not reproduce the live baseline hash; landing aborted before any target change. {error}"
2920 ))
2921 })?;
2922 let verification = sync_directory(candidate, live_target)
2923 .and_then(|()| hash_target_directory(live_target).map(|report| report.content_hash));
2924 let after_hash = match verification {
2925 Ok(after_hash) if after_hash == candidate_hash => after_hash,
2926 Ok(after_hash) => {
2927 return Err(landing_verification_failure(
2928 live_target,
2929 backup,
2930 baseline_hash,
2931 format!(
2932 "landed hash {}… != candidate hash",
2933 &after_hash[..after_hash.len().min(12)]
2934 ),
2935 ));
2936 }
2937 Err(error) => {
2938 return Err(landing_verification_failure(
2939 live_target,
2940 backup,
2941 baseline_hash,
2942 error.to_string(),
2943 ));
2944 }
2945 };
2946 let mirror_status = landing_mirror_status(live_target, mirror);
2947 let changed_files = diff_directories(backup, live_target)?;
2948 Ok(LandingMechanicsReceipt {
2949 after_hash,
2950 changed_files,
2951 mirror_status,
2952 })
2953}
2954
2955fn landing_verification_failure(
2956 live_target: &Path,
2957 backup: &Path,
2958 baseline_hash: &str,
2959 reason: String,
2960) -> Error {
2961 let restoration = sync_directory(backup, live_target)
2962 .and_then(|()| hash_target_directory(live_target).map(|report| report.content_hash));
2963 match restoration {
2964 Ok(restored_hash) if restored_hash == baseline_hash => unsafe_failure(format!(
2965 "Landing verification failed: {reason}. Live baseline restored from backup."
2966 )),
2967 Ok(restored_hash) => unsafe_failure(format!(
2968 "Landing verification failed: {reason}. RESTORE ALSO FAILED (live hash {}…); recover from {} or Git.",
2969 &restored_hash[..restored_hash.len().min(12)],
2970 backup.display()
2971 )),
2972 Err(error) => unsafe_failure(format!(
2973 "Landing verification failed: {reason}. RESTORE ALSO FAILED ({error}); recover from {} or Git.",
2974 backup.display()
2975 )),
2976 }
2977}
2978
2979fn expected_mirror_path(target: &TargetContext) -> Option<PathBuf> {
2980 if !target.repo_relative_path.starts_with(".claude/skills/") {
2981 return None;
2982 }
2983 Some(
2984 target
2985 .repository_root
2986 .join(".agents/skills")
2987 .join(&target.target_name),
2988 )
2989}
2990
2991fn landing_mirror_status(live_target: &Path, mirror: Option<&Path>) -> &'static str {
2992 let Some(mirror) = mirror else {
2993 return "not_applicable";
2994 };
2995 if !mirror.exists() {
2996 return "absent";
2997 }
2998 if mirror
2999 .canonicalize()
3000 .is_ok_and(|resolved| resolved == live_target)
3001 {
3002 "ok"
3003 } else {
3004 "broken"
3005 }
3006}
3007
3008fn sync_directory(source: &Path, destination: &Path) -> Result<(), Error> {
3009 if destination.exists() {
3010 for entry in fs::read_dir(destination).map_err(|error| {
3011 unsafe_failure(format!(
3012 "Could not read destination directory {}: {error}",
3013 destination.display()
3014 ))
3015 })? {
3016 let path = entry
3017 .map_err(|error| {
3018 unsafe_failure(format!(
3019 "Could not read destination entry in {}: {error}",
3020 destination.display()
3021 ))
3022 })?
3023 .path();
3024 remove_path(&path)?;
3025 }
3026 } else {
3027 fs::create_dir_all(destination).map_err(|error| {
3028 unsafe_failure(format!(
3029 "Could not create destination directory {}: {error}",
3030 destination.display()
3031 ))
3032 })?;
3033 }
3034 copy_directory_contents(source, destination)
3035}
3036
3037fn copy_directory_contents(source: &Path, destination: &Path) -> Result<(), Error> {
3038 fs::create_dir_all(destination).map_err(|error| {
3039 unsafe_failure(format!(
3040 "Could not create copied directory {}: {error}",
3041 destination.display()
3042 ))
3043 })?;
3044 for entry in fs::read_dir(source).map_err(|error| {
3045 unsafe_failure(format!(
3046 "Could not read source directory {}: {error}",
3047 source.display()
3048 ))
3049 })? {
3050 let entry = entry.map_err(|error| {
3051 unsafe_failure(format!(
3052 "Could not read source entry in {}: {error}",
3053 source.display()
3054 ))
3055 })?;
3056 copy_path(&entry.path(), &destination.join(entry.file_name()))?;
3057 }
3058 Ok(())
3059}
3060
3061fn copy_path(source: &Path, destination: &Path) -> Result<(), Error> {
3062 let metadata = fs::symlink_metadata(source).map_err(|error| {
3063 unsafe_failure(format!(
3064 "Could not inspect source path {}: {error}",
3065 source.display()
3066 ))
3067 })?;
3068 if metadata.file_type().is_symlink() {
3069 let link = fs::read_link(source).map_err(|error| {
3070 unsafe_failure(format!(
3071 "Could not read source symlink {}: {error}",
3072 source.display()
3073 ))
3074 })?;
3075 return create_symlink(source, &link, destination);
3076 }
3077 if metadata.is_dir() {
3078 return copy_directory_contents(source, destination);
3079 }
3080 fs::copy(source, destination).map_err(|error| {
3081 unsafe_failure(format!(
3082 "Could not copy {} to {}: {error}",
3083 source.display(),
3084 destination.display()
3085 ))
3086 })?;
3087 Ok(())
3088}
3089
3090fn remove_path(path: &Path) -> Result<(), Error> {
3091 let metadata = fs::symlink_metadata(path).map_err(|error| {
3092 unsafe_failure(format!(
3093 "Could not inspect target path {}: {error}",
3094 path.display()
3095 ))
3096 })?;
3097 if metadata.is_dir() && !metadata.file_type().is_symlink() {
3098 fs::remove_dir_all(path)
3099 } else {
3100 fs::remove_file(path)
3101 }
3102 .map_err(|error| unsafe_failure(format!("Could not remove {}: {error}", path.display())))
3103}
3104
3105#[cfg(unix)]
3106fn create_symlink(_source: &Path, link: &Path, destination: &Path) -> Result<(), Error> {
3107 std::os::unix::fs::symlink(link, destination).map_err(|error| {
3108 unsafe_failure(format!(
3109 "Could not copy symlink to {}: {error}",
3110 destination.display()
3111 ))
3112 })
3113}
3114
3115#[cfg(windows)]
3116fn create_symlink(source: &Path, link: &Path, destination: &Path) -> Result<(), Error> {
3117 let target = if link.is_absolute() {
3118 link.to_owned()
3119 } else {
3120 source
3121 .parent()
3122 .expect("source symlink has a parent")
3123 .join(link)
3124 };
3125 let result = if target.is_dir() {
3126 std::os::windows::fs::symlink_dir(link, destination)
3127 } else {
3128 std::os::windows::fs::symlink_file(link, destination)
3129 };
3130 result.map_err(|error| {
3131 unsafe_failure(format!(
3132 "Could not copy symlink to {}: {error}",
3133 destination.display()
3134 ))
3135 })
3136}
3137
3138fn normalized_path(path: &Path) -> String {
3139 path.to_string_lossy()
3140 .replace(std::path::MAIN_SEPARATOR, "/")
3141}
3142
3143fn list_files(directory: &Path) -> Result<Vec<PathBuf>, Error> {
3144 fn visit(base: &Path, directory: &Path, files: &mut Vec<PathBuf>) -> Result<(), Error> {
3145 let entries = fs::read_dir(directory).map_err(|error| {
3146 unsafe_failure(format!(
3147 "Could not read target directory {}: {error}",
3148 directory.display()
3149 ))
3150 })?;
3151 for entry in entries {
3152 let entry = entry.map_err(|error| {
3153 unsafe_failure(format!(
3154 "Could not read target directory entry in {}: {error}",
3155 directory.display()
3156 ))
3157 })?;
3158 let path = entry.path();
3159 let metadata = fs::metadata(&path).map_err(|error| {
3160 unsafe_failure(format!(
3161 "Could not inspect target path {}: {error}",
3162 path.display()
3163 ))
3164 })?;
3165 if metadata.is_dir() {
3166 visit(base, &path, files)?;
3167 } else {
3168 files.push(
3169 path.strip_prefix(base)
3170 .expect("visited path is below base")
3171 .to_owned(),
3172 );
3173 }
3174 }
3175 Ok(())
3176 }
3177
3178 let mut files = Vec::new();
3179 visit(directory, directory, &mut files)?;
3180 files.sort();
3181 Ok(files)
3182}
3183
3184#[cfg(unix)]
3185fn path_bytes(path: &Path) -> Cow<'_, [u8]> {
3186 use std::os::unix::ffi::OsStrExt;
3187 normalize_separator_for_hash(path.as_os_str().as_bytes(), b'/')
3188}
3189
3190#[cfg(not(unix))]
3191fn path_bytes(path: &Path) -> Cow<'_, [u8]> {
3192 Cow::Owned(
3193 normalize_separator_for_hash(
3194 path.to_str()
3195 .expect("skill evidence paths must be valid UTF-8 on this platform")
3196 .as_bytes(),
3197 std::path::MAIN_SEPARATOR as u8,
3198 )
3199 .into_owned(),
3200 )
3201}
3202
3203fn normalize_separator_for_hash(bytes: &[u8], separator: u8) -> Cow<'_, [u8]> {
3204 if separator == b'/' || !bytes.contains(&separator) {
3205 Cow::Borrowed(bytes)
3206 } else {
3207 Cow::Owned(
3208 bytes
3209 .iter()
3210 .map(|byte| if *byte == separator { b'/' } else { *byte })
3211 .collect(),
3212 )
3213 }
3214}
3215
3216fn refusal(message: String) -> Error {
3217 Error {
3218 class: ErrorClass::Refusal,
3219 message,
3220 recovery: None,
3221 }
3222}
3223
3224fn unsafe_failure(message: String) -> Error {
3225 Error {
3226 class: ErrorClass::UnsafeFailure,
3227 message,
3228 recovery: None,
3229 }
3230}
3231
3232fn unsafe_failure_with_recovery(message: String, recovery: Recovery) -> Error {
3239 Error {
3240 class: ErrorClass::UnsafeFailure,
3241 message,
3242 recovery: Some(recovery),
3243 }
3244}
3245
3246#[cfg(test)]
3247mod tests {
3248 use serde_json::json;
3249
3250 use super::*;
3251
3252 fn persisted_bytes(event: &Value) -> Vec<u8> {
3254 event_line_bytes(event).expect("an event fixture always serializes")
3255 }
3256
3257 fn envelope(event_type: &str, operator_workflow: &str, payload: Value) -> Value {
3258 json!({
3259 "schema_version": 1,
3260 "event_id": "evt_00000000-0000-4000-8000-000000000000",
3261 "event_type": event_type,
3262 "recorded_at": "2026-01-01T00:00:00Z",
3263 "operator_workflow": operator_workflow,
3264 "target": {
3265 "name": "demo-skill",
3266 "repo_relative_path": ".claude/skills/demo-skill",
3267 "content_hash": "sha256:target",
3268 "repo_head": "0000000000000000000000000000000000000000"
3269 },
3270 "top_level_session_id": "session-fixture",
3271 "payload": payload
3272 })
3273 }
3274
3275 fn writable_event_shapes() -> Vec<(&'static str, Value)> {
3283 vec![
3284 (
3285 "use_recorded",
3286 envelope(
3287 "use_recorded",
3288 "skill-evidence-capture",
3289 json!({
3290 "qualifying_use": true,
3291 "retrospective": false,
3292 "task_label": "a task",
3293 "task_fingerprint": "fp_0001",
3294 "outcome": "friction",
3295 "symptom_key": "execution",
3296 "expected": "expected",
3297 "observed": "observed",
3298 "consequence": "consequence",
3299 "workaround_taken": "workaround",
3300 "run_condition": "condition",
3301 "evidence_refs": ["ref-a"],
3302 "same_run_group": false
3303 }),
3304 ),
3305 ),
3306 (
3307 "review_started (different_session proof)",
3308 envelope(
3309 "review_started",
3310 "skill-evolution",
3311 json!({
3312 "review_id": "rev_0001",
3313 "target_hash": "sha256:target",
3314 "trigger_event_ids": ["evt_a", "evt_b"],
3315 "authorizing_rule": "threshold_reached",
3316 "risk_tier": "standard",
3317 "session_or_cooldown_proof": {
3318 "type": "different_session",
3319 "threshold_session_id": "session-threshold",
3320 "review_session_id": "session-review"
3321 },
3322 "operating_skill_hash": "sha256:operating",
3323 "operating_package_matches_shipped": true
3324 }),
3325 ),
3326 ),
3327 (
3328 "review_started (cooldown_elapsed proof)",
3329 envelope(
3330 "review_started",
3331 "skill-evolution",
3332 json!({
3333 "review_id": "rev_0002",
3334 "target_hash": "sha256:target",
3335 "trigger_event_ids": ["evt_c"],
3336 "authorizing_rule": "cooldown_elapsed",
3337 "risk_tier": "standard",
3338 "session_or_cooldown_proof": {
3339 "type": "cooldown_elapsed",
3340 "not_before": "2026-01-01T00:00:00Z",
3341 "claimed_at": "2026-01-02T00:00:00Z"
3342 },
3343 "operating_skill_hash": "sha256:operating",
3344 "operating_package_matches_shipped": false
3345 }),
3346 ),
3347 ),
3348 (
3349 "validation_completed",
3350 envelope(
3351 "validation_completed",
3352 "skill-evolution",
3353 json!({
3354 "review_id": "rev_0001",
3355 "decision": "accepted",
3356 "risk_tier": "standard",
3357 "candidate_hash": "sha256:candidate",
3358 "candidate_path": "candidate-evolution",
3359 "trial_count": 3,
3360 "artifacts_path": "artifacts",
3361 "summary": "a summary",
3362 "operating_skill_hash": "sha256:operating",
3363 "operating_package_matches_shipped": true
3364 }),
3365 ),
3366 ),
3367 (
3368 "change_landed",
3369 envelope(
3370 "change_landed",
3371 "skill-evolution",
3372 json!({
3373 "review_id": "rev_0001",
3374 "before_hash": "sha256:before",
3375 "after_hash": "sha256:after",
3376 "changed_files": {
3377 "added": ["added.md"],
3378 "removed": ["removed.md"],
3379 "modified": ["SKILL.md"]
3380 },
3381 "mirror_status": "mirrored",
3382 "operating_skill_hash": "sha256:operating",
3383 "operating_package_matches_shipped": true
3384 }),
3385 ),
3386 ),
3387 (
3388 "review_disposition (every optional key present)",
3389 envelope(
3390 "review_disposition",
3391 "skill-evolution",
3392 json!({
3393 "review_id": "rev_0001",
3394 "disposition": "landed",
3395 "adjudicated_event_ids": ["evt_a"],
3396 "instrument_limited_event_ids": ["evt_b"],
3397 "constraint_provenance": [{"event_id": "evt_a", "checked": true}],
3398 "external_owners": ["owner"],
3399 "note": "a note",
3400 "trial_count": 2,
3401 "artifacts_path": "artifacts",
3402 "operating_skill_hash": "sha256:operating",
3403 "operating_package_matches_shipped": true
3404 }),
3405 ),
3406 ),
3407 (
3408 "review_disposition (every optional key absent)",
3409 envelope(
3410 "review_disposition",
3411 "skill-evolution",
3412 json!({
3413 "review_id": "rev_0002",
3414 "disposition": "abandoned",
3415 "adjudicated_event_ids": [],
3416 "note": "a note",
3417 "operating_skill_hash": "sha256:operating",
3418 "operating_package_matches_shipped": false
3419 }),
3420 ),
3421 ),
3422 ]
3423 }
3424
3425 fn frozen_line(shape: &str) -> &'static str {
3438 match shape {
3439 "use_recorded" => {
3440 r#"{"event_id":"evt_00000000-0000-4000-8000-000000000000","event_type":"use_recorded","operator_workflow":"skill-evidence-capture","payload":{"consequence":"consequence","evidence_refs":["ref-a"],"expected":"expected","observed":"observed","outcome":"friction","qualifying_use":true,"retrospective":false,"run_condition":"condition","same_run_group":false,"symptom_key":"execution","task_fingerprint":"fp_0001","task_label":"a task","workaround_taken":"workaround"},"recorded_at":"2026-01-01T00:00:00Z","schema_version":1,"target":{"content_hash":"sha256:target","name":"demo-skill","repo_head":"0000000000000000000000000000000000000000","repo_relative_path":".claude/skills/demo-skill"},"top_level_session_id":"session-fixture"}"#
3441 }
3442 "review_started (different_session proof)" => {
3443 r#"{"schema_version":1,"event_id":"evt_00000000-0000-4000-8000-000000000000","event_type":"review_started","recorded_at":"2026-01-01T00:00:00Z","operator_workflow":"skill-evolution","target":{"name":"demo-skill","repo_relative_path":".claude/skills/demo-skill","content_hash":"sha256:target","repo_head":"0000000000000000000000000000000000000000"},"top_level_session_id":"session-fixture","payload":{"review_id":"rev_0001","target_hash":"sha256:target","trigger_event_ids":["evt_a","evt_b"],"authorizing_rule":"threshold_reached","risk_tier":"standard","session_or_cooldown_proof":{"type":"different_session","threshold_session_id":"session-threshold","review_session_id":"session-review"},"operating_skill_hash":"sha256:operating","operating_package_matches_shipped":true}}"#
3444 }
3445 "review_started (cooldown_elapsed proof)" => {
3446 r#"{"schema_version":1,"event_id":"evt_00000000-0000-4000-8000-000000000000","event_type":"review_started","recorded_at":"2026-01-01T00:00:00Z","operator_workflow":"skill-evolution","target":{"name":"demo-skill","repo_relative_path":".claude/skills/demo-skill","content_hash":"sha256:target","repo_head":"0000000000000000000000000000000000000000"},"top_level_session_id":"session-fixture","payload":{"review_id":"rev_0002","target_hash":"sha256:target","trigger_event_ids":["evt_c"],"authorizing_rule":"cooldown_elapsed","risk_tier":"standard","session_or_cooldown_proof":{"type":"cooldown_elapsed","not_before":"2026-01-01T00:00:00Z","claimed_at":"2026-01-02T00:00:00Z"},"operating_skill_hash":"sha256:operating","operating_package_matches_shipped":false}}"#
3447 }
3448 "validation_completed" => {
3449 r#"{"schema_version":1,"event_id":"evt_00000000-0000-4000-8000-000000000000","event_type":"validation_completed","recorded_at":"2026-01-01T00:00:00Z","operator_workflow":"skill-evolution","target":{"name":"demo-skill","repo_relative_path":".claude/skills/demo-skill","content_hash":"sha256:target","repo_head":"0000000000000000000000000000000000000000"},"top_level_session_id":"session-fixture","payload":{"review_id":"rev_0001","decision":"accepted","risk_tier":"standard","candidate_hash":"sha256:candidate","candidate_path":"candidate-evolution","trial_count":3,"artifacts_path":"artifacts","summary":"a summary","operating_skill_hash":"sha256:operating","operating_package_matches_shipped":true}}"#
3450 }
3451 "change_landed" => {
3452 r#"{"schema_version":1,"event_id":"evt_00000000-0000-4000-8000-000000000000","event_type":"change_landed","recorded_at":"2026-01-01T00:00:00Z","operator_workflow":"skill-evolution","target":{"name":"demo-skill","repo_relative_path":".claude/skills/demo-skill","content_hash":"sha256:target","repo_head":"0000000000000000000000000000000000000000"},"top_level_session_id":"session-fixture","payload":{"review_id":"rev_0001","before_hash":"sha256:before","after_hash":"sha256:after","changed_files":{"added":["added.md"],"removed":["removed.md"],"modified":["SKILL.md"]},"mirror_status":"mirrored","operating_skill_hash":"sha256:operating","operating_package_matches_shipped":true}}"#
3453 }
3454 "review_disposition (every optional key present)" => {
3455 r#"{"schema_version":1,"event_id":"evt_00000000-0000-4000-8000-000000000000","event_type":"review_disposition","recorded_at":"2026-01-01T00:00:00Z","operator_workflow":"skill-evolution","target":{"name":"demo-skill","repo_relative_path":".claude/skills/demo-skill","content_hash":"sha256:target","repo_head":"0000000000000000000000000000000000000000"},"top_level_session_id":"session-fixture","payload":{"review_id":"rev_0001","disposition":"landed","adjudicated_event_ids":["evt_a"],"instrument_limited_event_ids":["evt_b"],"constraint_provenance":[{"checked":true,"event_id":"evt_a"}],"external_owners":["owner"],"note":"a note","trial_count":2,"artifacts_path":"artifacts","operating_skill_hash":"sha256:operating","operating_package_matches_shipped":true}}"#
3456 }
3457 "review_disposition (every optional key absent)" => {
3458 r#"{"schema_version":1,"event_id":"evt_00000000-0000-4000-8000-000000000000","event_type":"review_disposition","recorded_at":"2026-01-01T00:00:00Z","operator_workflow":"skill-evolution","target":{"name":"demo-skill","repo_relative_path":".claude/skills/demo-skill","content_hash":"sha256:target","repo_head":"0000000000000000000000000000000000000000"},"top_level_session_id":"session-fixture","payload":{"review_id":"rev_0002","disposition":"abandoned","adjudicated_event_ids":[],"note":"a note","operating_skill_hash":"sha256:operating","operating_package_matches_shipped":false}}"#
3459 }
3460 other => panic!("no frozen line recorded for {other}"),
3461 }
3462 }
3463
3464 #[test]
3473 fn every_writable_event_persists_every_key_it_was_validated_with() {
3474 for (shape, event) in writable_event_shapes() {
3475 let written = persisted_bytes(&event);
3476 let read_back: Value = serde_json::from_slice(&written)
3477 .unwrap_or_else(|error| panic!("{shape}: persisted line is not JSON: {error}"));
3478 assert_eq!(
3479 read_back, event,
3480 "{shape}: the persisted event differs from the event that was validated. \
3481 A key present in the writer and missing from \
3482 `serialize_legacy_ordered_lifecycle_event` is lost the moment it is written."
3483 );
3484 }
3485 }
3486
3487 #[test]
3498 fn the_persisted_bytes_of_every_writable_type_are_frozen() {
3499 for (shape, event) in writable_event_shapes() {
3500 let written = String::from_utf8(persisted_bytes(&event))
3501 .unwrap_or_else(|error| panic!("{shape}: persisted line is not UTF-8: {error}"));
3502 assert_eq!(
3503 written,
3504 frozen_line(shape),
3505 "{shape}: the persisted byte order changed. Consumers hold over a thousand \
3506 events no release can regenerate; this is a change to what their streams \
3507 receive next, not a stale expectation."
3508 );
3509 }
3510 }
3511}