{"record":{"id":"fa4cc8aae7238dfd","repo":"Hmbown/CodeWhale","slug":"audited-skill-root-identity-changed-refusing-muta","errorCode":null,"errorMessage":"audited skill root identity changed; refusing mutation","messagePattern":"audited skill root identity changed; refusing mutation","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/tui/src/skills/mutation.rs","lineNumber":333,"sourceCode":"    let canonical_child = fs::canonicalize(child)\n        .with_context(|| format!(\"failed to resolve owned skill path {}\", child.display()))?;\n    if canonical_child.parent() != Some(canonical_root.as_path()) {\n        bail!(\n            \"owned skill path {} escapes direct root {}\",\n            child.display(),\n            skills_dir.display()\n        );\n    }\n    Ok(true)\n}\n\nfn validate_owned_skill_path(\n    ctx: &MutationContext<'_>,\n    skill: &AuditedSkill,\n    path: &Path,\n) -> Result<PathBuf> {\n    if skill.id.root_id != skill.root.id {\n        bail!(\"audited skill root identity changed; refusing mutation\");\n    }\n    let skills_dir = validate_owned_root_descriptor(ctx, &skill.root)?;\n    let package_name = on_disk_package_name(&skill.id)?;\n    let expected = skills_dir.join(package_name);\n    if path != expected {\n        bail!(\n            \"audited skill path {} does not match owned package {}\",\n            path.display(),\n            expected.display()\n        );\n    }\n    validate_owned_child(&skills_dir, path, true)?;\n    Ok(skills_dir)\n}\n\n/// Resolve the on-disk CodeWhale-owned skills directory for a target scope.\npub fn resolve_owned_target(\n    workspace: &Path,","sourceCodeStart":315,"sourceCodeEnd":351,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/73e0f67d83c59909b571efdfc88c4bc28c309cb1/crates/tui/src/skills/mutation.rs#L315-L351","documentation":"Before mutating a skill (update/remove/trust), the library re-resolves the skill and checks that the root it currently lives under still matches the root recorded in the audited skill ID. If the root identity differs, the audit is stale — the skill may have been moved, re-imported, or its catalog entry replaced — so mutation is refused rather than acting on unverified content.","triggerScenarios":"Calling update_skill, remove_skill, or trust_skill with an AuditedSkill whose id.root_id no longer equals skill.root.id — e.g. the skill was moved between project and global roots, the root directory was re-created with a different identity, or a stale AuditedSkill was cached from a previous session and the catalog changed underneath it.","commonSituations":"A skill was imported into the global root while the app held an audited reference to the project root (or vice versa); the user deleted and re-created the .codewhale/skills directory; a long-lived session used a skill snapshot taken before the user rearranged roots manually.","solutions":["Re-scan/re-list the skill catalog to get a fresh AuditedSkill, then retry the mutation with the fresh handle","Check whether the skill was moved between project and global roots and target the root it actually lives in now","If the root directory was deleted and re-created, re-import the skill so the audit records the new root identity","Remove the stale skill and re-add it instead of updating in place"],"exampleFix":"// before\nlet audited = cached_skill; // stale handle from an earlier scan\nmutation::remove_skill(&audited, &ctx)?; // may fail: root changed\n// after\nlet audited = catalog.list_owned_skills()?.into_iter()\n    .find(|s| s.id.canonical_name == \"my-skill\")\n    .context(\"skill not found\")?;\nmutation::remove_skill(&audited, &ctx)?; // fresh audit matches root","handlingStrategy":"validation","validationCode":"fn audited_root_is_fresh(skill: &AuditedSkill) -> bool {\n    skill.id.root_id == skill.root.id\n}\n// only call update/remove/trust when this returns true; otherwise re-scan the catalog","typeGuard":null,"tryCatchPattern":"match mutation::remove_skill(&audited, &path, &ctx) {\n    Err(e) if e.to_string().contains(\"root identity changed\") => {\n        let fresh = re_scan_catalog_for(&audited.id.canonical_name)?;\n        mutation::remove_skill(&fresh, &path, &ctx)?\n    }\n    other => other?,\n}","preventionTips":["Always obtain AuditedSkill handles from a fresh catalog scan immediately before mutating","Avoid caching skill handles across operations that can move or re-import skills","Re-scan whenever the skills directories change on disk"],"tags":["skills","security","stale-state","mutation-guard"],"backgroundTag":"internal-invariant-violation","analyzedSha":"73e0f67d83c59909b571efdfc88c4bc28c309cb1","analyzedAt":"2026-09-22T01:30:00.501Z","contentChangedAt":"2026-09-22T01:30:00.501Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}