Skip to main content

ignition_core/
error.rs

1//! Typed error taxonomy — THE exit-code contract for `ign`.
2//!
3//! The mapping error-class → exit code is LOCKED (Phase-1 API freeze). The
4//! table exists in exactly two places: [`CoreError::exit_code`] here and the
5//! README — kept in sync by the `exit_code_mapping_enumerated` unit test and
6//! the golden-file tests in `crates/ignition-cli/tests/`.
7//!
8//! | exit | class          | slugs
9//! |------|----------------|-----------------------------------------------
10//! | 1    | internal       | `internal`
11//! | 2    | usage          | `confirmation_required`, `invalid_import_file`, `invalid_input`, `gateway_client_error` (09-01) (clap renders its own usage errors — never hook clap)
12//! | 3    | config         | `profile_not_found`, `no_active_profile`, `secret_unavailable`, `config_invalid`, `poll_interval_too_small` (08-01)
13//! | 4    | network        | `network_error`
14//! | 5    | auth           | `auth_rejected`
15//! | 6    | target_state   | `gateway_too_old`, `gateway_not_commissioned`, `gateway_restarting`, `not_found`, `project_exists`, `resource_binary`, `trial_not_expired` (04-03), `provider_not_found` (05-04), `routes_not_deployed`, `webdev_unlicensed`, `route_version_mismatch`, `webdev_route_error` (05-03), `tag_collision` (05-05), `alarm_journal_missing` (05-06), `import_denied` (05-07), `session_not_prunable` (06-07), `eam_not_controller` (07-02), `eam_task_type_refused` (07-02), `eam_task_in_flight` (07-06), `script_exec_not_configured` (07-03), `lint_tool_absent` (07-04), `provider_root_unsupported` (07-06), `bundle_not_available` (09-07)
16//! | 7    | rig            | `rig_error` (reserved — first used in Phase 4)
17//!
18//! Slugs are public contract: never respell them. Exit codes are public
19//! contract: never renumber them (the enumerated test guards both).
20
21use serde::Serialize;
22
23/// The `ign tui` TTY-refusal reason (06-07). The InvalidInput hint is
24/// content-addressed off this exact string: the frozen taxonomy keeps
25/// ONE usage-input variant (no hint field, no new variant), but the
26/// TTY refusal's fix is terminal-related, not `--file`/stdin —
27/// [`CoreError::hint`] special-cases this one reason while every other
28/// raise site keeps the resource-put default. Construct via
29/// [`CoreError::tui_tty_refusal`] so the reason/hint pair cannot drift.
30pub const TUI_TTY_REFUSAL_REASON: &str = "ign tui requires a terminal (stdout is not a TTY)";
31
32/// The TAGS-12 loss-gate refusal reason prefix (11-07 gap closure). The
33/// InvalidInput hint is content-addressed off this literal: the loss
34/// gate's reason is DYNAMIC prose (the CLI's `render_loss_prose` header
35/// `loss report ({label}): …` plus per-fact lines), so unlike
36/// [`TUI_TTY_REFUSAL_REASON`] the sentinel cannot be the whole reason —
37/// it is the stable header prefix instead. Same slug (`invalid_input`),
38/// same exit 2 (frozen taxonomy; only the hint differs) — the 06-07
39/// TTY-refusal pattern at one removal. The contract_tags loss-gate
40/// pins are the drift guard: if the prose header ever changes, the
41/// hint silently regresses to the generic default and those pins fail.
42pub const LOSS_GATE_REFUSAL_REASON_PREFIX: &str = "loss report (";
43
44/// The api-call catch-all's body cap (09-01): a gateway 4xx body rides
45/// [`CoreError::GatewayClientError`] VERBATIM up to this many bytes; a
46/// larger body is truncated at [`truncate_api_body`] with the explicit
47/// [`GATEWAY_CLIENT_BODY_TRUNCATION_MARKER`]. 4 KiB is the plan-locked
48/// cap — an unbounded passthrough would let a pathological gateway
49/// page flood the agent's envelope.
50pub const GATEWAY_CLIENT_BODY_CAP_BYTES: usize = 4096;
51
52/// The truncation marker [`truncate_api_body`] appends when the api-call
53/// body exceeds [`GATEWAY_CLIENT_BODY_CAP_BYTES`]. ASCII-pinned (no
54/// multi-byte characters) so golden-file consumers never see an encoding
55/// surprise at the cut.
56pub const GATEWAY_CLIENT_BODY_TRUNCATION_MARKER: &str = "... [truncated]";
57
58/// The ONE construction site for a capped api-call body: returns `body`
59/// verbatim when it fits [`GATEWAY_CLIENT_BODY_CAP_BYTES`], otherwise its
60/// first cap bytes (on a UTF-8 char boundary) plus
61/// [`GATEWAY_CLIENT_BODY_TRUNCATION_MARKER`]. The variant always carries
62/// its final form — construction cannot forget the cap.
63pub fn truncate_api_body(body: &str) -> String {
64    if body.len() <= GATEWAY_CLIENT_BODY_CAP_BYTES {
65        return body.to_string();
66    }
67    let mut end = GATEWAY_CLIENT_BODY_CAP_BYTES;
68    while !body.is_char_boundary(end) {
69        end -= 1;
70    }
71    let mut truncated = body[..end].to_string();
72    truncated.push_str(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER);
73    truncated
74}
75
76/// Every failure `ign` can report. One variant per contract class; `code()`,
77/// `exit_code()`, `hint()` are total functions over it.
78#[derive(Debug, thiserror::Error)]
79pub enum CoreError {
80    /// Unexpected runtime failure — the catch-all; report as a bug. Exit 1.
81    #[error("internal error: {0}")]
82    Internal(String),
83
84    /// Destructive operation invoked without `--yes`. Exit 2 (same class as
85    /// usage: it names a flag the caller must add; clap renders its own
86    /// usage errors with its exit 2 — never hook clap).
87    #[error("{operation} is destructive; rerun with --yes to confirm")]
88    ConfirmationRequired { operation: String },
89
90    /// An import byte source the caller must fix (wrong file, too big,
91    /// unreadable). Exit 2 — usage class: it names what the CALLER must
92    /// change, like [`Self::ConfirmationRequired`] (03-02).
93    #[error("invalid import file: {reason}")]
94    InvalidImportFile { reason: String },
95
96    /// The gateway ANSWERED the import POST with HTTP 200 but the
97    /// body says `{"success": false, "problem": "…"}` — the
98    /// denial-rides-200 class the WebDev family handles (05-01)
99    /// applied to the project-import family (05-07, UAT Gap 1):
100    /// without this check the import caller reports ok while nothing
101    /// landed. Exit 6 — target state: the gateway refused the
102    /// import (the problem text names why, verbatim).
103    #[error("gateway rejected the project import for {project:?}: {problem}")]
104    ImportDenied {
105        /// The project the import was headed for.
106        project: String,
107        /// The gateway's own `problem` text (verbatim when present).
108        problem: String,
109        /// URL of the import request, when known.
110        endpoint: Option<String>,
111    },
112
113    /// A command input the caller must fix (unreadable `--file`, failed
114    /// stdin read). Exit 2 — usage class, the generic sibling of
115    /// [`Self::InvalidImportFile`] (03-03: `resource put`'s byte
116    /// source).
117    #[error("invalid input: {reason}")]
118    InvalidInput { reason: String },
119
120    /// Named profile absent from config. Exit 3.
121    #[error("profile {name:?} not found (known profiles: {known:?})")]
122    ProfileNotFound { name: String, known: Vec<String> },
123
124    /// No `--profile`, no `IGNITION_PROFILE`, no active profile in config.
125    /// Exit 3. Constructible from the CLI once config resolution lands
126    /// (01-03); the taxonomy is complete on day one.
127    #[error("no active profile configured")]
128    NoActiveProfile,
129
130    /// No credential resolvable for the profile (env, token_env, keyring all
131    /// missed or failed). Exit 3.
132    #[error("secret unavailable for profile {profile:?}")]
133    SecretUnavailable { profile: String },
134
135    /// Config file unreadable or wrong shape. Exit 3.
136    #[error("invalid configuration: {reason}")]
137    ConfigInvalid { reason: String },
138
139    /// A profile's `poll_interval_secs` is below the 1-second floor
140    /// (08-01, TUIX-05 clamp): sub-second gateway polling is refused at
141    /// load time — the TUI's background refresh cadence may not hammer
142    /// the gateway. Exit 3 — the CONFIG class (the Phase-7 additive-slug
143    /// precedent, e.g. `eam_not_controller`: same exit class, own slug,
144    /// never a new exit code — the 1–7 taxonomy is frozen).
145    #[error("profile {profile:?}: poll_interval_secs must be >= 1 (sub-second polling refused)")]
146    PollIntervalTooSmall {
147        /// The profile carrying the refused value.
148        profile: String,
149    },
150
151    /// Gateway unreachable / timeout / TLS failure. Exit 4.
152    ///
153    /// `source: None` marks a POLL deadline expiry (02-04 `poll.rs`):
154    /// same class, same slug (`network_error`), no new variant — the
155    /// transport-error `source` a real failure carries is simply
156    /// absent, and `url` describes what was being waited on instead
157    /// (the poll's subject). The deadline's last observation rides
158    /// `observation` (09-07): when it is `Some` the gateway ANSWERED
159    /// with a concrete state, and the message leads with "no terminal
160    /// state" — it NEVER claims unreachability for an observed
161    /// answer; `None` keeps the plain unreachability wording.
162    #[error(
163        "{lead} at {url}{source_note}{observation_note}",
164        lead = if observation.is_some() {
165            "no terminal state"
166        } else {
167            "gateway unreachable"
168        },
169        source_note = source.as_ref().map(|source| format!(": {source}")).unwrap_or_default(),
170        observation_note = observation
171            .as_deref()
172            .map(|obs| format!("; last observation: {obs}"))
173            .unwrap_or_default()
174    )]
175    Network {
176        url: String,
177        #[source]
178        source: Option<reqwest::Error>,
179        /// The poll deadline's last concrete observation (e.g. the
180        /// gateway's reported state) — `None` for transport failures
181        /// and observation-less deadlines. `Some` ⇒ the gateway
182        /// answered; the Display never says "unreachable" then.
183        observation: Option<String>,
184    },
185
186    /// Gateway reachable but rejected credentials (401/403). Exit 5.
187    #[error("gateway rejected credentials (HTTP {status})")]
188    Auth {
189        status: u16,
190        /// URL/path of the request that was rejected, when known.
191        endpoint: Option<String>,
192    },
193
194    /// Gateway reachable but the command is invalid for its current state —
195    /// version below minimum, uncommissioned, mid-restart, or a missing
196    /// resource. Exit 6.
197    #[error("gateway version {found} is below minimum {minimum}")]
198    GatewayTooOld {
199        found: String,
200        minimum: String,
201        /// URL/path of the request that answered, when known.
202        endpoint: Option<String>,
203    },
204
205    /// Gateway reachable but uncommissioned — every `/data` route 302s to
206    /// `/welcome` (verified on a fresh 8.3.6 container; 02-RESEARCH
207    /// §Error-Body Sniffing). Exit 6.
208    #[error("gateway at {} is not commissioned", endpoint.as_deref().unwrap_or("unknown address"))]
209    GatewayNotCommissioned {
210        /// URL that was redirected to the commissioning wizard.
211        endpoint: Option<String>,
212    },
213
214    /// Gateway restarting — webserver answers (503) but services are down
215    /// (verified restart lifecycle: webserver never drops the connection).
216    /// Exit 6.
217    #[error("gateway is restarting (webserver up, services down)")]
218    GatewayRestarting {
219        /// URL that answered 503.
220        endpoint: Option<String>,
221    },
222
223    /// Named resource absent (404) — terminating a nonexistent session id,
224    /// an unknown path, or a pre-8.3 gateway's JSON
225    /// `{"message": "No route match for path: …"}`. Exit 6.
226    #[error("resource not found on the gateway")]
227    NotFound {
228        /// URL that answered 404.
229        endpoint: Option<String>,
230    },
231
232    /// A project of this name already exists and the import's collision
233    /// policy is abort — the CLI-side pre-check refused BEFORE any
234    /// upload (the server's own answer remains the backstop). Exit 6 —
235    /// target state: the command is invalid for the gateway's current
236    /// state (03-02, the GatewayTooOld action-built-variant precedent:
237    /// constructed by the actions layer, not classify).
238    #[error("project {name} already exists on the gateway")]
239    ProjectExists {
240        /// The colliding project name.
241        name: String,
242        /// URL of the pre-check request, when known.
243        endpoint: Option<String>,
244    },
245
246    /// A binary (data.bin-class) resource met the surgical JSON/text
247    /// loop — REFUSED rather than corrupted through it. Exit 6 —
248    /// target state: the command is invalid for that resource's
249    /// nature; the export/import family owns binary resources
250    /// (Pitfall 7).
251    #[error("resource {path:?} has binary content — not editable via the resource loop")]
252    ResourceBinary {
253        /// The resource path that was refused.
254        path: String,
255        /// URL of the request involved, when known.
256        endpoint: Option<String>,
257    },
258
259    /// The gateway refuses trial resets while the trial is still
260    /// active — live-discovered on 8.3.3 during 04-03's spike: the
261    /// reset POST answers 403 on a NON-expired trial (verified from
262    /// the browser page itself with the exact UI headers), and 200 +
263    /// the flip on an expired one. The action layer's expiry pre-check
264    /// turns that misleading auth-shaped 403 into the honest
265    /// target-state refusal. Exit 6 (the ProjectExists precedent:
266    /// action-constructed, not classify).
267    #[error(
268        "trial is not expired ({remaining_s}s left) — the gateway only honors resets once the trial expires"
269    )]
270    TrialNotExpired {
271        /// Seconds left on the active trial.
272        remaining_s: i64,
273        /// URL of the rig's trial endpoint, when known.
274        endpoint: Option<String>,
275    },
276
277    /// The WebDev route family a command depends on is not deployed —
278    /// the presence probe answered 405, the live-proven 8.3 absent
279    /// marker (missing routes AND missing projects both answer 405,
280    /// NOT 404; 05-RESEARCH Pitfall 1). Exit 6 — target state: the
281    /// command is invalid until `ign webdev deploy` installs the
282    /// routes (the TrialNotExpired precedent: action-constructed, not
283    /// classify).
284    #[error(
285        "webdev routes are not deployed (probe of {route:?} in project {project:?} answered 405)"
286    )]
287    RoutesNotDeployed {
288        /// The deploy project the probe targeted.
289        project: String,
290        /// The route folder the probe named.
291        route: String,
292        /// Path of the probe request, when known.
293        endpoint: Option<String>,
294    },
295
296    /// The WebDev module answered 402 — installed but unlicensed (a
297    /// trial-expired gateway; live-verified cross-version on 8.3.6,
298    /// 05-RESEARCH §Servlet). Exit 6 — no `/system/webdev` route can
299    /// answer until the gateway is licensed.
300    #[error(
301        "the WebDev module is unlicensed on this gateway (HTTP 402 — trial-expired rigs cannot serve /system/webdev routes)"
302    )]
303    WebdevUnlicensed {
304        /// Path of the probe request, when known.
305        endpoint: Option<String>,
306    },
307
308    /// A deployed route's handshake version differs from the embedded
309    /// bundle's — the CLI refuses rather than auto-upgrading either
310    /// side (roadmap-locked: actionable error, no auto-upgrade
311    /// magic). Exit 6.
312    #[error("route {route:?} version mismatch: deployed {deployed}, this CLI expects {expected}")]
313    RouteVersionMismatch {
314        /// The route folder that answered.
315        route: String,
316        /// The route's deployed `routeVersion`.
317        deployed: String,
318        /// The embedded bundle's version
319        /// ([`crate::webdev::ROUTE_BUNDLE_VERSION`]).
320        expected: String,
321        /// Path of the probe request, when known.
322        endpoint: Option<String>,
323    },
324
325    /// A tag provider of this name does not exist — the
326    /// find→signature→delete chain's find half missed (05-04,
327    /// TAGS-01). Exit 6 — target state: the named thing is absent
328    /// (the ProjectExists precedent family: action-constructed, not
329    /// classify — the honest, family-specific refusal over a bare
330    /// 404).
331    #[error("tag provider {name:?} not found on the gateway")]
332    ProviderNotFound {
333        /// The provider name that missed.
334        name: String,
335        /// URL of the find request, when known.
336        endpoint: Option<String>,
337    },
338
339    /// A WebDev route answered HTTP 200 with a body denial
340    /// (`{ok:false, error{code,message}}`) whose machine code this CLI
341    /// does not specifically map — code + message ride verbatim so
342    /// agents can branch on the stable route contract (05-01). Exit 6
343    /// — target state: the deployed route refused the action.
344    #[error("webdev route denied the call ({code}): {message}")]
345    WebdevRouteError {
346        /// The route's machine error code (stable contract).
347        code: String,
348        /// The route's human message.
349        message: String,
350        /// Path of the request, when known.
351        endpoint: Option<String>,
352    },
353
354    /// A tag import under abort policy found EXISTING tags at the
355    /// target provider (05-05, TAGS-09) — the browse pre-check
356    /// refuses BEFORE any route write (the LOCKED Phase-3 collision
357    /// matrix mapped onto configure's 'a'/'o'). Exit 6 — target
358    /// state: the named tags exist; overwrite is the explicit,
359    /// guarded opt-in.
360    #[error(
361        "tag collision importing into provider {provider:?}: {} already exist(s)",
362        names.join(", ")
363    )]
364    TagCollision {
365        /// The target provider the import was headed for.
366        provider: String,
367        /// The colliding top-level tag names the pre-check found.
368        names: Vec<String>,
369        /// URL of the pre-check browse request, when known.
370        endpoint: Option<String>,
371    },
372
373    /// The gateway has no alarm-journal profile configured — alarm
374    /// history has nowhere to read from. The alarms route's
375    /// structured `no_alarm_journal` denial maps here (the
376    /// denial_to_error seam, 05-06 TAGS-07): DEFAULT rigs hit this
377    /// ALWAYS, because the journal is a config-resource chain —
378    /// database connection + `ignition/alarm-journal` profile + the
379    /// `general-alarm-settings` singleton pointing at it. Exit 6 —
380    /// target state: the command is invalid for the gateway's
381    /// current state until that chain is provisioned (the honest,
382    /// actionable refusal over a bare route error).
383    #[error(
384        "no alarm journal profile is configured on this gateway — alarm history has nothing to read"
385    )]
386    AlarmJournalMissing {
387        /// URL of the alarms route request, when known.
388        endpoint: Option<String>,
389    },
390
391    /// Pruning a LIVE Designer session entry — the gateway's prune
392    /// route answers 409 (empty body, wire-verified on 8.3.3,
393    /// 06-UAT test 6): prune removes STALE entries only, so the
394    /// command is invalid while the Designer is still open. Exit 6 —
395    /// target state (additive slug in the frozen taxonomy's
396    /// established growth pattern; classify()'s ROUTE-SCOPED 409 arm
397    /// constructs this — the first classify-constructed refusal added
398    /// since the 02-01 set).
399    #[error("designer session {id} is live — the gateway refused the prune")]
400    SessionNotPrunable {
401        /// The Designer session id that was refused.
402        id: String,
403        /// URL of the refused DELETE, when known.
404        endpoint: Option<String>,
405    },
406
407    /// Docker/compose rig failure. Exit 7. Reserved — first used in Phase 4;
408    /// trivially constructible so the taxonomy enumerates completely today.
409    #[error("rig error: {0}")]
410    Rig(String),
411
412    /// The gateway's EAM module is not configured as a controller —
413    /// every `/data/eam/api/v1/*` runtime endpoint answers 403 with
414    /// "This operation can only be performed when EAM is configured
415    /// as a controller" on a stock gateway (live-proven 8.3.3,
416    /// 07-RESEARCH): a STATE refusal, not auth — the token is fine,
417    /// the module's role is not. Message-classified at the classify
418    /// seam, path-scoped to `/data/eam/` so generic 403s elsewhere
419    /// cannot shift (the trial_not_expired pattern, classify
420    /// edition). Exit 6 — target state.
421    #[error(
422        "EAM is not configured as a controller on this gateway — every EAM runtime \
423         operation refuses until the module's installMode is flipped"
424    )]
425    EamNotController {
426        /// URL of the refused request, when known.
427        endpoint: Option<String>,
428    },
429
430    /// A provider-ROOT tag path (`[default]` alone, or a bare first
431    /// segment that resolves to a provider) met the tagConfig route
432    /// — `system.tag.getConfiguration`/`exportTags` need an RPC
433    /// context WebDev threads don't carry (live-proven 8.3.3
434    /// b2026012009, both gateways; 07-UAT test 12). The route
435    /// refuses honestly (pre-call bracket detection + RpcContext
436    /// translation for the bare form) instead of surfacing the
437    /// IllegalStateException as a generic route error; subtree
438    /// paths (`[default]folder`) are the supported form. Exit 6 —
439    /// target state: a platform limitation, not a bug.
440    #[error(
441        "provider-root tag paths are not supported by the deployed route (the gateway needs an \
442         RPC context WebDev threads don't carry) — target a subtree like [provider]folder"
443    )]
444    ProviderRootUnsupported {
445        /// URL of the tagConfig route request, when known.
446        endpoint: Option<String>,
447    },
448
449    /// An `eam task new` whose type is in the REFUSED set —
450    /// `eam_restoreBackup`, `eam_installModules`, `eam_remoteUpgrade`
451    /// are fleet-destructive (they push backups/modules/upgrades to
452    /// every AGENT target), and the CLI refuses them outright over
453    /// guard-everything: honest refusal with the v2 scope pointer
454    /// (the planner-locked create ladder's top rung). Exit 6 —
455    /// target state (additive slug alongside Task 2's).
456    #[error(
457        "EAM task type {task_type} is fleet-destructive — refused (restore/install/upgrade \
458         are EXT-03 (v2) scope; run them from the EAM console)"
459    )]
460    EamTaskTypeRefused {
461        /// The refused `profile.type` token.
462        task_type: String,
463    },
464
465    /// An `eam task force` whose slot a leftover run occupies — the
466    /// force route answers 409 with the gateway's own Jetty page
467    /// ("Task 'X (forced)' already exists! It must be completed or
468    /// deleted before another task of this type can be force
469    /// executed."; live-captured 8.3.3, 07-UAT test 7). Exit 6 —
470    /// target state (the `session_not_prunable` precedent,
471    /// force-route edition): the gateway's state refused the command,
472    /// not a bug; classify()'s path-scoped 409 arm constructs this.
473    #[error("EAM task {task} has a run in flight — the gateway refused the force: {detail}")]
474    EamTaskInFlight {
475        /// The forced task's name (the force URL's last segment).
476        task: String,
477        /// The gateway's Jetty page message verbatim when sniffed;
478        /// the '(forced)' fallback text otherwise.
479        detail: String,
480        /// URL of the refused force POST, when known.
481        endpoint: Option<String>,
482    },
483
484    /// The scriptExec route is not configured for this profile — no
485    /// webdev secret is persisted, which can only mean `ign webdev
486    /// deploy --with-script-exec` has never run (deploy persists the
487    /// secret 0600 BEFORE upload, so a deployed route without a
488    /// stored secret is not a reachable state). `ign script run`'s
489    /// opt-in is STRUCTURAL — the deploy flag — so the verb carries
490    /// no `--yes` guard and refuses here instead (07-03, SCRPT-01:
491    /// the TrialNotExpired precedent — action-constructed, not
492    /// classify). Exit 6 — target state.
493    #[error(
494        "scriptExec is not configured for profile {profile:?} — the secret-gated route deploys \
495         only via the explicit opt-in"
496    )]
497    ScriptExecNotConfigured {
498        /// The profile whose secret store is empty.
499        profile: String,
500    },
501
502    /// `ign lint` found no `ignition-lint` executable on PATH — the
503    /// delegation has nothing to delegate to (07-04, INTR-02). The
504    /// hint carries the install command + repo. Exit 6 — target
505    /// state (additive slug; the environment lacks the tool, the
506    /// command is fine).
507    #[error("ignition-lint is not installed (no executable found on PATH)")]
508    LintToolAbsent,
509
510    /// The gateway answered the api call with a 4xx this CLI does not
511    /// curate — the caller's request is the problem, and the body is
512    /// theirs to read. Exit 2 (usage class; additive slug — the
513    /// poll_interval_too_small precedent: same class, own slug, no new
514    /// exit code). Constructed ONLY by the api-call-scoped classify arm
515    /// (09-01: the `api_call` parameter) — a curated command's 4xx keeps
516    /// its existing classification (the catch-all cannot fire without
517    /// the parameter). The body is the gateway's answer VERBATIM,
518    /// truncated at [`GATEWAY_CLIENT_BODY_CAP_BYTES`] with the explicit
519    /// [`GATEWAY_CLIENT_BODY_TRUNCATION_MARKER`] via [`truncate_api_body`]
520    /// at construction.
521    #[error("gateway rejected the api call (HTTP {status} from {endpoint}): {body}")]
522    GatewayClientError {
523        /// HTTP status the gateway answered with.
524        status: u16,
525        /// Full URL of the rejected request.
526        endpoint: String,
527        /// The response body VERBATIM, truncated at
528        /// [`GATEWAY_CLIENT_BODY_CAP_BYTES`] with an explicit marker.
529        body: String,
530    },
531
532    /// No diagnostics bundle is available — the status poll ANSWERED
533    /// with a captured TERMINAL steady state meaning "no current
534    /// bundle" (`Invalid`: live-proven on 8.3.6 rig ign-p9-836,
535    /// 2026-09-07 UAT / 09-UAT.md Gap 3 — a `Valid` bundle decays to
536    /// `Invalid` within ~2 minutes UNPROMPTED and stays `Invalid`;
537    /// only a fresh generate changes it, polling cannot). Exit 6 —
538    /// target state (the `ImportDenied` precedent: a gateway-answered
539    /// refusal riding its own class, action-constructed by `bundle
540    /// wait`'s probe, not classify).
541    #[error(
542        "no diagnostics bundle available (gateway reports state {state:?}) — run \
543         `ign diagnostics bundle generate` first; polling cannot change this state"
544    )]
545    BundleNotAvailable {
546        /// The observed steady state (the captured unavailable
547        /// vocabulary, e.g. `Invalid`).
548        state: String,
549    },
550}
551
552impl CoreError {
553    /// Stable machine slug — public contract, never respell.
554    pub fn code(&self) -> &'static str {
555        match self {
556            Self::Internal(_) => "internal",
557            Self::ConfirmationRequired { .. } => "confirmation_required",
558            Self::InvalidImportFile { .. } => "invalid_import_file",
559            Self::ImportDenied { .. } => "import_denied",
560            Self::InvalidInput { .. } => "invalid_input",
561            Self::ProfileNotFound { .. } => "profile_not_found",
562            Self::NoActiveProfile => "no_active_profile",
563            Self::SecretUnavailable { .. } => "secret_unavailable",
564            Self::ConfigInvalid { .. } => "config_invalid",
565            Self::PollIntervalTooSmall { .. } => "poll_interval_too_small",
566            Self::Network { .. } => "network_error",
567            Self::Auth { .. } => "auth_rejected",
568            Self::GatewayTooOld { .. } => "gateway_too_old",
569            Self::GatewayNotCommissioned { .. } => "gateway_not_commissioned",
570            Self::GatewayRestarting { .. } => "gateway_restarting",
571            Self::NotFound { .. } => "not_found",
572            Self::ProjectExists { .. } => "project_exists",
573            Self::ResourceBinary { .. } => "resource_binary",
574            Self::TrialNotExpired { .. } => "trial_not_expired",
575            Self::ProviderNotFound { .. } => "provider_not_found",
576            Self::RoutesNotDeployed { .. } => "routes_not_deployed",
577            Self::WebdevUnlicensed { .. } => "webdev_unlicensed",
578            Self::RouteVersionMismatch { .. } => "route_version_mismatch",
579            Self::WebdevRouteError { .. } => "webdev_route_error",
580            Self::TagCollision { .. } => "tag_collision",
581            Self::AlarmJournalMissing { .. } => "alarm_journal_missing",
582            Self::SessionNotPrunable { .. } => "session_not_prunable",
583            Self::Rig(_) => "rig_error",
584            Self::EamNotController { .. } => "eam_not_controller",
585            Self::ProviderRootUnsupported { .. } => "provider_root_unsupported",
586            Self::EamTaskTypeRefused { .. } => "eam_task_type_refused",
587            Self::EamTaskInFlight { .. } => "eam_task_in_flight",
588            Self::ScriptExecNotConfigured { .. } => "script_exec_not_configured",
589            Self::LintToolAbsent => "lint_tool_absent",
590            Self::GatewayClientError { .. } => "gateway_client_error",
591            Self::BundleNotAvailable { .. } => "bundle_not_available",
592        }
593    }
594
595    /// The LOCKED exit-code mapping — the only place exit codes are decided.
596    pub fn exit_code(&self) -> u8 {
597        match self {
598            Self::Internal(_) => 1,
599            Self::ConfirmationRequired { .. }
600            | Self::InvalidImportFile { .. }
601            | Self::InvalidInput { .. }
602            | Self::GatewayClientError { .. } => 2,
603            Self::ProfileNotFound { .. }
604            | Self::NoActiveProfile
605            | Self::SecretUnavailable { .. }
606            | Self::ConfigInvalid { .. }
607            | Self::PollIntervalTooSmall { .. } => 3,
608            Self::Network { .. } => 4,
609            Self::Auth { .. } => 5,
610            Self::GatewayTooOld { .. }
611            | Self::GatewayNotCommissioned { .. }
612            | Self::GatewayRestarting { .. }
613            | Self::NotFound { .. }
614            | Self::ProjectExists { .. }
615            | Self::ResourceBinary { .. }
616            | Self::TrialNotExpired { .. }
617            | Self::ProviderNotFound { .. }
618            | Self::RoutesNotDeployed { .. }
619            | Self::WebdevUnlicensed { .. }
620            | Self::RouteVersionMismatch { .. }
621            | Self::WebdevRouteError { .. }
622            | Self::TagCollision { .. }
623            | Self::AlarmJournalMissing { .. }
624            | Self::SessionNotPrunable { .. }
625            | Self::ImportDenied { .. }
626            | Self::EamNotController { .. }
627            | Self::ProviderRootUnsupported { .. }
628            | Self::EamTaskTypeRefused { .. }
629            | Self::EamTaskInFlight { .. }
630            | Self::ScriptExecNotConfigured { .. }
631            | Self::LintToolAbsent
632            | Self::BundleNotAvailable { .. } => 6,
633            Self::Rig(_) => 7,
634        }
635    }
636
637    /// Actionable next step (CORE-05). Every class carries one.
638    pub fn hint(&self) -> Option<String> {
639        match self {
640            Self::Internal(_) => Some(
641                "internal errors are bugs; re-run with -vv and report the \
642                 diagnostics output"
643                    .to_string(),
644            ),
645            Self::ConfirmationRequired { .. } => Some(
646                "this operation is destructive; re-run with --yes or set \
647                   IGNITION_YES=1"
648                    .to_string(),
649            ),
650            Self::InvalidImportFile { .. } => Some(
651                "import expects a project-export ZIP (PK\\x03\\x04 magic) of at \
652                   most 512 MB — pass a file produced by `ign project export` \
653                   via --file (or `-` to pipe one on stdin)"
654                    .to_string(),
655            ),
656            Self::ImportDenied { problem, .. } => Some(format!(
657                "the gateway refused the import over a 200 answer — the problem \
658                   text above is the gateway's own; `ign project export` of the \
659                   current state is the honest baseline for hand-editing ({problem})"
660            )),
661            Self::InvalidInput { reason } => Some(
662                if reason == TUI_TTY_REFUSAL_REASON {
663                    // The ONE contextual InvalidInput hint (06-07): the
664                    // TTY refusal's fix is terminal-related — the
665                    // --file/stdin default is meaningless for a pipe.
666                    "run `ign tui` in an interactive terminal (the cockpit \
667                     needs a TTY on stdout — not a pipe or redirect)"
668                } else if reason.starts_with(LOSS_GATE_REFUSAL_REASON_PREFIX) {
669                    // The loss-gate refusal (11-07): the message above already names
670                    // every finding and ends with the actionable guidance — the hint
671                    // restates it for envelope readers instead of the file-read
672                    // default, which is meaningless here (the file WAS readable).
673                    "the loss report above names what this import would drop or \
674                     coerce — re-run with --yes to import anyway"
675                } else {
676                    "fix the input source — a readable file path via --file, or `-` \
677                     to pipe the content on stdin"
678                }
679                .to_string(),
680            ),
681            Self::ProfileNotFound { known, .. } => Some(if known.is_empty() {
682                "no profiles configured yet; run `ign profile add` to create \
683                     one"
684                .to_string()
685            } else {
686                format!(
687                    "known profiles: {}; run `ign profile add` to add another",
688                    known.join(", ")
689                )
690            }),
691            Self::NoActiveProfile => Some(
692                "pass --profile NAME, set IGNITION_PROFILE, or mark a profile \
693                 active with `ign profile use`"
694                    .to_string(),
695            ),
696            Self::SecretUnavailable { profile } => Some(format!(
697                "set IGNITION_TOKEN (or token_env in the profile), or store a \
698                 keyring entry: service 'ignition-cli', user 'profile:{profile}'"
699            )),
700            Self::ConfigInvalid { .. } => Some(
701                "verify the config file is valid TOML with [profiles.NAME] \
702                 tables; `ign profile add` writes a known-good one"
703                    .to_string(),
704            ),
705            Self::PollIntervalTooSmall { profile } => Some(format!(
706                "set poll_interval_secs to 1 or higher in [profiles.{profile}], \
707                 or remove the key to use the default cadence"
708            )),
709            Self::Network {
710                url,
711                observation,
712                ..
713            } => Some(match observation {
714                Some(observation) => format!(
715                    "the gateway answered but no terminal state arrived before the deadline — \
716                     the last observation was: {observation}; address that state, not the \
717                     connection ({url})"
718                ),
719                None => format!("check the gateway is reachable at {url} (host, port, VPN, TLS)"),
720            }),
721            Self::Auth { status, .. } => Some(match status {
722                401 => {
723                    // 401 = token not recognized — the #1 setup failure is
724                    // a key-only header (verified: key-only → 401, full
725                    // `name:key` → 200; Basic is dead on 8.3 /data).
726                    "token not recognized — the X-Ignition-API-Token header must be the FULL `name:key` string from the gateway UI (Platform→Security→API Keys); Basic auth does not work on 8.3 /data routes — create an API token"
727                }
728                403 => {
729                    // 403 = recognized but under-permitted (verified
730                    // semantics; see 02-RESEARCH Auth §4/§5).
731                    "token recognized but under-permitted — Ignition token setup is three parts: (1) token holds an adequate security level, (2) gateway read/write permissions include that level, (3) 'Require secure connections' is unchecked for http gateways; run `ign doctor` for a diagnosis"
732                }
733                _ => "check the credential; Ignition token setup is three parts: security level, write permissions, token assignment",
734            }
735            .to_string()),
736            Self::GatewayTooOld { minimum, .. } => {
737                Some(format!("upgrade the gateway to at least {minimum}"))
738            }
739            Self::GatewayNotCommissioned { .. } => Some(
740                "open http://<host>:<port>/welcome in a browser and complete the \
741                 commissioning wizard"
742                    .to_string(),
743            ),
744            Self::GatewayRestarting { .. } => Some(
745                "wait for readiness with `ign wait restart` or retry in ~1 minute".to_string(),
746            ),
747            Self::NotFound { .. } => Some(
748                "check the id/path; a 404 JSON 'No route match' body can also mean \
749                  a pre-8.3 gateway"
750                    .to_string(),
751            ),
752            Self::ProjectExists { .. } => Some(
753                "the default collision policy refuses to overwrite; re-run with \
754                  --collision-policy overwrite to replace it — overwrite \
755                  REPLACES the ENTIRE project (resources absent from the ZIP \
756                  are deleted; merge is Designer-only)"
757                    .to_string(),
758            ),
759            Self::ResourceBinary { .. } => Some(
760                "resource content is binary — use `ign project export`/`import` \
761                  for data.bin-class resources"
762                    .to_string(),
763            ),
764            Self::TrialNotExpired { .. } => Some(
765                "wait for the trial to expire (watch `ign rig trial status`), or \
766                  run `ign rig reset --yes` for a completely fresh trial volume"
767                    .to_string(),
768            ),
769            Self::RoutesNotDeployed { .. } => Some(
770                "run `ign webdev deploy` to install the CLI's WebDev routes into \
771                  the gateway, then retry"
772                    .to_string(),
773            ),
774            Self::WebdevUnlicensed { .. } => Some(
775                "license the gateway — the WebDev module answers 402 while \
776                  unlicensed (on a rig, `ign rig trial reset --yes` restarts an \
777                  expired trial)"
778                    .to_string(),
779            ),
780            Self::RouteVersionMismatch { deployed, expected, .. } => {
781                // Direction decides the fix (roadmap criterion): an older
782                // deployed route → redeploy from THIS binary; a NEWER
783                // deployed route → this CLI is behind (the route bundle
784                // travels with the binary). Same slug either way.
785                let newer = semver::Version::parse(deployed)
786                    .ok()
787                    .zip(semver::Version::parse(expected).ok())
788                    .is_some_and(|(deployed, expected)| deployed > expected);
789                Some(if newer {
790                    "the deployed routes are NEWER than this CLI — update ign \
791                      (the route bundle travels with the binary)"
792                        .to_string()
793                } else {
794                    "run `ign webdev deploy` to redeploy the route version \
795                      this CLI expects"
796                        .to_string()
797                })
798            }
799            Self::ProviderNotFound { .. } => Some(
800                "check the provider name; `ign tags provider list` shows the \
801                  gateway's tag providers"
802                    .to_string(),
803            ),
804            Self::TagCollision { .. } => Some(
805                "re-run with --collision-policy overwrite to replace the \
806                  existing tags (destructive: requires --yes)"
807                    .to_string(),
808            ),
809            Self::AlarmJournalMissing { .. } => Some(
810                "alarm history needs a journal profile — provision a database \
811                  connection + alarm-journal profile on the gateway (and point \
812                  the general-alarm-settings singleton at it), then retry; see \
813                  the README 'Alarm history' section"
814                    .to_string(),
815            ),
816            Self::SessionNotPrunable { .. } => Some(
817                "close the Designer first — prune removes stale entries only".to_string(),
818            ),
819            Self::WebdevRouteError { code, .. } => Some(if code == "secret_required" || code == "secret_mismatch" {
820                "the scriptExec route is secret-gated — deploy it with `ign \
821                  webdev deploy --with-script-exec` (the secret is generated \
822                  and stored in the profile config at 0600); a mismatch means \
823                  the route was deployed with a different secret: redeploy or \
824                  pass --rotate-secret"
825                    .to_string()
826            } else {
827                "the deployed route refused the action — the code and message \
828                  are the route's stable contract; `ign webdev status` \
829                  diagnoses the deployment"
830                    .to_string()
831            }),
832            Self::EamNotController { .. } => Some(
833                "flip the gateway's EAM role: config-resource PUT on \
834                   com.inductiveautomation.eam/module-settings with \
835                   installMode \"Controller\" (array body carrying the current \
836                   signature) — a manual gateway-role decision this CLI \
837                   deliberately does not automate; see the README 'EAM tasks' \
838                   section"
839                    .to_string(),
840            ),
841            Self::ProviderRootUnsupported { .. } => Some(
842                "target a subtree path like [provider]folder — provider-ROOT \
843                   forms ([default] alone, or a bare provider name) need an \
844                   RPC context WebDev threads don't carry (8.3.3); subtree \
845                   paths are the supported form"
846                    .to_string(),
847            ),
848            Self::EamTaskTypeRefused { .. } => Some(
849                "restore/install/upgrade tasks dispatch fleet-wide (every \
850                   agent target); run them from the Ignition EAM console — \
851                   EXT-03 (v2) will scope them into the CLI"
852                    .to_string(),
853            ),
854            Self::EamTaskInFlight { .. } => Some(
855                "complete or delete the leftover '(forced)' run from the EAM \
856                   console — no ign verb deletes runs; the slot frees once the \
857                   run is resolved"
858                    .to_string(),
859            ),
860            Self::ScriptExecNotConfigured { .. } => Some(
861                "run `ign webdev deploy --with-script-exec` to deploy the route and \
862                  generate + persist its secret (the deploy flag IS the opt-in — \
863                  `ign script run` has no --yes by design)"
864                    .to_string(),
865            ),
866            Self::LintToolAbsent => Some(
867                "install the linter: `uv tool install ignition-lint-toolkit` \
868                 (or `pip install ignition-lint-toolkit`) — \
869                 github.com/TheThoughtagen/ignition-lint; then re-run with \
870                 ignition-lint on PATH"
871                    .to_string(),
872            ),
873            Self::GatewayClientError { .. } => Some(
874                "the gateway rejected this request — the body above is the \
875                 gateway's own answer; fix the path/method/body, or use a \
876                 curated `ign` command when one exists"
877                    .to_string(),
878            ),
879            Self::BundleNotAvailable { .. } => Some(
880                "generate a fresh bundle with `ign diagnostics bundle generate`, \
881                 then wait again — the gateway reports no current bundle and \
882                 polling cannot produce one"
883                    .to_string(),
884            ),
885            Self::Rig(_) => Some(
886                "check Docker is running and inspect the rig containers \
887                 (docker ps)"
888                    .to_string(),
889            ),
890        }
891    }
892
893    /// URL/path of the request involved, when one was — populated for the
894    /// network, auth, and target-state classes (CORE-05).
895    pub fn endpoint(&self) -> Option<String> {
896        match self {
897            Self::Network { url, .. } => Some(url.clone()),
898            Self::Auth { endpoint, .. } => endpoint.clone(),
899            Self::GatewayTooOld { endpoint, .. }
900            | Self::GatewayNotCommissioned { endpoint }
901            | Self::GatewayRestarting { endpoint }
902            | Self::NotFound { endpoint }
903            | Self::ProjectExists { endpoint, .. }
904            | Self::ResourceBinary { endpoint, .. }
905            | Self::TrialNotExpired { endpoint, .. }
906            | Self::ProviderNotFound { endpoint, .. }
907            | Self::RoutesNotDeployed { endpoint, .. }
908            | Self::WebdevUnlicensed { endpoint }
909            | Self::RouteVersionMismatch { endpoint, .. }
910            | Self::WebdevRouteError { endpoint, .. }
911            | Self::TagCollision { endpoint, .. }
912            | Self::AlarmJournalMissing { endpoint }
913            | Self::SessionNotPrunable { endpoint, .. }
914            | Self::ImportDenied { endpoint, .. }
915            | Self::EamNotController { endpoint }
916            | Self::ProviderRootUnsupported { endpoint }
917            | Self::EamTaskInFlight { endpoint, .. } => endpoint.clone(),
918            _ => None,
919        }
920    }
921
922    /// The `ign tui` TTY refusal (06-07): InvalidInput carrying the
923    /// ONE reason whose hint is terminal-contextual — see
924    /// [`TUI_TTY_REFUSAL_REASON`]. Same slug, same exit 2 (frozen
925    /// taxonomy; only the hint differs).
926    pub fn tui_tty_refusal() -> Self {
927        Self::InvalidInput {
928            reason: TUI_TTY_REFUSAL_REASON.to_string(),
929        }
930    }
931
932    /// Build the LOCKED failure envelope for this error (field order is part
933    /// of the golden contract: `ok`, `profile`, `error` then `code`,
934    /// `message`, `endpoint`, `hint`).
935    pub fn envelope<'a>(&self, profile: Option<&'a str>) -> ErrorEnvelope<'a> {
936        ErrorEnvelope {
937            ok: false,
938            profile,
939            error: ErrorBody {
940                code: self.code(),
941                message: self.to_string(),
942                endpoint: self.endpoint(),
943                hint: self.hint(),
944            },
945        }
946    }
947}
948
949/// LOCKED failure envelope shape: exactly the top-level fields `ok`,
950/// `profile`, `error` — changing the set is a breaking change for agents.
951#[derive(Debug, Serialize)]
952pub struct ErrorEnvelope<'a> {
953    /// Always `false` in this envelope.
954    pub ok: bool,
955    /// Active profile echoed in every output (CORE-01); `None` until config
956    /// resolution lands.
957    pub profile: Option<&'a str>,
958    /// The typed error body.
959    pub error: ErrorBody,
960}
961
962/// LOCKED error body: `code` (stable slug), `message` (human-readable),
963/// `endpoint` (when a request was involved), `hint` (actionable next step).
964#[derive(Debug, Serialize)]
965pub struct ErrorBody {
966    /// Stable slug from [`CoreError::code`] — never respelled.
967    pub code: &'static str,
968    /// Human-readable description.
969    pub message: String,
970    /// URL/path when a request was involved.
971    pub endpoint: Option<String>,
972    /// Actionable next step.
973    pub hint: Option<String>,
974}
975
976#[cfg(test)]
977mod tests {
978    use super::{
979        CoreError, ErrorBody, ErrorEnvelope, GATEWAY_CLIENT_BODY_CAP_BYTES,
980        GATEWAY_CLIENT_BODY_TRUNCATION_MARKER, LOSS_GATE_REFUSAL_REASON_PREFIX, truncate_api_body,
981    };
982
983    /// Build a real `reqwest::Error` for the Network variant: a request to
984    /// an unroutable loopback port fails at connect time (instant refusal —
985    /// `reqwest::Error` has no public constructor).
986    fn network_error() -> CoreError {
987        let rt = tokio::runtime::Builder::new_current_thread()
988            .enable_all()
989            .build()
990            .expect("test runtime");
991        let url = "http://127.0.0.1:1";
992        let source = rt
993            .block_on(reqwest::get(url))
994            .expect_err("request to an unroutable port must fail");
995        CoreError::Network {
996            url: url.to_string(),
997            source: Some(source),
998            observation: None,
999        }
1000    }
1001
1002    /// Pitfall-5 guard: the FULL 1–7 taxonomy enumerated on day one so no
1003    /// later phase can silently renumber it or respell a slug. The slugs are
1004    /// asserted against literals — that IS the stability contract.
1005    #[test]
1006    fn exit_code_mapping_enumerated() {
1007        let cases: Vec<(CoreError, u8, &'static str)> = vec![
1008            (CoreError::Internal("boom".into()), 1, "internal"),
1009            (
1010                CoreError::ConfirmationRequired {
1011                    operation: "project download".into(),
1012                },
1013                2,
1014                "confirmation_required",
1015            ),
1016            (
1017                CoreError::InvalidImportFile {
1018                    reason: "missing ZIP magic".into(),
1019                },
1020                2,
1021                "invalid_import_file",
1022            ),
1023            (
1024                CoreError::InvalidInput {
1025                    reason: "cannot read put.json".into(),
1026                },
1027                2,
1028                "invalid_input",
1029            ),
1030            (
1031                CoreError::ProfileNotFound {
1032                    name: "nope".into(),
1033                    known: vec!["dev".into()],
1034                },
1035                3,
1036                "profile_not_found",
1037            ),
1038            (CoreError::NoActiveProfile, 3, "no_active_profile"),
1039            (
1040                CoreError::SecretUnavailable {
1041                    profile: "dev".into(),
1042                },
1043                3,
1044                "secret_unavailable",
1045            ),
1046            (
1047                CoreError::ConfigInvalid {
1048                    reason: "bad toml".into(),
1049                },
1050                3,
1051                "config_invalid",
1052            ),
1053            (
1054                CoreError::PollIntervalTooSmall {
1055                    profile: "dev".into(),
1056                },
1057                3,
1058                "poll_interval_too_small",
1059            ),
1060            (network_error(), 4, "network_error"),
1061            (
1062                CoreError::Auth {
1063                    status: 401,
1064                    endpoint: None,
1065                },
1066                5,
1067                "auth_rejected",
1068            ),
1069            (
1070                CoreError::GatewayTooOld {
1071                    found: "8.1.0".into(),
1072                    minimum: "8.3.1".into(),
1073                    endpoint: None,
1074                },
1075                6,
1076                "gateway_too_old",
1077            ),
1078            (
1079                CoreError::GatewayNotCommissioned {
1080                    endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
1081                },
1082                6,
1083                "gateway_not_commissioned",
1084            ),
1085            (
1086                CoreError::GatewayRestarting {
1087                    endpoint: Some("http://gw:8088/data/api/v1/gateway-info".into()),
1088                },
1089                6,
1090                "gateway_restarting",
1091            ),
1092            (
1093                CoreError::NotFound {
1094                    endpoint: Some("http://gw:8088/data/api/v1/designer/42".into()),
1095                },
1096                6,
1097                "not_found",
1098            ),
1099            (
1100                CoreError::ProjectExists {
1101                    name: "PlantFloor".into(),
1102                    endpoint: None,
1103                },
1104                6,
1105                "project_exists",
1106            ),
1107            (
1108                CoreError::ResourceBinary {
1109                    path: "com.x/perspective/session-permissions".into(),
1110                    endpoint: None,
1111                },
1112                6,
1113                "resource_binary",
1114            ),
1115            (
1116                CoreError::TrialNotExpired {
1117                    remaining_s: 6590,
1118                    endpoint: Some("http://localhost:9088/data/api/v1/trial".into()),
1119                },
1120                6,
1121                "trial_not_expired",
1122            ),
1123            (
1124                CoreError::ProviderNotFound {
1125                    name: "nope".into(),
1126                    endpoint: Some("/data/api/v1/resources/find/ignition/tag-provider/nope".into()),
1127                },
1128                6,
1129                "provider_not_found",
1130            ),
1131            (
1132                CoreError::RoutesNotDeployed {
1133                    project: "ign-cli".into(),
1134                    route: "tags".into(),
1135                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
1136                },
1137                6,
1138                "routes_not_deployed",
1139            ),
1140            (
1141                CoreError::WebdevUnlicensed {
1142                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
1143                },
1144                6,
1145                "webdev_unlicensed",
1146            ),
1147            (
1148                CoreError::RouteVersionMismatch {
1149                    route: "tags".into(),
1150                    deployed: "0.9.0".into(),
1151                    expected: "1.0.0".into(),
1152                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
1153                },
1154                6,
1155                "route_version_mismatch",
1156            ),
1157            (
1158                CoreError::WebdevRouteError {
1159                    code: "route_error".into(),
1160                    message: "boom".into(),
1161                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
1162                },
1163                6,
1164                "webdev_route_error",
1165            ),
1166            (
1167                CoreError::TagCollision {
1168                    provider: "p5import".into(),
1169                    names: vec!["T1".into(), "P5".into()],
1170                    endpoint: Some("/system/webdev/ign-cli/cli/tags".into()),
1171                },
1172                6,
1173                "tag_collision",
1174            ),
1175            (
1176                CoreError::AlarmJournalMissing {
1177                    endpoint: Some("/system/webdev/ign-cli/cli/alarms".into()),
1178                },
1179                6,
1180                "alarm_journal_missing",
1181            ),
1182            (
1183                CoreError::ImportDenied {
1184                    project: "PlantFloor".into(),
1185                    problem: "resource already exists: ResourceId{resourcePath=com.example, collectionName=views}".into(),
1186                    endpoint: Some("http://gw:8088/data/api/v1/projects/import/PlantFloor?overwrite=true".into()),
1187                },
1188                6,
1189                "import_denied",
1190            ),
1191            (
1192                CoreError::SessionNotPrunable {
1193                    id: "d-live-1".into(),
1194                    endpoint: Some("http://gw:8088/data/api/v1/designer/d-live-1".into()),
1195                },
1196                6,
1197                "session_not_prunable",
1198            ),
1199            (
1200                CoreError::EamNotController {
1201                    endpoint: Some("http://gw:8088/data/eam/api/v1/eam-tasks/history".into()),
1202                },
1203                6,
1204                "eam_not_controller",
1205            ),
1206            (
1207                CoreError::ProviderRootUnsupported {
1208                    endpoint: Some("/system/webdev/ign-cli/cli/tagConfig".into()),
1209                },
1210                6,
1211                "provider_root_unsupported",
1212            ),
1213            (
1214                CoreError::EamTaskTypeRefused {
1215                    task_type: "eam_remoteUpgrade".into(),
1216                },
1217                6,
1218                "eam_task_type_refused",
1219            ),
1220            (
1221                CoreError::EamTaskInFlight {
1222                    task: "cli-research-backup".into(),
1223                    detail: "Task 'cli-research-backup (forced)' already exists! It must be completed or deleted before another task of this type can be force executed.".into(),
1224                    endpoint: Some(
1225                        "http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/cli-research-backup"
1226                            .into(),
1227                    ),
1228                },
1229                6,
1230                "eam_task_in_flight",
1231            ),
1232            (
1233                CoreError::ScriptExecNotConfigured {
1234                    profile: "dev".into(),
1235                },
1236                6,
1237                "script_exec_not_configured",
1238            ),
1239            (CoreError::LintToolAbsent, 6, "lint_tool_absent"),
1240            (
1241                CoreError::BundleNotAvailable {
1242                    state: "Invalid".into(),
1243                },
1244                6,
1245                "bundle_not_available",
1246            ),
1247            (
1248                CoreError::GatewayClientError {
1249                    status: 400,
1250                    endpoint: "http://gw:8088/data/api/v1/nonexistent".into(),
1251                    body: r#"{"error":{"code":"NOT_FOUND"}}"#.into(),
1252                },
1253                2,
1254                "gateway_client_error",
1255            ),
1256            (CoreError::Rig("compose up failed".into()), 7, "rig_error"),
1257        ];
1258        for (err, code, slug) in cases {
1259            assert_eq!(err.exit_code(), code, "wrong exit code for: {err}");
1260            assert_eq!(err.code(), slug, "unstable slug for: {err}");
1261        }
1262    }
1263
1264    /// The (exit_code, slug) literal table the Three-Place rule syncs.
1265    /// DUPLICATED from [`exit_code_mapping_enumerated`]'s triple list by
1266    /// design: that test is the literal source of truth (enum ↔ literals);
1267    /// this flat table is what the README cross-check runs against, so a
1268    /// drift inside error.rs surfaces as a disagreement instead of silently
1269    /// re-shuffling both sides. Keep the two lists in lockstep — the
1270    /// enumerated test fails if the enum respells/renumbers, and a stale
1271    /// copy here fails the README check below until updated.
1272    const EXIT_SLUG_LITERALS: &[(u8, &str)] = &[
1273        (1, "internal"),
1274        (2, "confirmation_required"),
1275        (2, "invalid_import_file"),
1276        (2, "invalid_input"),
1277        (2, "gateway_client_error"),
1278        (3, "profile_not_found"),
1279        (3, "no_active_profile"),
1280        (3, "secret_unavailable"),
1281        (3, "config_invalid"),
1282        (3, "poll_interval_too_small"),
1283        (4, "network_error"),
1284        (5, "auth_rejected"),
1285        (6, "gateway_too_old"),
1286        (6, "gateway_not_commissioned"),
1287        (6, "gateway_restarting"),
1288        (6, "not_found"),
1289        (6, "project_exists"),
1290        (6, "resource_binary"),
1291        (6, "trial_not_expired"),
1292        (6, "provider_not_found"),
1293        (6, "routes_not_deployed"),
1294        (6, "webdev_unlicensed"),
1295        (6, "route_version_mismatch"),
1296        (6, "webdev_route_error"),
1297        (6, "tag_collision"),
1298        (6, "alarm_journal_missing"),
1299        (6, "import_denied"),
1300        (6, "session_not_prunable"),
1301        (6, "eam_not_controller"),
1302        (6, "eam_task_type_refused"),
1303        (6, "eam_task_in_flight"),
1304        (6, "script_exec_not_configured"),
1305        (6, "lint_tool_absent"),
1306        (6, "provider_root_unsupported"),
1307        (6, "bundle_not_available"),
1308        (7, "rig_error"),
1309    ];
1310
1311    /// Parse the README's exit-code table: rows `| <exit> | class | meaning |
1312    /// \`slug\`, ... |` where `<exit>` is 1–7 (row 0 is the success row, no
1313    /// slugs). Parsing is SCOPED to the `## Exit codes` section — the README
1314    /// contains unrelated tables whose rows coincidentally begin `| 5 |`,
1315    /// `| 3 |`, etc. Std-only string ops: split on `|`, trim, take the LAST
1316    /// non-empty cell as the slug column (the meaning column can carry
1317    /// backticked non-slugs like `--yes`), and keep the backtick-delimited
1318    /// tokens that start with an ASCII letter and contain no spaces.
1319    fn parse_readme_exit_table(readme: &str) -> Vec<(u8, Vec<String>)> {
1320        let section = readme.split("## Exit codes").nth(1).unwrap_or_default();
1321        let mut rows = Vec::new();
1322        let table_lines = section
1323            .lines()
1324            .skip_while(|line| !line.trim_start().starts_with('|'));
1325        for line in table_lines {
1326            if !line.trim_start().starts_with('|') {
1327                break; // table ended
1328            }
1329            let cells: Vec<&str> = line.split('|').map(str::trim).collect();
1330            let Some(exit) = cells.get(1).and_then(|cell| cell.parse::<u8>().ok()) else {
1331                continue;
1332            };
1333            if !(1..=7).contains(&exit) {
1334                continue;
1335            }
1336            let slug_cell = cells
1337                .iter()
1338                .rev()
1339                .find(|cell| !cell.is_empty())
1340                .copied()
1341                .unwrap_or_default();
1342            let slugs: Vec<String> = slug_cell
1343                .split('`')
1344                .enumerate()
1345                .filter(|(idx, _)| idx % 2 == 1)
1346                .map(|(_, token)| token.trim().to_string())
1347                .filter(|token| {
1348                    token.starts_with(|c: char| c.is_ascii_alphabetic()) && !token.contains(' ')
1349                })
1350                .collect();
1351            rows.push((exit, slugs));
1352        }
1353        rows
1354    }
1355
1356    /// CORE-11, the Three-Place slug rule made executable: the exit-code
1357    /// table exists in the enum ([`CoreError::exit_code`]), this file's
1358    /// literal triples, and the README — and the README side is now
1359    /// machine-checked. The README is parsed verbatim via `include_str!`
1360    /// and cross-checked against the literal table in BOTH directions:
1361    /// (a) every literal slug appears under its exit code (a README row
1362    /// that lost or misspelled a slug fails), (b) every README slug token
1363    /// exists in the literal table (a stale/deleted row fails). Exit 6
1364    /// carries 23 slugs, so the full cross-check is the value — not the
1365    /// happy-path smoke.
1366    #[test]
1367    fn readme_exit_table_agreement() {
1368        let readme = include_str!("../../../README.md");
1369        let rows = parse_readme_exit_table(readme);
1370        assert!(
1371            rows.len() >= 7,
1372            "README exit-code table not found — the parser must see all 7 \
1373             failure-class rows (found {})",
1374            rows.len()
1375        );
1376
1377        // Direction (a): every literal (exit, slug) is present in the
1378        // README row matching its exit code.
1379        for (exit, slug) in EXIT_SLUG_LITERALS {
1380            let row = rows
1381                .iter()
1382                .find(|(readme_exit, _)| readme_exit == exit)
1383                .unwrap_or_else(|| panic!("README table has no row for exit {exit}"));
1384            assert!(
1385                row.1.iter().any(|s| s == slug),
1386                "README exit-{exit} row is missing slug {slug:?} (row: {:?})",
1387                row.1
1388            );
1389        }
1390
1391        // Direction (b): every README slug token exists in the literal
1392        // table under the same exit code — catches stale/deleted rows.
1393        for (exit, slugs) in &rows {
1394            for slug in slugs {
1395                assert!(
1396                    EXIT_SLUG_LITERALS
1397                        .iter()
1398                        .any(|(lit_exit, lit_slug)| lit_exit == exit && lit_slug == slug),
1399                    "README exit-{exit} row carries slug {slug:?} that no \
1400                     CoreError variant emits — stale table row or slug/exit \
1401                     drift between README and error.rs"
1402                );
1403            }
1404        }
1405    }
1406
1407    /// CORE-05: config, auth, and target-state classes carry actionable
1408    /// hints (and every other class does too).
1409    #[test]
1410    fn hints_are_actionable_for_config_auth_target_state() {
1411        let profile_not_found = CoreError::ProfileNotFound {
1412            name: "x".into(),
1413            known: vec!["dev".into(), "prod".into()],
1414        };
1415        let hint = profile_not_found.hint().expect("hint required");
1416        assert!(
1417            hint.contains("dev"),
1418            "hint must list known profiles: {hint}"
1419        );
1420        assert!(
1421            hint.contains("ign profile add"),
1422            "hint must name the fix: {hint}"
1423        );
1424
1425        let auth = CoreError::Auth {
1426            status: 403,
1427            endpoint: None,
1428        };
1429        let hint = auth.hint().expect("hint required");
1430        assert!(
1431            hint.contains("three parts"),
1432            "auth hint must name the three-part token setup: {hint}"
1433        );
1434
1435        // Status-aware auth hints (02-RESEARCH Auth §4): 401 = not
1436        // recognized (name:key format), 403 = under-permitted.
1437        let unauthorized = CoreError::Auth {
1438            status: 401,
1439            endpoint: None,
1440        };
1441        let hint = unauthorized.hint().expect("hint required");
1442        assert!(
1443            hint.contains("name:key"),
1444            "401 hint must name the full name:key token format: {hint}"
1445        );
1446        assert!(
1447            hint.contains("API token"),
1448            "401 hint must say Basic cannot work: {hint}"
1449        );
1450        let hint403 = auth.hint().expect("hint required");
1451        assert!(
1452            hint403.contains("secure connections"),
1453            "403 hint must name the secure-channel part: {hint403}"
1454        );
1455
1456        let too_old = CoreError::GatewayTooOld {
1457            found: "8.1.0".into(),
1458            minimum: "8.3.1".into(),
1459            endpoint: None,
1460        };
1461        let hint = too_old.hint().expect("hint required");
1462        assert!(
1463            hint.contains("8.3.1"),
1464            "target-state hint must name the minimum: {hint}"
1465        );
1466
1467        // The WebDev refusal matrix (05-03): every hint names the fix
1468        // — `ign webdev deploy` for absent/older routes, `update ign`
1469        // for newer ones (the roadmap's actionable-error criterion).
1470        let undeployed = CoreError::RoutesNotDeployed {
1471            project: "ign-cli".into(),
1472            route: "tags".into(),
1473            endpoint: None,
1474        };
1475        let hint = undeployed.hint().expect("hint required");
1476        assert!(
1477            hint.contains("ign webdev deploy"),
1478            "absent-routes hint must name the fix: {hint}"
1479        );
1480
1481        let older = CoreError::RouteVersionMismatch {
1482            route: "tags".into(),
1483            deployed: "0.9.0".into(),
1484            expected: "1.0.0".into(),
1485            endpoint: None,
1486        };
1487        let hint = older.hint().expect("hint required");
1488        assert!(
1489            hint.contains("ign webdev deploy") && !hint.contains("update ign"),
1490            "older-route hint says redeploy: {hint}"
1491        );
1492
1493        let newer = CoreError::RouteVersionMismatch {
1494            route: "tags".into(),
1495            deployed: "1.1.0".into(),
1496            expected: "1.0.0".into(),
1497            endpoint: None,
1498        };
1499        let hint = newer.hint().expect("hint required");
1500        assert!(
1501            hint.contains("update ign") && !hint.contains("ign webdev deploy"),
1502            "newer-route hint says update ign: {hint}"
1503        );
1504
1505        let secret_gate = CoreError::WebdevRouteError {
1506            code: "secret_required".into(),
1507            message: "missing x-ignition-cli-secret header".into(),
1508            endpoint: None,
1509        };
1510        let hint = secret_gate.hint().expect("hint required");
1511        assert!(
1512            hint.contains("--with-script-exec"),
1513            "secret-gate hint names the deploy flag: {hint}"
1514        );
1515
1516        // The alarm-journal refusal (05-06): the hint names the missing
1517        // provisioning chain AND the README section.
1518        let journal = CoreError::AlarmJournalMissing { endpoint: None };
1519        let hint = journal.hint().expect("hint required");
1520        assert!(
1521            hint.contains("journal profile") && hint.contains("database connection"),
1522            "journal hint names the chain: {hint}"
1523        );
1524        assert!(
1525            hint.contains("README"),
1526            "journal hint points at the README section: {hint}"
1527        );
1528
1529        // The live-designer prune refusal (06-07): the hint names the
1530        // action — close the Designer — and the stale-only semantics.
1531        let prunable = CoreError::SessionNotPrunable {
1532            id: "d-live-1".into(),
1533            endpoint: None,
1534        };
1535        let hint = prunable.hint().expect("hint required");
1536        assert!(
1537            hint.contains("close the Designer"),
1538            "prune hint must name the action: {hint}"
1539        );
1540        assert!(
1541            hint.contains("stale entries"),
1542            "prune hint must name stale-only semantics: {hint}"
1543        );
1544
1545        // The TTY refusal's hint is contextual (06-07): the ONE
1546        // InvalidInput reason whose fix is terminal-related. Every
1547        // other reason keeps the --file/stdin resource-put hint (pinned
1548        // here so the special case can never leak or regress).
1549        let tty = CoreError::tui_tty_refusal();
1550        assert_eq!(tty.code(), "invalid_input", "slug unchanged");
1551        assert_eq!(tty.exit_code(), 2, "usage class unchanged");
1552        let hint = tty.hint().expect("hint required");
1553        assert!(
1554            hint.contains("interactive terminal"),
1555            "TTY hint must name the fix: {hint}"
1556        );
1557        assert!(
1558            !hint.contains("--file"),
1559            "TTY hint must not carry the resource-put hint: {hint}"
1560        );
1561        let put = CoreError::InvalidInput {
1562            reason: "cannot read put.json".into(),
1563        };
1564        let hint = put.hint().expect("hint required");
1565        assert!(
1566            hint.contains("--file") && hint.contains("stdin"),
1567            "resource-put hint unchanged: {hint}"
1568        );
1569
1570        // The EAM controller state gate (07-02): the hint names the
1571        // manual flip recipe + the README section (role decisions
1572        // stay one config-PUT away from the CLI, never one flag).
1573        let controller = CoreError::EamNotController { endpoint: None };
1574        let hint = controller.hint().expect("hint required");
1575        assert!(
1576            hint.contains("installMode"),
1577            "controller hint names the flip: {hint}"
1578        );
1579        assert!(
1580            hint.contains("Controller"),
1581            "controller hint names the target role: {hint}"
1582        );
1583        assert!(
1584            hint.contains("README"),
1585            "controller hint points at the README section: {hint}"
1586        );
1587
1588        // The refused task-type ladder top (07-02 Task 3): the hint
1589        // names the EAM console + the v2 scope pointer.
1590        let refused = CoreError::EamTaskTypeRefused {
1591            task_type: "eam_restoreBackup".into(),
1592        };
1593        assert_eq!(refused.exit_code(), 6, "target state");
1594        let hint = refused.hint().expect("hint required");
1595        assert!(
1596            hint.contains("EAM console"),
1597            "refused hint names where to run it: {hint}"
1598        );
1599        assert!(
1600            hint.contains("fleet-wide") || hint.contains("EXT-03"),
1601            "refused hint names the fleet consequence / scope: {hint}"
1602        );
1603
1604        // The force-route 409 (07-06 gap 4): the hint names the
1605        // resolution — complete or delete the leftover '(forced)'
1606        // run via the EAM console (no ign verb deletes runs).
1607        let in_flight = CoreError::EamTaskInFlight {
1608            task: "cli-research-backup".into(),
1609            detail: "the previous '(forced)' run must be completed or deleted first".into(),
1610            endpoint: None,
1611        };
1612        assert_eq!(in_flight.exit_code(), 6, "target state");
1613        let hint = in_flight.hint().expect("hint required");
1614        assert!(
1615            hint.contains("EAM console"),
1616            "in-flight hint names where to resolve it: {hint}"
1617        );
1618        assert!(
1619            hint.contains("complete or delete"),
1620            "in-flight hint names the resolution: {hint}"
1621        );
1622
1623        // The scriptExec structural gate (07-03): the hint names the
1624        // deploy flag VERBATIM — the flag IS the opt-in (no --yes
1625        // exists on script run).
1626        let unconfigured = CoreError::ScriptExecNotConfigured {
1627            profile: "dev".into(),
1628        };
1629        assert_eq!(unconfigured.exit_code(), 6, "target state");
1630        let hint = unconfigured.hint().expect("hint required");
1631        assert!(
1632            hint.contains("ign webdev deploy --with-script-exec"),
1633            "the hint names the deploy flag verbatim: {hint}"
1634        );
1635
1636        // Totality: no class silently loses its hint later.
1637        let no_active = CoreError::NoActiveProfile;
1638        assert!(no_active.hint().is_some());
1639    }
1640
1641    /// The failure envelope's serialized field order and endpoint population
1642    /// are contract: `ok`, `profile`, `error` / `code`, `message`,
1643    /// `endpoint`, `hint` (string-level comparison because `serde_json::Value`
1644    /// maps are key-sorted and would hide ordering).
1645    #[test]
1646    fn error_envelope_locked_shape_and_endpoint() {
1647        let auth = CoreError::Auth {
1648            status: 401,
1649            endpoint: Some("https://gw.example.com/data/api/v1/gateway-info".into()),
1650        };
1651        let envelope: ErrorEnvelope<'_> = auth.envelope(Some("dev"));
1652        let json = serde_json::to_string(&envelope).expect("serialize envelope");
1653
1654        assert_eq!(
1655            json,
1656            concat!(
1657                r#"{"ok":false,"profile":"dev","error":{"code":"auth_rejected","#,
1658                r#""message":"gateway rejected credentials (HTTP 401)","#,
1659                r#""endpoint":"https://gw.example.com/data/api/v1/gateway-info","#,
1660                r#""hint":"token not recognized — the X-Ignition-API-Token header must be the FULL `name:key` string from the gateway UI (Platform→Security→API Keys); Basic auth does not work on 8.3 /data routes — create an API token"}}"#
1661            )
1662        );
1663
1664        // Classes without a request involved carry no endpoint.
1665        let no_request = CoreError::NoActiveProfile;
1666        let body: &ErrorBody = &no_request.envelope(None).error;
1667        assert_eq!(body.endpoint, None);
1668    }
1669
1670    /// The api-call body cap (09-01): a body at or under
1671    /// [`GATEWAY_CLIENT_BODY_CAP_BYTES`] rides verbatim (no marker); a
1672    /// larger body is truncated to the cap on a UTF-8 char boundary with
1673    /// the exact ASCII marker appended — and the cap is enforced at
1674    /// CONSTRUCTION, so the variant always carries its final form.
1675    #[test]
1676    fn truncates_at_cap_with_marker() {
1677        // Under the cap: byte-identical passthrough.
1678        let short = r#"{"error":{"code":"NOT_FOUND"}}"#;
1679        assert_eq!(truncate_api_body(short), short);
1680        assert!(!short.contains(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
1681
1682        // Exactly at the cap: still verbatim (the marker only joins an
1683        // OVER-cap body — the boundary is <=, not <).
1684        let exact = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES);
1685        assert_eq!(truncate_api_body(&exact), exact);
1686
1687        // One byte over: truncated, marker appended, total size bounded.
1688        let over = "x".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES + 1);
1689        let truncated = truncate_api_body(&over);
1690        assert!(
1691            truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER),
1692            "truncated body must end with the explicit marker"
1693        );
1694        assert!(
1695            truncated.len()
1696                <= GATEWAY_CLIENT_BODY_CAP_BYTES + GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len(),
1697            "truncated body must stay within cap + marker: {}",
1698            truncated.len()
1699        );
1700
1701        // A multi-byte character straddling the cap boundary does not
1702        // panic (the cut backs up to a char boundary) and still carries
1703        // the marker.
1704        let multibyte = "é".repeat(GATEWAY_CLIENT_BODY_CAP_BYTES); // 2 bytes each
1705        let truncated = truncate_api_body(&multibyte);
1706        assert!(truncated.ends_with(GATEWAY_CLIENT_BODY_TRUNCATION_MARKER));
1707        assert!(
1708            truncated
1709                .is_char_boundary(truncated.len() - GATEWAY_CLIENT_BODY_TRUNCATION_MARKER.len())
1710        );
1711    }
1712
1713    /// The loss-gate hint override (11-07 gap closure): a reason with the
1714    /// [`LOSS_GATE_REFUSAL_REASON_PREFIX`] sentinel carries the --yes
1715    /// hint, while every OTHER InvalidInput reason (generic file-read)
1716    /// and the TTY-refusal precedent keep their hints byte-identically.
1717    /// The loss-gate reason is built from the CONST via format! — a
1718    /// string literal of the sentinel text here would be a third
1719    /// production-literal hit and break the prefix-uniqueness gate the
1720    /// plan's verify pins.
1721    #[test]
1722    fn loss_gate_hint_override_and_neighbors_unchanged() {
1723        // Sentinel-prefixed reason (dynamic prose after the header):
1724        // the --yes hint, never the file-read default.
1725        let loss_gate = CoreError::InvalidInput {
1726            reason: format!(
1727                "{}xml): the scan reports 2 finding(s) before the import:\nre-run \
1728                 with --yes to import anyway",
1729                LOSS_GATE_REFUSAL_REASON_PREFIX
1730            ),
1731        };
1732        assert_eq!(loss_gate.code(), "invalid_input", "slug unchanged");
1733        assert_eq!(loss_gate.exit_code(), 2, "usage class unchanged");
1734        let hint = loss_gate.hint().expect("hint required");
1735        assert!(
1736            hint.contains("--yes"),
1737            "loss-gate hint must name the --yes re-run: {hint}"
1738        );
1739        assert!(
1740            hint.contains("loss report above"),
1741            "loss-gate hint must point back at the report: {hint}"
1742        );
1743        assert!(
1744            !hint.contains("fix the input source"),
1745            "the file-read default must never leak onto the loss-gate path: {hint}"
1746        );
1747
1748        // Generic InvalidInput: the file-read default UNCHANGED (the
1749        // regression pin for every other raise site).
1750        let generic = CoreError::InvalidInput {
1751            reason: "x is not valid JSON: expected value at line 1".into(),
1752        };
1753        let hint = generic.hint().expect("hint required");
1754        assert!(
1755            hint.contains("--file") && hint.contains("stdin"),
1756            "resource-put hint unchanged: {hint}"
1757        );
1758        assert!(
1759            !hint.contains("--yes"),
1760            "the --yes hint must not leak onto generic reasons: {hint}"
1761        );
1762
1763        // The TTY-refusal precedent intact (06-07).
1764        let tty = CoreError::tui_tty_refusal();
1765        let hint = tty.hint().expect("hint required");
1766        assert!(
1767            hint.contains("interactive terminal"),
1768            "TTY hint unchanged: {hint}"
1769        );
1770        assert!(
1771            !hint.contains("--yes"),
1772            "the --yes hint must not leak onto the TTY path: {hint}"
1773        );
1774    }
1775}