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