{"record":{"id":"1eecc5b4e97b53a5","repo":"jdx/mise","slug":"ownership-receipt-has-changed","errorCode":null,"errorMessage":"ownership receipt has changed","messagePattern":"ownership receipt has changed","errorType":"exception","errorClass":"eyre::Report","httpStatus":null,"severity":"error","filePath":"src/system/packages/brew/cask.rs","lineNumber":7010,"sourceCode":"            .get(&target.path)\n            .is_some_and(|tokens| tokens.iter().any(|token| token != &candidate.token))\n        {\n            bail!(\n                \"artifact target is now claimed by another cask: {}\",\n                target.path.display()\n            );\n        }\n    }\n    Ok(())\n}\n\nfn validate_cask_prune_candidate(candidate: &CaskPruneCandidate) -> Result<()> {\n    if homebrew_metadata_present(&candidate.token) {\n        bail!(\"Homebrew now owns this cask\");\n    }\n    let receipt = &candidate.receipt;\n    if read_receipt(&candidate.version_dir)?.as_ref() != Some(receipt) {\n        bail!(\"ownership receipt has changed\");\n    }\n    if receipt.schema_version != 3 || !receipt.prune_safe || !receipt.pkg_ids.is_empty() {\n        bail!(\"receipt is not marked safe for direct-artifact pruning\");\n    }\n    let records = receipt\n        .targets\n        .iter()\n        .map(|record| (record.path.clone(), record))\n        .collect::<BTreeMap<_, _>>();\n    let expected = receipt\n        .apps\n        .iter()\n        .chain(&receipt.binaries)\n        .chain(&receipt.fonts)\n        .chain(&receipt.completions)\n        .cloned()\n        .collect::<BTreeSet<_>>();\n    if expected.is_empty()","sourceCodeStart":6992,"sourceCodeEnd":7028,"githubUrl":"https://github.com/jdx/mise/blob/9dcfcaa0dc8747a2577d3270b69bb9d8313b2807/src/system/packages/brew/cask.rs#L6992-L7028","documentation":"Thrown while pruning a mise-managed Homebrew cask: apply_cask_prune_plan re-reads the cask's ownership receipt (.mise-cask.toml inside the Caskroom version directory) and compares it to the snapshot taken when the prune plan was built. A mismatch means the cask's ownership changed between planning and execution, so pruning is refused rather than risk deleting artifacts that now belong to a different install.","triggerScenarios":"apply_cask_prune_plan -> validate_cask_prune_candidate: read_receipt(version_dir) returns a receipt not equal (PartialEq) to candidate.receipt. Happens when a concurrent mise brew install/upgrade rewrites the receipt after prune_plan ran, when Homebrew takes over the cask and metadata changes, or when .mise-cask.toml is edited/deleted between plan and apply.","commonSituations":"Two mise (or mise + brew) processes racing on the same Caskroom; running prune from a stale plan file; an interrupted earlier transaction leaving a half-written receipt; hand-editing receipts.","solutions":["Re-run the prune so it replans from the receipts currently on disk (plans are validated against live state, stale plans are expected to be discarded)","Make sure no other mise brew install/upgrade/uninstall or Homebrew command is running against the same prefix while pruning","Inspect Caskroom/<token>/<version>/.mise-cask.toml — if it is corrupt or foreign, reinstall the cask to regenerate a schema-3 receipt"],"exampleFix":"// before: pruning from a plan built earlier, while another mise process ran\nlet plan = cask_prune_plan(...)?;\n// ... other mise brew install runs here, rewriting receipts ...\napply_cask_prune_plan(&plan, false)?; // \"ownership receipt has changed\"\n\n// after: plan and apply in one session, no concurrent brew/mise operations\nlet plan = cask_prune_plan(...)?;\napply_cask_prune_plan(&plan, false)?;","handlingStrategy":"validation","validationCode":"// Re-read the receipt immediately before applying a prune plan and drop\n// candidates whose receipt no longer matches the plan snapshot.\nfn still_valid(candidate: &CaskPruneCandidate) -> bool {\n    read_receipt(&candidate.version_dir)\n        .ok()\n        .flatten()\n        .is_some_and(|current| current == candidate.receipt)\n}\nlet plan_remove: Vec<_> = plan.remove.into_iter().filter(still_valid).collect();","typeGuard":"fn receipt_unchanged(c: &CaskPruneCandidate) -> bool {\n    matches!(read_receipt(&c.version_dir), Ok(Some(ref r)) if r == &c.receipt)\n}","tryCatchPattern":"if let Err(reason) = validate_cask_prune_candidate(&candidate) {\n    // do not abort the whole prune; skip this cask and continue,\n    // surfacing the reason to the user (same pattern as apply_cask_prune_plan)\n    warn!(\"brew-cask:{}: skipped: {reason:#}\", candidate.token);\n    continue;\n}","preventionTips":["Build and apply prune plans in the same invocation; never persist plans across sessions","Serialize mise brew operations per prefix — the caskroom lock only covers apply, not plan","Treat receipt churn as expected under concurrency: re-plan instead of retrying the stale plan"],"tags":["brew","cask","prune","receipt","toctou","concurrency"],"backgroundTag":"concurrent-modification-detected","analyzedSha":"9dcfcaa0dc8747a2577d3270b69bb9d8313b2807","analyzedAt":"2026-08-17T14:28:50.624Z","schemaVersion":2},"datasetVersion":"2026-08-21T18:17:14.833Z"}