Skip to main content

mif_rh/
error.rs

1//! Errors from loading, resolving, reviewing, indexing, or locking a
2//! research-harness-template (rht) corpus.
3
4use mif_problem::{
5    Applicability, CodeAction, ProblemDetails, ProblemMeta, SuggestedFix, ToProblem,
6};
7
8/// Errors from `mif-rh`'s engine core.
9///
10/// Variants split into two classes: those that produce a
11/// [`crate::resolve::MapRecord`] anyway (a finding that classifies as
12/// invalid/ambiguous/unresolved is still recorded, per rht's own
13/// `resolve-ontology.sh` — see [`crate::resolve::resolve_finding`]) and
14/// those below, which mean no record could be produced at all (the file
15/// itself is unreadable, the catalog is missing, or an ontology definition
16/// is broken). [`crate::review::review`] folds the latter into its
17/// reconciliation "gap" count rather than aborting the whole review.
18#[derive(Debug, thiserror::Error)]
19pub enum MifRhError {
20    /// Failed to read a finding file.
21    #[error("failed to read finding {path}: {source}")]
22    FindingIo {
23        /// The path that failed to read.
24        path: String,
25        /// The underlying I/O error.
26        #[source]
27        source: std::io::Error,
28    },
29    /// A finding file was not valid JSON.
30    #[error("finding {path} is not valid JSON: {source}")]
31    FindingJson {
32        /// The path that failed to parse.
33        path: String,
34        /// The underlying parse error.
35        #[source]
36        source: serde_json::Error,
37    },
38    /// A generic I/O failure reading a supporting file (ontology directory,
39    /// map file, `harness.config.json`).
40    #[error("failed to read {path}: {source}")]
41    Io {
42        /// The path that failed to read.
43        path: String,
44        /// The underlying I/O error.
45        #[source]
46        source: std::io::Error,
47    },
48    /// A supporting file was not valid JSON.
49    #[error("failed to parse {path} as JSON: {source}")]
50    Json {
51        /// The path that failed to parse.
52        path: String,
53        /// The underlying parse error.
54        #[source]
55        source: serde_json::Error,
56    },
57    /// A value could not be serialized to JSON for an atomic write. This
58    /// indicates a bug in the value's `Serialize` implementation (e.g. a
59    /// non-finite float) rather than anything a caller can fix by changing
60    /// its input.
61    #[error("failed to serialize {path} as JSON: {source}")]
62    JsonSerialize {
63        /// The path the value was being written to.
64        path: String,
65        /// The underlying serialization error.
66        #[source]
67        source: serde_json::Error,
68    },
69    /// An ontology pack YAML file failed to parse (the direct equivalent of
70    /// `yq` failing to read an ontology's `extends`/`entity_types`/full
71    /// YAML — a fail-closed abort, matching rht's own bash exit code 4).
72    #[error("failed to parse ontology pack {path} as YAML: {source}")]
73    OntologyPackYaml {
74        /// The path that failed to parse.
75        path: String,
76        /// The underlying parse error.
77        #[source]
78        source: serde_norway::Error,
79    },
80    /// A rendered artifact's MIF concept frontmatter could not be serialized
81    /// to YAML. This indicates a bug in the concept's `Serialize`
82    /// implementation (e.g. a non-finite float) rather than anything a
83    /// caller can fix by changing its input.
84    #[error("failed to serialize report frontmatter as YAML: {source}")]
85    FrontmatterYamlSerialize {
86        /// The underlying serialization error.
87        #[source]
88        source: serde_norway::Error,
89    },
90    /// The catalog file (`.claude/enabled-packs.json`) is missing.
91    #[error("catalog file {path} does not exist")]
92    CatalogMissing {
93        /// The missing catalog path.
94        path: String,
95    },
96    /// The config file (`harness.config.json`) is missing.
97    #[error("config file {path} does not exist")]
98    ConfigMissing {
99        /// The missing config path.
100        path: String,
101    },
102    /// A topic directly binds an ontology id that is not cataloged, or pins
103    /// a version that does not match the cataloged one.
104    #[error("topic '{topic}' binds ontology '{id}', which is not cataloged or version-mismatched")]
105    DirectBindingInvalid {
106        /// The topic with the invalid binding.
107        topic: String,
108        /// The offending ontology id.
109        id: String,
110    },
111    /// Resolving the transitive `extends` ancestry for an allowed ontology
112    /// failed (an ancestor is missing from the supplied ontology-pack
113    /// directory, or the `extends` graph is cyclic).
114    #[error(transparent)]
115    Ontology(#[from] mif_ontology::OntologyError),
116    /// Splitting a report's frontmatter/body failed (missing frontmatter,
117    /// malformed YAML, or a non-mapping frontmatter document).
118    #[error(transparent)]
119    Frontmatter(#[from] mif_frontmatter::FrontmatterError),
120    /// A report schema (or one of its `$ref` dependencies) was not valid
121    /// JSON, or the `jsonschema` validator could not be compiled from it.
122    #[error("failed to compile schema {path}: {detail}")]
123    SchemaCompilation {
124        /// The schema file that failed to compile.
125        path: String,
126        /// The underlying compilation error, stringified (the original
127        /// `jsonschema` error type is not `'static`).
128        detail: String,
129    },
130    /// A `$ref` dependency schema has no `$id`, so it cannot be registered
131    /// for the main schema to resolve references against.
132    #[error("schema {path} has no $id — it cannot be registered as a $ref target")]
133    RefSchemaMissingId {
134        /// The dependency schema file with no `$id`.
135        path: String,
136    },
137    /// A projected report failed validation against its schema.
138    #[error("{path} failed schema validation against {schema_path}: {detail}")]
139    SchemaValidationFailed {
140        /// The report file that failed validation.
141        path: String,
142        /// The schema it was validated against.
143        schema_path: String,
144        /// Every validation error, joined for display.
145        detail: String,
146    },
147    /// A manifest toggle's value is not one of the allowed values.
148    #[error("{field} must be one of {allowed} (got '{value}')")]
149    InvalidToggleValue {
150        /// The toggled field's name.
151        field: String,
152        /// The rejected value.
153        value: String,
154        /// The pipe-joined list of allowed values.
155        allowed: String,
156    },
157    /// No source content was available from `--content-file`,
158    /// `--content`, or stdin.
159    #[error("empty content (provide --content-file, --content, or stdin)")]
160    EmptySourceContent,
161    /// A `pack-toggle` target is not declared in `harness.config.json`'s
162    /// `packs[]` array. Distinct from [`Self::PackNotFound`] (a
163    /// `bump-version --pack` target with no `packs/<family>/<name>/`
164    /// directory on disk) — this is a config-declaration check, not a
165    /// filesystem one.
166    #[error("pack '{name}' is not declared in {path} packs[] — declare it first")]
167    PackNotDeclared {
168        /// The undeclared pack name.
169        name: String,
170        /// The manifest path checked.
171        path: String,
172    },
173    /// A findings directory has no `*.json` files to build a graph/index
174    /// from.
175    #[error("no finding JSON in {path}")]
176    NoFindingsFound {
177        /// The empty findings directory.
178        path: String,
179    },
180    /// Every finding in a directory is falsified — nothing to synthesize.
181    #[error("no surviving findings to synthesize in {path}")]
182    NoSurvivingFindings {
183        /// The findings directory with no surviving findings.
184        path: String,
185    },
186    /// A synthesized artifact has no sections, finding refs, or sources —
187    /// there is nothing publishable to render.
188    #[error(
189        "artifact from {path} has no publishable content (no surviving findings, or no citations to cite)"
190    )]
191    ArtifactNotPublishable {
192        /// The findings directory the artifact was synthesized from.
193        path: String,
194    },
195    /// One or more findings in a corpus import lack a provenance block
196    /// (SPEC §8a: provenance must survive an import).
197    #[error(
198        "{count} finding(s) lack a provenance block; import aborted (provenance must be preserved)"
199    )]
200    MissingProvenance {
201        /// How many findings lack provenance.
202        count: usize,
203        /// The paths of the findings missing provenance.
204        paths: Vec<String>,
205    },
206    /// A session reconciliation's known-good sample finding failed schema
207    /// validation — the schema/toolchain itself is broken. Must never be
208    /// read as "every finding is invalid" (that would re-run an entire
209    /// expensive research session).
210    #[error(
211        "the known-good sample finding at {sample_path} failed schema validation — the schema/toolchain is broken, refusing to emit a plan"
212    )]
213    ReconcileEnvironmentBroken {
214        /// The sample finding path that unexpectedly failed to validate.
215        sample_path: String,
216    },
217    /// A topic README build/check was requested for a topic with no entry
218    /// in `harness.config.json`'s `topics[]`.
219    #[error("topic '{topic}' is not registered in {config_path}")]
220    TopicNotRegistered {
221        /// The unregistered topic id.
222        topic: String,
223        /// The manifest path checked.
224        config_path: String,
225    },
226    /// A concordance file parsed but is not a valid graph (no `.nodes`
227    /// array) — the corpus atlas is a projection of the spine, and there
228    /// is nothing to project without one.
229    #[error("concordance is not a valid graph (no .nodes array): {path}")]
230    InvalidConcordance {
231        /// The invalid concordance path.
232        path: String,
233    },
234    /// The shippable-typing gate's `ontology-map.json` is missing or
235    /// present-but-unparseable (not a JSON array of records) — the gate
236    /// cannot prove any shippable finding is typed, so it fails closed
237    /// rather than passing vacuously (every per-finding lookup would
238    /// otherwise silently resolve to "no record").
239    #[error(
240        "ontology-map.json {reason} for topic '{topic}' — synthesis BLOCKED (fail closed): {path}"
241    )]
242    OntologyMapUnusable {
243        /// The ontology-map path checked.
244        path: String,
245        /// The topic (derived from the reports-dir name) named in the
246        /// operator unblock hint.
247        topic: String,
248        /// Either `"is missing"` or `"is unparseable or not a record array"`.
249        reason: String,
250    },
251    /// A finding under `<topic>/findings/` failed to parse while building
252    /// the corpus-wide active-`@id`/relationship-target universes for the
253    /// relationship-targets gate. Hard-fails the whole gate (never silently
254    /// dropped) — an unparseable file could otherwise hide a real dangling
255    /// target elsewhere in the corpus.
256    #[error("finding is not valid JSON, cannot check its relationship targets: {path}")]
257    RelationshipTargetFindingUnparseable {
258        /// The unparseable finding path.
259        path: String,
260    },
261    /// A `subtype_of` chain across the loaded ontology registry revisits
262    /// its own starting type — a cycle. User-authored ontology data must
263    /// never drive unbounded recursion during the concordance's transitive
264    /// supertype closure.
265    #[error("subtype_of graph contains a cycle involving entity type '{entity_type}'")]
266    SubtypeOfCycle {
267        /// The entity type where the cycle was detected.
268        entity_type: String,
269    },
270    /// Building a dynamic `jsonschema` validator for a resolved entity
271    /// type's `schema` field failed. Indicates a malformed ontology pack,
272    /// not a bug in the finding being validated.
273    #[error("entity type '{entity_type}' has a malformed validation schema: {detail}")]
274    EntityTypeSchemaInvalid {
275        /// The offending entity type name.
276        entity_type: String,
277        /// The underlying schema compilation error, stringified (the
278        /// original `jsonschema` error type is not `'static` and cannot be
279        /// stored here directly).
280        detail: String,
281    },
282    /// A `SQLite` index operation failed.
283    #[error("sqlite index operation failed: {source}")]
284    Index {
285        /// The underlying `SQLite` error.
286        #[source]
287        source: rusqlite::Error,
288    },
289    /// Failed to open or write the exclusive review lock file.
290    #[error("failed to acquire the review lock at {path}: {source}")]
291    LockIo {
292        /// The lock file path.
293        path: String,
294        /// The underlying I/O error.
295        #[source]
296        source: std::io::Error,
297    },
298    /// Another `review` run already holds the lock.
299    #[error("another review is already in progress (lock held by pid {holder_pid})")]
300    LockHeld {
301        /// The PID recorded in the held lock file.
302        holder_pid: u32,
303    },
304    /// A suggestion queue file on disk belongs to a different topic than
305    /// the one being upserted — a copied/renamed queue file or a
306    /// wrong-path caller, which must not silently mix topics' entries.
307    #[error("suggestion queue at {path} belongs to topic '{found}', not '{expected}'")]
308    QueueTopicMismatch {
309        /// The queue file path.
310        path: String,
311        /// The topic the caller is upserting.
312        expected: String,
313        /// The topic recorded inside the queue file.
314        found: String,
315    },
316    /// Computing an embedding failed.
317    #[error(transparent)]
318    Embed(#[from] mif_embed::EmbedError),
319    /// Failed to fetch a file (the registry index, or a vendored ontology)
320    /// from the resolved ontology source (a local directory or an http(s)
321    /// base URL).
322    #[error("failed to fetch from ontology source {registry_source}: {detail}")]
323    RegistryFetch {
324        /// The resolved source (directory path or URL base). Named
325        /// `registry_source`, not `source`: `thiserror` treats a field
326        /// literally named `source` as the error-chain cause and requires
327        /// it to implement `std::error::Error`, which a plain `String`
328        /// does not.
329        registry_source: String,
330        /// A human-readable failure detail.
331        detail: String,
332    },
333    /// The registry index (`index.json`) was not valid JSON or did not
334    /// match the expected shape.
335    #[error("ontology registry index at {registry_source} is malformed: {detail}")]
336    RegistryIndexInvalid {
337        /// The resolved source the index was read from. See
338        /// [`Self::RegistryFetch`]'s doc comment for why this is
339        /// `registry_source`, not `source`.
340        registry_source: String,
341        /// A human-readable failure detail.
342        detail: String,
343    },
344    /// A requested or `extends`-ancestor ontology id has no entry in the
345    /// registry index — it has no canonical definition yet.
346    #[error(
347        "ontology '{id}' is not in the registry index — it has no canonical definition yet \
348         (author one with the `ontology author` subcommand)"
349    )]
350    OntologyNotInRegistry {
351        /// The unresolvable ontology id.
352        id: String,
353    },
354    /// `ontologies.lock.json`'s pinned source differs from the source a
355    /// read-only pin-safety check was asked to evaluate against — the
356    /// lock's per-id version/sha256 pins were established against a
357    /// different registry and are not meaningful trust anchors for this
358    /// one. Unlike `fetch`, which legitimately adopts a new source on
359    /// first use (and re-pins it), a read-only check never mutates the
360    /// lock, so it must fail closed rather than silently compare a
361    /// mismatched source's registry entries against pins it did not
362    /// establish.
363    #[error(
364        "ontologies.lock.json is pinned to source '{lock_source}', not '{requested_source}' — \
365         refusing to evaluate pin safety against a different registry than the one the lock \
366         trusts"
367    )]
368    LockSourceMismatch {
369        /// The source recorded in the lock file.
370        lock_source: String,
371        /// The source the check was asked to evaluate against.
372        requested_source: String,
373    },
374    /// A checksum-verified, freshly-fetched ontology pack's bytes were not
375    /// valid UTF-8 — refusing to lossily convert content whose exact bytes
376    /// were already pinned by checksum. A lossy conversion would silently
377    /// substitute replacement characters, meaning the diffed schema would
378    /// not be the schema that was actually hashed and fetched.
379    #[error("ontology '{id}' file '{file}' is not valid UTF-8")]
380    OntologyPackNotUtf8 {
381        /// The ontology id being diffed.
382        id: String,
383        /// The index's declared file name.
384        file: String,
385    },
386    /// The registry index's sha256 no longer matches the value pinned in
387    /// `ontologies.lock.json` for the same source (trust-on-first-use, then
388    /// pin) — the trust root moved.
389    #[error(
390        "registry index sha256 changed from the pinned value for source {registry_source} \
391         (pinned {pinned}, got {got}) — refusing to trust a moved index (clear index_sha256 \
392         in the lock to re-pin deliberately)"
393    )]
394    IndexPinMismatch {
395        /// The source whose index changed. See [`Self::RegistryFetch`]'s
396        /// doc comment for why this is `registry_source`, not `source`.
397        registry_source: String,
398        /// The previously pinned index sha256.
399        pinned: String,
400        /// The newly fetched index's sha256.
401        got: String,
402    },
403    /// A fetched ontology file's sha256 did not match the registry index's
404    /// pinned value — refusing to vendor a file that does not match the
405    /// trusted hash (fail-closed).
406    #[error("checksum mismatch for ontology '{id}' ({file}): expected {expected}, got {got}")]
407    ChecksumMismatch {
408        /// The ontology id being vendored.
409        id: String,
410        /// The index's declared file name.
411        file: String,
412        /// The expected (pinned) sha256.
413        expected: String,
414        /// The sha256 actually computed from the fetched bytes.
415        got: String,
416    },
417    /// The registry index named an unsafe (non-bare) file path for an
418    /// ontology — a poisoned index could otherwise escape the vendored
419    /// packs directory.
420    #[error(
421        "registry index entry for '{id}' has an unsafe file path: '{file}' (must be a bare \
422         filename)"
423    )]
424    UnsafeIndexPath {
425        /// The ontology id whose index entry is unsafe.
426        id: String,
427        /// The offending file path.
428        file: String,
429    },
430    /// A registry-discovered ontology id is not a bare, lowercase slug — a
431    /// poisoned or malformed index entry that could otherwise escape its
432    /// intended vendored directory once written into `harness.config.json`.
433    #[error("registry index declares a malformed ontology id: '{id}' (refusing, fail-closed)")]
434    MalformedOntologyId {
435        /// The malformed id.
436        id: String,
437    },
438    /// `harness.config.json`'s `.ontologies` field exists but is not an
439    /// array — refusing to guess how to append a discovered ontology to it.
440    #[error("{path}'s .ontologies is not an array: {detail}")]
441    ConfigMalformed {
442        /// The config path.
443        path: String,
444        /// A human-readable failure detail.
445        detail: String,
446    },
447    /// A topic's `ontology-map.json` carries no typed entity types to
448    /// mine an ontology draft from.
449    #[error(
450        "no entity types found in reports/{topic}/ontology-map.json — nothing to draft an \
451         ontology from"
452    )]
453    NoEntityTypesFound {
454        /// The topic whose map carried no typed entities.
455        topic: String,
456    },
457    /// An `expansion-candidates` output file carries no clusters to draft
458    /// candidate types from.
459    #[error("no clusters in {path} — nothing to draft an ontology from")]
460    NoClustersFound {
461        /// The clusters file with no clusters.
462        path: String,
463    },
464    /// A version string is not well-formed `X.Y.Z` semver.
465    #[error("version is not well-formed semver: {value}")]
466    VersionNotSemver {
467        /// The offending value.
468        value: String,
469    },
470    /// A file expected to carry a `.version` field has none.
471    #[error("{path} has no .version")]
472    VersionMissing {
473        /// The file with no version.
474        path: String,
475    },
476    /// The requested new version equals the current one.
477    #[error("new version equals current ({value}); nothing to bump")]
478    VersionUnchanged {
479        /// The unchanged value.
480        value: String,
481    },
482    /// A `--pack` component name resolves to no directory under
483    /// `packs/<family>/`.
484    #[error(
485        "pack '{name}' not found under packs/<family>/ (ontology packs version independently and are not bumpable here)"
486    )]
487    PackNotFound {
488        /// The unresolved component name.
489        name: String,
490    },
491    /// A `--pack` component name resolves to more than one directory.
492    #[error("pack '{name}' is ambiguous: more than one packs/<family>/{name} directory exists")]
493    PackAmbiguous {
494        /// The ambiguous component name.
495        name: String,
496    },
497    /// A pack is missing a file `bump_version` needs (its `plugin.json`,
498    /// `SKILL.md`, or family doc section/row).
499    #[error("pack '{name}': missing or malformed {path}")]
500    PackFileMissing {
501        /// The pack's component name.
502        name: String,
503        /// The missing or malformed file/section.
504        path: String,
505    },
506    /// A pack's declared version is not well-formed semver.
507    #[error("pack '{name}' {path} has no valid semver .version (got '{value}')")]
508    PackVersionInvalid {
509        /// The pack's component name.
510        name: String,
511        /// The pack's `plugin.json` path.
512        path: String,
513        /// The malformed value found.
514        value: String,
515    },
516    /// A pack's current version is already ahead of the release being cut —
517    /// bumping it would move it backward.
518    #[error(
519        "pack '{name}' is at {pack_version}, ahead of the new release {new_version} — refusing to move it backward"
520    )]
521    PackAheadOfRelease {
522        /// The pack's component name.
523        name: String,
524        /// The pack's current version.
525        pack_version: String,
526        /// The release version being cut.
527        new_version: String,
528    },
529    /// `CHANGELOG.md` has neither an `## [Unreleased]` anchor to insert a
530    /// new section under, nor an existing section for the new version.
531    #[error(
532        "{path} has no '## [Unreleased]' anchor to insert the new version under (nor an existing section for it)"
533    )]
534    ChangelogAnchorMissing {
535        /// The CHANGELOG path.
536        path: String,
537    },
538    /// A post-write self-verification found a file that did not update to
539    /// the expected new value.
540    #[error("verification failed: {path} was not updated as expected")]
541    VerificationFailed {
542        /// The file that failed verification.
543        path: String,
544    },
545    /// A `git` invocation exited non-zero — a real failure (not a repo, a
546    /// corrupted ref, etc.), distinct from a command that legitimately
547    /// produced no output.
548    #[error("git {command} failed: {stderr}")]
549    GitCommandFailed {
550        /// The git subcommand invoked (e.g. `"tag --list v*"`).
551        command: String,
552        /// Captured stderr.
553        stderr: String,
554    },
555}
556
557impl MifRhError {
558    // One arm per error variant, each a flat struct literal — length is
559    // inherent to the variant count, not a complexity signal.
560    #[allow(clippy::too_many_lines)]
561    const fn meta(&self) -> ProblemMeta {
562        match self {
563            Self::FindingIo { .. } => ProblemMeta {
564                slug: "finding-io",
565                version: "v1",
566                title: "Failed to read a finding file",
567                status: 500,
568                exit_code: 2,
569            },
570            Self::FindingJson { .. } => ProblemMeta {
571                slug: "finding-invalid-json",
572                version: "v1",
573                title: "Finding file is not valid JSON",
574                status: 400,
575                exit_code: 2,
576            },
577            Self::Io { .. } => ProblemMeta {
578                slug: "mif-rh-io",
579                version: "v1",
580                title: "Failed to read a supporting file",
581                status: 500,
582                exit_code: 1,
583            },
584            Self::Json { .. } => ProblemMeta {
585                slug: "mif-rh-invalid-json",
586                version: "v1",
587                title: "Supporting file is not valid JSON",
588                status: 400,
589                exit_code: 1,
590            },
591            Self::JsonSerialize { .. } => ProblemMeta {
592                slug: "json-serialize-failure",
593                version: "v1",
594                title: "A value could not be serialized to JSON",
595                status: 500,
596                exit_code: 1,
597            },
598            Self::OntologyPackYaml { .. } => ProblemMeta {
599                slug: "ontology-pack-invalid-yaml",
600                version: "v1",
601                title: "Ontology pack YAML failed to parse",
602                status: 422,
603                exit_code: 4,
604            },
605            Self::FrontmatterYamlSerialize { .. } => ProblemMeta {
606                slug: "frontmatter-yaml-serialize-failure",
607                version: "v1",
608                title: "Report frontmatter could not be serialized to YAML",
609                status: 500,
610                exit_code: 1,
611            },
612            Self::CatalogMissing { .. } => ProblemMeta {
613                slug: "catalog-missing",
614                version: "v1",
615                title: "Ontology catalog file does not exist",
616                status: 404,
617                exit_code: 3,
618            },
619            Self::ConfigMissing { .. } => ProblemMeta {
620                slug: "config-missing",
621                version: "v1",
622                title: "Harness config file does not exist",
623                status: 404,
624                exit_code: 2,
625            },
626            Self::DirectBindingInvalid { .. } => ProblemMeta {
627                slug: "direct-binding-invalid",
628                version: "v1",
629                title: "Topic binds an uncataloged or version-mismatched ontology",
630                status: 422,
631                exit_code: 1,
632            },
633            Self::Ontology(_) => ProblemMeta {
634                slug: "delegated-ontology",
635                version: "v1",
636                title: "Delegated ontology error",
637                status: 500,
638                exit_code: 4,
639            },
640            Self::Frontmatter(_) => ProblemMeta {
641                slug: "delegated-frontmatter",
642                version: "v1",
643                title: "Delegated frontmatter error",
644                status: 500,
645                exit_code: 4,
646            },
647            Self::SchemaCompilation { .. } => ProblemMeta {
648                slug: "schema-compilation-failed",
649                version: "v1",
650                title: "Report schema failed to compile",
651                status: 422,
652                exit_code: 2,
653            },
654            Self::RefSchemaMissingId { .. } => ProblemMeta {
655                slug: "ref-schema-missing-id",
656                version: "v1",
657                title: "Ref-target schema has no $id",
658                status: 422,
659                exit_code: 2,
660            },
661            Self::SchemaValidationFailed { .. } => ProblemMeta {
662                slug: "schema-validation-failed",
663                version: "v1",
664                title: "Report failed schema validation",
665                status: 422,
666                exit_code: 1,
667            },
668            Self::InvalidToggleValue { .. } => ProblemMeta {
669                slug: "invalid-toggle-value",
670                version: "v1",
671                title: "Manifest toggle value is not one of the allowed values",
672                status: 422,
673                exit_code: 2,
674            },
675            Self::EmptySourceContent => ProblemMeta {
676                slug: "empty-source-content",
677                version: "v1",
678                title: "No source content was available from any input",
679                status: 422,
680                exit_code: 2,
681            },
682            Self::PackNotDeclared { .. } => ProblemMeta {
683                slug: "pack-not-declared",
684                version: "v1",
685                title: "Pack is not declared in the harness manifest",
686                status: 404,
687                exit_code: 2,
688            },
689            Self::NoFindingsFound { .. } => ProblemMeta {
690                slug: "no-findings-found",
691                version: "v1",
692                title: "Findings directory has no finding JSON",
693                status: 404,
694                exit_code: 2,
695            },
696            Self::NoSurvivingFindings { .. } => ProblemMeta {
697                slug: "no-surviving-findings",
698                version: "v1",
699                title: "Every finding is falsified — nothing to synthesize",
700                status: 422,
701                exit_code: 1,
702            },
703            Self::ArtifactNotPublishable { .. } => ProblemMeta {
704                slug: "artifact-not-publishable",
705                version: "v1",
706                title: "Synthesized artifact has no publishable content",
707                status: 422,
708                exit_code: 1,
709            },
710            Self::MissingProvenance { .. } => ProblemMeta {
711                slug: "missing-provenance",
712                version: "v1",
713                title: "Corpus import contains findings with no provenance block",
714                status: 422,
715                exit_code: 1,
716            },
717            Self::ReconcileEnvironmentBroken { .. } => ProblemMeta {
718                slug: "reconcile-environment-broken",
719                version: "v1",
720                title: "Known-good sample finding failed schema validation",
721                status: 500,
722                exit_code: 3,
723            },
724            Self::TopicNotRegistered { .. } => ProblemMeta {
725                slug: "topic-not-registered",
726                version: "v1",
727                title: "Topic is not registered in harness.config.json",
728                status: 422,
729                exit_code: 2,
730            },
731            Self::InvalidConcordance { .. } => ProblemMeta {
732                slug: "invalid-concordance",
733                version: "v1",
734                title: "Concordance is not a valid graph",
735                status: 422,
736                exit_code: 2,
737            },
738            Self::OntologyMapUnusable { .. } => ProblemMeta {
739                slug: "ontology-map-unusable",
740                version: "v1",
741                title: "ontology-map.json is missing or unparseable — cannot prove typing",
742                status: 422,
743                exit_code: 3,
744            },
745            Self::RelationshipTargetFindingUnparseable { .. } => ProblemMeta {
746                slug: "relationship-target-finding-unparseable",
747                version: "v1",
748                title: "A finding failed to parse while checking relationship targets",
749                status: 400,
750                exit_code: 2,
751            },
752            Self::SubtypeOfCycle { .. } => ProblemMeta {
753                slug: "subtype-of-cycle",
754                version: "v1",
755                title: "subtype_of graph contains a cycle",
756                status: 422,
757                exit_code: 4,
758            },
759            Self::EntityTypeSchemaInvalid { .. } => ProblemMeta {
760                slug: "entity-type-schema-invalid",
761                version: "v1",
762                title: "Entity type validation schema is malformed",
763                status: 422,
764                exit_code: 4,
765            },
766            Self::Index { .. } => ProblemMeta {
767                slug: "index-failure",
768                version: "v1",
769                title: "A SQLite index operation failed",
770                status: 500,
771                exit_code: 1,
772            },
773            Self::LockIo { .. } => ProblemMeta {
774                slug: "lock-io",
775                version: "v1",
776                title: "Failed to acquire the review lock file",
777                status: 500,
778                exit_code: 2,
779            },
780            Self::LockHeld { .. } => ProblemMeta {
781                slug: "lock-held",
782                version: "v1",
783                title: "Another review is already in progress",
784                status: 409,
785                exit_code: 2,
786            },
787            Self::QueueTopicMismatch { .. } => ProblemMeta {
788                slug: "queue-topic-mismatch",
789                version: "v1",
790                title: "Suggestion queue belongs to a different topic",
791                status: 409,
792                exit_code: 2,
793            },
794            Self::Embed(_) => ProblemMeta {
795                slug: "delegated-embed",
796                version: "v1",
797                title: "Delegated embedding error",
798                status: 500,
799                exit_code: 1,
800            },
801            Self::RegistryFetch { .. } => ProblemMeta {
802                slug: "registry-fetch-failed",
803                version: "v1",
804                title: "Failed to fetch from the ontology registry source",
805                status: 502,
806                exit_code: 1,
807            },
808            Self::RegistryIndexInvalid { .. } => ProblemMeta {
809                slug: "registry-index-invalid",
810                version: "v1",
811                title: "Ontology registry index is malformed",
812                status: 502,
813                exit_code: 1,
814            },
815            Self::OntologyNotInRegistry { .. } => ProblemMeta {
816                slug: "ontology-not-in-registry",
817                version: "v1",
818                title: "Ontology id has no registry index entry",
819                status: 404,
820                exit_code: 1,
821            },
822            Self::LockSourceMismatch { .. } => ProblemMeta {
823                slug: "lock-source-mismatch",
824                version: "v1",
825                title: "Lock file is pinned to a different registry source",
826                status: 409,
827                exit_code: 1,
828            },
829            Self::OntologyPackNotUtf8 { .. } => ProblemMeta {
830                slug: "ontology-pack-not-utf8",
831                version: "v1",
832                title: "Fetched ontology pack is not valid UTF-8",
833                status: 422,
834                exit_code: 1,
835            },
836            Self::IndexPinMismatch { .. } => ProblemMeta {
837                slug: "index-pin-mismatch",
838                version: "v1",
839                title: "Registry index sha256 no longer matches the pinned value",
840                status: 409,
841                exit_code: 1,
842            },
843            Self::ChecksumMismatch { .. } => ProblemMeta {
844                slug: "ontology-checksum-mismatch",
845                version: "v1",
846                title: "Fetched ontology file does not match its pinned sha256",
847                status: 422,
848                exit_code: 1,
849            },
850            Self::UnsafeIndexPath { .. } => ProblemMeta {
851                slug: "unsafe-index-path",
852                version: "v1",
853                title: "Registry index names an unsafe file path",
854                status: 422,
855                exit_code: 1,
856            },
857            Self::MalformedOntologyId { .. } => ProblemMeta {
858                slug: "malformed-ontology-id",
859                version: "v1",
860                title: "Registry index declares a malformed ontology id",
861                status: 422,
862                exit_code: 1,
863            },
864            Self::ConfigMalformed { .. } => ProblemMeta {
865                slug: "config-malformed",
866                version: "v1",
867                title: "Harness config's .ontologies field is not an array",
868                status: 422,
869                exit_code: 1,
870            },
871            Self::NoEntityTypesFound { .. } => ProblemMeta {
872                slug: "no-entity-types-found",
873                version: "v1",
874                title: "Topic's ontology map carries no typed entities to draft from",
875                status: 422,
876                exit_code: 1,
877            },
878            Self::NoClustersFound { .. } => ProblemMeta {
879                slug: "no-clusters-found",
880                version: "v1",
881                title: "Expansion-candidates file carries no clusters to draft from",
882                status: 422,
883                exit_code: 1,
884            },
885            Self::VersionNotSemver { .. } => ProblemMeta {
886                slug: "version-not-semver",
887                version: "v1",
888                title: "Version is not well-formed semver",
889                status: 422,
890                exit_code: 2,
891            },
892            Self::VersionMissing { .. } => ProblemMeta {
893                slug: "version-missing",
894                version: "v1",
895                title: "File has no .version field",
896                status: 422,
897                exit_code: 2,
898            },
899            Self::VersionUnchanged { .. } => ProblemMeta {
900                slug: "version-unchanged",
901                version: "v1",
902                title: "New version equals the current version",
903                status: 422,
904                exit_code: 2,
905            },
906            Self::PackNotFound { .. } => ProblemMeta {
907                slug: "pack-not-found",
908                version: "v1",
909                title: "Pack component not found",
910                status: 404,
911                exit_code: 2,
912            },
913            Self::PackAmbiguous { .. } => ProblemMeta {
914                slug: "pack-ambiguous",
915                version: "v1",
916                title: "Pack component name is ambiguous",
917                status: 422,
918                exit_code: 2,
919            },
920            Self::PackFileMissing { .. } => ProblemMeta {
921                slug: "pack-file-missing",
922                version: "v1",
923                title: "Pack is missing a required file or section",
924                status: 422,
925                exit_code: 2,
926            },
927            Self::PackVersionInvalid { .. } => ProblemMeta {
928                slug: "pack-version-invalid",
929                version: "v1",
930                title: "Pack's declared version is not well-formed semver",
931                status: 422,
932                exit_code: 2,
933            },
934            Self::PackAheadOfRelease { .. } => ProblemMeta {
935                slug: "pack-ahead-of-release",
936                version: "v1",
937                title: "Pack's current version is ahead of the release being cut",
938                status: 409,
939                exit_code: 2,
940            },
941            Self::ChangelogAnchorMissing { .. } => ProblemMeta {
942                slug: "changelog-anchor-missing",
943                version: "v1",
944                title: "CHANGELOG has no anchor to insert the new version under",
945                status: 422,
946                exit_code: 2,
947            },
948            Self::VerificationFailed { .. } => ProblemMeta {
949                slug: "verification-failed",
950                version: "v1",
951                title: "Post-write self-verification found an unexpected file",
952                status: 500,
953                exit_code: 2,
954            },
955            Self::GitCommandFailed { .. } => ProblemMeta {
956                slug: "git-command-failed",
957                version: "v1",
958                title: "A git invocation exited non-zero",
959                status: 500,
960                exit_code: 2,
961            },
962        }
963    }
964}
965
966/// The `(suggested_fix, code_action)` pair for every variant that carries
967/// its own static remediation text (everything except the delegated
968/// `Ontology`/`Embed` variants and the IO-classified variants, which
969/// `to_problem` handles separately).
970// One arm per error variant, each a flat struct literal — length is
971// inherent to the variant count, not a complexity signal.
972#[allow(clippy::too_many_lines)]
973fn fix_and_action(error: &MifRhError) -> (SuggestedFix, CodeAction) {
974    match error {
975        MifRhError::OntologyPackYaml { .. } => (
976            SuggestedFix::new(
977                "Fix the YAML syntax error in the ontology pack, then retry.",
978                Applicability::MaybeIncorrect,
979            ),
980            CodeAction::new(
981                "Fix the malformed ontology pack YAML",
982                "quickfix",
983                Applicability::MaybeIncorrect,
984            ),
985        ),
986        MifRhError::CatalogMissing { .. } => (
987            SuggestedFix::new(
988                "Run rht's scripts/sync-packs.sh (or mif-rh-cli's equivalent) to generate the \
989                 catalog, then retry.",
990                Applicability::MachineApplicable,
991            ),
992            CodeAction::new(
993                "Generate the missing catalog",
994                "quickfix",
995                Applicability::MachineApplicable,
996            ),
997        ),
998        MifRhError::ConfigMissing { .. } => (
999            SuggestedFix::new(
1000                "Supply the correct --config path to harness.config.json, then retry.",
1001                Applicability::MaybeIncorrect,
1002            ),
1003            CodeAction::new(
1004                "Correct the --config path",
1005                "quickfix",
1006                Applicability::MaybeIncorrect,
1007            ),
1008        ),
1009        MifRhError::DirectBindingInvalid { .. } => (
1010            SuggestedFix::new(
1011                "Catalog the missing ontology, correct its pinned version, or remove the \
1012                 invalid topic binding, then retry.",
1013                Applicability::MaybeIncorrect,
1014            ),
1015            CodeAction::new(
1016                "Fix the topic's ontology binding",
1017                "quickfix",
1018                Applicability::MaybeIncorrect,
1019            ),
1020        ),
1021        MifRhError::EntityTypeSchemaInvalid { .. } => (
1022            SuggestedFix::new(
1023                "Fix the entity type's schema field in the ontology pack, then retry.",
1024                Applicability::MaybeIncorrect,
1025            ),
1026            CodeAction::new(
1027                "Fix the entity type's schema",
1028                "quickfix",
1029                Applicability::MaybeIncorrect,
1030            ),
1031        ),
1032        MifRhError::Index { .. } => (
1033            SuggestedFix::new(
1034                "This indicates a corrupt or inaccessible index database. Delete it and \
1035                 rebuild with `mif-rh-cli review --build-index`, then retry.",
1036                Applicability::Unspecified,
1037            ),
1038            CodeAction::new("Rebuild the index", "quickfix", Applicability::Unspecified),
1039        ),
1040        MifRhError::LockHeld { .. } => (
1041            SuggestedFix::new(
1042                "Wait for the in-progress review to finish, then retry.",
1043                Applicability::MaybeIncorrect,
1044            ),
1045            CodeAction::new("Wait and retry", "quickfix", Applicability::MaybeIncorrect),
1046        ),
1047        MifRhError::QueueTopicMismatch { .. } => (
1048            SuggestedFix::new(
1049                "Point the upsert at reports/_meta/suggestions/<topic>.json for the topic \
1050                 being reviewed, or remove the stray queue file that was copied or renamed \
1051                 across topics.",
1052                Applicability::MaybeIncorrect,
1053            ),
1054            CodeAction::new(
1055                "Use the topic's own suggestion queue path",
1056                "quickfix",
1057                Applicability::MaybeIncorrect,
1058            ),
1059        ),
1060        MifRhError::JsonSerialize { .. } => (
1061            SuggestedFix::new(
1062                "This indicates a bug in mif-rh: a value could not be serialized to JSON. \
1063                 Report it upstream with the record that triggered it; no caller-side fix \
1064                 exists.",
1065                Applicability::Unspecified,
1066            ),
1067            CodeAction::new(
1068                "Report the serialization bug",
1069                "quickfix",
1070                Applicability::Unspecified,
1071            ),
1072        ),
1073        MifRhError::FrontmatterYamlSerialize { .. } => (
1074            SuggestedFix::new(
1075                "This indicates a bug in mif-rh: a report's concept frontmatter could not be \
1076                 serialized to YAML. Report it upstream with the artifact that triggered it; \
1077                 no caller-side fix exists.",
1078                Applicability::Unspecified,
1079            ),
1080            CodeAction::new(
1081                "Report the serialization bug",
1082                "quickfix",
1083                Applicability::Unspecified,
1084            ),
1085        ),
1086        // FindingJson/Json carry no additional remediation beyond the
1087        // error message itself; the caller (`to_problem`) never invokes
1088        // this helper for the delegated or IO-classified variants.
1089        MifRhError::FindingJson { .. } | MifRhError::Json { .. } => (
1090            SuggestedFix::new(
1091                "Correct the file so it is valid JSON, then retry.",
1092                Applicability::MaybeIncorrect,
1093            ),
1094            CodeAction::new(
1095                "Fix the JSON syntax error",
1096                "quickfix",
1097                Applicability::MaybeIncorrect,
1098            ),
1099        ),
1100        MifRhError::RegistryFetch { .. } => (
1101            SuggestedFix::new(
1102                "Check network access to the ontology source (or, for a local directory \
1103                 source, that the path exists), then retry.",
1104                Applicability::MaybeIncorrect,
1105            ),
1106            CodeAction::new(
1107                "Check the ontology source is reachable",
1108                "quickfix",
1109                Applicability::MaybeIncorrect,
1110            ),
1111        ),
1112        MifRhError::RegistryIndexInvalid { .. } => (
1113            SuggestedFix::new(
1114                "The registry source's index.json is not valid — fix it upstream, or point \
1115                 MIF_ONTOLOGY_SOURCE at a known-good mirror, then retry.",
1116                Applicability::MaybeIncorrect,
1117            ),
1118            CodeAction::new(
1119                "Fix or repoint the registry index",
1120                "quickfix",
1121                Applicability::MaybeIncorrect,
1122            ),
1123        ),
1124        MifRhError::OntologyNotInRegistry { .. } => (
1125            SuggestedFix::new(
1126                "Author the ontology from your research and contribute it upstream with the \
1127                 `ontology author` subcommand, then retry.",
1128                Applicability::MaybeIncorrect,
1129            ),
1130            CodeAction::new(
1131                "Author the missing ontology",
1132                "quickfix",
1133                Applicability::MaybeIncorrect,
1134            ),
1135        ),
1136        MifRhError::LockSourceMismatch { .. } => (
1137            SuggestedFix::new(
1138                "Re-run against the source ontologies.lock.json is actually pinned to, or \
1139                 delete the lock to intentionally re-pin against the new source.",
1140                Applicability::Unspecified,
1141            ),
1142            CodeAction::new(
1143                "Match the requested source to the pinned lock source",
1144                "quickfix",
1145                Applicability::Unspecified,
1146            ),
1147        ),
1148        MifRhError::OntologyPackNotUtf8 { .. } => (
1149            SuggestedFix::new(
1150                "The registry-published ontology pack file is corrupt or was published with \
1151                 non-UTF-8 content; investigate upstream and re-publish a valid pack.",
1152                Applicability::Unspecified,
1153            ),
1154            CodeAction::new(
1155                "Investigate the corrupt registry pack",
1156                "quickfix",
1157                Applicability::Unspecified,
1158            ),
1159        ),
1160        MifRhError::IndexPinMismatch { .. } => (
1161            SuggestedFix::new(
1162                "Confirm the registry source is trustworthy, then clear index_sha256 in \
1163                 ontologies.lock.json to re-pin deliberately, or investigate why the trust \
1164                 root moved.",
1165                Applicability::Unspecified,
1166            ),
1167            CodeAction::new(
1168                "Investigate the moved trust root",
1169                "quickfix",
1170                Applicability::Unspecified,
1171            ),
1172        ),
1173        MifRhError::ChecksumMismatch { .. } => (
1174            SuggestedFix::new(
1175                "The fetched ontology does not match the pinned registry hash. Do not vendor \
1176                 it; investigate the registry source for tampering or corruption.",
1177                Applicability::Unspecified,
1178            ),
1179            CodeAction::new(
1180                "Investigate the checksum mismatch",
1181                "quickfix",
1182                Applicability::Unspecified,
1183            ),
1184        ),
1185        MifRhError::NoEntityTypesFound { .. } => (
1186            SuggestedFix::new(
1187                "Run /ontology-review on the topic first so its findings get typed, then \
1188                 retry.",
1189                Applicability::MaybeIncorrect,
1190            ),
1191            CodeAction::new(
1192                "Review the topic before authoring",
1193                "quickfix",
1194                Applicability::MaybeIncorrect,
1195            ),
1196        ),
1197        MifRhError::NoClustersFound { .. } => (
1198            SuggestedFix::new(
1199                "Run `mif-rh-cli ontology expansion-candidates` again once more tier-3 misses \
1200                 have recurred across runs, then retry.",
1201                Applicability::MaybeIncorrect,
1202            ),
1203            CodeAction::new(
1204                "Wait for recurring misses before authoring",
1205                "quickfix",
1206                Applicability::MaybeIncorrect,
1207            ),
1208        ),
1209        MifRhError::VersionNotSemver { .. } | MifRhError::PackVersionInvalid { .. } => (
1210            SuggestedFix::new(
1211                "Correct the version to well-formed X.Y.Z semver, then retry.",
1212                Applicability::MaybeIncorrect,
1213            ),
1214            CodeAction::new(
1215                "Fix the malformed version",
1216                "quickfix",
1217                Applicability::MaybeIncorrect,
1218            ),
1219        ),
1220        MifRhError::VersionMissing { .. } => (
1221            SuggestedFix::new(
1222                "Add a .version field to the file, then retry.",
1223                Applicability::MaybeIncorrect,
1224            ),
1225            CodeAction::new(
1226                "Add the missing .version field",
1227                "quickfix",
1228                Applicability::MaybeIncorrect,
1229            ),
1230        ),
1231        MifRhError::VersionUnchanged { .. } => (
1232            SuggestedFix::new(
1233                "Pass a different version or bump keyword — the requested version matches the \
1234                 current one, so there is nothing to bump.",
1235                Applicability::MaybeIncorrect,
1236            ),
1237            CodeAction::new(
1238                "Choose a different version",
1239                "quickfix",
1240                Applicability::MaybeIncorrect,
1241            ),
1242        ),
1243        MifRhError::PackNotFound { .. } | MifRhError::PackAmbiguous { .. } => (
1244            SuggestedFix::new(
1245                "Check the component name against packs/<family>/<name>/ and retry.",
1246                Applicability::MaybeIncorrect,
1247            ),
1248            CodeAction::new(
1249                "Correct the pack component name",
1250                "quickfix",
1251                Applicability::MaybeIncorrect,
1252            ),
1253        ),
1254        MifRhError::PackFileMissing { .. } => (
1255            SuggestedFix::new(
1256                "Add the missing file or section the pack's own conventions require \
1257                 (plugin.json .version, SKILL.md version: frontmatter, or the family doc's \
1258                 **Version:** row), then retry.",
1259                Applicability::MaybeIncorrect,
1260            ),
1261            CodeAction::new(
1262                "Add the missing pack file/section",
1263                "quickfix",
1264                Applicability::MaybeIncorrect,
1265            ),
1266        ),
1267        MifRhError::PackAheadOfRelease { .. } => (
1268            SuggestedFix::new(
1269                "Cut a release at or above the pack's current version, or leave this pack out \
1270                 of this bump.",
1271                Applicability::MaybeIncorrect,
1272            ),
1273            CodeAction::new(
1274                "Reconcile the release version with the pack's version",
1275                "quickfix",
1276                Applicability::MaybeIncorrect,
1277            ),
1278        ),
1279        MifRhError::ChangelogAnchorMissing { .. } => (
1280            SuggestedFix::new(
1281                "Add an '## [Unreleased]' section to the CHANGELOG, then retry.",
1282                Applicability::MaybeIncorrect,
1283            ),
1284            CodeAction::new(
1285                "Add the Unreleased anchor",
1286                "quickfix",
1287                Applicability::MaybeIncorrect,
1288            ),
1289        ),
1290        MifRhError::VerificationFailed { .. } => (
1291            SuggestedFix::new(
1292                "Inspect the named file directly — the write may have partially applied. This \
1293                 indicates a bug in the bump logic, not a caller-side fix.",
1294                Applicability::Unspecified,
1295            ),
1296            CodeAction::new(
1297                "Inspect the file that failed verification",
1298                "quickfix",
1299                Applicability::Unspecified,
1300            ),
1301        ),
1302        MifRhError::GitCommandFailed { .. } => (
1303            SuggestedFix::new(
1304                "Run the command's directory root through `git status` to confirm it's a real, \
1305                 uncorrupted git repository, then retry.",
1306                Applicability::MaybeIncorrect,
1307            ),
1308            CodeAction::new(
1309                "Verify the git repository is valid",
1310                "quickfix",
1311                Applicability::MaybeIncorrect,
1312            ),
1313        ),
1314        MifRhError::ConfigMalformed { .. } => (
1315            SuggestedFix::new(
1316                "Fix harness.config.json so its .ontologies field is an array, then retry.",
1317                Applicability::MaybeIncorrect,
1318            ),
1319            CodeAction::new(
1320                "Fix the .ontologies field's shape",
1321                "quickfix",
1322                Applicability::MaybeIncorrect,
1323            ),
1324        ),
1325        MifRhError::UnsafeIndexPath { .. } | MifRhError::MalformedOntologyId { .. } => (
1326            SuggestedFix::new(
1327                "The registry index entry is malformed or unsafe — fix it upstream in the \
1328                 canonical registry before retrying.",
1329                Applicability::Unspecified,
1330            ),
1331            CodeAction::new(
1332                "Fix the malformed registry entry",
1333                "quickfix",
1334                Applicability::Unspecified,
1335            ),
1336        ),
1337        MifRhError::SchemaCompilation { .. } => (
1338            SuggestedFix::new(
1339                "Fix the schema (or its $ref dependency) so it is valid JSON Schema, then \
1340                 retry.",
1341                Applicability::MaybeIncorrect,
1342            ),
1343            CodeAction::new(
1344                "Fix the malformed schema",
1345                "quickfix",
1346                Applicability::MaybeIncorrect,
1347            ),
1348        ),
1349        MifRhError::RefSchemaMissingId { .. } => (
1350            SuggestedFix::new(
1351                "Add a $id to the dependency schema, then retry.",
1352                Applicability::MaybeIncorrect,
1353            ),
1354            CodeAction::new(
1355                "Add the missing $id",
1356                "quickfix",
1357                Applicability::MaybeIncorrect,
1358            ),
1359        ),
1360        MifRhError::SchemaValidationFailed { .. } => (
1361            SuggestedFix::new(
1362                "Fix the report to conform to its schema (see the listed validation errors), \
1363                 then retry.",
1364                Applicability::MaybeIncorrect,
1365            ),
1366            CodeAction::new(
1367                "Fix the schema violations",
1368                "quickfix",
1369                Applicability::MaybeIncorrect,
1370            ),
1371        ),
1372        MifRhError::InvalidToggleValue { .. } => (
1373            SuggestedFix::new(
1374                "Pass one of the allowed values, then retry.",
1375                Applicability::MaybeIncorrect,
1376            ),
1377            CodeAction::new(
1378                "Correct the toggle value",
1379                "quickfix",
1380                Applicability::MaybeIncorrect,
1381            ),
1382        ),
1383        MifRhError::EmptySourceContent => (
1384            SuggestedFix::new(
1385                "Provide content via --content-file, --content, or stdin.",
1386                Applicability::MaybeIncorrect,
1387            ),
1388            CodeAction::new(
1389                "Provide source content",
1390                "quickfix",
1391                Applicability::MaybeIncorrect,
1392            ),
1393        ),
1394        MifRhError::PackNotDeclared { .. } => (
1395            SuggestedFix::new(
1396                "Declare the pack in the manifest's packs[] array, then retry.",
1397                Applicability::MaybeIncorrect,
1398            ),
1399            CodeAction::new(
1400                "Declare the pack first",
1401                "quickfix",
1402                Applicability::MaybeIncorrect,
1403            ),
1404        ),
1405        MifRhError::NoFindingsFound { .. } => (
1406            SuggestedFix::new(
1407                "Point at a directory containing finding JSON files, then retry.",
1408                Applicability::MaybeIncorrect,
1409            ),
1410            CodeAction::new(
1411                "Point at a non-empty findings directory",
1412                "quickfix",
1413                Applicability::MaybeIncorrect,
1414            ),
1415        ),
1416        MifRhError::NoSurvivingFindings { .. } => (
1417            SuggestedFix::new(
1418                "Nothing to do until at least one finding survives falsification.",
1419                Applicability::MaybeIncorrect,
1420            ),
1421            CodeAction::new(
1422                "Wait for a surviving finding",
1423                "quickfix",
1424                Applicability::MaybeIncorrect,
1425            ),
1426        ),
1427        MifRhError::ArtifactNotPublishable { .. } => (
1428            SuggestedFix::new(
1429                "Ensure at least one surviving finding carries a citation before synthesizing.",
1430                Applicability::MaybeIncorrect,
1431            ),
1432            CodeAction::new(
1433                "Add a citation to a surviving finding",
1434                "quickfix",
1435                Applicability::MaybeIncorrect,
1436            ),
1437        ),
1438        MifRhError::MissingProvenance { .. } => (
1439            SuggestedFix::new(
1440                "Add a provenance block to every listed finding before retrying the import.",
1441                Applicability::MaybeIncorrect,
1442            ),
1443            CodeAction::new(
1444                "Add the missing provenance blocks",
1445                "quickfix",
1446                Applicability::MaybeIncorrect,
1447            ),
1448        ),
1449        MifRhError::ReconcileEnvironmentBroken { .. } => (
1450            SuggestedFix::new(
1451                "Check the schema/$ref files and the jsonschema toolchain — a known-good \
1452                 sample should always validate.",
1453                Applicability::Unspecified,
1454            ),
1455            CodeAction::new(
1456                "Diagnose the schema toolchain",
1457                "quickfix",
1458                Applicability::Unspecified,
1459            ),
1460        ),
1461        MifRhError::TopicNotRegistered { .. } => (
1462            SuggestedFix::new(
1463                "Register the topic in harness.config.json's topics[] before building its README.",
1464                Applicability::MaybeIncorrect,
1465            ),
1466            CodeAction::new(
1467                "Register the topic",
1468                "quickfix",
1469                Applicability::MaybeIncorrect,
1470            ),
1471        ),
1472        MifRhError::InvalidConcordance { .. } => (
1473            SuggestedFix::new(
1474                "Build the concordance first (scripts/build-concordance.sh) before synthesizing the corpus atlas.",
1475                Applicability::MaybeIncorrect,
1476            ),
1477            CodeAction::new(
1478                "Build the concordance",
1479                "quickfix",
1480                Applicability::MaybeIncorrect,
1481            ),
1482        ),
1483        MifRhError::OntologyMapUnusable { topic, .. } => (
1484            SuggestedFix::new(
1485                format!(
1486                    "Regenerate the ontology map: /ontology-review --topic {topic} --enrich, then /resume --topic {topic}."
1487                ),
1488                Applicability::MaybeIncorrect,
1489            ),
1490            CodeAction::new(
1491                "Regenerate the ontology map",
1492                "quickfix",
1493                Applicability::MaybeIncorrect,
1494            ),
1495        ),
1496        MifRhError::RelationshipTargetFindingUnparseable { .. } => (
1497            SuggestedFix::new(
1498                "Fix the invalid JSON in this finding before re-running the relationship-targets gate.",
1499                Applicability::MachineApplicable,
1500            ),
1501            CodeAction::new(
1502                "Fix the malformed finding JSON",
1503                "quickfix",
1504                Applicability::MachineApplicable,
1505            ),
1506        ),
1507        MifRhError::SubtypeOfCycle { .. } => (
1508            SuggestedFix::new(
1509                "Break the subtype_of cycle in the ontology registry — a type cannot (transitively) subtype itself.",
1510                Applicability::MaybeIncorrect,
1511            ),
1512            CodeAction::new(
1513                "Fix the subtype_of cycle",
1514                "quickfix",
1515                Applicability::MaybeIncorrect,
1516            ),
1517        ),
1518        MifRhError::FindingIo { .. }
1519        | MifRhError::Io { .. }
1520        | MifRhError::LockIo { .. }
1521        | MifRhError::Ontology(_)
1522        | MifRhError::Frontmatter(_)
1523        | MifRhError::Embed(_) => unreachable!(
1524            "to_problem handles the IO-classified and delegated variants before calling \
1525             fix_and_action"
1526        ),
1527    }
1528}
1529
1530impl ToProblem for MifRhError {
1531    fn to_problem(&self) -> ProblemDetails {
1532        match self {
1533            Self::Ontology(inner) => inner.to_problem(),
1534            Self::Frontmatter(inner) => inner.to_problem(),
1535            Self::Embed(inner) => inner.to_problem(),
1536            Self::FindingIo { source, .. }
1537            | Self::Io { source, .. }
1538            | Self::LockIo { source, .. } => {
1539                let (status, fix, action) = mif_problem::classify_io_error(source);
1540                let mut problem = self
1541                    .meta()
1542                    .into_details(env!("CARGO_PKG_NAME"), self.to_string());
1543                problem.status = status;
1544                problem.with_suggested_fix(fix).with_code_action(action)
1545            },
1546            _ => {
1547                let (fix, action) = fix_and_action(self);
1548                self.meta()
1549                    .into_details(env!("CARGO_PKG_NAME"), self.to_string())
1550                    .with_suggested_fix(fix)
1551                    .with_code_action(action)
1552            },
1553        }
1554    }
1555}
1556
1557impl From<rusqlite::Error> for MifRhError {
1558    fn from(source: rusqlite::Error) -> Self {
1559        Self::Index { source }
1560    }
1561}
1562
1563#[cfg(test)]
1564mod tests {
1565    use super::MifRhError;
1566
1567    fn io_error() -> std::io::Error {
1568        std::io::Error::new(std::io::ErrorKind::NotFound, "not found")
1569    }
1570
1571    fn json_error() -> serde_json::Error {
1572        serde_json::from_str::<serde_json::Value>("not json").unwrap_err()
1573    }
1574
1575    fn yaml_error() -> serde_norway::Error {
1576        serde_norway::from_str::<serde_json::Value>("- a\n  bad: [unterminated").unwrap_err()
1577    }
1578
1579    // One entry per error variant, each a flat struct literal — length is
1580    // inherent to the variant count, not a complexity signal.
1581    #[allow(clippy::too_many_lines)]
1582    fn every_variant() -> Vec<MifRhError> {
1583        vec![
1584            MifRhError::FindingIo {
1585                path: "f.json".to_string(),
1586                source: io_error(),
1587            },
1588            MifRhError::FindingJson {
1589                path: "f.json".to_string(),
1590                source: json_error(),
1591            },
1592            MifRhError::Io {
1593                path: "x".to_string(),
1594                source: io_error(),
1595            },
1596            MifRhError::Json {
1597                path: "x.json".to_string(),
1598                source: json_error(),
1599            },
1600            MifRhError::JsonSerialize {
1601                path: "x.json".to_string(),
1602                source: json_error(),
1603            },
1604            MifRhError::OntologyPackYaml {
1605                path: "x.yaml".to_string(),
1606                source: yaml_error(),
1607            },
1608            MifRhError::FrontmatterYamlSerialize {
1609                source: yaml_error(),
1610            },
1611            MifRhError::CatalogMissing {
1612                path: "catalog.json".to_string(),
1613            },
1614            MifRhError::ConfigMissing {
1615                path: "config.json".to_string(),
1616            },
1617            MifRhError::DirectBindingInvalid {
1618                topic: "t".to_string(),
1619                id: "o".to_string(),
1620            },
1621            MifRhError::Ontology(mif_ontology::OntologyError::Io {
1622                path: "onto.yaml".to_string(),
1623                source: io_error(),
1624            }),
1625            MifRhError::EntityTypeSchemaInvalid {
1626                entity_type: "widget".to_string(),
1627                detail: "bad schema".to_string(),
1628            },
1629            MifRhError::Index {
1630                source: rusqlite::Error::InvalidParameterName("p".to_string()),
1631            },
1632            MifRhError::LockIo {
1633                path: "lock".to_string(),
1634                source: io_error(),
1635            },
1636            MifRhError::LockHeld { holder_pid: 1234 },
1637            MifRhError::QueueTopicMismatch {
1638                path: "reports/_meta/suggestions/edu.json".to_string(),
1639                expected: "edu".to_string(),
1640                found: "sec".to_string(),
1641            },
1642            MifRhError::Embed(mif_embed::EmbedError::NoCacheDir {
1643                model: "test-model",
1644            }),
1645            MifRhError::RegistryFetch {
1646                registry_source: "https://example.test/ontologies".to_string(),
1647                detail: "connection refused".to_string(),
1648            },
1649            MifRhError::RegistryIndexInvalid {
1650                registry_source: "https://example.test/ontologies".to_string(),
1651                detail: "no .ontologies key".to_string(),
1652            },
1653            MifRhError::OntologyNotInRegistry {
1654                id: "clinical-trials".to_string(),
1655            },
1656            MifRhError::LockSourceMismatch {
1657                lock_source: "https://example.test/ontologies".to_string(),
1658                requested_source: "https://other.test/ontologies".to_string(),
1659            },
1660            MifRhError::OntologyPackNotUtf8 {
1661                id: "edu-fixture".to_string(),
1662                file: "edu-fixture.ontology.yaml".to_string(),
1663            },
1664            MifRhError::IndexPinMismatch {
1665                registry_source: "https://example.test/ontologies".to_string(),
1666                pinned: "aaa".to_string(),
1667                got: "bbb".to_string(),
1668            },
1669            MifRhError::ChecksumMismatch {
1670                id: "edu-fixture".to_string(),
1671                file: "edu-fixture.ontology.yaml".to_string(),
1672                expected: "aaa".to_string(),
1673                got: "bbb".to_string(),
1674            },
1675            MifRhError::UnsafeIndexPath {
1676                id: "edu-fixture".to_string(),
1677                file: "../../etc/passwd".to_string(),
1678            },
1679            MifRhError::MalformedOntologyId {
1680                id: "../etc".to_string(),
1681            },
1682            MifRhError::ConfigMalformed {
1683                path: "harness.config.json".to_string(),
1684                detail: ".ontologies is a string, not an array".to_string(),
1685            },
1686            MifRhError::NoEntityTypesFound {
1687                topic: "edu".to_string(),
1688            },
1689            MifRhError::NoClustersFound {
1690                path: "clusters.json".to_string(),
1691            },
1692            MifRhError::VersionNotSemver {
1693                value: "1.0".to_string(),
1694            },
1695            MifRhError::VersionMissing {
1696                path: "harness.config.json".to_string(),
1697            },
1698            MifRhError::VersionUnchanged {
1699                value: "1.0.0".to_string(),
1700            },
1701            MifRhError::PackNotFound {
1702                name: "pdf".to_string(),
1703            },
1704            MifRhError::PackAmbiguous {
1705                name: "pdf".to_string(),
1706            },
1707            MifRhError::PackFileMissing {
1708                name: "pdf".to_string(),
1709                path: "packs/x/pdf/.claude-plugin/plugin.json".to_string(),
1710            },
1711            MifRhError::PackVersionInvalid {
1712                name: "pdf".to_string(),
1713                path: "packs/x/pdf/.claude-plugin/plugin.json".to_string(),
1714                value: "not-semver".to_string(),
1715            },
1716            MifRhError::PackAheadOfRelease {
1717                name: "pdf".to_string(),
1718                pack_version: "2.0.0".to_string(),
1719                new_version: "1.5.0".to_string(),
1720            },
1721            MifRhError::ChangelogAnchorMissing {
1722                path: "CHANGELOG.md".to_string(),
1723            },
1724            MifRhError::VerificationFailed {
1725                path: "harness.config.json".to_string(),
1726            },
1727            MifRhError::GitCommandFailed {
1728                command: "tag --list v*".to_string(),
1729                stderr: "fatal: not a git repository".to_string(),
1730            },
1731            MifRhError::Frontmatter(mif_frontmatter::FrontmatterError::MissingFrontmatter),
1732            MifRhError::SchemaCompilation {
1733                path: "findings.schema.json".to_string(),
1734                detail: "not valid JSON".to_string(),
1735            },
1736            MifRhError::RefSchemaMissingId {
1737                path: "entity-reference.schema.json".to_string(),
1738            },
1739            MifRhError::SchemaValidationFailed {
1740                path: "reports/x/findings/1.md".to_string(),
1741                schema_path: "schemas/findings.schema.json".to_string(),
1742                detail: "missing required field 'title'".to_string(),
1743            },
1744            MifRhError::InvalidToggleValue {
1745                field: "primarySurface".to_string(),
1746                value: "bogus".to_string(),
1747                allowed: "reports|docs|auto".to_string(),
1748            },
1749            MifRhError::EmptySourceContent,
1750            MifRhError::PackNotDeclared {
1751                name: "pdf".to_string(),
1752                path: "harness.config.json".to_string(),
1753            },
1754            MifRhError::NoFindingsFound {
1755                path: "reports/x/findings".to_string(),
1756            },
1757            MifRhError::NoSurvivingFindings {
1758                path: "reports/x/findings".to_string(),
1759            },
1760            MifRhError::ArtifactNotPublishable {
1761                path: "reports/x/findings".to_string(),
1762            },
1763            MifRhError::MissingProvenance {
1764                count: 1,
1765                paths: vec!["src/findings/f1.json".to_string()],
1766            },
1767            MifRhError::ReconcileEnvironmentBroken {
1768                sample_path: "schemas/samples/finding.sample.json".to_string(),
1769            },
1770            MifRhError::TopicNotRegistered {
1771                topic: "x".to_string(),
1772                config_path: "harness.config.json".to_string(),
1773            },
1774            MifRhError::InvalidConcordance {
1775                path: "reports/concordance.json".to_string(),
1776            },
1777            MifRhError::OntologyMapUnusable {
1778                path: "reports/edu/ontology-map.json".to_string(),
1779                topic: "edu".to_string(),
1780                reason: "is missing".to_string(),
1781            },
1782            MifRhError::RelationshipTargetFindingUnparseable {
1783                path: "reports/edu/findings/bad.json".to_string(),
1784            },
1785            MifRhError::SubtypeOfCycle {
1786                entity_type: "a".to_string(),
1787            },
1788        ]
1789    }
1790
1791    #[test]
1792    fn every_variant_produces_a_distinct_problem_type() {
1793        use mif_problem::ToProblem;
1794
1795        let problem_types: Vec<String> = every_variant()
1796            .iter()
1797            .map(|e| e.to_problem().problem_type)
1798            .collect();
1799        let mut deduped = problem_types.clone();
1800        deduped.sort();
1801        deduped.dedup();
1802        assert_eq!(
1803            problem_types.len(),
1804            deduped.len(),
1805            "expected every variant to produce a distinct problem_type: {problem_types:?}"
1806        );
1807    }
1808
1809    #[test]
1810    fn every_variant_has_a_display_message() {
1811        for error in every_variant() {
1812            assert!(!error.to_string().is_empty());
1813        }
1814    }
1815
1816    #[test]
1817    fn io_classified_variants_delegate_status_to_classify_io_error() {
1818        use mif_problem::ToProblem;
1819
1820        let not_found = MifRhError::Io {
1821            path: "missing".to_string(),
1822            source: io_error(),
1823        };
1824        // classify_io_error maps NotFound to 404, overriding meta()'s generic 500.
1825        assert_eq!(not_found.to_problem().status, 404);
1826    }
1827
1828    #[test]
1829    fn delegated_variants_forward_to_the_inner_error() {
1830        use mif_problem::ToProblem;
1831
1832        let ontology_problem = MifRhError::Ontology(mif_ontology::OntologyError::Io {
1833            path: "onto.yaml".to_string(),
1834            source: io_error(),
1835        })
1836        .to_problem();
1837        let inner_problem = mif_ontology::OntologyError::Io {
1838            path: "onto.yaml".to_string(),
1839            source: io_error(),
1840        }
1841        .to_problem();
1842        assert_eq!(ontology_problem.problem_type, inner_problem.problem_type);
1843
1844        let embed_problem = MifRhError::Embed(mif_embed::EmbedError::NoCacheDir {
1845            model: "test-model",
1846        })
1847        .to_problem();
1848        let inner_embed_problem = mif_embed::EmbedError::NoCacheDir {
1849            model: "test-model",
1850        }
1851        .to_problem();
1852        assert_eq!(embed_problem.problem_type, inner_embed_problem.problem_type);
1853    }
1854
1855    #[test]
1856    fn exit_codes_match_the_documented_scheme() {
1857        use mif_problem::ToProblem;
1858
1859        assert_eq!(
1860            MifRhError::CatalogMissing {
1861                path: "c".to_string()
1862            }
1863            .to_problem()
1864            .exit_code,
1865            Some(3)
1866        );
1867        assert_eq!(
1868            MifRhError::LockHeld { holder_pid: 1 }
1869                .to_problem()
1870                .exit_code,
1871            Some(2)
1872        );
1873        assert_eq!(
1874            MifRhError::DirectBindingInvalid {
1875                topic: "t".to_string(),
1876                id: "o".to_string(),
1877            }
1878            .to_problem()
1879            .exit_code,
1880            Some(1)
1881        );
1882    }
1883
1884    #[test]
1885    fn rusqlite_error_converts_into_the_index_variant() {
1886        let source = rusqlite::Error::InvalidParameterName("p".to_string());
1887        let error: MifRhError = source.into();
1888        assert!(matches!(error, MifRhError::Index { .. }));
1889    }
1890}