Skip to main content

skill_evidence/
lib.rs

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];
59/// Dispositions that reach no conclusion about the skill and still retire their
60/// covered evidence from the gate, because the review established that this
61/// instrument cannot test it.
62///
63/// See [`docs/adr/0002-blocked-no-valid-test-retires-its-evidence-from-the-gate.md`](../docs/adr/0002-blocked-no-valid-test-retires-its-evidence-from-the-gate.md).
64/// The events stay in the stream forever and stay open in the projection; they
65/// stop driving a gate whose own instrument proved unable to act on them.
66const 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    /// The operator action that would finish what this error interrupted, when
96    /// there is one.
97    ///
98    /// [`Display`](std::fmt::Display) deliberately omits it: rendering the
99    /// action means naming a binary, and a host that prints this error knows
100    /// its own name. See [`Host::recovery_instruction`].
101    #[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/// What hashing a skill directory found, before anyone decides to emit it.
116///
117/// Almost every caller in this crate wants only `content_hash`, to compare a
118/// directory against what the evidence says it was. [`HashReport`] is the same
119/// facts addressed to a reader outside the process, which is the only reason a
120/// schema identity is involved at all.
121#[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    /// The subset deriving actually reads.
150    ///
151    /// `lock_owner` is deliberately not part of it: a derivation takes no lock, and a
152    /// caller that only wants to read a gate should not have to invent an owner for
153    /// one it will never hold.
154    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    /// Legacy compatibility switch retained as an accepted no-op.
188    ///
189    /// The crate always hashes `LifecycleEventInputs::operator_skill`; callers compiled against
190    /// the earlier opt-in API can continue to set this field without changing the event.
191    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    /// Coverage this adjudicating close reached a conclusion about. Together with
306    /// [`Self::instrument_limited`], this must explicitly partition the complete
307    /// coverage list before the close can append its irreversible event.
308    pub concluded: Vec<String>,
309    /// The positive external owner of each concluded event when the disposition is
310    /// `outside_target`. Each owner is bound to one event rather than asserted once for
311    /// the whole review.
312    pub external_owners: Vec<ExternalOwner>,
313    /// Coverage this review could not decide: either no trial could express the
314    /// mechanism they record, or the acceptance gate grades outcome while their evidence
315    /// bears no outcome claim. A subset of the coverage list, and possibly all of it —
316    /// naming an event here does not remove it from what the close accounted for, it records
317    /// that the accounting was *undecided* rather than *concluded* — and it asserts nothing
318    /// about whether the mechanism reproduced.
319    pub instrument_limited: Vec<String>,
320    /// Immutable-event fields cited as the recorded ground for binding constraints behind
321    /// instrument-limited coverage. The caller supplies only pointers; the close copies
322    /// the complete source field into the recorded disposition without asserting entailment.
323    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    /// Declares that this record is another incident from a run this store has already
369    /// recorded, rather than a fresh use.
370    ///
371    /// One completed run can deviate in several distinct ways, and a review can only name
372    /// what has an identity — so each deviation is its own record, sharing the run group
373    /// and task fingerprint of its siblings. The duplicate refusal stays in force for
374    /// everything that does not declare itself this way.
375    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    /// Open incidents a review could not decide, so they no longer cluster and can no
425    /// longer reach a threshold. Contributed by an instrument-limited disposition, which
426    /// reaches its authorization reason, and by untestable coverage an adjudicating close
427    /// named, which reaches only those names.
428    ///
429    /// Omitted when empty, like `integrity_errors`: the overwhelming majority of
430    /// projections have none, and every projection ever written before this field
431    /// existed must keep validating.
432    #[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
526/// Reads a target's recorded stream from disk.
527///
528/// A stream that is not there yet reads as an empty one rather than as a failure:
529/// nobody has recorded a use against this target, which is not an integrity problem.
530fn 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    // Resolve every fallible provenance input before landing mutates the live target: a hash
850    // failure after that point could otherwise leave changed files without their event receipt.
851    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
939/// Appends the identities not already present, preserving caller order.
940///
941/// The coverage list uses this for additional `--adjudicate` names. Explicit terminal
942/// routes are stricter: repeating either route is refused rather than deduplicated.
943fn 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    // A non-adjudicating close already concluded nothing about anything it covered, so
1006    // singling out part of that coverage claims a distinction the disposition does not
1007    // carry. `blocked_no_valid_test` retires its whole reason-scoped reach either way.
1008    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    // Naming untestable coverage narrows what the close concluded; it never widens what
1150    // the close accounted for. An event outside the coverage list is one this review has
1151    // no standing to report anything about, testable or not — so refuse rather than
1152    // silently widen the reach past what the threshold froze.
1153    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    // Naming the whole coverage list is allowed, and deliberately so. A review can decide
1267    // none of what it covered while having concluded a great deal: the acceptance gate
1268    // grades outcome, so it cannot decide a covered trigger whose evidence bears no outcome
1269    // claim, however thoroughly the trials tested its mechanism. Refusing here would push
1270    // that review onto `blocked_no_valid_test`, whose reach is the authorization reason's
1271    // whole cluster — wider than this close ever covered — while asserting an untestability
1272    // its own trials disprove. Which limit a review met is semantic, and the command cannot
1273    // see it; the reference decides it at step 9.
1274    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        // Route validation above already refused repetition. Preserve the operator's
1285        // explicit order so the immutable event remains an inspectable rendering of the
1286        // accepted request.
1287        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    // One channel reported at two scopes. A close reports what *it* moved out of the gate,
1326    // so both scopes read the standing set after the close, drop whatever was already
1327    // retired before it, and keep only what this close can claim: every new member for a
1328    // disposition-level close, and just the named ones for an event-level naming. Reading
1329    // the standing set rather than the request is what keeps a contemporaneous severe
1330    // incident out of the reach — it is named but never retired, because it still drives
1331    // the gate on its own.
1332    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        // Without this the packet shows more open incidents than its clusters account
1591        // for, and the reviewer — told to work from the packet and not the ledger — has
1592        // to rediscover the reason from `prior_reviews`.
1593        "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    // A refusal that says only "collecting" invites the operator to wait for a gate that
1755    // will never open on this evidence. If a close retired some, say so here — this is the
1756    // surface they hit when they try to act.
1757    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    // A further incident shares its siblings' run group, so it takes that group off the
1870    // record already carrying it rather than deriving its own. A run whose first record was
1871    // written with `--same-run-group` carries a group no derivation reproduces, and deriving
1872    // one anyway would leave that run unable to record a second deviation from the very
1873    // session that recorded it — with no exit that does not mint a second group for one run.
1874    //
1875    // The sibling is matched on the run's own session and task fingerprint, both computed.
1876    // So the group still never comes from this caller: it is read out of the store, which is
1877    // what keeps the denominator resting on identity the store owns. It also keeps the reach
1878    // inside one session, since that is what the fingerprint alone would not bound.
1879    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    // `sibling_in_run_group` already implies the flag: nothing is adopted without it.
1900    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        // A run group is derived from the target, the task label and this session, so a run
1916        // recorded in an earlier session is out of reach. Answering that with the advice
1917        // meant for a run with no record at all would have the operator mint a second group
1918        // for one run — the denominator inflation counting run groups exists to prevent.
1919        // Scoped to another session, because the same-session case can no longer reach here:
1920        // a run recorded in this session is matched above whatever group it carries. Saying
1921        // "written elsewhere" of the operator's own session would be false.
1922        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
2100/// Whether the open incidents include more than one from a single run.
2101///
2102/// This is exactly the condition under which the two counts the collecting reply prints can
2103/// diverge, so it is derived from the projection's own open list rather than from the raw
2104/// stream. Adjudicated incidents have already left that list and stop inflating the open
2105/// count, while an incident retired as untestable stays open and keeps inflating it — both
2106/// of which the reply must follow rather than approximate.
2107fn 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
2200/// What a lock this process could not take says about itself.
2201///
2202/// [`Drop`] releases a lock only while the run that took it is alive to run it, so a
2203/// killed run leaves its lock behind and every later command on that evidence store
2204/// stops here. This unsafe failure is the only place an operator learns which of the
2205/// two they are facing: queued behind a run that is working, or stranded behind one
2206/// that died. Both facts are read off the lock rather than asserted — the holder from
2207/// the `owner` file it wrote, the moment it began from the directory's own timestamp.
2208/// Either can be missing, on a lock caught mid-creation or a filesystem that keeps no
2209/// timestamp, and naming one of them still beats naming neither.
2210fn 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
2228/// When a lock directory was last written, as epoch milliseconds.
2229///
2230/// For a lock this process could not take, that is when it began: `acquire` writes
2231/// the `owner` file into the directory immediately after creating it and nothing
2232/// writes there again, so the only later modification is the holder's own [`Drop`]
2233/// removing the file it is about to remove the directory with.
2234///
2235/// `None` rather than a guess whenever the filesystem does not keep the timestamp or
2236/// reports one no calendar can express. This is a diagnostic inside an unsafe
2237/// failure, and one that panics on its way to being written tells the operator
2238/// nothing at all.
2239fn lock_start_milliseconds(directory: &Path) -> Option<i64> {
2240    /// Midnight opening the year 10000 UTC, past what [`time`] can express.
2241    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
2256/// The bytes an event is persisted as, chosen by the event's own type.
2257///
2258/// The two orders this returns are both recorded history and neither is a default:
2259/// `use_recorded` is written by `serde_json`, whose `Value` is a `BTreeMap`, so its
2260/// keys come out alphabetically, while every lifecycle type keeps the order
2261/// JavaScript wrote it in. Which one an event gets is a property of the event, so it
2262/// is decided here rather than by whichever writer happens to call. An unrecognized
2263/// type reaches the longhand serializer and is refused by name; that is deliberate,
2264/// because the alternative is guessing an order into an append-only stream that no
2265/// release can regenerate.
2266fn 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            // Adjacent to the list it narrows, and omitted entirely when absent, so every
2426            // close written before this key existed stays byte-identical.
2427            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
2496/// `multi_incident_run` says whether the open incidents include more than one from a single
2497/// run.
2498///
2499/// It is not on [`GateStatus`], which is both a published projection and a public struct.
2500/// Serialized, it would need `gate-status.v1` to admit a property this change is not
2501/// allowed to add; unserialized, it would be a public field existing only to phrase one
2502/// terminal reply. Either way it is a detail of what the reply says, not a fact the gate
2503/// turns on.
2504fn 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            // Open incidents a close retired as untestable are still counted as
2516            // open — nothing was adjudicated — but they cluster no longer, so the symptom
2517            // list can now be empty while the count is not. Reporting the count without
2518            // saying where those incidents went reads as a gate quietly failing to
2519            // advance.
2520            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            // One run can record several incidents, so the open-incident count rises
2530            // faster than the use count. Printing both without saying which of the two
2531            // this is leaves the operator to reconcile a store they cannot see.
2532            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    /// The two labels a projection copies from the target.
2571    ///
2572    /// Deriving needs no more of a target than this — not its canonical path, not
2573    /// its evidence directory — so it is handed no more.
2574    fn labels(&self) -> GateTarget<'_> {
2575        GateTarget {
2576            name: &self.target_name,
2577            repo_relative_path: &self.repo_relative_path,
2578        }
2579    }
2580}
2581
2582/// Resolves the target a lifecycle command names, refusing when it is the very
2583/// package the running workflow is defined by.
2584///
2585/// Skill Evolution is the only workflow left that writes lifecycle events, so
2586/// the refusal has one shape. This took a `LifecycleWorkflow` selector while
2587/// Legacy Skill Decontamination also wrote them.
2588fn 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
2729/// Finds the skill directory `target` names, under `root` if it can.
2730///
2731/// A relative target is tried against `root` first and the working directory
2732/// second. The order matters only when both exist, and then `root` is the one
2733/// the operator named — explicitly with `--root`, or implicitly as the git
2734/// toplevel that the working directory sits inside. Preferring the working
2735/// directory there means auditing a nested copy while writing evidence to the
2736/// named repository's store, which is a wrong answer that looks like a right
2737/// one.
2738///
2739/// The opposite order was inherited, not chosen: the JavaScript helper this was
2740/// ported from in GitHub #116 wrote `resolve(process.cwd(), target),
2741/// resolve(root, target)`, and the port preserved it along with everything else
2742/// under that issue's compatibility boundary. No skill package documents it —
2743/// all four say "from the repository root", where the two candidates agree —
2744/// and across 980 recorded events in the two repositories that carried this
2745/// code, no target ever resolved to anything but a top-level
2746/// `.claude/skills/<name>`.
2747///
2748/// The working-directory candidate is kept rather than dropped, so a relative
2749/// path that is *only* meaningful from where the operator stands still
2750/// resolves.
2751fn 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
3232/// An unsafe failure the operator can still finish by hand.
3233///
3234/// Reach for this instead of [`unsafe_failure`] whenever the effect is partial
3235/// in a way a named command repairs. The message says what happened; the
3236/// [`Recovery`] says what to do, and the host says what to call the binary that
3237/// does it.
3238fn 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    /// The bytes production would append, through production's own dispatch.
3253    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    /// One event per writable shape, carrying every key its writer can emit.
3276    ///
3277    /// Maximal on purpose: a key the serializer forgets to persist can only show up as
3278    /// a difference if the fixture supplied it in the first place. The two
3279    /// `review_started` entries and the two `review_disposition` entries cover the
3280    /// serializer's own branches — the proof variants, and the optional keys whose
3281    /// omission keeps every close written before they existed byte-identical.
3282    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    /// The exact bytes each writable shape has been observed to persist.
3426    ///
3427    /// Frozen from the tree, not hand-authored. Two orders are visible here and both
3428    /// are deliberate: `use_recorded` is written by `serde_json`, whose `Value` is a
3429    /// `BTreeMap`, so its keys come out alphabetically; every lifecycle type is written
3430    /// by the longhand serializer in the order JavaScript wrote it, `schema_version`
3431    /// first. A nested payload object inside a lifecycle event is handed to `serde_json`
3432    /// whole, so it alphabetizes too — see `constraint_provenance` below.
3433    ///
3434    /// Changing any byte here changes what a consumer's append-only stream receives from
3435    /// the next event on. Nothing regenerates the events already written, so a diff in
3436    /// this function is a decision about recorded history, not a test to update.
3437    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    /// The persisted line must carry back exactly the event that was validated.
3465    ///
3466    /// Validation, gate derivation and the returned receipt all read the in-memory
3467    /// event; only this serializer decides what actually reaches the append-only
3468    /// stream, and it names the keys it persists by hand. A payload key added to a
3469    /// writer but not to that list would validate, derive and report while never being
3470    /// written — and the store re-reads from disk, so the omission is permanent. This
3471    /// test is what makes that a failure instead of silent loss.
3472    #[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    /// The bytes each writable type persists are frozen.
3488    ///
3489    /// `use_recorded` is the reason this test exists. Its byte order drifted once
3490    /// already — the JavaScript predecessor wrote every type `schema_version` first,
3491    /// and the Rust port writes `use_recorded` alphabetically, which
3492    /// `fixtures/skill-evidence/further-incidents-v1` has frozen into the corpus.
3493    /// Nothing noticed, because `rust_appended_lifecycle_events_keep_the_javascript_byte_order`
3494    /// drives only the four evolution commands and never appends a use record. That
3495    /// gap is what this closes: every writable type now has its bytes pinned, not
3496    /// just the four the replay happens to exercise.
3497    #[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}